From ae219412a9f6a8c5df34e29b72b682c37074bcc0 Mon Sep 17 00:00:00 2001
From: Ido Shamun <1993245+idoshamun@users.noreply.github.com>
Date: Sun, 28 Jun 2026 14:55:07 +0300
Subject: [PATCH 1/4] feat(giveback): add Causes management tab
New "Causes" tab to manage your backed causes: your picks sit up top as
compact rows, with a category-filterable discovery grid below. Add/remove
auto-saves via a new toggleAndSave on useGivebackCauseSelection, which
reads the freshest selection through a ref (no stale closure on rapid
toggles) and rolls back the optimistic change if the save fails.
- GivebackCauseCard gains a buttonToggle variant (corner + toggles, card
isn't a big tap target) for the discovery grid
- Shared GivebackTabHeading primitive for consistent tab headers
- GivebackFilterChip restyled as a bordered tag pill
- Tab nav now scrolls horizontally on narrow screens
The old 'why'/Campaign tab stays for now; it is removed in the final
restructure PR.
---
.../giveback/components/GivebackCauseCard.tsx | 174 ++++++-----
.../components/GivebackCausesPanel.tsx | 280 ++++++++++++++++++
.../components/GivebackFilterChip.tsx | 11 +-
.../giveback/components/GivebackPage.tsx | 4 +
.../components/GivebackTabHeading.tsx | 43 +++
.../giveback/components/GivebackTabNav.tsx | 8 +-
.../hooks/useGivebackCauseSelection.spec.tsx | 41 +++
.../hooks/useGivebackCauseSelection.ts | 33 +++
8 files changed, 521 insertions(+), 73 deletions(-)
create mode 100644 packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx
create mode 100644 packages/shared/src/features/giveback/components/GivebackTabHeading.tsx
diff --git a/packages/shared/src/features/giveback/components/GivebackCauseCard.tsx b/packages/shared/src/features/giveback/components/GivebackCauseCard.tsx
index 73b265badc7..d26496f9ffe 100644
--- a/packages/shared/src/features/giveback/components/GivebackCauseCard.tsx
+++ b/packages/shared/src/features/giveback/components/GivebackCauseCard.tsx
@@ -8,9 +8,10 @@ import {
TypographyTag,
TypographyType,
} from '../../../components/typography/Typography';
-import { OpenLinkIcon, VIcon } from '../../../components/icons';
+import { OpenLinkIcon, PlusIcon, VIcon } from '../../../components/icons';
import { IconSize } from '../../../components/Icon';
import { CauseEmblem } from './CauseEmblem';
+import { anchorDefaultRel } from '../../../lib/strings';
import type { ContributionCause } from '../types';
interface GivebackCauseCardProps {
@@ -19,6 +20,10 @@ interface GivebackCauseCardProps {
index: number;
selected: boolean;
onToggle: (id: string) => void;
+ // When true, only the corner "+" button toggles selection (the card itself
+ // isn't a big toggle target). Used on the manage tab's "more causes" grid; the
+ // onboarding funnel leaves it false so the whole card is tappable.
+ buttonToggle?: boolean;
}
export const GivebackCauseCard = ({
@@ -26,81 +31,116 @@ export const GivebackCauseCard = ({
index,
selected,
onToggle,
-}: GivebackCauseCardProps): ReactElement => (
-
-);
+ {cause.url && (
+ event.stopPropagation()}
+ 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/GivebackCausesPanel.tsx b/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx
new file mode 100644
index 00000000000..ec4d8b40288
--- /dev/null
+++ b/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx
@@ -0,0 +1,280 @@
+import type { ReactElement } from 'react';
+import React, { useMemo, useState } from 'react';
+import classNames from 'classnames';
+import { FlexCol, FlexRow } from '../../../components/utilities';
+import {
+ Typography,
+ TypographyColor,
+ TypographyTag,
+ TypographyType,
+} from '../../../components/typography/Typography';
+import {
+ MiniCloseIcon,
+ OpenLinkIcon,
+ PlusIcon,
+} from '../../../components/icons';
+import { IconSize } from '../../../components/Icon';
+import { anchorDefaultRel } from '../../../lib/strings';
+import { useLogContext } from '../../../contexts/LogContext';
+import { LogEvent } from '../../../lib/log';
+import { useGivebackCauseSelection } from '../hooks/useGivebackCauseSelection';
+import type { ContributionCause } from '../types';
+import { GivebackFilterChip } from './GivebackFilterChip';
+import { GivebackCauseCard } from './GivebackCauseCard';
+import { GivebackTabHeading } from './GivebackTabHeading';
+import { CauseEmblem } from './CauseEmblem';
+
+const ALL_FILTER = 'all';
+
+interface CauseWithIndex {
+ cause: ContributionCause;
+ index: number;
+}
+
+// On the management tab each cause is a compact row (not the tall onboarding
+// card): emblem, name, an optional "learn more", and a select toggle on the
+// right. The toggles line up in a single right-hand column so the whole list
+// scans cleanly and stays short - the onboarding funnel keeps the rich cards.
+const CauseRow = ({
+ cause,
+ index,
+ selected,
+ onToggle,
+ onLearnMore,
+}: {
+ cause: ContributionCause;
+ index: number;
+ selected: boolean;
+ onToggle: (id: string) => void;
+ onLearnMore: (id: string) => void;
+}): ReactElement => (
+
+
+
+
+ {cause.title}
+
+ {cause.category && (
+
+ {cause.category}
+
+ )}
+
+ {cause.url && (
+ onLearnMore(cause.id)}
+ className="flex size-8 shrink-0 items-center justify-center rounded-10 text-text-tertiary transition-colors hover:bg-surface-hover hover:text-text-primary"
+ >
+
+
+ )}
+ onToggle(cause.id)}
+ className={classNames(
+ 'flex size-8 shrink-0 items-center justify-center rounded-10 border transition-colors [&_svg]:size-4',
+ selected
+ ? 'border-border-subtlest-secondary text-text-tertiary hover:border-status-error hover:bg-status-error hover:text-white'
+ : 'border-border-subtlest-secondary text-text-tertiary hover:border-accent-cabbage-default hover:text-accent-cabbage-default',
+ )}
+ >
+ {selected ? : }
+
+
+);
+
+interface GivebackCausesPanelProps {
+ // Scrolls the tab strip to the top when the filter changes, so the narrowed
+ // "more causes" list always starts in view.
+ onFilter?: () => void;
+}
+
+// Manage-your-causes tab: the ones you back sit up top, everything else is right
+// below ready to add. Adding/removing auto-saves.
+export const GivebackCausesPanel = ({
+ onFilter,
+}: GivebackCausesPanelProps): ReactElement => {
+ const { logEvent } = useLogContext();
+ const { causes, isLoading, selectedIds, toggleAndSave, selectedCount } =
+ useGivebackCauseSelection(true);
+ const [activeFilter, setActiveFilter] = useState(ALL_FILTER);
+
+ const selectFilter = (filter: string) => {
+ setActiveFilter(filter);
+ onFilter?.();
+ };
+
+ const categories = useMemo(
+ () =>
+ Array.from(
+ new Set(
+ causes
+ .map((cause) => cause.category)
+ .filter((category): category is string => Boolean(category)),
+ ),
+ ),
+ [causes],
+ );
+
+ const matchesFilter = (cause: { category?: string | null }) =>
+ activeFilter === ALL_FILTER || cause.category === activeFilter;
+
+ const indexed = useMemo(
+ () => causes.map((cause, index) => ({ cause, index })),
+ [causes],
+ );
+ // Your causes always show every pick, regardless of the filter; the filter
+ // only narrows the "more causes to explore" list below.
+ const selectedCauses = indexed.filter(({ cause }) =>
+ selectedIds.has(cause.id),
+ );
+ const otherCauses = indexed.filter(
+ ({ cause }) => !selectedIds.has(cause.id) && matchesFilter(cause),
+ );
+ // Unfiltered: keeps the filter strip visible even when the active category has
+ // no unselected causes, so the user can always switch back.
+ const hasOtherCauses = indexed.some(
+ ({ cause }) => !selectedIds.has(cause.id),
+ );
+
+ const onLearnMore = (causeId: string) =>
+ logEvent({ event_name: LogEvent.ClickGivebackCause, target_id: causeId });
+
+ // Auto-saved on every toggle, so each add/remove is also logged here.
+ const onToggle = (causeId: string) => {
+ toggleAndSave(causeId);
+ logEvent({
+ event_name: LogEvent.SaveGivebackCauses,
+ extra: JSON.stringify({ cause_id: causeId, origin: 'causes_tab' }),
+ });
+ };
+
+ if (isLoading) {
+ return (
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+ ))}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ Your causes · {selectedCount}
+
+
+ {selectedCauses.length > 0 ? (
+
+ {selectedCauses.map(({ cause, index }) => (
+
+ ))}
+
+ ) : (
+
+
+ You haven't backed any causes yet
+
+
+ Pick one below and your next action starts funding it.
+
+
+ )}
+
+
+ {/* Filters sit just above the discovery grid they control (and below your
+ own causes), so the connection is obvious. */}
+ {hasOtherCauses && (
+
+
+ More causes to explore
+
+ {categories.length > 0 && (
+
+ selectFilter(ALL_FILTER)}
+ />
+ {categories.map((category) => (
+ selectFilter(category)}
+ />
+ ))}
+
+ )}
+ {otherCauses.length > 0 ? (
+ // Richer detail cards (like the funnel) so people have enough context
+ // to decide; only the "+" adds, so the card isn't a big toggle.
+
+ {otherCauses.map(({ cause, index }) => (
+
+ ))}
+
+ ) : (
+
+ No more causes in this category.
+
+ )}
+
+ )}
+
+ );
+};
diff --git a/packages/shared/src/features/giveback/components/GivebackFilterChip.tsx b/packages/shared/src/features/giveback/components/GivebackFilterChip.tsx
index 6d57ec35dc7..833d790496e 100644
--- a/packages/shared/src/features/giveback/components/GivebackFilterChip.tsx
+++ b/packages/shared/src/features/giveback/components/GivebackFilterChip.tsx
@@ -8,7 +8,10 @@ interface GivebackFilterChipProps {
onClick: () => void;
}
-// Pill used for the cause-picker category filters.
+// Tag-style pill for the category filters. Every chip carries a visible border
+// and surface so the row reads clearly as a set of filters/tags (not loose
+// text); the active one fills with the brand color so the current filter is
+// unmistakable.
export const GivebackFilterChip = ({
isSelected,
label,
@@ -18,10 +21,10 @@ export const GivebackFilterChip = ({
type="button"
aria-pressed={isSelected}
className={classNames(
- 'h-8 shrink-0 rounded-10 px-3 font-medium transition-colors typo-footnote',
+ 'h-8 shrink-0 rounded-10 border px-3.5 font-bold transition-colors typo-footnote',
isSelected
- ? 'bg-accent-cabbage-default text-white'
- : 'bg-transparent text-text-tertiary hover:bg-surface-float hover:text-text-primary',
+ ? 'border-accent-cabbage-default bg-accent-cabbage-default text-white'
+ : 'border-border-subtlest-tertiary bg-surface-float text-text-secondary hover:border-accent-cabbage-default hover:text-text-primary',
)}
onClick={onClick}
>
diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx
index 25fb260cf8d..184b342f1af 100644
--- a/packages/shared/src/features/giveback/components/GivebackPage.tsx
+++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx
@@ -17,6 +17,7 @@ import { GivebackTabNav, givebackTabs } from './GivebackTabNav';
import { GivebackActionCatalog } from './GivebackActionCatalog';
import { GivebackContributionSummary } from './GivebackContributionSummary';
import { GivebackImpactPanel } from './GivebackImpactPanel';
+import { GivebackCausesPanel } from './GivebackCausesPanel';
import { GivebackCampaignPanel } from './GivebackCampaignPanel';
import { GivebackFundingBar } from './GivebackFundingBar';
import type { GivebackTabId } from './GivebackTabNav';
@@ -178,6 +179,9 @@ export const GivebackPage = (): ReactElement => {
{activeTab === 'impact' && (
)}
+ {activeTab === 'causes' && (
+
+ )}
{activeTab === 'why' && }
diff --git a/packages/shared/src/features/giveback/components/GivebackTabHeading.tsx b/packages/shared/src/features/giveback/components/GivebackTabHeading.tsx
new file mode 100644
index 00000000000..2880af4b3a3
--- /dev/null
+++ b/packages/shared/src/features/giveback/components/GivebackTabHeading.tsx
@@ -0,0 +1,43 @@
+import type { ReactElement, ReactNode } from 'react';
+import React from 'react';
+import { FlexCol } from '../../../components/utilities';
+import {
+ Typography,
+ TypographyColor,
+ TypographyTag,
+ TypographyType,
+} from '../../../components/typography/Typography';
+
+interface GivebackTabHeadingProps {
+ title: ReactNode;
+ description?: ReactNode;
+}
+
+// One shared header for every onboarded tab (Take action / Your impact / Causes
+// / FAQ) so the title and subtitle always share the exact same size, color, gap
+// and width. Keeping this in one place stops the per-tab styling from drifting.
+export const GivebackTabHeading = ({
+ title,
+ description,
+}: GivebackTabHeadingProps): ReactElement => (
+
+
+ {title}
+
+ {description != null && (
+
+ {description}
+
+ )}
+
+);
diff --git a/packages/shared/src/features/giveback/components/GivebackTabNav.tsx b/packages/shared/src/features/giveback/components/GivebackTabNav.tsx
index 887adee29fd..ec8310cf188 100644
--- a/packages/shared/src/features/giveback/components/GivebackTabNav.tsx
+++ b/packages/shared/src/features/giveback/components/GivebackTabNav.tsx
@@ -2,7 +2,7 @@ import type { ReactElement } from 'react';
import React from 'react';
import TabList, { TabListVariant } from '../../../components/tabs/TabList';
-export type GivebackTabId = 'actions' | 'impact' | 'why';
+export type GivebackTabId = 'actions' | 'impact' | 'causes' | 'why';
interface GivebackTab {
id: GivebackTabId;
@@ -12,6 +12,7 @@ interface GivebackTab {
export const givebackTabs: GivebackTab[] = [
{ id: 'actions', label: 'Take action' },
{ id: 'impact', label: 'Impact' },
+ { id: 'causes', label: 'Causes' },
{ id: 'why', label: 'Campaign' },
];
@@ -37,11 +38,14 @@ export const GivebackTabNav = ({
aria-hidden
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. */}
+
({ label: tab.label }))}
active={activeLabel}
variant={TabListVariant.Bordered}
+ autoScrollActive
onClick={(label) => {
const tab = givebackTabs.find((item) => item.label === label);
if (tab) {
diff --git a/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.spec.tsx b/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.spec.tsx
index cff63557362..cb5cec86e7a 100644
--- a/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.spec.tsx
+++ b/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.spec.tsx
@@ -121,3 +121,44 @@ it('toasts a generic error and reports failure when saving fails', async () => {
expect(displayToast).toHaveBeenCalledWith(labels.error.generic);
expect(saved).toBe(false);
});
+
+it('toggleAndSave persists the new selection immediately with no toast', async () => {
+ const { result } = renderHook(() => useGivebackCauseSelection(true));
+
+ act(() => result.current.toggleAndSave('c1'));
+
+ expect(result.current.selectedIds.has('c1')).toBe(true);
+ await waitFor(() =>
+ expect(saveCausePreferences).toHaveBeenCalledWith(['c1']),
+ );
+ // The cause moving between sections is the feedback; no success toast.
+ expect(displayToast).not.toHaveBeenCalled();
+});
+
+it('toggleAndSave chains back-to-back toggles from the freshest set', async () => {
+ const { result } = renderHook(() => useGivebackCauseSelection(true));
+
+ // Two toggles in the same tick must not persist from a stale snapshot.
+ act(() => {
+ result.current.toggleAndSave('c1');
+ result.current.toggleAndSave('c2');
+ });
+
+ expect(result.current.selectedIds.has('c1')).toBe(true);
+ expect(result.current.selectedIds.has('c2')).toBe(true);
+ await waitFor(() =>
+ expect(saveCausePreferences).toHaveBeenLastCalledWith(['c1', 'c2']),
+ );
+});
+
+it('toggleAndSave rolls back the optimistic change when the save fails', async () => {
+ saveCausePreferences.mockRejectedValueOnce(new Error('network'));
+ const { result } = renderHook(() => useGivebackCauseSelection(true));
+
+ await act(async () => {
+ result.current.toggleAndSave('c1');
+ });
+
+ await waitFor(() => expect(result.current.selectedIds.has('c1')).toBe(false));
+ expect(displayToast).toHaveBeenCalledWith(labels.error.generic);
+});
diff --git a/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts b/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts
index 3c9e779548c..8a9f76dafe0 100644
--- a/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts
+++ b/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts
@@ -10,6 +10,9 @@ interface UseGivebackCauseSelection {
isLoading: boolean;
selectedIds: Set;
toggleCause: (id: string) => void;
+ // Toggle and persist immediately (no working-set/Save step). Used by the
+ // manage tab; the funnel uses toggleCause + an explicit save instead.
+ toggleAndSave: (id: string) => void;
selectedCount: number;
// Whether the visitor has confirmed causes before (drives the onboarded view).
hasSavedCauses: boolean;
@@ -30,6 +33,10 @@ export const useGivebackCauseSelection = (
useUpdateContributionCausePreferences();
const [selectedIds, setSelectedIds] = useState>(new Set());
+ // Mirror of the committed selection so toggleAndSave reads the freshest set
+ // (and chains correctly across same-tick toggles) without a stale closure.
+ const selectedIdsRef = useRef(selectedIds);
+ selectedIdsRef.current = selectedIds;
// Seed from saved preferences once they resolve, so editing starts from the
// visitor's current selection without stomping later in-picker toggles. Wait
@@ -57,6 +64,31 @@ export const useGivebackCauseSelection = (
});
}, []);
+ // Persist on each toggle so the manage tab needs no "Save" step. The cause
+ // visibly moving between sections is the feedback, so no toast on success.
+ // Derive the next set from the committed state (not the closed-over value) so
+ // back-to-back toggles can't persist from a stale snapshot, and roll the
+ // optimistic change back if the save fails.
+ const toggleAndSave = useCallback(
+ (id: string) => {
+ const previous = selectedIdsRef.current;
+ const next = new Set(previous);
+ if (next.has(id)) {
+ next.delete(id);
+ } else {
+ next.add(id);
+ }
+ selectedIdsRef.current = next;
+ setSelectedIds(next);
+ saveCausePreferences([...next]).catch(() => {
+ displayToast(labels.error.generic);
+ selectedIdsRef.current = previous;
+ setSelectedIds(previous);
+ });
+ },
+ [saveCausePreferences, displayToast],
+ );
+
const save = useCallback(async () => {
try {
await saveCausePreferences([...selectedIds]);
@@ -73,6 +105,7 @@ export const useGivebackCauseSelection = (
isLoading: isPending,
selectedIds,
toggleCause,
+ toggleAndSave,
selectedCount: selectedIds.size,
hasSavedCauses: selectedCauseIds.length > 0,
save,
From e1c29862a3c47c96a065844db99a92a4320caa42 Mon Sep 17 00:00:00 2001
From: Ido Shamun <1993245+idoshamun@users.noreply.github.com>
Date: Sun, 28 Jun 2026 15:11:49 +0300
Subject: [PATCH 2/4] feat(giveback): remove obsolete Campaign tab
The redesign drops the 'why'/Campaign tab entirely (its content is split
between the funnel and FAQ), so remove it now instead of keeping a
transitional shim. Deletes the tab plus the cluster that existed only to
serve it: GivebackCampaignPanel, GivebackSelectedCauses,
GivebackEditCausesModal, GivebackBudgetStory.
Tabs are now Take action / Impact / Causes. Kept GivebackFaq, Headline,
Mascot and Section: those are wired in by the upcoming FAQ and hero PRs.
---
.../components/GivebackBudgetStory.tsx | 52 -------
.../components/GivebackCampaignPanel.spec.tsx | 119 ---------------
.../components/GivebackCampaignPanel.tsx | 22 ---
.../components/GivebackEditCausesModal.tsx | 130 -----------------
.../giveback/components/GivebackPage.tsx | 2 -
.../components/GivebackSelectedCauses.tsx | 137 ------------------
.../components/GivebackTabNav.spec.tsx | 6 +-
.../giveback/components/GivebackTabNav.tsx | 3 +-
8 files changed, 4 insertions(+), 467 deletions(-)
delete mode 100644 packages/shared/src/features/giveback/components/GivebackBudgetStory.tsx
delete mode 100644 packages/shared/src/features/giveback/components/GivebackCampaignPanel.spec.tsx
delete mode 100644 packages/shared/src/features/giveback/components/GivebackCampaignPanel.tsx
delete mode 100644 packages/shared/src/features/giveback/components/GivebackEditCausesModal.tsx
delete mode 100644 packages/shared/src/features/giveback/components/GivebackSelectedCauses.tsx
diff --git a/packages/shared/src/features/giveback/components/GivebackBudgetStory.tsx b/packages/shared/src/features/giveback/components/GivebackBudgetStory.tsx
deleted file mode 100644
index 5ff51aa990b..00000000000
--- a/packages/shared/src/features/giveback/components/GivebackBudgetStory.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import type { ReactElement } from 'react';
-import React from 'react';
-import { FlexCol, FlexRow } from '../../../components/utilities';
-import {
- Typography,
- TypographyColor,
- TypographyTag,
- TypographyType,
-} from '../../../components/typography/Typography';
-import { useContributionStatus } from '../hooks/useContributionStatus';
-import { formatDonationAmount } from '../utils';
-import { GivebackSection } from './GivebackSection';
-import { GivebackHeadline } from './GivebackHeadline';
-import { GivebackMascot } from './GivebackMascot';
-
-interface GivebackBudgetStoryProps {
- headline: { title: string; highlight: string };
-}
-
-// "Why we do it" — kept short and emotional. The headline + reason stack in a
-// left column with the charm beside both as the "genie" who grants the
-// community's wishes, so the row stays tight with no empty space top/bottom.
-export const GivebackBudgetStory = ({
- headline,
-}: GivebackBudgetStoryProps): ReactElement => {
- const { status } = useContributionStatus();
- const goal = status?.currentCycleTargetPoints ?? 0;
-
- return (
-
-
-
-
-
- {goal > 0
- ? `${formatDonationAmount(goal)} goes`
- : 'Every dollar goes'}{' '}
- straight to the causes you pick: scholarships, open source, and
- access to tech. We could have spent it on ads. We would rather let
- the community decide what its work is worth.
-
-
-
-
-
- );
-};
diff --git a/packages/shared/src/features/giveback/components/GivebackCampaignPanel.spec.tsx b/packages/shared/src/features/giveback/components/GivebackCampaignPanel.spec.tsx
deleted file mode 100644
index 744584f564b..00000000000
--- a/packages/shared/src/features/giveback/components/GivebackCampaignPanel.spec.tsx
+++ /dev/null
@@ -1,119 +0,0 @@
-import React from 'react';
-import { fireEvent, render, screen } from '@testing-library/react';
-import { GivebackCampaignPanel } from './GivebackCampaignPanel';
-import { useContributionStatus } from '../hooks/useContributionStatus';
-import { useContributionCausePicker } from '../hooks/useContributionCausePicker';
-import { useLogContext } from '../../../contexts/LogContext';
-import { LogEvent } from '../../../lib/log';
-import type { ContributionCause } from '../types';
-
-jest.mock('../hooks/useContributionStatus');
-jest.mock('../hooks/useContributionCausePicker');
-jest.mock('../../../contexts/LogContext');
-jest.mock('./GivebackEditCausesModal', () => ({
- GivebackEditCausesModal: (): JSX.Element => (
- Edit your causes
- ),
-}));
-
-const mockStatus = useContributionStatus as jest.MockedFunction<
- typeof useContributionStatus
->;
-const mockPicker = useContributionCausePicker as jest.MockedFunction<
- typeof useContributionCausePicker
->;
-const mockLog = useLogContext as jest.MockedFunction;
-const logEvent = jest.fn();
-
-const cause = (id: string, title: string): ContributionCause => ({
- id,
- title,
- url: 'https://example.com',
- description: null,
- category: 'Open source',
- logoUrl: null,
-});
-
-beforeEach(() => {
- jest.clearAllMocks();
- mockLog.mockReturnValue({ logEvent } as unknown as ReturnType<
- typeof useLogContext
- >);
- mockStatus.mockReturnValue({
- status: {
- enabled: true,
- eligible: true,
- currentCyclePoints: 0,
- currentCycleTargetPoints: 50000,
- lifetimePoints: 0,
- lifetimeAmountCents: 0,
- contributorsCount: 0,
- userPoints: 0,
- },
- isPending: false,
- });
- mockPicker.mockReturnValue({
- causes: [cause('c1', 'Open Source Fund'), cause('c2', 'Code Scholarships')],
- selectedCauseIds: ['c1'],
- isPending: false,
- });
-});
-
-it('renders the campaign headline and reason', () => {
- render( );
-
- expect(screen.getByText('Big tech buys ads.')).toBeInTheDocument();
- expect(screen.getByText('We fund developers.')).toBeInTheDocument();
- expect(screen.getByText(/\$50,000 goes/)).toBeInTheDocument();
-});
-
-it('lists only the picked causes', () => {
- render( );
-
- expect(screen.getByText('Open Source Fund')).toBeInTheDocument();
- expect(screen.queryByText('Code Scholarships')).not.toBeInTheDocument();
-});
-
-it('opens the edit modal and logs it', () => {
- render( );
-
- fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
-
- expect(screen.getByRole('dialog')).toBeInTheDocument();
- expect(screen.getByText('Edit your causes')).toBeInTheDocument();
- expect(logEvent).toHaveBeenCalledWith({
- event_name: LogEvent.ClickGivebackEditCauses,
- extra: JSON.stringify({ has_causes: true }),
- });
-});
-
-it('expands an FAQ answer on click', () => {
- render( );
-
- expect(screen.getByText('Frequently asked questions')).toBeInTheDocument();
- const question = screen.getByRole('button', {
- name: 'Who chooses the causes?',
- });
- expect(question).toHaveAttribute('aria-expanded', 'false');
-
- fireEvent.click(question);
-
- expect(question).toHaveAttribute('aria-expanded', 'true');
- expect(logEvent).toHaveBeenCalledWith({
- event_name: LogEvent.ClickGivebackFaq,
- target_id: 'causes',
- });
-});
-
-it('prompts to pick causes when none are selected', () => {
- mockPicker.mockReturnValue({
- causes: [cause('c1', 'Open Source Fund')],
- selectedCauseIds: [],
- isPending: false,
- });
- render( );
-
- expect(
- screen.getByRole('button', { name: 'Pick your causes' }),
- ).toBeInTheDocument();
-});
diff --git a/packages/shared/src/features/giveback/components/GivebackCampaignPanel.tsx b/packages/shared/src/features/giveback/components/GivebackCampaignPanel.tsx
deleted file mode 100644
index 1690188e141..00000000000
--- a/packages/shared/src/features/giveback/components/GivebackCampaignPanel.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import type { ReactElement } from 'react';
-import React from 'react';
-import { FlexCol } from '../../../components/utilities';
-import { GivebackBudgetStory } from './GivebackBudgetStory';
-import { GivebackSelectedCauses } from './GivebackSelectedCauses';
-import { GivebackFaq } from './GivebackFaq';
-
-// The Campaign tab ("why"): the short emotional reason for the campaign with the
-// charm, the visitor's picked causes (editable), and the FAQ. The big two-part
-// headline rides inside the story so it sits beside the charm.
-const headline = {
- title: 'Big tech buys ads.',
- highlight: 'We fund developers.',
-};
-
-export const GivebackCampaignPanel = (): ReactElement => (
-
-
-
-
-
-);
diff --git a/packages/shared/src/features/giveback/components/GivebackEditCausesModal.tsx b/packages/shared/src/features/giveback/components/GivebackEditCausesModal.tsx
deleted file mode 100644
index 38264ce0337..00000000000
--- a/packages/shared/src/features/giveback/components/GivebackEditCausesModal.tsx
+++ /dev/null
@@ -1,130 +0,0 @@
-import type { ReactElement } from 'react';
-import React, { useEffect } from 'react';
-import {
- Button,
- ButtonSize,
- ButtonVariant,
-} from '../../../components/buttons/Button';
-import {
- Typography,
- TypographyTag,
- TypographyType,
-} from '../../../components/typography/Typography';
-import { FlexCol, FlexRow } from '../../../components/utilities';
-import { RootPortal } from '../../../components/tooltips/Portal';
-import { useLogContext } from '../../../contexts/LogContext';
-import { LogEvent } from '../../../lib/log';
-import { useGivebackCauseSelection } from '../hooks/useGivebackCauseSelection';
-import { GivebackCauseSelection } from './GivebackCauseSelection';
-
-interface GivebackEditCausesModalProps {
- onClose: () => void;
-}
-
-// Edits the visitor's cause preferences in place, reusing the onboarding picker
-// grid and its save hook. Seeds from the saved selection, so opening it shows
-// the current picks ready to toggle.
-export const GivebackEditCausesModal = ({
- onClose,
-}: GivebackEditCausesModalProps): ReactElement => {
- const { logEvent } = useLogContext();
- const {
- causes,
- isLoading,
- selectedIds,
- toggleCause,
- selectedCount,
- save,
- isSaving,
- } = useGivebackCauseSelection(true);
-
- // Close on Escape, matching the backdrop click below.
- useEffect(() => {
- const onKeyDown = (event: KeyboardEvent) => {
- if (event.key === 'Escape') {
- onClose();
- }
- };
- document.addEventListener('keydown', onKeyDown);
- return () => document.removeEventListener('keydown', onKeyDown);
- }, [onClose]);
-
- const onSave = async () => {
- const saved = await save();
- if (!saved) {
- return;
- }
- logEvent({
- event_name: LogEvent.SaveGivebackCauses,
- extra: JSON.stringify({
- cause_count: selectedIds.size,
- cause_ids: [...selectedIds],
- }),
- });
- onClose();
- };
-
- return (
-
-
-
-
-
-
- Edit your causes
-
-
- Choose where your actions send the money.
-
-
-
-
-
-
-
-
-
- Cancel
-
-
- Save causes
-
-
-
-
-
- );
-};
diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx
index 184b342f1af..6e45a1075e0 100644
--- a/packages/shared/src/features/giveback/components/GivebackPage.tsx
+++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx
@@ -18,7 +18,6 @@ import { GivebackActionCatalog } from './GivebackActionCatalog';
import { GivebackContributionSummary } from './GivebackContributionSummary';
import { GivebackImpactPanel } from './GivebackImpactPanel';
import { GivebackCausesPanel } from './GivebackCausesPanel';
-import { GivebackCampaignPanel } from './GivebackCampaignPanel';
import { GivebackFundingBar } from './GivebackFundingBar';
import type { GivebackTabId } from './GivebackTabNav';
import { useLogContext } from '../../../contexts/LogContext';
@@ -182,7 +181,6 @@ export const GivebackPage = (): ReactElement => {
{activeTab === 'causes' && (
)}
- {activeTab === 'why' && }
)}
diff --git a/packages/shared/src/features/giveback/components/GivebackSelectedCauses.tsx b/packages/shared/src/features/giveback/components/GivebackSelectedCauses.tsx
deleted file mode 100644
index 315ec8b0ab4..00000000000
--- a/packages/shared/src/features/giveback/components/GivebackSelectedCauses.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-import type { ReactElement } from 'react';
-import React, { useState } from 'react';
-import {
- Button,
- ButtonSize,
- ButtonVariant,
-} from '../../../components/buttons/Button';
-import {
- Typography,
- TypographyColor,
- TypographyTag,
- TypographyType,
-} from '../../../components/typography/Typography';
-import { EditIcon, OpenLinkIcon } from '../../../components/icons';
-import { IconSize } from '../../../components/Icon';
-import { FlexCol, FlexRow } from '../../../components/utilities';
-import { anchorDefaultRel } from '../../../lib/strings';
-import { useLogContext } from '../../../contexts/LogContext';
-import { LogEvent } from '../../../lib/log';
-import { useContributionCausePicker } from '../hooks/useContributionCausePicker';
-import { GivebackSection } from './GivebackSection';
-import { CauseEmblem } from './CauseEmblem';
-import { GivebackEditCausesModal } from './GivebackEditCausesModal';
-
-// "Your causes" recap on the Campaign tab. Shows only the causes the visitor
-// picked, with a quick action to open the picker and edit. Editing lives here
-// now instead of a gear button in the tab bar.
-export const GivebackSelectedCauses = (): ReactElement => {
- const { logEvent } = useLogContext();
- const { causes, selectedCauseIds } = useContributionCausePicker(true);
- const [isEditOpen, setIsEditOpen] = useState(false);
-
- // Keep each cause's position in the full list so the fallback emblem tint
- // stays stable between the picker and this recap.
- const selectedCauses = causes
- .map((cause, index) => ({ cause, index }))
- .filter(({ cause }) => selectedCauseIds.includes(cause.id));
-
- const openEdit = () => {
- logEvent({
- event_name: LogEvent.ClickGivebackEditCauses,
- extra: JSON.stringify({ has_causes: selectedCauses.length > 0 }),
- });
- setIsEditOpen(true);
- };
-
- const onCauseClick = (causeId: string) =>
- logEvent({ event_name: LogEvent.ClickGivebackCause, target_id: causeId });
-
- return (
-
- {selectedCauses.length > 0 ? (
-
-
-
- }
- onClick={openEdit}
- >
- Edit
-
-
-
- ) : (
-
-
- You haven't picked any causes yet. Choose where your actions
- send the money.
-
- }
- onClick={openEdit}
- >
- Pick your causes
-
-
- )}
-
- {isEditOpen && (
- setIsEditOpen(false)} />
- )}
-
- );
-};
diff --git a/packages/shared/src/features/giveback/components/GivebackTabNav.spec.tsx b/packages/shared/src/features/giveback/components/GivebackTabNav.spec.tsx
index 173527625ac..28c274fdd24 100644
--- a/packages/shared/src/features/giveback/components/GivebackTabNav.spec.tsx
+++ b/packages/shared/src/features/giveback/components/GivebackTabNav.spec.tsx
@@ -7,14 +7,14 @@ it('renders the tabs from the shared tab list', () => {
expect(screen.getByText('Take action')).toBeInTheDocument();
expect(screen.getByText('Impact')).toBeInTheDocument();
- expect(screen.getByText('Campaign')).toBeInTheDocument();
+ expect(screen.getByText('Causes')).toBeInTheDocument();
});
it('maps a tab click back to its id', () => {
const onSelect = jest.fn();
render( );
- fireEvent.click(screen.getByText('Campaign'));
+ fireEvent.click(screen.getByText('Causes'));
- expect(onSelect).toHaveBeenCalledWith('why');
+ expect(onSelect).toHaveBeenCalledWith('causes');
});
diff --git a/packages/shared/src/features/giveback/components/GivebackTabNav.tsx b/packages/shared/src/features/giveback/components/GivebackTabNav.tsx
index ec8310cf188..1038657bb40 100644
--- a/packages/shared/src/features/giveback/components/GivebackTabNav.tsx
+++ b/packages/shared/src/features/giveback/components/GivebackTabNav.tsx
@@ -2,7 +2,7 @@ import type { ReactElement } from 'react';
import React from 'react';
import TabList, { TabListVariant } from '../../../components/tabs/TabList';
-export type GivebackTabId = 'actions' | 'impact' | 'causes' | 'why';
+export type GivebackTabId = 'actions' | 'impact' | 'causes';
interface GivebackTab {
id: GivebackTabId;
@@ -13,7 +13,6 @@ export const givebackTabs: GivebackTab[] = [
{ id: 'actions', label: 'Take action' },
{ id: 'impact', label: 'Impact' },
{ id: 'causes', label: 'Causes' },
- { id: 'why', label: 'Campaign' },
];
interface GivebackTabNavProps {
From 1715b62376b9957b512fb8cecec68a03852b9c7f Mon Sep 17 00:00:00 2001
From: Ido Shamun <1993245+idoshamun@users.noreply.github.com>
Date: Sun, 28 Jun 2026 15:20:20 +0300
Subject: [PATCH 3/4] feat(giveback): log add vs remove on cause toggle
The causes tab fires SaveGivebackCauses on every toggle but couldn't tell
an add from a removal. Capture the direction from the pre-toggle state so
the event carries action: 'add' | 'remove' alongside cause_id and origin.
---
.../giveback/components/GivebackCausesPanel.tsx | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx b/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx
index ec4d8b40288..0996214689d 100644
--- a/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx
+++ b/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx
@@ -151,12 +151,18 @@ export const GivebackCausesPanel = ({
const onLearnMore = (causeId: string) =>
logEvent({ event_name: LogEvent.ClickGivebackCause, target_id: causeId });
- // Auto-saved on every toggle, so each add/remove is also logged here.
+ // Auto-saved on every toggle, so each add/remove is also logged here. Capture
+ // the direction from the pre-toggle state, since one event covers both.
const onToggle = (causeId: string) => {
+ const action = selectedIds.has(causeId) ? 'remove' : 'add';
toggleAndSave(causeId);
logEvent({
event_name: LogEvent.SaveGivebackCauses,
- extra: JSON.stringify({ cause_id: causeId, origin: 'causes_tab' }),
+ extra: JSON.stringify({
+ cause_id: causeId,
+ action,
+ origin: 'causes_tab',
+ }),
});
};
From 3d5633336bc45f2f0d83e8398ed9b73537e02044 Mon Sep 17 00:00:00 2001
From: Ido Shamun <1993245+idoshamun@users.noreply.github.com>
Date: Sun, 28 Jun 2026 15:23:09 +0300
Subject: [PATCH 4/4] chore(giveback): remove dead ClickGivebackEditCauses log
event
The edit-causes modal flow was removed with the Campaign tab; this event
is no longer fired anywhere. The 'opened cause manager' signal is now
ClickGivebackTab with tab='causes'.
---
packages/shared/src/lib/log.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts
index 34cd556d014..af6e47d36e8 100644
--- a/packages/shared/src/lib/log.ts
+++ b/packages/shared/src/lib/log.ts
@@ -507,7 +507,6 @@ export enum LogEvent {
SubmitGivebackActionError = 'submit giveback action error',
ClickGivebackLoveAction = 'click giveback love action',
ClaimGivebackReward = 'claim giveback reward',
- ClickGivebackEditCauses = 'click giveback edit causes',
ClickGivebackCause = 'click giveback cause',
ClickGivebackFaq = 'click giveback faq',
// Daily homepage