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 => ( -
- {/* Full-card overlay drives the toggle so the "Learn more" link can live - inside the card without nesting interactives. */} - + ) : ( + + {selected && } + + )} + - - - {cause.title} - - {cause.category && ( - - {cause.category} - - )} {cause.description && ( {cause.description} )} - - {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 - - - )} -
-); + {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" + > + + + )} + + +); + +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 ( - -
- - - - -
-
- ); -}; 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 ? ( - -
- {selectedCauses.map(({ cause, index }) => ( - - - - - {cause.title} - - {cause.category && ( - - {cause.category} - - )} - - {cause.url && ( - onCauseClick(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" - > - - - )} - - ))} -
- - - -
- ) : ( - - - You haven't picked any causes yet. Choose where your actions - send the money. - - - - )} - - {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