From bcdc31edaeb8ddf05e4ceb9dfc9382d009776046 Mon Sep 17 00:00:00 2001
From: Ido Shamun <1993245+idoshamun@users.noreply.github.com>
Date: Mon, 29 Jun 2026 09:51:12 +0300
Subject: [PATCH 1/2] fix(giveback): align tab nav, import deferred polish,
close analytics gap
The remaining items after reconciling against the design source:
- Tab nav gutter now matches the page column (px-4 tablet:px-8
laptop:px-12) so the tabs line up with the content on tablet+; relabel
'Impact' -> 'Your impact'.
- useGivebackCauseSelection.save() force-clears the 'Your causes are
saved' toast after 3s, since the global toast only auto-dismisses when
the user's setting is on (deferred earlier, now imported).
- Updated the /giveback SEO description to the new messaging.
- GivebackCauseCard now logs ClickGivebackCause on 'Learn more', closing
the gap where the funnel + 'more causes' grid weren't tracked (the
causes-tab row already logged).
---
.../giveback/components/GivebackCauseCard.tsx | 11 ++++-
.../components/GivebackTabNav.spec.tsx | 2 +-
.../giveback/components/GivebackTabNav.tsx | 7 +--
.../hooks/useGivebackCauseSelection.spec.tsx | 48 +++++++++++++++----
.../hooks/useGivebackCauseSelection.ts | 27 +++++++++--
packages/webapp/pages/giveback/index.tsx | 2 +-
6 files changed, 78 insertions(+), 19 deletions(-)
diff --git a/packages/shared/src/features/giveback/components/GivebackCauseCard.tsx b/packages/shared/src/features/giveback/components/GivebackCauseCard.tsx
index d26496f9ffe..5e08bd1e627 100644
--- a/packages/shared/src/features/giveback/components/GivebackCauseCard.tsx
+++ b/packages/shared/src/features/giveback/components/GivebackCauseCard.tsx
@@ -12,6 +12,8 @@ import { OpenLinkIcon, PlusIcon, VIcon } from '../../../components/icons';
import { IconSize } from '../../../components/Icon';
import { CauseEmblem } from './CauseEmblem';
import { anchorDefaultRel } from '../../../lib/strings';
+import { useLogContext } from '../../../contexts/LogContext';
+import { LogEvent } from '../../../lib/log';
import type { ContributionCause } from '../types';
interface GivebackCauseCardProps {
@@ -33,6 +35,7 @@ export const GivebackCauseCard = ({
onToggle,
buttonToggle = false,
}: GivebackCauseCardProps): ReactElement => {
+ const { logEvent } = useLogContext();
const toggle = () => onToggle(cause.id);
const cardClickable = !buttonToggle;
@@ -131,7 +134,13 @@ export const GivebackCauseCard = ({
href={cause.url}
target="_blank"
rel={anchorDefaultRel}
- onClick={(event) => event.stopPropagation()}
+ onClick={(event) => {
+ event.stopPropagation();
+ logEvent({
+ event_name: LogEvent.ClickGivebackCause,
+ target_id: cause.id,
+ });
+ }}
className="group/learn relative z-1 inline-flex w-fit items-center gap-1 font-bold text-text-link underline-offset-2 typo-footnote hover:underline focus-visible:underline"
>
Learn more
diff --git a/packages/shared/src/features/giveback/components/GivebackTabNav.spec.tsx b/packages/shared/src/features/giveback/components/GivebackTabNav.spec.tsx
index 3d622905dc1..688b8f439b8 100644
--- a/packages/shared/src/features/giveback/components/GivebackTabNav.spec.tsx
+++ b/packages/shared/src/features/giveback/components/GivebackTabNav.spec.tsx
@@ -6,7 +6,7 @@ it('renders the tabs from the shared tab list', () => {
render();
expect(screen.getByText('Take action')).toBeInTheDocument();
- expect(screen.getByText('Impact')).toBeInTheDocument();
+ expect(screen.getByText('Your impact')).toBeInTheDocument();
expect(screen.getByText('Causes')).toBeInTheDocument();
expect(screen.getByText('FAQ')).toBeInTheDocument();
});
diff --git a/packages/shared/src/features/giveback/components/GivebackTabNav.tsx b/packages/shared/src/features/giveback/components/GivebackTabNav.tsx
index 01890996760..4287dd8752b 100644
--- a/packages/shared/src/features/giveback/components/GivebackTabNav.tsx
+++ b/packages/shared/src/features/giveback/components/GivebackTabNav.tsx
@@ -11,7 +11,7 @@ interface GivebackTab {
export const givebackTabs: GivebackTab[] = [
{ id: 'actions', label: 'Take action' },
- { id: 'impact', label: 'Impact' },
+ { id: 'impact', label: 'Your impact' },
{ id: 'causes', label: 'Causes' },
{ id: 'faq', label: 'FAQ' },
];
@@ -39,8 +39,9 @@ export const GivebackTabNav = ({
className="via-accent-cabbage-default/40 pointer-events-none absolute inset-x-0 bottom-0 h-px bg-gradient-to-r from-transparent to-transparent"
/>
{/* Scrollable on narrow screens so every tab stays reachable instead of
- overflowing or wrapping. */}
-
+ overflowing or wrapping. Gutter matches the page column so the tabs
+ line up with the content below on every breakpoint. */}
+
({ label: tab.label }))}
active={activeLabel}
diff --git a/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.spec.tsx b/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.spec.tsx
index cb5cec86e7a..114a02223ed 100644
--- a/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.spec.tsx
+++ b/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.spec.tsx
@@ -1,4 +1,7 @@
+import type { ReactNode } from 'react';
+import React from 'react';
import { act, renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useGivebackCauseSelection } from './useGivebackCauseSelection';
import { useContributionCausePicker } from './useContributionCausePicker';
import { useUpdateContributionCausePreferences } from './useUpdateContributionCausePreferences';
@@ -23,6 +26,13 @@ const mockUseToast = useToastNotification as jest.MockedFunction<
const saveCausePreferences = jest.fn().mockResolvedValue(undefined);
const displayToast = jest.fn();
+// The hook reads the query client (to force-clear the save toast), so renders
+// need a provider.
+const queryClient = new QueryClient();
+const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+);
+
beforeEach(() => {
jest.clearAllMocks();
mockUseUpdate.mockReturnValue({ saveCausePreferences, isPending: false });
@@ -43,7 +53,9 @@ it('seeds the selection from saved preferences', () => {
isPending: false,
});
- const { result } = renderHook(() => useGivebackCauseSelection(true));
+ const { result } = renderHook(() => useGivebackCauseSelection(true), {
+ wrapper,
+ });
expect(result.current.selectedCount).toBe(2);
expect(result.current.selectedIds.has('c1')).toBe(true);
@@ -51,7 +63,9 @@ it('seeds the selection from saved preferences', () => {
});
it('reports no saved causes when the visitor has none', () => {
- const { result } = renderHook(() => useGivebackCauseSelection(true));
+ const { result } = renderHook(() => useGivebackCauseSelection(true), {
+ wrapper,
+ });
expect(result.current.hasSavedCauses).toBe(false);
});
@@ -66,7 +80,7 @@ it('does not seed an empty selection while disabled, then seeds once enabled', (
const { result, rerender } = renderHook(
({ enabled }) => useGivebackCauseSelection(enabled),
- { initialProps: { enabled: false } },
+ { initialProps: { enabled: false }, wrapper },
);
expect(result.current.selectedCount).toBe(0);
@@ -84,7 +98,9 @@ it('does not seed an empty selection while disabled, then seeds once enabled', (
});
it('toggles a cause on and off', () => {
- const { result } = renderHook(() => useGivebackCauseSelection(true));
+ const { result } = renderHook(() => useGivebackCauseSelection(true), {
+ wrapper,
+ });
act(() => result.current.toggleCause('c1'));
expect(result.current.selectedIds.has('c1')).toBe(true);
@@ -94,7 +110,9 @@ it('toggles a cause on and off', () => {
});
it('saves the current selection and toasts', async () => {
- const { result } = renderHook(() => useGivebackCauseSelection(true));
+ const { result } = renderHook(() => useGivebackCauseSelection(true), {
+ wrapper,
+ });
act(() => result.current.toggleCause('c1'));
let saved: boolean | undefined;
@@ -105,13 +123,17 @@ it('saves the current selection and toasts', async () => {
await waitFor(() =>
expect(saveCausePreferences).toHaveBeenCalledWith(['c1']),
);
- expect(displayToast).toHaveBeenCalledWith('Your causes are saved');
+ expect(displayToast).toHaveBeenCalledWith('Your causes are saved', {
+ timer: 3000,
+ });
expect(saved).toBe(true);
});
it('toasts a generic error and reports failure when saving fails', async () => {
saveCausePreferences.mockRejectedValueOnce(new Error('network'));
- const { result } = renderHook(() => useGivebackCauseSelection(true));
+ const { result } = renderHook(() => useGivebackCauseSelection(true), {
+ wrapper,
+ });
let saved: boolean | undefined;
await act(async () => {
@@ -123,7 +145,9 @@ it('toasts a generic error and reports failure when saving fails', async () => {
});
it('toggleAndSave persists the new selection immediately with no toast', async () => {
- const { result } = renderHook(() => useGivebackCauseSelection(true));
+ const { result } = renderHook(() => useGivebackCauseSelection(true), {
+ wrapper,
+ });
act(() => result.current.toggleAndSave('c1'));
@@ -136,7 +160,9 @@ it('toggleAndSave persists the new selection immediately with no toast', async (
});
it('toggleAndSave chains back-to-back toggles from the freshest set', async () => {
- const { result } = renderHook(() => useGivebackCauseSelection(true));
+ const { result } = renderHook(() => useGivebackCauseSelection(true), {
+ wrapper,
+ });
// Two toggles in the same tick must not persist from a stale snapshot.
act(() => {
@@ -153,7 +179,9 @@ it('toggleAndSave chains back-to-back toggles from the freshest set', async () =
it('toggleAndSave rolls back the optimistic change when the save fails', async () => {
saveCausePreferences.mockRejectedValueOnce(new Error('network'));
- const { result } = renderHook(() => useGivebackCauseSelection(true));
+ const { result } = renderHook(() => useGivebackCauseSelection(true), {
+ wrapper,
+ });
await act(async () => {
result.current.toggleAndSave('c1');
diff --git a/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts b/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts
index 8a9f76dafe0..0fd37dbea7a 100644
--- a/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts
+++ b/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts
@@ -1,5 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react';
-import { useToastNotification } from '../../../hooks/useToastNotification';
+import { useQueryClient } from '@tanstack/react-query';
+import {
+ TOAST_NOTIF_KEY,
+ useToastNotification,
+} from '../../../hooks/useToastNotification';
import { labels } from '../../../lib/labels';
import { useContributionCausePicker } from './useContributionCausePicker';
import { useUpdateContributionCausePreferences } from './useUpdateContributionCausePreferences';
@@ -27,6 +31,7 @@ export const useGivebackCauseSelection = (
enabled: boolean,
): UseGivebackCauseSelection => {
const { displayToast } = useToastNotification();
+ const queryClient = useQueryClient();
const { causes, selectedCauseIds, isPending } =
useContributionCausePicker(enabled);
const { saveCausePreferences, isPending: isSaving } =
@@ -38,6 +43,11 @@ export const useGivebackCauseSelection = (
const selectedIdsRef = useRef(selectedIds);
selectedIdsRef.current = selectedIds;
+ // Tracks the pending toast force-clear timer so a quick unmount (or a second
+ // save) doesn't leave a stray timeout firing later.
+ const clearToastTimer = useRef>();
+ useEffect(() => () => clearTimeout(clearToastTimer.current), []);
+
// Seed from saved preferences once they resolve, so editing starts from the
// visitor's current selection without stomping later in-picker toggles. Wait
// for `enabled`: while the query is gated off it reports not-loading with an
@@ -92,13 +102,24 @@ export const useGivebackCauseSelection = (
const save = useCallback(async () => {
try {
await saveCausePreferences([...selectedIds]);
- displayToast('Your causes are saved');
+ displayToast('Your causes are saved', { timer: 3000 });
+ // Force-clear after the timer: the global toast only auto-dismisses when
+ // the user's "auto-dismiss notifications" setting is on, and a save
+ // confirmation should never sit there waiting to be closed manually.
+ // Guard on identity so a newer toast is never clobbered.
+ const shown = queryClient.getQueryData(TOAST_NOTIF_KEY);
+ clearTimeout(clearToastTimer.current);
+ clearToastTimer.current = setTimeout(() => {
+ if (queryClient.getQueryData(TOAST_NOTIF_KEY) === shown) {
+ queryClient.setQueryData(TOAST_NOTIF_KEY, null);
+ }
+ }, 3000);
return true;
} catch {
displayToast(labels.error.generic);
return false;
}
- }, [saveCausePreferences, selectedIds, displayToast]);
+ }, [saveCausePreferences, selectedIds, displayToast, queryClient]);
return {
causes,
diff --git a/packages/webapp/pages/giveback/index.tsx b/packages/webapp/pages/giveback/index.tsx
index 0b46171a0af..58d014bf678 100644
--- a/packages/webapp/pages/giveback/index.tsx
+++ b/packages/webapp/pages/giveback/index.tsx
@@ -21,7 +21,7 @@ const seo: NextSeoProps = {
},
...defaultSeo,
description:
- 'Help daily.dev grow and we will fund good causes. Complete community actions to help unlock donations toward a shared goal.',
+ 'daily.dev would rather fund real-world causes than pay for ads. Take small actions to help us grow, and we turn that budget into donations to the causes you choose, at no cost to you.',
nofollow: true,
noindex: true,
};
From a900aa3b35bd438f6ac38d7da115b475f2b81113 Mon Sep 17 00:00:00 2001
From: Ido Shamun <1993245+idoshamun@users.noreply.github.com>
Date: Mon, 29 Jun 2026 13:29:14 +0300
Subject: [PATCH 2/2] refactor(giveback): drop the save-toast force-clear hack
autoDismissNotifications defaults to true, so the {timer: 3000} option
already auto-dismisses the save toast for the default majority. The manual
queryClient force-clear only did anything for users who explicitly turned
auto-dismiss off, where it overrode their choice. Removed it (and the ref,
cleanup effect, and queryClient/TOAST_NOTIF_KEY usage); the toast now
respects the user setting like every other toast.
---
.../hooks/useGivebackCauseSelection.spec.tsx | 44 ++++---------------
.../hooks/useGivebackCauseSelection.ts | 27 ++----------
2 files changed, 13 insertions(+), 58 deletions(-)
diff --git a/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.spec.tsx b/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.spec.tsx
index 114a02223ed..070096bb349 100644
--- a/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.spec.tsx
+++ b/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.spec.tsx
@@ -1,7 +1,4 @@
-import type { ReactNode } from 'react';
-import React from 'react';
import { act, renderHook, waitFor } from '@testing-library/react';
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useGivebackCauseSelection } from './useGivebackCauseSelection';
import { useContributionCausePicker } from './useContributionCausePicker';
import { useUpdateContributionCausePreferences } from './useUpdateContributionCausePreferences';
@@ -26,13 +23,6 @@ const mockUseToast = useToastNotification as jest.MockedFunction<
const saveCausePreferences = jest.fn().mockResolvedValue(undefined);
const displayToast = jest.fn();
-// The hook reads the query client (to force-clear the save toast), so renders
-// need a provider.
-const queryClient = new QueryClient();
-const wrapper = ({ children }: { children: ReactNode }) => (
- {children}
-);
-
beforeEach(() => {
jest.clearAllMocks();
mockUseUpdate.mockReturnValue({ saveCausePreferences, isPending: false });
@@ -53,9 +43,7 @@ it('seeds the selection from saved preferences', () => {
isPending: false,
});
- const { result } = renderHook(() => useGivebackCauseSelection(true), {
- wrapper,
- });
+ const { result } = renderHook(() => useGivebackCauseSelection(true));
expect(result.current.selectedCount).toBe(2);
expect(result.current.selectedIds.has('c1')).toBe(true);
@@ -63,9 +51,7 @@ it('seeds the selection from saved preferences', () => {
});
it('reports no saved causes when the visitor has none', () => {
- const { result } = renderHook(() => useGivebackCauseSelection(true), {
- wrapper,
- });
+ const { result } = renderHook(() => useGivebackCauseSelection(true));
expect(result.current.hasSavedCauses).toBe(false);
});
@@ -80,7 +66,7 @@ it('does not seed an empty selection while disabled, then seeds once enabled', (
const { result, rerender } = renderHook(
({ enabled }) => useGivebackCauseSelection(enabled),
- { initialProps: { enabled: false }, wrapper },
+ { initialProps: { enabled: false } },
);
expect(result.current.selectedCount).toBe(0);
@@ -98,9 +84,7 @@ it('does not seed an empty selection while disabled, then seeds once enabled', (
});
it('toggles a cause on and off', () => {
- const { result } = renderHook(() => useGivebackCauseSelection(true), {
- wrapper,
- });
+ const { result } = renderHook(() => useGivebackCauseSelection(true));
act(() => result.current.toggleCause('c1'));
expect(result.current.selectedIds.has('c1')).toBe(true);
@@ -110,9 +94,7 @@ it('toggles a cause on and off', () => {
});
it('saves the current selection and toasts', async () => {
- const { result } = renderHook(() => useGivebackCauseSelection(true), {
- wrapper,
- });
+ const { result } = renderHook(() => useGivebackCauseSelection(true));
act(() => result.current.toggleCause('c1'));
let saved: boolean | undefined;
@@ -131,9 +113,7 @@ it('saves the current selection and toasts', async () => {
it('toasts a generic error and reports failure when saving fails', async () => {
saveCausePreferences.mockRejectedValueOnce(new Error('network'));
- const { result } = renderHook(() => useGivebackCauseSelection(true), {
- wrapper,
- });
+ const { result } = renderHook(() => useGivebackCauseSelection(true));
let saved: boolean | undefined;
await act(async () => {
@@ -145,9 +125,7 @@ it('toasts a generic error and reports failure when saving fails', async () => {
});
it('toggleAndSave persists the new selection immediately with no toast', async () => {
- const { result } = renderHook(() => useGivebackCauseSelection(true), {
- wrapper,
- });
+ const { result } = renderHook(() => useGivebackCauseSelection(true));
act(() => result.current.toggleAndSave('c1'));
@@ -160,9 +138,7 @@ it('toggleAndSave persists the new selection immediately with no toast', async (
});
it('toggleAndSave chains back-to-back toggles from the freshest set', async () => {
- const { result } = renderHook(() => useGivebackCauseSelection(true), {
- wrapper,
- });
+ const { result } = renderHook(() => useGivebackCauseSelection(true));
// Two toggles in the same tick must not persist from a stale snapshot.
act(() => {
@@ -179,9 +155,7 @@ it('toggleAndSave chains back-to-back toggles from the freshest set', async () =
it('toggleAndSave rolls back the optimistic change when the save fails', async () => {
saveCausePreferences.mockRejectedValueOnce(new Error('network'));
- const { result } = renderHook(() => useGivebackCauseSelection(true), {
- wrapper,
- });
+ const { result } = renderHook(() => useGivebackCauseSelection(true));
await act(async () => {
result.current.toggleAndSave('c1');
diff --git a/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts b/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts
index 0fd37dbea7a..70b9fa8c5ee 100644
--- a/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts
+++ b/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts
@@ -1,9 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
-import { useQueryClient } from '@tanstack/react-query';
-import {
- TOAST_NOTIF_KEY,
- useToastNotification,
-} from '../../../hooks/useToastNotification';
+import { useToastNotification } from '../../../hooks/useToastNotification';
import { labels } from '../../../lib/labels';
import { useContributionCausePicker } from './useContributionCausePicker';
import { useUpdateContributionCausePreferences } from './useUpdateContributionCausePreferences';
@@ -31,7 +27,6 @@ export const useGivebackCauseSelection = (
enabled: boolean,
): UseGivebackCauseSelection => {
const { displayToast } = useToastNotification();
- const queryClient = useQueryClient();
const { causes, selectedCauseIds, isPending } =
useContributionCausePicker(enabled);
const { saveCausePreferences, isPending: isSaving } =
@@ -43,11 +38,6 @@ export const useGivebackCauseSelection = (
const selectedIdsRef = useRef(selectedIds);
selectedIdsRef.current = selectedIds;
- // Tracks the pending toast force-clear timer so a quick unmount (or a second
- // save) doesn't leave a stray timeout firing later.
- const clearToastTimer = useRef>();
- useEffect(() => () => clearTimeout(clearToastTimer.current), []);
-
// Seed from saved preferences once they resolve, so editing starts from the
// visitor's current selection without stomping later in-picker toggles. Wait
// for `enabled`: while the query is gated off it reports not-loading with an
@@ -102,24 +92,15 @@ export const useGivebackCauseSelection = (
const save = useCallback(async () => {
try {
await saveCausePreferences([...selectedIds]);
+ // Auto-dismisses for users who keep notifications on auto-dismiss (the
+ // default); others have explicitly chosen to dismiss toasts themselves.
displayToast('Your causes are saved', { timer: 3000 });
- // Force-clear after the timer: the global toast only auto-dismisses when
- // the user's "auto-dismiss notifications" setting is on, and a save
- // confirmation should never sit there waiting to be closed manually.
- // Guard on identity so a newer toast is never clobbered.
- const shown = queryClient.getQueryData(TOAST_NOTIF_KEY);
- clearTimeout(clearToastTimer.current);
- clearToastTimer.current = setTimeout(() => {
- if (queryClient.getQueryData(TOAST_NOTIF_KEY) === shown) {
- queryClient.setQueryData(TOAST_NOTIF_KEY, null);
- }
- }, 3000);
return true;
} catch {
displayToast(labels.error.generic);
return false;
}
- }, [saveCausePreferences, selectedIds, displayToast, queryClient]);
+ }, [saveCausePreferences, selectedIds, displayToast]);
return {
causes,