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/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..0996214689d --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx @@ -0,0 +1,286 @@ +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. 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, + action, + 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/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/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..6e45a1075e0 100644 --- a/packages/shared/src/features/giveback/components/GivebackPage.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx @@ -17,7 +17,7 @@ import { GivebackTabNav, givebackTabs } from './GivebackTabNav'; import { GivebackActionCatalog } from './GivebackActionCatalog'; import { GivebackContributionSummary } from './GivebackContributionSummary'; import { GivebackImpactPanel } from './GivebackImpactPanel'; -import { GivebackCampaignPanel } from './GivebackCampaignPanel'; +import { GivebackCausesPanel } from './GivebackCausesPanel'; import { GivebackFundingBar } from './GivebackFundingBar'; import type { GivebackTabId } from './GivebackTabNav'; import { useLogContext } from '../../../contexts/LogContext'; @@ -178,7 +178,9 @@ export const GivebackPage = (): ReactElement => { {activeTab === 'impact' && ( )} - {activeTab === 'why' && } + {activeTab === 'causes' && ( + + )}
)} 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/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.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 887adee29fd..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' | 'why'; +export type GivebackTabId = 'actions' | 'impact' | 'causes'; interface GivebackTab { id: GivebackTabId; @@ -12,7 +12,7 @@ interface GivebackTab { export const givebackTabs: GivebackTab[] = [ { id: 'actions', label: 'Take action' }, { id: 'impact', label: 'Impact' }, - { id: 'why', label: 'Campaign' }, + { id: 'causes', label: 'Causes' }, ]; interface GivebackTabNavProps { @@ -37,11 +37,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, 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