diff --git a/packages/shared/src/features/giveback/components/GivebackActionCard.tsx b/packages/shared/src/features/giveback/components/GivebackActionCard.tsx index a0317d0f3bf..672ab2f3e59 100644 --- a/packages/shared/src/features/giveback/components/GivebackActionCard.tsx +++ b/packages/shared/src/features/giveback/components/GivebackActionCard.tsx @@ -1,5 +1,5 @@ import type { ComponentType, ReactElement, ReactNode } from 'react'; -import React, { useState } from 'react'; +import React from 'react'; import classNames from 'classnames'; import { Typography, @@ -15,6 +15,7 @@ import type { ContributionAction } from '../types'; import { ContributionSubmissionStatus } from '../types'; import { formatDonationAmount } from '../utils'; import { getActionPlatformVisual } from '../actionPlatform'; +import { GivebackPlatformLogo } from './GivebackPlatformLogo'; interface GivebackActionCardProps { action: ContributionAction; @@ -39,48 +40,7 @@ const formatCooldownRemaining = (endsAt: string): string => { return `${Math.max(1, minutes)}m`; }; -interface PlatformLogoProps { - logoUrl?: string; - Icon: ComponentType; - forceDark?: boolean; - isDimmed: boolean; -} - -// Prefers the real brand logo (an SVG from the logo CDN) and falls back to the -// internal glyph if there is no logo for the surface or the remote one fails to -// load — so a tile is never broken or blank. The parent tile already pins the -// background and applies the dimmed/grayscale treatment. -const PlatformLogo = ({ - logoUrl, - Icon, - forceDark, - isDimmed, -}: PlatformLogoProps): ReactElement => { - const [failed, setFailed] = useState(false); - - if (logoUrl && !failed) { - return ( - setFailed(true)} - className="size-6 object-contain" - /> - ); - } - - return ( - - ); -}; - -// One sharp, explicit title carries the ask — no competing subtitle. The +// One sharp, explicit title carries the ask - no competing subtitle. The // supporting details (payout, status, "just for love") sit in a calm top/bottom // frame around it so the card stays easy to scan at a glance. export const GivebackActionCard = ({ @@ -206,7 +166,7 @@ export const GivebackActionCard = ({ : 'shadow-1 bg-white text-black group-hover:scale-105', )} > - - {/* Glossy sheen that sweeps across on hover — a small reward flourish. */} + {/* Glossy sheen that sweeps across on hover - a small reward flourish. */} ); -export const GivebackActionCatalog = (): ReactElement => { +interface GivebackActionCatalogProps { + // Scrolls the tab strip back to the top so a filtered list always starts in + // view (no jump from the previous scroll position). + onFilter?: () => void; +} + +export const GivebackActionCatalog = ({ + onFilter, +}: GivebackActionCatalogProps): ReactElement => { const { logEvent } = useLogContext(); const { actions, categories, isPending } = useContributionActions(true); const [selectedCategory, setSelectedCategory] = useState(ALL_FILTER); @@ -73,6 +81,7 @@ export const GivebackActionCatalog = (): ReactElement => { extra: JSON.stringify({ category_id: categoryId }), }); setSelectedCategory(categoryId); + onFilter?.(); }; const toggleShowAll = () => { @@ -203,8 +212,8 @@ export const GivebackActionCatalog = (): ReactElement => { type={TypographyType.Footnote} color={TypographyColor.Tertiary} > - We can't pay for these, but we'd genuinely appreciate - them. + No donation rides on these. They just help us out. We'd love + you for it. diff --git a/packages/shared/src/features/giveback/components/GivebackActionSubmissionModal.tsx b/packages/shared/src/features/giveback/components/GivebackActionSubmissionModal.tsx index eda65288c99..e5dc5c0be38 100644 --- a/packages/shared/src/features/giveback/components/GivebackActionSubmissionModal.tsx +++ b/packages/shared/src/features/giveback/components/GivebackActionSubmissionModal.tsx @@ -7,34 +7,198 @@ import { } from '../../../components/buttons/Button'; import { Typography, + TypographyColor, TypographyTag, TypographyType, } from '../../../components/typography/Typography'; import { FlexCol, FlexRow } from '../../../components/utilities'; import { RootPortal } from '../../../components/tooltips/Portal'; +import { OpenLinkIcon } from '../../../components/icons'; import { uploadContentImage } from '../../../graphql/posts'; import { useToastNotification } from '../../../hooks/useToastNotification'; import { useLogContext } from '../../../contexts/LogContext'; import { LogEvent } from '../../../lib/log'; import { labels } from '../../../lib/labels'; +import { anchorDefaultRel } from '../../../lib/strings'; import type { ContributionAction } from '../types'; import { formatDonationAmount } from '../utils'; +import { getActionPlatformVisual } from '../actionPlatform'; import { useSubmitContributionAction } from '../hooks/useSubmitContributionAction'; import { GivebackScreenshotField } from './GivebackScreenshotField'; +import { GivebackPlatformLogo } from './GivebackPlatformLogo'; interface GivebackActionSubmissionModalProps { action: ContributionAction; onClose: () => void; } -const getDialogTitle = (isLove: boolean, isSubmitted: boolean): string => { - if (isLove) { - return 'Show some love'; - } - if (isSubmitted) { - return 'Proof submitted'; - } - return 'Submit proof'; +// Instructions may arrive as one paragraph or as several lines (one step each). +// Split on line breaks so multi-line how-tos render as a numbered checklist the +// user can follow top to bottom. +const toInstructionSteps = (instructions: string): string[] => + instructions + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + +// The explicit "what we're asking you to do" block at the top of every action. +// Leads with the platform identity and the reward, states the ask in a big +// title, and hands the user a one-tap way to go start it on the real surface. +const ActionBrief = ({ + action, + titleId, +}: { + action: ContributionAction; + titleId: string; +}): ReactElement => { + const { metadata } = action; + const isLove = metadata.isLoveAction; + const { + Icon, + name: platformName, + forceDark, + logoUrl, + } = getActionPlatformVisual(metadata.platform); + + return ( + + + + + + + + {platformName} + + + {isLove ? ( + + + Just for love + + + ) : ( + + + +{formatDonationAmount(action.points)} + + + to your causes + + + )} + + + + + {action.title} + + {action.description && ( + + {action.description} + + )} + + + {metadata.externalUrl && ( + + )} + + ); +}; + +// The full how-to, surfaced prominently (not as fine print) so the requirement +// is impossible to miss. Multi-line instructions become a numbered checklist. +const InstructionsBlock = ({ + instructions, +}: { + instructions: string; +}): ReactElement => { + const steps = toInstructionSteps(instructions); + + return ( + + + How to complete it + + {steps.length > 1 ? ( +
    + {steps.map((step, index) => ( +
  1. + + {index + 1} + + + {step} + +
  2. + ))} +
+ ) : ( + + {instructions} + + )} +
+ ); }; export const GivebackActionSubmissionModal = ({ @@ -172,63 +336,42 @@ export const GivebackActionSubmissionModal = ({ aria-hidden className="bg-accent-onion-default/20 pointer-events-none absolute -bottom-24 -left-16 size-56 rounded-full blur-3xl" /> - - - - {getDialogTitle(isLove, isSubmitted)} - + + {!isSubmitted && ( + + )} + + {!isSubmitted && metadata.instructions && ( + + )} + + {isLove && !isSubmitted && ( - {!isLove && isSubmitted - ? "Added to your contribution. We'll only subtract it if validation fails." - : action.title} + This one's a voluntary thank-you. No reward or donation is + attached, we just genuinely appreciate it. - - - {isLove && ( - - - - Just for love - - - This one's a voluntary thank-you — no reward or - donation is attached. We just genuinely appreciate it. - - - {metadata.instructions && ( - - {metadata.instructions} - - )} - )} {!isLove && isSubmitted && ( - + You helped unlock {formatDonationAmount(action.points)} It already counts toward your contribution. If it's rejected, we'll subtract it. @@ -238,31 +381,14 @@ export const GivebackActionSubmissionModal = ({ {!isLove && !isSubmitted && ( - - - - Counts toward your contribution the moment you submit. - - - +{formatDonationAmount(action.points)} - - - {metadata.instructions && ( - - {metadata.instructions} - - )} - + + Done it? Add your proof below. It counts toward your + contribution the moment you submit. + {showUrl && ( )} + - - {isLove ? ( + {/* Pinned action bar: stays visible while the body above scrolls, so + the submit control is always reachable on tall forms. */} + + {isLove ? ( + + ) : ( + <> - ) : ( - <> + {!isSubmitted && ( - {!isSubmitted && ( - - )} - - )} - - + )} + + )} + diff --git a/packages/shared/src/features/giveback/components/GivebackBackground.tsx b/packages/shared/src/features/giveback/components/GivebackBackground.tsx index 525c2f1a48f..aac9d339b87 100644 --- a/packages/shared/src/features/giveback/components/GivebackBackground.tsx +++ b/packages/shared/src/features/giveback/components/GivebackBackground.tsx @@ -3,9 +3,15 @@ import React from 'react'; // daily.dev brand canvas: the signature pink → purple → blue gradient (cabbage → // onion → blueCheese) glowing softly from the top of a dark surface, the way the -// marketing site and Plus pages feel — smooth and pastel, no hard shapes or +// marketing site and Plus pages feel - smooth and pastel, no hard shapes or // grids. Every tint is a theme token via color-mix so it tracks the design // system, and a whisper of grain keeps the gradient from banding. +// The sweep fades only vertically (a top-anchored linear mask) so it fills the +// full width edge to edge - including the top corners. A radial mask centered at +// the top looks prettier but starves the corners, and once the page sits inside +// the app's rounded, clipped content card those starved corners read as dark +// gaps where the gradient gets "cut". A straight downward fade has no such +// horizontal falloff, so the brand color reaches every corner cleanly. const brandSweep: CSSProperties = { backgroundImage: 'linear-gradient(125deg, ' + @@ -14,12 +20,12 @@ const brandSweep: CSSProperties = { 'color-mix(in srgb, var(--theme-accent-blueCheese-default) 30%, transparent) 62%, ' + 'color-mix(in srgb, var(--theme-accent-onion-default) 34%, transparent) 82%, ' + 'color-mix(in srgb, var(--theme-accent-cabbage-default) 34%, transparent))', - maskImage: 'radial-gradient(125% 75% at 50% -10%, black, transparent 70%)', + maskImage: 'linear-gradient(to bottom, black 0%, black 32%, transparent 92%)', WebkitMaskImage: - 'radial-gradient(125% 75% at 50% -10%, black, transparent 70%)', + 'linear-gradient(to bottom, black 0%, black 32%, transparent 92%)', }; -// A soft horizon glow anchored to the bottom edge for depth — a wide, flat +// A soft horizon glow anchored to the bottom edge for depth - a wide, flat // ellipse, so it's ambient light rather than a circle. const horizonGlow: CSSProperties = { background: @@ -38,12 +44,18 @@ const vignette: CSSProperties = { }; export const GivebackBackground = (): ReactElement => ( + // The app's content card (MainLayout) wraps the page in a rounded, clipped + // border with a 2px inner padding on `laptop:`. Filling only `inset-0` leaves + // that padding (the dark card bg) showing as a crescent where our square + // corners meet the card's rounded corner. Bleed a few px past the padding so + // the gradient reaches the inner border edge; the card's `overflow-clip` + + // `rounded-24` then clips it to a clean rounded corner with no dark gap.
{/* The brand glow is a fixed-height hero band anchored to the top. Sizing it - in px (not inset-0) keeps it consistent — otherwise its mask scales with + in px (not inset-0) keeps it consistent - otherwise its mask scales with the page height and the glow spreads down on longer pages. */}
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/GivebackCampaignVideo.tsx b/packages/shared/src/features/giveback/components/GivebackCampaignVideo.tsx index 988bd788661..96a83795503 100644 --- a/packages/shared/src/features/giveback/components/GivebackCampaignVideo.tsx +++ b/packages/shared/src/features/giveback/components/GivebackCampaignVideo.tsx @@ -4,7 +4,7 @@ import { FlexCol } from '../../../components/utilities'; import { PlayIcon } from '../../../components/icons'; import { IconSize } from '../../../components/Icon'; -// The campaign clip behind a lightweight click-to-play facade — the heavy embed +// The campaign clip behind a lightweight click-to-play facade - the heavy embed // only mounts on click, so the hero never autoplays or loads the iframe up // front. The placeholder poster shows the Giveback charm on the brand backdrop; // swap VIDEO_ID for the final film when it's ready. @@ -15,7 +15,7 @@ const CHARM_IMAGE_SRC = 'https://media.daily.dev/image/upload/s--d1dldAty--/f_auto,q_auto/v1780848838/public/daily.dev%20Charm%20-%20Giveback%20(1)'; // A dark, on-brand backdrop (page background + a soft cabbage glow from the top) -// so the charm — rendered with `mix-blend-screen` — reads as floating, exactly +// so the charm - rendered with `mix-blend-screen` - reads as floating, exactly // like it does elsewhere on the dark page. const posterBackdrop: CSSProperties = { background: 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/GivebackCauseSelection.tsx b/packages/shared/src/features/giveback/components/GivebackCauseSelection.tsx index 12d647587ae..7de5749e9b1 100644 --- a/packages/shared/src/features/giveback/components/GivebackCauseSelection.tsx +++ b/packages/shared/src/features/giveback/components/GivebackCauseSelection.tsx @@ -4,7 +4,6 @@ import { FlexCol, FlexRow } from '../../../components/utilities'; import { Typography, TypographyColor, - TypographyTag, TypographyType, } from '../../../components/typography/Typography'; import { GivebackFilterChip } from './GivebackFilterChip'; @@ -52,7 +51,7 @@ export const GivebackCauseSelection = ({ if (isLoading) { return ( -
+
{Array.from({ length: 6 }).map((_, index) => (
- - Pick as many as you like. daily.dev funds every donation, and you can - change them anytime. - - {categories.length > 0 && ( )} -
+
{visibleCauses.map(({ cause, index }) => ( 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/GivebackCommunityGoalProgress.tsx b/packages/shared/src/features/giveback/components/GivebackCommunityGoalProgress.tsx deleted file mode 100644 index fe8169f78f1..00000000000 --- a/packages/shared/src/features/giveback/components/GivebackCommunityGoalProgress.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import type { ReactElement, ReactNode } from 'react'; -import React, { useMemo } from 'react'; -import classNames from 'classnames'; -import { FlexCol, FlexRow } from '../../../components/utilities'; -import { - Typography, - TypographyColor, - TypographyTag, - TypographyType, -} from '../../../components/typography/Typography'; -import { ProgressBar } from '../../../components/fields/ProgressBar'; -import { useContributionStatus } from '../hooks/useContributionStatus'; -import { useContributionSponsors } from '../hooks/useContributionSponsors'; -import { useCountUp, useInView } from '../useGivebackMotion'; -import { formatDonationAmount, getGoalProgressPercentage } from '../utils'; -import { GivebackMeterShine } from './GivebackMeterShine'; -import { GivebackSponsorBudgetBar } from './GivebackSponsorBudgetBar'; -import type { BudgetSponsor } from './GivebackSponsorBudgetBar'; - -const milestones = [25, 50, 75, 100]; - -const FundingSection = ({ - children, -}: { - children: ReactNode; -}): ReactElement => ( -
- - - Funding progress - - {children} - -
-); - -// Campaign funding for the Impact tab: how much the community has unlocked of -// the goal, plus the sponsors topping up the pot. All numbers are live from the -// public contribution status; sponsor amounts come back in cents. -export const GivebackCommunityGoalProgress = (): ReactElement => { - const { status } = useContributionStatus(); - const { sponsors } = useContributionSponsors(); - - const approvedAmount = status?.currentCyclePoints ?? 0; - const goalAmount = status?.currentCycleTargetPoints ?? 0; - const backersCount = status?.contributorsCount ?? 0; - - const budgetSponsors = useMemo( - () => - sponsors.map((sponsor) => ({ - id: sponsor.id, - name: sponsor.name, - amount: sponsor.amountCents / 100, - logoUrl: sponsor.logoUrl, - })), - [sponsors], - ); - const sponsoredAmount = budgetSponsors.reduce( - (sum, sponsor) => sum + sponsor.amount, - 0, - ); - const hasSponsors = sponsoredAmount > 0; - - const percentage = getGoalProgressPercentage(approvedAmount, goalAmount); - // Bar (and milestone dots) grow from 0 the first time the meter scrolls in, so - // the money raised visibly "fills up". The numeric label counts up to the - // exact amount, including when the pot grows after an action lands. - const { ref: meterRef, inView } = useInView(); - const animatedPercentage = useCountUp(Math.round(percentage), inView); - const animatedAmount = useCountUp(approvedAmount, inView, 900); - - const sponsorGoalShare = goalAmount - ? Math.round((sponsoredAmount / goalAmount) * 100) - : 0; - - if (!status || goalAmount === 0) { - return ( - - -
-
-
- - - ); - } - - return ( - - -
- - - - - {formatDonationAmount(animatedAmount)} - - - pledged of {formatDonationAmount(goalAmount)} - - - -
- - - - {milestones.map((milestone) => ( - = milestone - ? 'bg-accent-cheese-default' - : 'bg-accent-pepper-subtler', - )} - /> - ))} - -
- - - {Math.round(percentage)}% funded - - - - - {backersCount.toLocaleString('en-US')} - {' '} - total backers - -
-
- - {hasSponsors && ( - - - - Sponsors topping up the pot - - - {formatDonationAmount(sponsoredAmount)} - - - - - - - {sponsorGoalShare}% of the {formatDonationAmount(goalAmount)}{' '} - goal · {budgetSponsors.length} sponsors - - - )} -
- - - Funded by daily.dev, not you. Only approved actions count toward the - goal. - -
-
- ); -}; diff --git a/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx index ef7103fa074..43a836aefa0 100644 --- a/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx +++ b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx @@ -7,7 +7,7 @@ import { TypographyTag, TypographyType, } from '../../../components/typography/Typography'; -import { GiftIcon, InfoIcon } from '../../../components/icons'; +import { InfoIcon } from '../../../components/icons'; import { IconSize } from '../../../components/Icon'; import { ProfilePicture, @@ -18,16 +18,15 @@ import { useGivebackContribution } from '../hooks/useGivebackContribution'; import { useContributionActions } from '../hooks/useContributionActions'; import { formatDonationAmount } from '../utils'; -// Personal recap shown above the action catalog: how much the visitor has -// unlocked for their causes and the next reward they're working toward. No -// rank, level or leaderboard — the campaign starts from scratch, so this stays -// a purely personal progress cue. +// Personal stat card above the action catalog. A rounded-square avatar (matching +// daily.dev's app tiles) carries identity with a small level badge tucked in its +// corner; the money you've unlocked is the hero number beside it. Clean and +// concrete — no floating ring, no game-y framing. export const GivebackContributionSummary = (): ReactElement => { const { user } = useAuthContext(); - const { earnedPoints, nextReward, pointsToNext, currentLevel, isPending } = + const { earnedPoints, currentLevel, isPending } = useGivebackContribution(true); - // Shares the catalog's query key, so this adds no extra request. Sums the - // visitor's completions across every action into one "actions taken" count. + // Shares the catalog's query key, so this adds no extra request. const { actions, isPending: isActionsPending } = useContributionActions(true); const actionsTaken = actions.reduce( (sum, action) => sum + action.userCompletions, @@ -35,118 +34,80 @@ export const GivebackContributionSummary = (): ReactElement => { ); if (isPending) { + // Matches the flat loaded layout (avatar + stat lines) so it doesn't flash + // from a card into a flat row when data lands. return ( - -
-
- + +
+ +
+
+ + ); } return ( - + {user && (
- + Lvl {currentLevel}
)} - + - Your contribution - - - - - - Counts the moment you act, because we trust you. If a submission - is rejected, we'll subtract it. - - - - - - {formatDonationAmount(earnedPoints)} - - unlocked for your causes - - {!isActionsPending && ( - - {actionsTaken} {actionsTaken === 1 ? 'action' : 'actions'} taken - - )} - - {nextReward && ( - - - Next reward - - - - - {nextReward.title} + {!isActionsPending && ( + + + {actionsTaken} {actionsTaken === 1 ? 'action' : 'actions'} taken + + + + + It counts the moment you act. We trust you. If something gets + rejected, we just subtract it. + + + - - {formatDonationAmount(pointsToNext)} to go - - - )} + )} +
); }; 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/GivebackFaq.tsx b/packages/shared/src/features/giveback/components/GivebackFaq.tsx index 8f42a0b3d1e..b7282501d19 100644 --- a/packages/shared/src/features/giveback/components/GivebackFaq.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFaq.tsx @@ -12,6 +12,7 @@ import { IconSize } from '../../../components/Icon'; import { useLogContext } from '../../../contexts/LogContext'; import { LogEvent } from '../../../lib/log'; import { GivebackSection } from './GivebackSection'; +import { GivebackTabHeading } from './GivebackTabHeading'; interface FaqItem { id: string; @@ -24,13 +25,13 @@ const faqs: FaqItem[] = [ id: 'cost', question: 'Does this cost me anything?', answer: - 'No. daily.dev funds every donation. You never pay a cent. You just take small actions and we turn them into money for good causes.', + 'Nothing. Not a cent. daily.dev funds every donation. You bring the actions, we bring the money.', }, { id: 'how', question: 'How do my actions turn into donations?', answer: - 'Each approved action unlocks a fixed amount that daily.dev donates to the causes you picked. The community meter is the sum of everyone’s actions.', + 'Each action you complete unlocks a fixed amount that daily.dev donates to the causes you picked. The community meter is every dev’s actions added up.', }, { id: 'causes', @@ -54,7 +55,7 @@ const faqs: FaqItem[] = [ id: 'why', question: 'Why is daily.dev doing this?', answer: - 'We’d rather put our growth budget into causes the community cares about than burn it in ad auctions.', + 'Most companies buy growth with ads. We’d rather earn it with you, by helping more developers discover daily.dev, and send that budget to causes the community actually cares about. You bring the growth, the world gets the money.', }, { id: 'geo', @@ -76,7 +77,8 @@ export const GivebackFaq = (): ReactElement => { }; return ( - + +
{faqs.map((faq) => { const isOpen = openId === faq.id; @@ -121,7 +123,7 @@ export const GivebackFaq = (): ReactElement => { tag={TypographyTag.P} type={TypographyType.Callout} color={TypographyColor.Secondary} - className="max-w-2xl pb-4" + className="max-w-2xl pb-4 [text-wrap:pretty]" > {faq.answer} diff --git a/packages/shared/src/features/giveback/components/GivebackFaqPanel.tsx b/packages/shared/src/features/giveback/components/GivebackFaqPanel.tsx new file mode 100644 index 00000000000..6d4c28ec52f --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackFaqPanel.tsx @@ -0,0 +1,7 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { GivebackFaq } from './GivebackFaq'; + +// The FAQ tab. The campaign's "why" now headlines the page hero, so this tab +// focuses purely on the answers. +export const GivebackFaqPanel = (): ReactElement => ; 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/GivebackFundingBar.tsx b/packages/shared/src/features/giveback/components/GivebackFundingBar.tsx deleted file mode 100644 index c6ecd30d3a2..00000000000 --- a/packages/shared/src/features/giveback/components/GivebackFundingBar.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import type { ReactElement, ReactNode } from 'react'; -import React from 'react'; -import { FlexCol, FlexRow } from '../../../components/utilities'; -import { - Typography, - TypographyColor, - TypographyTag, - TypographyType, -} from '../../../components/typography/Typography'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '../../../components/buttons/Button'; -import ProgressCircle from '../../../components/ProgressCircle'; -import { useLogContext } from '../../../contexts/LogContext'; -import { LogEvent } from '../../../lib/log'; -import { useGivebackContribution } from '../hooks/useGivebackContribution'; -import { formatDonationAmount } from '../utils'; - -interface GivebackFundingBarProps { - onTakeAction: () => void; -} - -// Persistent personal-progress bar for the onboarded experience. Deliberately -// spare: the exact amount you've unlocked, one explicit next reward, and the -// CTA — nothing else competing for attention in the thumb zone. -export const GivebackFundingBar = ({ - onTakeAction, -}: GivebackFundingBarProps): ReactElement => { - const { logEvent } = useLogContext(); - const { - earnedPoints, - nextReward, - pointsToNext, - progressPercentage, - hasRewards, - isPending, - } = useGivebackContribution(true); - - const handleTakeAction = () => { - logEvent({ - event_name: LogEvent.ClickGivebackTakeAction, - extra: JSON.stringify({ origin: 'funding_bar' }), - }); - onTakeAction(); - }; - - const renderNextStep = (): ReactNode => { - if (isPending) { - return ( - - ); - } - - if (nextReward) { - return ( - <> - - {formatDonationAmount(pointsToNext)} - {' '} - to unlock{' '} - - {nextReward.title} - - - ); - } - - if (hasRewards) { - return <>You've unlocked every reward. Keep the good going.; - } - - return <>Take an action to start unlocking donations.; - }; - - return ( -
-
- {/* Brand hairline along the top edge, matching the sticky tabs bar. */} -
- - - -
- -
- - - - - {formatDonationAmount(earnedPoints)} - - - unlocked - for your causes - - - - - {renderNextStep()} - - -
- - -
-
-
- ); -}; diff --git a/packages/shared/src/features/giveback/components/GivebackFundingSummary.spec.tsx b/packages/shared/src/features/giveback/components/GivebackFundingSummary.spec.tsx index 6f30d8a8ca7..040e3589635 100644 --- a/packages/shared/src/features/giveback/components/GivebackFundingSummary.spec.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFundingSummary.spec.tsx @@ -36,7 +36,7 @@ const renderSummary = (status: Partial) => { return render(); }; -it('renders campaign points as dollars against the cycle goal with backers', () => { +it('renders campaign points as dollars against the cycle goal with contributors', () => { renderSummary({ currentCyclePoints: 5000, currentCycleTargetPoints: 10000, @@ -44,12 +44,12 @@ it('renders campaign points as dollars against the cycle goal with backers', () }); expect(screen.getByText('$5,000')).toBeInTheDocument(); - expect(screen.getByText('pledged of $10,000 goal')).toBeInTheDocument(); + expect(screen.getByText('unlocked of $10,000 goal')).toBeInTheDocument(); expect(screen.getByText('50%')).toBeInTheDocument(); - expect(screen.getByText(/12,480 backers/)).toBeInTheDocument(); + expect(screen.getByText(/12,480 contributors/)).toBeInTheDocument(); }); -it('shows a goal-forward empty state when nothing is pledged yet', () => { +it('shows a goal-forward empty state when nothing is unlocked yet', () => { renderSummary({ currentCyclePoints: 0, currentCycleTargetPoints: 10000, @@ -57,13 +57,13 @@ it('shows a goal-forward empty state when nothing is pledged yet', () => { }); expect(screen.getByText('$10,000')).toBeInTheDocument(); + expect(screen.getByText("goal we'll fund together")).toBeInTheDocument(); expect( - screen.getByText('goal to unlock for good causes'), + screen.getByText('Be the first to move the meter.'), ).toBeInTheDocument(); - expect(screen.getByText('Be the first to back this.')).toBeInTheDocument(); - // None of the "$0 / 0% / 0 backers" zeros leak through. + // None of the "$0 / 0% / 0 contributors" zeros leak through. expect(screen.queryByText('$0')).not.toBeInTheDocument(); - expect(screen.queryByText(/0 backers/)).not.toBeInTheDocument(); + expect(screen.queryByText(/0 contributors/)).not.toBeInTheDocument(); }); it('renders a skeleton without zeros before data arrives', () => { @@ -75,5 +75,5 @@ it('renders a skeleton without zeros before data arrives', () => { render(); expect(screen.queryByText('$0')).not.toBeInTheDocument(); - expect(screen.queryByText(/pledged of/)).not.toBeInTheDocument(); + expect(screen.queryByText(/unlocked of/)).not.toBeInTheDocument(); }); diff --git a/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx b/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx index 478ffbae8ed..63647a6c0ab 100644 --- a/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx @@ -1,5 +1,6 @@ import type { ReactElement, RefObject } from 'react'; import React from 'react'; +import classNames from 'classnames'; import { FlexCol, FlexRow } from '../../../components/utilities'; import { Typography, @@ -16,6 +17,10 @@ import { GivebackMeterShine } from './GivebackMeterShine'; const barColor = 'bg-gradient-to-r from-accent-avocado-default via-accent-cabbage-default to-accent-cheese-default'; +// Quarter-way milestone markers sit on the track (like the impact roadmap's +// nodes) so the goal reads as a journey with checkpoints, not one long fill. +const MILESTONES = [25, 50, 75] as const; + const Meter = ({ meterRef, percentage, @@ -25,13 +30,16 @@ const Meter = ({ percentage: number; empty?: boolean; }): ReactElement => ( -
+ // The track owns the styling: a taller, dark (primary background) fill with a + // hairline border so the meter reads as a crisp, contained gauge. +
@@ -40,20 +48,30 @@ const Meter = ({ // "ready to fill" rather than broken.
) : ( - + )} + {MILESTONES.map((milestone) => ( + = milestone + ? 'border-white bg-white' + : 'border-border-subtlest-secondary bg-background-default', + )} + style={{ left: `${milestone}%` }} + /> + ))}
); -// Compact funding block for the hero sidebar — the crowdfunding "pledge panel": +// Compact funding block for the hero sidebar - the crowdfunding "pledge panel": // raised, goal and progress at a glance. Points come back from the contribution // API as whole currency units, so they format directly as dollars. export const GivebackFundingSummary = (): ReactElement => { @@ -72,7 +90,7 @@ export const GivebackFundingSummary = (): ReactElement => { return (
-
+
); @@ -97,7 +115,7 @@ export const GivebackFundingSummary = (): ReactElement => { color={TypographyColor.Tertiary} className="pb-1" > - goal to unlock for good causes + goal we'll fund together @@ -110,14 +128,14 @@ export const GivebackFundingSummary = (): ReactElement => { color={TypographyColor.Primary} bold > - Be the first to back this. + Be the first to move the meter. - Take an action to start the meter. + Take one action and it starts climbing. @@ -136,7 +154,7 @@ export const GivebackFundingSummary = (): ReactElement => { color={TypographyColor.Tertiary} className="pb-1" > - pledged of {formatDonationAmount(goal)} goal + unlocked of {formatDonationAmount(goal)} goal @@ -156,7 +174,7 @@ export const GivebackFundingSummary = (): ReactElement => { type={TypographyType.Footnote} color={TypographyColor.Tertiary} > - funded · {contributorsCount.toLocaleString('en-US')} backers + funded · {contributorsCount.toLocaleString('en-US')} contributors diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.spec.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.spec.tsx new file mode 100644 index 00000000000..ab044243172 --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.spec.tsx @@ -0,0 +1,145 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { GivebackFunnel } from './GivebackFunnel'; +import type { useGivebackCauseSelection } from '../hooks/useGivebackCauseSelection'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent } from '../../../lib/log'; + +jest.mock('../../../contexts/LogContext'); + +// The funnel portals to document.body via RootPortal, which reads the request +// protocol from a query client, so the tree needs a QueryClientProvider. One +// stable client keeps `rerender` from swapping the provider (which would remount +// the funnel and reset its step state). +const queryClient = new QueryClient(); +const wrapper = ({ children }: { children: ReactNode }): ReactElement => ( + {children} +); + +const mockUseLogContext = useLogContext as jest.MockedFunction< + typeof useLogContext +>; +const logEvent = jest.fn(); + +type Selection = ReturnType; + +const buildSelection = (overrides: Partial = {}): Selection => ({ + causes: [], + isLoading: false, + selectedIds: new Set(), + toggleCause: jest.fn(), + toggleAndSave: jest.fn(), + selectedCount: 0, + hasSavedCauses: false, + save: jest.fn().mockResolvedValue(true), + isSaving: false, + ...overrides, +}); + +beforeEach(() => { + jest.clearAllMocks(); + mockUseLogContext.mockReturnValue({ logEvent } as unknown as ReturnType< + typeof useLogContext + >); +}); + +const advance = (label: string) => + fireEvent.click(screen.getByRole('button', { name: label })); + +it('walks every step and completes on the final CTA', () => { + const onComplete = jest.fn(); + render( + , + { wrapper }, + ); + + expect( + screen.getByText("We'd rather fund the world than pay for ads"), + ).toBeInTheDocument(); + + advance('Got it'); + expect(screen.getByText('You act. We pay. Causes win.')).toBeInTheDocument(); + + // No causes available → Continue is not blocked. + advance('Sounds good'); + advance('Continue'); + expect(screen.getByText('Real causes. Real impact.')).toBeInTheDocument(); + + // Impact is now the final step; its CTA completes the funnel. + advance("Let's start"); + + expect(onComplete).toHaveBeenCalledTimes(1); + expect(logEvent).toHaveBeenCalledWith( + expect.objectContaining({ event_name: LogEvent.CompleteGivebackFunnel }), + ); +}); + +it('blocks the causes step until at least one cause is picked', () => { + const { rerender } = render( + , + { wrapper }, + ); + + advance('Got it'); + advance('Sounds good'); + + expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled(); + + rerender( + , + ); + + expect(screen.getByRole('button', { name: 'Continue' })).toBeEnabled(); +}); + +it('shows a close control only when it can be closed', () => { + const onClose = jest.fn(); + const { rerender } = render( + , + { wrapper }, + ); + expect(screen.queryByTitle('Close')).not.toBeInTheDocument(); + + rerender( + , + ); + fireEvent.click(screen.getByTitle('Close')); + expect(onClose).toHaveBeenCalled(); +}); diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx new file mode 100644 index 00000000000..4e98093dad1 --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -0,0 +1,632 @@ +import type { CSSProperties, ReactElement, ReactNode, RefObject } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { FlexCol, FlexRow } from '../../../components/utilities'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import CloseButton from '../../../components/CloseButton'; +import { RootPortal } from '../../../components/tooltips/Portal'; +import { + CoinIcon, + GiftIcon, + MoveToIcon, + VIcon, +} from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { useViewSize, ViewSize } from '../../../hooks'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent } from '../../../lib/log'; +import { cloudinaryCharmBookmarks } from '../../../lib/image'; +import type { useGivebackCauseSelection } from '../hooks/useGivebackCauseSelection'; +import { GivebackBackground } from './GivebackBackground'; +import { GivebackMascot } from './GivebackMascot'; +import { GivebackCauseSelection } from './GivebackCauseSelection'; +import { GivebackCampaignVideo } from './GivebackCampaignVideo'; + +// The finale reassures the choice by spelling out the value the visitor just +// unlocked - three short, deck-ready propositions rather than a recap of the +// causes (which would just echo the picker screen). +const IMPACT_VALUES: ReadonlyArray<{ + icon: ReactElement; + title: string; + sub: string; +}> = [ + { + icon: , + title: 'You call the shots', + sub: 'Real, vetted nonprofits, picked by you.', + }, + { + icon: , + title: 'Costs you nothing', + sub: 'We fund every single dollar.', + }, + { + icon: , + title: 'Real-world impact', + sub: 'Small actions add up to real support.', + }, +]; + +type CauseSelection = ReturnType; + +// Step keys double as analytics labels and drive the progress bar. Kept tight +// so the funnel only highlights what matters: what it is, how it works, pick +// causes, and the impact — then straight into the campaign. +const STEP_KEYS = ['intro', 'how', 'causes', 'impact'] as const; +type StepKey = (typeof STEP_KEYS)[number]; + +interface GivebackFunnelProps { + selection: CauseSelection; + // Replay (opened from "How it works") can be dismissed; the forced first-run + // cannot, so the user always reaches the campaign with the context they need. + canClose?: boolean; + onClose?: () => void; + onComplete: () => void; +} + +// Choreographed enter: rise + de-blur + fade, staggered per element so each step +// reveals top-to-bottom rather than popping in as a block (motion-safe only). +const Reveal = ({ + delay = 0, + className, + children, +}: { + delay?: number; + className?: string; + children: ReactNode; +}): ReactElement => ( +
+ {children} +
+); + +// A soft, on-brand glow behind each step's hero icon/illustration so the visual +// feels alive and the campaign reads as a real, considered initiative. +const Stage = ({ children }: { children: ReactNode }): ReactElement => ( +
+ +
{children}
+
+); + +// The campaign explainer that starts inline on step 1, then docks to a floating +// bottom-right player for the rest of the funnel. It is a SINGLE mounted +// instance positioned over an in-flow slot (step 1) or pinned to the corner +// (later steps), so playback never restarts when it moves. +const DOCK_WIDTH = 320; + +const GivebackFunnelVideo = ({ + slotRef, + docked, + onClose, +}: { + slotRef: RefObject; + docked: boolean; + onClose: () => void; +}): ReactElement | null => { + const [style, setStyle] = useState(null); + + useEffect(() => { + if (typeof window === 'undefined') { + return undefined; + } + const update = () => { + if (docked) { + const width = Math.min(DOCK_WIDTH, window.innerWidth - 32); + const height = (width * 9) / 16; + setStyle({ + top: window.innerHeight - height - 16, + left: window.innerWidth - width - 16, + width, + }); + return; + } + const el = slotRef.current; + if (!el) { + setStyle(null); + return; + } + const rect = el.getBoundingClientRect(); + setStyle({ top: rect.top, left: rect.left, width: rect.width }); + }; + // Coalesce scroll/resize bursts into one measure per frame so the docked + // player doesn't thrash getBoundingClientRect + setState on every event. + let frame: number | null = null; + const scheduleUpdate = () => { + if (frame !== null) { + return; + } + frame = window.requestAnimationFrame(() => { + frame = null; + update(); + }); + }; + + update(); + window.addEventListener('resize', scheduleUpdate); + window.addEventListener('scroll', scheduleUpdate, true); + let observer: ResizeObserver | undefined; + if (typeof ResizeObserver !== 'undefined' && slotRef.current) { + observer = new ResizeObserver(scheduleUpdate); + observer.observe(slotRef.current); + } + return () => { + if (frame !== null) { + window.cancelAnimationFrame(frame); + } + window.removeEventListener('resize', scheduleUpdate); + window.removeEventListener('scroll', scheduleUpdate, true); + observer?.disconnect(); + }; + }, [docked, slotRef]); + + if (!style) { + return null; + } + + return ( +
+
+ + {docked && ( + + )} +
+
+ ); +}; + +// "How it works" as a vertical editorial timeline: oversized brand-gradient +// numerals threaded by a gradient rail (the money "flowing" down to causes), +// rather than a row of identical gradient icon-circles. Reads intentional and +// on-brand instead of generic. +const FLOW_STEPS: ReadonlyArray<{ title: string; sub: string }> = [ + { + title: 'You take an action', + sub: 'Share us, post, leave a review, cast a vote. Small things that help more devs find daily.dev.', + }, + { + title: 'The pot fills up', + sub: 'Each action drops real daily.dev money in. Never yours.', + }, + { + title: 'We fund your causes', + sub: 'Hit the goal together and the money goes out to the causes you picked.', + }, +]; + +const FlowSequence = (): ReactElement => ( + + {FLOW_STEPS.map((step, index) => { + const isLast = index === FLOW_STEPS.length - 1; + return ( + + + + + {index + 1} + + + {!isLast && ( + + )} + + + + {step.title} + + + {step.sub} + + + + ); + })} + +); + +export const GivebackFunnel = ({ + selection, + canClose = false, + onClose, + onComplete, +}: GivebackFunnelProps): ReactElement => { + const { logEvent } = useLogContext(); + const [stepIndex, setStepIndex] = useState(0); + const stepKey = STEP_KEYS[stepIndex]; + const isFirst = stepIndex === 0; + const isLast = stepIndex === STEP_KEYS.length - 1; + + // The explainer plays inline on step 1, then floats in the corner; one mounted + // instance keeps it playing across the move. + const videoSlotRef = useRef(null); + const [videoClosed, setVideoClosed] = useState(false); + // A floating corner video overlaps the content/footer on small screens, so on + // mobile the explainer only shows inline on step 1 and is dropped afterwards. + const isMobile = !useViewSize(ViewSize.Tablet); + + // The visitor's own picks, surfaced front-and-center on the finale so the + // moment celebrates exactly what they chose to fund. Keep each cause's index + // in the full list so its branded emblem tint stays stable. + const selectedCauses = selection.causes + .map((cause, index) => ({ cause, index })) + .filter(({ cause }) => selection.selectedIds.has(cause.id)) + .slice(0, 3); + + useEffect(() => { + logEvent({ + event_name: LogEvent.StartGivebackFunnel, + extra: JSON.stringify({ mode: canClose ? 'replay' : 'forced' }), + }); + // Only on mount - a fresh funnel run is one "start". + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + logEvent({ + event_name: LogEvent.ViewGivebackFunnelStep, + extra: JSON.stringify({ step: stepKey, index: stepIndex }), + }); + }, [logEvent, stepKey, stepIndex]); + + // Never trap the user on the causes step: only require a pick when there are + // causes to pick from. + const causesBlock = + stepKey === 'causes' && + selection.causes.length > 0 && + selection.selectedCount === 0; + + const goNext = () => { + if (isLast) { + logEvent({ event_name: LogEvent.CompleteGivebackFunnel }); + onComplete(); + return; + } + setStepIndex((index) => index + 1); + }; + + const goBack = () => setStepIndex((index) => Math.max(0, index - 1)); + + const ctaLabel = useMemo>( + () => ({ + intro: 'Got it', + how: 'Sounds good', + causes: 'Continue', + impact: "Let's start", + }), + [], + ); + + const renderStep = (): ReactElement => { + switch (stepKey) { + case 'how': + return ( + + + + You act. We pay. Causes win. + + + + + + + ); + case 'causes': + return ( + + + + + Pick the causes we'll fund together + + + Choose as many as you like. You can change them anytime. + + + + + + + + ); + case 'impact': + return ( + + + + + + + + + + {selectedCauses.length > 0 + ? "You're in. Now every action funds them." + : 'Real causes. Real impact.'} + + + {selectedCauses.length > 0 + ? 'From here on, every action you take becomes real money for the causes you picked. We fund all of it. You never pay a thing.' + : "Your actions become real money for open-source maintainers, students, and devs who can't afford access. We fund all of it, no cost to you."} + + + + + +
+ {IMPACT_VALUES.map((value) => ( + // Horizontal (icon left, copy right) on mobile to keep the + // finale short; stacks/centers in the 3-up grid on tablet+. + + + {value.icon} + + + + {value.title} + + + {value.sub} + + + + ))} +
+
+ {selectedCauses.length > 0 && ( + + + Thanks for choosing who to back. From now on, your everyday + actions are working for them. 💜 + + + )} +
+ ); + case 'intro': + default: + return ( + + {/* The floating player overlays this slot while on step 1. */} +
+ + + We'd rather fund the world than pay for ads + + + + + Most companies grow by buying ads. We'd rather grow through + developers who love daily.dev, and put that budget into causes + that matter. The deal is simple: help more people discover us, + and we fund the causes you choose. It never costs you a thing. + + + + ); + } + }; + + return ( + +
+ {/* Background lives outside the scroll container, so it stays fixed and the + page behind is never revealed no matter how far the content scrolls. */} + + +
+ {/* Just a close affordance on replay — the heavy progress bar is gone. */} +
+ {canClose && ( + + )} +
+ +
+ {/* Lightweight carousel dots sit above the step content as a quick "where + am I" cue, just over each step's title. */} + + {STEP_KEYS.map((key, index) => ( + + ))} + + + {/* Keyed by step so the choreographed enter replays on every advance. */} +
+ {renderStep()} +
+
+ + {/* A small floating control bar of a fixed width: a glass pill that hovers + above the page (shadow for depth), centered. The buttons flex to fill + it - so a lone CTA spans the whole bar, and Back + Next split it evenly, + keeping the bar the same width on every step. */} + {/* Nested-radius rule: the bar's corner = the inner button radius + (Large = rounded-14) + the bar's padding (p-2 = 8px) => rounded-22, so + the inner and outer curves stay concentric. */} +
+ + {!isFirst && ( + + )} + + +
+
+ + {!videoClosed && !(isMobile && stepIndex > 0) && ( + 0} + onClose={() => setVideoClosed(true)} + /> + )} +
+
+ ); +}; diff --git a/packages/shared/src/features/giveback/components/GivebackHeadline.tsx b/packages/shared/src/features/giveback/components/GivebackHeadline.tsx index 831039218dd..4b0c665e503 100644 --- a/packages/shared/src/features/giveback/components/GivebackHeadline.tsx +++ b/packages/shared/src/features/giveback/components/GivebackHeadline.tsx @@ -22,9 +22,9 @@ export const GivebackHeadline = ({ }: GivebackHeadlineProps): ReactElement => ( {title} diff --git a/packages/shared/src/features/giveback/components/GivebackHero.tsx b/packages/shared/src/features/giveback/components/GivebackHero.tsx index f2cef2e6355..143eb6a828d 100644 --- a/packages/shared/src/features/giveback/components/GivebackHero.tsx +++ b/packages/shared/src/features/giveback/components/GivebackHero.tsx @@ -2,110 +2,110 @@ import type { ReactElement } from 'react'; import React from 'react'; import { FlexCol, FlexRow } from '../../../components/utilities'; import Logo, { LogoPosition } from '../../../components/Logo'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; import { Typography, TypographyColor, TypographyTag, TypographyType, } from '../../../components/typography/Typography'; -import { GivebackStartPanel } from './GivebackStartPanel'; -import { GivebackCampaignVideo } from './GivebackCampaignVideo'; +import { GivebackHeadline } from './GivebackHeadline'; import { GivebackFundingSummary } from './GivebackFundingSummary'; +import { GivebackMascot } from './GivebackMascot'; interface GivebackHeroProps { - // Holds the CTA empty until the onboarding status is known. - isResolving: boolean; - // True once the visitor has confirmed causes: the CTA becomes "Take action". - hasSelectedCauses: boolean; - // Reveals the cause picker when an authenticated visitor joins. - onJoin: () => void; - // Jumps an already-onboarded visitor to the action tab. - onTakeAction: () => void; + // Re-opens the warm-up funnel; rendered top-right next to the brand. + onHowItWorks?: () => void; } +// The page cover: brand + "how it works" across the top, then the campaign's +// reason as the headline with the live funding meter on the left and the charm +// on the right. export const GivebackHero = ({ - isResolving, - hasSelectedCauses, - onJoin, - onTakeAction, -}: GivebackHeroProps): ReactElement => { - return ( -
- {/* Clip only the decorative glows, not the content, so hover effects on - buttons (scale + shadow) aren't cut off by the section bounds. */} + onHowItWorks, +}: GivebackHeroProps): ReactElement => ( +
+ {/* Clip only the decorative glows, not the content. */} +
+
-
-
-
-
- - - - - - - - Giveback - - + className="bg-accent-onion-default/20 absolute -right-10 top-10 size-72 rounded-full blur-3xl motion-safe:animate-glow-pulse" + style={{ animationDelay: '1s' }} + /> +
+ + + + + - Grow the community. Redirect the budget. - - Fund good causes. - - - - Ad giants don't need our money. The causes you care about do. - We redirect our growth budget to them, and you never pay a cent. + Giveback - + -
- + {onHowItWorks && ( + + )} + - - -
- + + + + + We'd rather grow through developers than through ads. Help + more people discover daily.dev, and we'll turn that budget + into real donations to the causes you choose. You never pay a + cent. + -
-
-
- ); -}; + +
+ +
+ + + + + +
+); diff --git a/packages/shared/src/features/giveback/components/GivebackImpactPanel.spec.tsx b/packages/shared/src/features/giveback/components/GivebackImpactPanel.spec.tsx index 46e342a90b2..19d0bbd11e4 100644 --- a/packages/shared/src/features/giveback/components/GivebackImpactPanel.spec.tsx +++ b/packages/shared/src/features/giveback/components/GivebackImpactPanel.spec.tsx @@ -1,23 +1,25 @@ import React from 'react'; import { act, fireEvent, render, screen } from '@testing-library/react'; import { GivebackImpactPanel } from './GivebackImpactPanel'; -import { useContributionStatus } from '../hooks/useContributionStatus'; -import { useContributionSponsors } from '../hooks/useContributionSponsors'; import { useGivebackContribution } from '../hooks/useGivebackContribution'; import { useContributionRewards } from '../hooks/useContributionRewards'; import { useContributionUserRewards } from '../hooks/useContributionUserRewards'; import { useClaimContributionReward } from '../hooks/useClaimContributionReward'; +import { useContributionCausePicker } from '../hooks/useContributionCausePicker'; +import { useContributionActions } from '../hooks/useContributionActions'; import { useLogContext } from '../../../contexts/LogContext'; +import { useAuthContext } from '../../../contexts/AuthContext'; import { LogEvent } from '../../../lib/log'; import { ContributionRewardType, type ContributionRewardTier } from '../types'; -jest.mock('../hooks/useContributionStatus'); -jest.mock('../hooks/useContributionSponsors'); jest.mock('../hooks/useGivebackContribution'); jest.mock('../hooks/useContributionRewards'); jest.mock('../hooks/useContributionUserRewards'); jest.mock('../hooks/useClaimContributionReward'); +jest.mock('../hooks/useContributionCausePicker'); +jest.mock('../hooks/useContributionActions'); jest.mock('../../../contexts/LogContext'); +jest.mock('../../../contexts/AuthContext'); // Resolve the reveal/count-up animations synchronously so assertions read final // values, not mid-animation frames. @@ -26,12 +28,6 @@ jest.mock('../useGivebackMotion', () => ({ useCountUp: (target: number) => target, })); -const mockStatus = useContributionStatus as jest.MockedFunction< - typeof useContributionStatus ->; -const mockSponsors = useContributionSponsors as jest.MockedFunction< - typeof useContributionSponsors ->; const mockContribution = useGivebackContribution as jest.MockedFunction< typeof useGivebackContribution >; @@ -44,7 +40,14 @@ const mockUserRewards = useContributionUserRewards as jest.MockedFunction< const mockClaim = useClaimContributionReward as jest.MockedFunction< typeof useClaimContributionReward >; +const mockCausePicker = useContributionCausePicker as jest.MockedFunction< + typeof useContributionCausePicker +>; +const mockActions = useContributionActions as jest.MockedFunction< + typeof useContributionActions +>; const mockLog = useLogContext as jest.MockedFunction; +const mockAuth = useAuthContext as jest.MockedFunction; const logEvent = jest.fn(); const tiers: ContributionRewardTier[] = [ @@ -73,20 +76,6 @@ const tiers: ContributionRewardTier[] = [ beforeEach(() => { jest.clearAllMocks(); - mockStatus.mockReturnValue({ - status: { - enabled: true, - eligible: true, - currentCyclePoints: 4000, - currentCycleTargetPoints: 10000, - lifetimePoints: 0, - lifetimeAmountCents: 0, - contributorsCount: 128, - userPoints: 40, - }, - isPending: false, - }); - mockSponsors.mockReturnValue({ sponsors: [], isPending: false }); mockContribution.mockReturnValue({ earnedPoints: 40, nextReward: tiers[1], @@ -102,28 +91,35 @@ beforeEach(() => { claim: jest.fn().mockResolvedValue(undefined), isPending: false, }); + mockCausePicker.mockReturnValue({ + causes: [], + selectedCauseIds: [], + isPending: false, + }); + mockActions.mockReturnValue({ + actions: [], + categories: [], + rewardTiers: [], + claimedRewardIds: [], + isPending: false, + }); mockLog.mockReturnValue({ logEvent } as unknown as ReturnType< typeof useLogContext >); -}); - -it('renders the funding progress section with live totals', () => { - render(); - - expect(screen.getByText('Funding progress')).toBeInTheDocument(); - expect(screen.getByText('$4,000')).toBeInTheDocument(); - expect(screen.getByText('128')).toBeInTheDocument(); + mockAuth.mockReturnValue({ user: null } as unknown as ReturnType< + typeof useAuthContext + >); }); it('renders the reward-ladder journey with the current level', () => { render(); - expect(screen.getByText('Your journey')).toBeInTheDocument(); + expect(screen.getByText(/for causes you love/)).toBeInTheDocument(); expect(screen.getByText('Sticker pack')).toBeInTheDocument(); expect(screen.getByText('One month of Plus')).toBeInTheDocument(); expect(screen.getByText('Hoodie')).toBeInTheDocument(); // $40 earned: the next milestone is the $100 tier. - expect(screen.getByText('Next up: One month of Plus')).toBeInTheDocument(); + expect(screen.getByText('$60 to your next reward')).toBeInTheDocument(); expect(screen.getByText('$60 to go')).toBeInTheDocument(); }); @@ -131,7 +127,7 @@ it('offers a claim for an unlocked, unclaimed tier and logs it', async () => { render(); // The $25 tier is reached at $40 and not yet claimed. - const claimButton = screen.getByRole('button', { name: 'Claim' }); + const claimButton = screen.getByRole('button', { name: /Claim reward/ }); expect(claimButton).toBeInTheDocument(); expect(screen.getByText(/ready to claim/)).toBeInTheDocument(); diff --git a/packages/shared/src/features/giveback/components/GivebackImpactPanel.tsx b/packages/shared/src/features/giveback/components/GivebackImpactPanel.tsx index 12d0b0b4e4c..035a4bc2e4b 100644 --- a/packages/shared/src/features/giveback/components/GivebackImpactPanel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackImpactPanel.tsx @@ -1,24 +1,17 @@ import type { ReactElement } from 'react'; import React from 'react'; import { FlexCol } from '../../../components/utilities'; -import { GivebackCommunityGoalProgress } from './GivebackCommunityGoalProgress'; import { GivebackPersonalRoadmap } from './GivebackPersonalRoadmap'; interface GivebackImpactPanelProps { onTakeAction: () => void; } -// The Impact tab: the campaign's funding progress (community pot + sponsors) -// followed by the visitor's own reward-ladder journey. The leaderboard and live -// community feed from the design are intentionally skipped — the campaign starts -// from scratch, so the social-proof half stays funding + personal progress. +// The Impact tab: the visitor's own reward-ladder journey through the campaign. export const GivebackImpactPanel = ({ onTakeAction, }: GivebackImpactPanelProps): ReactElement => ( - -
- -
+
); diff --git a/packages/shared/src/features/giveback/components/GivebackMascot.tsx b/packages/shared/src/features/giveback/components/GivebackMascot.tsx index ec37dfda7be..de403451dc8 100644 --- a/packages/shared/src/features/giveback/components/GivebackMascot.tsx +++ b/packages/shared/src/features/giveback/components/GivebackMascot.tsx @@ -12,11 +12,15 @@ export const GIVEBACK_CHARM_IMAGE = { interface GivebackMascotProps { className?: string; imageClassName?: string; + // Override the charm illustration (e.g. a different dog from the collection + // for a specific moment). Defaults to the Giveback charm. + image?: { src: string; alt: string }; } export const GivebackMascot = ({ className, imageClassName, + image = GIVEBACK_CHARM_IMAGE, }: GivebackMascotProps): ReactElement => (
{ if (!node || typeof node.scrollIntoView !== 'function') { @@ -40,26 +40,31 @@ export const GivebackPage = (): ReactElement => { const { logEvent } = useLogContext(); const { status } = useContributionStatus(); // Eligibility gates the cause picker query (backend-gated), so only eligible - // visitors load their picks — which also tells us whether to show the tabs. + // visitors load their picks - which also tells us whether to show the tabs. const isEligible = status?.eligible === true; const selection = useGivebackCauseSelection(isEligible); - // First-timers open the picker from the hero; once they confirm (or arrive - // already onboarded) the tabbed experience takes over. - const [startedPicker, setStartedPicker] = useState(false); + // Causes are confirmed inside the warm-up funnel; once they save (or the + // visitor arrives already onboarded) the tabbed experience takes over. const [completedOnboarding, setCompletedOnboarding] = useState(false); const [activeTab, setActiveTab] = useState('actions'); + // The warm-up funnel runs full-screen once for everyone, then only on demand + // via "How it works". Wait for the persisted flag so returning users never see + // a flash of it. + const [funnelSeen, setFunnelSeen, funnelLoaded] = + usePersistentContext('giveback:funnel_seen', false, [true, false]); + const [replayFunnel, setReplayFunnel] = useState(false); + const showTabs = selection.hasSavedCauses || completedOnboarding; - const showPicker = startedPicker && !showTabs; + const showFunnel = replayFunnel || (funnelLoaded && funnelSeen === false); - // Hold the hero CTA until we know the onboarding state, so its copy doesn't - // flip from "Join the campaign" to "Take action" after the data lands. Settled - // once the campaign status loads and (for eligible visitors) the picks do too. - const isCtaResolving = - !status || (isEligible && selection.isLoading && !completedOnboarding); + // Sponsors are gated off for launch (no sponsors yet); flip the flag on later. + const { value: showSponsors } = useConditionalFeature({ + feature: featureGivebackSponsors, + shouldEvaluate: true, + }); - const causesRef = useRef(null); const tabsRef = useRef(null); // The tab section can mount in the same tick we ask to scroll (right after @@ -82,12 +87,24 @@ export const GivebackPage = (): ReactElement => { event_name: LogEvent.ClickGivebackTab, extra: JSON.stringify({ tab }), }); + // Just swap the content below — don't scroll the page to the tab strip. + // The reserved min-height on the content area keeps the scroll position + // stable even when the newly-selected tab is shorter. setActiveTab(tab); }, [logEvent], ); - const handleContinue = useCallback(async () => { + const handleHowItWorks = useCallback(() => { + logEvent({ event_name: LogEvent.ClickGivebackHowItWorks }); + setReplayFunnel(true); + }, [logEvent]); + + // Finishing the funnel locks in the seen flag and saves the visitor's causes. + // A forced first run then drops them into the campaign; a replay just closes. + const handleFunnelComplete = useCallback(async () => { + const wasReplay = replayFunnel; + await setFunnelSeen(true); const saved = await selection.save(); if (saved) { logEvent({ @@ -95,19 +112,17 @@ export const GivebackPage = (): ReactElement => { extra: JSON.stringify({ cause_count: selection.selectedIds.size, cause_ids: [...selection.selectedIds], + origin: 'funnel', }), }); - setCompletedOnboarding(true); - goToActions(); } - }, [selection, goToActions, logEvent]); - - // Reveal the picker, then bring it into view as it mounts. - useEffect(() => { - if (showPicker) { - scrollIntoView(causesRef.current); + if (wasReplay) { + setReplayFunnel(false); + return; } - }, [showPicker]); + setCompletedOnboarding(true); + goToActions(); + }, [replayFunnel, setFunnelSeen, selection, logEvent, goToActions]); const activeLabel = givebackTabs.find((tab) => tab.id === activeTab)?.label; @@ -115,47 +130,14 @@ export const GivebackPage = (): ReactElement => {
- +
- setStartedPicker(true)} - onTakeAction={goToActions} - /> +
-
- -
- - {showPicker && ( -
- - - - Pick the causes you care about - - - Your actions fund the causes you choose. We fund developers, - not ads. - - - - + {showSponsors && ( +
+
)} @@ -167,18 +149,33 @@ export const GivebackPage = (): ReactElement => { key={activeTab} role="region" aria-label={activeLabel} - className={`${column} pt-8`} + // Only the filterable tabs reserve a viewport-tall area: when their + // list shrinks on filter, the extra height keeps the sticky tabs + // from springing back (the page "jump"). Impact/FAQ have no filters, + // so they fit content naturally — no dead gap before the footer. + className={`${column} pt-8 ${ + activeTab === 'actions' || activeTab === 'causes' + ? 'min-h-[calc(100dvh-3.5rem)]' + : '' + }`} > {activeTab === 'actions' && ( + - + )} {activeTab === 'impact' && ( )} - {activeTab === 'why' && } + {activeTab === 'causes' && ( + + )} + {activeTab === 'faq' && }
)} @@ -188,15 +185,14 @@ export const GivebackPage = (): ReactElement => {
- {showPicker && ( - setReplayFunnel(false)} + onComplete={handleFunnelComplete} /> )} - - {showTabs && }
); }; diff --git a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx index 2cec2b7eb08..527deb4343f 100644 --- a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import classNames from 'classnames'; import { FlexCol, FlexRow } from '../../../components/utilities'; import { @@ -10,6 +10,7 @@ import { } from '../../../components/typography/Typography'; import { Button, + ButtonColor, ButtonSize, ButtonVariant, } from '../../../components/buttons/Button'; @@ -21,18 +22,43 @@ import { LockIcon, MedalBadgeIcon, StarIcon, + UserIcon, VIcon, } from '../../../components/icons'; -import ConfettiSvg from '../../../svg/ConfettiSvg'; +import { + ProfilePicture, + ProfileImageSize, +} from '../../../components/ProfilePicture'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import type { LoggedUser } from '../../../lib/user'; import { useLogContext } from '../../../contexts/LogContext'; import { LogEvent } from '../../../lib/log'; import { useGivebackContribution } from '../hooks/useGivebackContribution'; import { useContributionRewards } from '../hooks/useContributionRewards'; import { useContributionUserRewards } from '../hooks/useContributionUserRewards'; import { useClaimContributionReward } from '../hooks/useClaimContributionReward'; +import { useContributionCausePicker } from '../hooks/useContributionCausePicker'; +import { useContributionActions } from '../hooks/useContributionActions'; import { ContributionRewardType } from '../types'; import { formatDonationAmount } from '../utils'; import { GivebackMeterShine } from './GivebackMeterShine'; +import { GivebackTabHeading } from './GivebackTabHeading'; + +// Joins up to three cause names into a natural list ("a, b, and c"), so the +// impact headline names exactly who the visitor's actions are funding. +const formatCauseNames = (names: string[]): string | null => { + const shown = names.slice(0, 3); + if (shown.length === 0) { + return null; + } + if (shown.length === 1) { + return shown[0]; + } + const head = shown.slice(0, -1).join(', '); + const tail = shown[shown.length - 1]; + const suffix = names.length > 3 ? ', and more' : ''; + return `${head}, and ${tail}${suffix}`; +}; // How many upcoming levels to reveal after the one you're on. The ladder can be // long, so we only ever render a window of it. @@ -60,21 +86,12 @@ interface RoadmapLevel { }; } -// One state drives every visual cue on a node, so "done", "you are here", and -// "locked" never disagree (RPG / battle-pass clarity). -type NodeState = 'claimed' | 'summit' | 'current' | 'unlocked' | 'locked'; - -const nodeStyles: Record = { - claimed: 'bg-accent-avocado-default text-white', - summit: - 'bg-gradient-to-br from-accent-cheese-default to-accent-bacon-default text-white shadow-2', - current: - 'bg-gradient-to-br from-accent-cabbage-default to-accent-onion-default text-white shadow-2-cabbage', - unlocked: 'bg-accent-cabbage-default text-white', - locked: - 'border border-border-subtlest-tertiary bg-surface-float text-text-quaternary', -}; - +// Contrast-first, branded palette so color carries meaning, not decoration: +// • markers are calm surface tiles by default (high contrast on the dark page) +// • green is only a "done" check accent, never a saturated fill +// • cabbage (brand) is the single live accent: you, your next goal, claimable +// • the summit alone gets a brand gradient fill so it reads as "the big one" +// • locked stays muted/dimmed type ConnectorFill = | { type: 'full' } | { type: 'partial'; progress: number } @@ -90,15 +107,18 @@ interface RoadmapNode { connector?: ConnectorFill; } +// A straight 3px track between nodes. Cleared segments are green; the live +// segment leading up to you fills in brand cabbage. One color per state, no +// gradients, so the rail reads as a single calm path. const Connector = ({ fill }: { fill: ConnectorFill }): ReactElement => ( -
-
+
+
{fill.type === 'full' && ( -
+
)} {fill.type === 'partial' && (
)} @@ -124,7 +144,7 @@ const RailToggle = ({ className="group flex w-full gap-4 text-left" >
- + {icon} {connectorBelow && } @@ -151,8 +171,38 @@ const RailToggle = ({ ); +// Directions the celebration confetti flies when a reward is claimed, fed to the +// reaction-burst keyframe via CSS custom properties. A fuller spread of brand +// colors makes the claim feel like a genuine "you did it" moment, not a flicker. +const claimSparkles: ReadonlyArray<{ + tx: string; + ty: string; + delay: string; + color: string; +}> = [ + { + tx: '-34px', + ty: '-26px', + delay: '0ms', + color: 'bg-accent-cabbage-default', + }, + { tx: '32px', ty: '-30px', delay: '30ms', color: 'bg-accent-cheese-default' }, + { tx: '44px', ty: '-2px', delay: '60ms', color: 'bg-accent-avocado-default' }, + { tx: '-42px', ty: '4px', delay: '20ms', color: 'bg-accent-onion-default' }, + { tx: '8px', ty: '-40px', delay: '10ms', color: 'bg-accent-cheese-default' }, + { + tx: '-14px', + ty: '-38px', + delay: '50ms', + color: 'bg-accent-cabbage-default', + }, + { tx: '24px', ty: '26px', delay: '40ms', color: 'bg-accent-avocado-default' }, + { tx: '-26px', ty: '24px', delay: '70ms', color: 'bg-accent-cheese-default' }, +]; + interface NodeRowProps { node: RoadmapNode; + user: LoggedUser | null; amountToNext: number; segmentProgress: number; isClaiming: boolean; @@ -160,8 +210,15 @@ interface NodeRowProps { onTakeAction: () => void; } +// Rounded rectangles, not circles - the squircle marker echoes daily.dev's +// branding (square avatars, rounded app tiles) and reads as custom-built rather +// than a generic battle-pass dot. +const markerBase = + 'flex size-10 items-center justify-center rounded-12 [&_svg]:size-5'; + const NodeRow = ({ node, + user, amountToNext, segmentProgress, isClaiming, @@ -179,69 +236,205 @@ const NodeRow = ({ onClaim(reward.id); }; - const getNodeState = (): NodeState => { - if (isReached && isSummit) { - return 'summit'; + // Clear the celebration once it has played so it can replay on a retry (e.g. + // after a failed claim) and never leaves the reward-pop class stuck on. + useEffect(() => { + if (!celebrate) { + return undefined; } + const timer = setTimeout(() => setCelebrate(false), 700); + return () => clearTimeout(timer); + }, [celebrate]); + + // The marker is the one cue that tells you, at a glance, what this stop is. + // Priority matters: "you" (your face) and the summit prize always win, so the + // trail never shows two competing highlights. + const renderMarker = (): ReactElement => { if (isCurrent) { - return 'current'; + // Your own face marks where you stand - a rounded square (not a circle) to + // match daily.dev's square avatars. + return user ? ( + + ) : ( + + + + ); + } + if (isSummit) { + // The grand prize: the single boldest tile, a brand gradient with a gift + // (white on cabbage→onion reads clearly, unlike a flat gold fill). + return ( + + {isClaimed ? : } + + ); } if (isClaimed) { - return 'claimed'; + // Done = a calm surface tile with a green check accent, not a saturated + // green fill (which washed out the icon). + return ( + + + + ); } if (isReached) { - return 'unlocked'; + // Unlocked, claim pending: cheese (yellow) accent matches the "ready to + // claim" cue and the Claim button, so claimable reads consistently. + return ( + + {rewardIconByType[reward.type]} + + ); } - return 'locked'; + if (isNext) { + // The immediate goal: the one filled brand tile, white on cabbage. + return ( + + {rewardIconByType[reward.type]} + + ); + } + return ( + + + + ); }; - const getNodeIcon = (): ReactElement => { - if (isClaimed) { - return ; + // The single right-hand action for this level. Exactly one of: claim the + // reward, take action toward it, a "done" tick, or a lock - so the column of + // actions reads cleanly top to bottom. + const buildActionSlot = (): ReactElement | null => { + if (canClaim) { + return ( +
+ {celebrate && ( + + + + {claimSparkles.map((sparkle) => ( + + ))} + + )} + +
+ ); } - if (isReached || isNext) { - return rewardIconByType[reward.type]; + if (isNext) { + // The current goal's action lives below the progress bar, not in the + // top-right slot, so it never overflows the card on narrow widths. + return null; } - return ; + if (isReached) { + return ( + + + + Done + + + ); + } + return ( + + + + ); }; const requirementLabel = level.requiredApprovedAmount > 0 ? formatDonationAmount(level.requiredApprovedAmount) : 'Free'; + const action = buildActionSlot(); return (
- {celebrate && ( - - )} - {isCurrent && ( + {(isCurrent || isNext) && ( )} - {getNodeIcon()} - - - {level.levelNumber} + {renderMarker()} {!isLast && } @@ -250,7 +443,10 @@ const NodeRow = ({
)} - {isClaimed && !isCurrent && ( - - - - Claimed - - - )} {reward.description} )} - {canClaim ? ( - - ) : ( - !isReached && - !isNext && ( - - - - ) + {/* Right-hand slot for claim / done / lock. The current goal's + action sits below the progress bar instead (see isNext). */} + {action && ( +
{action}
)} {isNext && ( - -
-
- + + +
+
+ +
-
- {formatDonationAmount(amountToNext)} to go - + )} @@ -377,11 +550,14 @@ export const GivebackPersonalRoadmap = ({ onTakeAction, }: GivebackPersonalRoadmapProps): ReactElement => { const { logEvent } = useLogContext(); - const { earnedPoints, currentLevel, isPending } = - useGivebackContribution(true); + const { user } = useAuthContext(); + const { earnedPoints, isPending } = useGivebackContribution(true); const { rewardTiers } = useContributionRewards(true); const { claimedRewardIds } = useContributionUserRewards(true); const { claim, isPending: isClaiming } = useClaimContributionReward(); + const { causes: pickerCauses, selectedCauseIds } = + useContributionCausePicker(true); + const { actions } = useContributionActions(true); const [claimingId, setClaimingId] = useState(null); const [showCompleted, setShowCompleted] = useState(true); const [showAllUpcoming, setShowAllUpcoming] = useState(false); @@ -428,6 +604,7 @@ export const GivebackPersonalRoadmap = ({ Reward milestones are on the way. Keep taking action to unlock them. @@ -436,11 +613,24 @@ export const GivebackPersonalRoadmap = ({ } const approved = earnedPoints; + const actionsTaken = actions.reduce( + (sum, action) => sum + action.userCompletions, + 0, + ); + const selectedNames = pickerCauses + .filter((cause) => selectedCauseIds.includes(cause.id)) + .map((cause) => cause.title); + const causeNames = formatCauseNames(selectedNames); + const hasImpact = approved > 0; const total = levels.length; - const focusIndex = Math.min(total - 1, Math.max(0, currentLevel - 1)); const nextIndex = levels.findIndex( (level) => level.requiredApprovedAmount > approved, ); + // "You're here" rides the goal you're climbing toward (the next unreached + // level), so the face marker, the highlighted card and the progress bar all + // land on one row — not split across the last-cleared and next levels. Once + // every level is reached, focus the summit. + const focusIndex = nextIndex === -1 ? total - 1 : nextIndex; const nextLevel = nextIndex === -1 ? undefined : levels[nextIndex]; const amountToNext = nextLevel ? Math.max(0, nextLevel.requiredApprovedAmount - approved) @@ -453,9 +643,11 @@ export const GivebackPersonalRoadmap = ({ approved >= level.requiredApprovedAmount && !claimedIds.has(level.id), ).length; - // Progress within the current segment (last reached → next level). + // Progress within the current segment (last reached → next level). With + // nothing reached yet the segment starts at 0, not the first tier's threshold + // (otherwise the denominator collapses to 0 and the bar sticks at 0%). const previousAmount = - levels[Math.max(0, reachedCount - 1)].requiredApprovedAmount; + reachedCount > 0 ? levels[reachedCount - 1].requiredApprovedAmount : 0; const segmentDenominator = nextLevel ? nextLevel.requiredApprovedAmount - previousAmount : 1; @@ -535,134 +727,139 @@ export const GivebackPersonalRoadmap = ({ return (
- - - - Your journey - - - - - Level - - - {focusIndex + 1} - - + + + + You turned {actionsTaken}{' '} + {actionsTaken === 1 ? 'action' : 'actions'} into{' '} + + {formatDonationAmount(approved)} + {' '} + for causes you love + + ) : ( + <> + Turn your everyday actions into{' '} + + real donations + + + ) + } + description={ + hasImpact && causeNames + ? `Headed to ${causeNames}. Every action you take adds more, and it never costs you a thing.` + : 'Every action you take sends real money to the causes you back. daily.dev funds it all, so you never pay a cent. Take your first one.' + } + /> - - - {nextLevel - ? `Next up: ${nextLevel.reward.title}` - : "You've unlocked every reward"} - - + + - Level {focusIndex + 1} of {total} ·{' '} - {formatDonationAmount(approved)} unlocked + {nextLevel + ? `${formatDonationAmount(amountToNext)} to your next reward` + : 'Every reward unlocked'} - - + {claimableCount > 0 && ( - + // A status note, not a tappable control: no pill background, just + // the gift glyph + cheese text pointing down to the ladder. + - - {claimableCount} ready to claim + + {claimableCount} {claimableCount === 1 ? 'reward' : 'rewards'}{' '} + ready to claim below )} - + - - {focusIndex > 0 && ( - - } - label={ - showCompleted - ? 'Hide completed levels' - : `Show ${focusIndex} completed ${ - focusIndex === 1 ? 'level' : 'levels' - }` - } - onClick={() => setShowCompleted((value) => !value)} - connectorBelow={{ type: 'full' }} - /> - )} + + + Rewards you unlock along the way + - {visibleNodes.map((node) => ( - - ))} - - {hiddenUpcoming > 0 && ( - } - label={`Show ${hiddenUpcoming} more ${ - hiddenUpcoming === 1 ? 'level' : 'levels' - }`} - onClick={() => setShowAllUpcoming(true)} - /> + + {focusIndex > 0 && ( + + } + label={ + showCompleted + ? 'Hide completed levels' + : `Show ${focusIndex} completed ${ + focusIndex === 1 ? 'level' : 'levels' + }` + } + onClick={() => setShowCompleted((value) => !value)} + connectorBelow={{ type: 'full' }} + /> + )} + + {visibleNodes.map((node) => ( + + ))} + + {hiddenUpcoming > 0 && ( + } + label={`Show ${hiddenUpcoming} more ${ + hiddenUpcoming === 1 ? 'level' : 'levels' + }`} + onClick={() => setShowAllUpcoming(true)} + /> + )} + + + {showAllUpcoming && canCollapseUpcoming && ( + + + )} - - {showAllUpcoming && canCollapseUpcoming && ( - - - - )}
); diff --git a/packages/shared/src/features/giveback/components/GivebackPlatformLogo.tsx b/packages/shared/src/features/giveback/components/GivebackPlatformLogo.tsx new file mode 100644 index 00000000000..3c4b55d7604 --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackPlatformLogo.tsx @@ -0,0 +1,52 @@ +import type { ComponentType, ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { IconSize } from '../../../components/Icon'; +import type { IconProps } from '../../../components/Icon'; + +interface GivebackPlatformLogoProps { + logoUrl?: string; + Icon: ComponentType; + forceDark?: boolean; + // Non-actionable tiles (done/in-review/cooldown) already grayscale the parent, + // so the glyph shouldn't be force-darkened on top of that. + isDimmed?: boolean; + iconSize?: IconSize; + className?: string; +} + +// Prefers the real brand logo (an SVG from the logo CDN) and falls back to the +// internal glyph if there is no logo for the surface or the remote one fails to +// load - so a tile is never broken or blank. The parent tile pins the +// background and applies any dimmed/grayscale treatment. +export const GivebackPlatformLogo = ({ + logoUrl, + Icon, + forceDark, + isDimmed, + iconSize = IconSize.Small, + className, +}: GivebackPlatformLogoProps): ReactElement => { + const [failed, setFailed] = useState(false); + + if (logoUrl && !failed) { + return ( + setFailed(true)} + className={classNames('object-contain', className ?? 'size-6')} + /> + ); + } + + return ( + + ); +}; diff --git a/packages/shared/src/features/giveback/components/GivebackSection.tsx b/packages/shared/src/features/giveback/components/GivebackSection.tsx index eff092eeda7..7bc24d12b34 100644 --- a/packages/shared/src/features/giveback/components/GivebackSection.tsx +++ b/packages/shared/src/features/giveback/components/GivebackSection.tsx @@ -39,6 +39,7 @@ export const GivebackSection = ({ tag={TypographyTag.H3} type={TypographyType.Title3} bold + className="[text-wrap:balance]" > {title} @@ -48,6 +49,7 @@ export const GivebackSection = ({ tag={TypographyTag.P} type={TypographyType.Callout} color={TypographyColor.Secondary} + className="[text-wrap:pretty]" > {description} 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/GivebackSponsorBudgetBar.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorBudgetBar.tsx index d8c6d2f5c36..6ab8122b868 100644 --- a/packages/shared/src/features/giveback/components/GivebackSponsorBudgetBar.tsx +++ b/packages/shared/src/features/giveback/components/GivebackSponsorBudgetBar.tsx @@ -21,7 +21,7 @@ export interface BudgetSponsor { } // iOS "storage bar" style: one bar split into proportional, color-coded -// segments — one per top sponsor, with the long tail grouped into "Others". The +// segments - one per top sponsor, with the long tail grouped into "Others". The // logo above defaults to the biggest sponsor and swaps to whichever segment you // hover or focus. const SEGMENT_PALETTE = [ diff --git a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.spec.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.spec.tsx index bb488474b5f..6d9eda34d19 100644 --- a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.spec.tsx +++ b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.spec.tsx @@ -38,7 +38,7 @@ const sponsor = ( ...overrides, }); -it('groups sponsors under their tier labels and renders brand logo cards', () => { +it('renders a brand logo card with an explicit tier pill per sponsor', () => { mockUseContributionSponsors.mockReturnValue({ sponsors: [ sponsor({ id: '1', name: 'Vercel', tier: ContributionSponsorTier.Gold }), diff --git a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx index a36dc4ce76b..bd163f0dbd7 100644 --- a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx +++ b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useState } from 'react'; import classNames from 'classnames'; import { FlexCol, FlexRow } from '../../../components/utilities'; import { @@ -8,6 +8,7 @@ import { TypographyTag, TypographyType, } from '../../../components/typography/Typography'; +import { MedalBadgeIcon } from '../../../components/icons'; import { useLogContext } from '../../../contexts/LogContext'; import { LogEvent } from '../../../lib/log'; import { useContributionSponsors } from '../hooks/useContributionSponsors'; @@ -16,47 +17,51 @@ import type { ContributionSponsor } from '../types'; import { ContributionSponsorTier } from '../types'; interface TierStyle { - // Tier marker dot + label color (soft, on-brand accent tints). - dotClass: string; + // Tint for the inline tier label. labelClass: string; - // White-card padding + logo height, stepping down platinum → backer. - chipClass: string; + // A FIXED logo height (not max-height): many brand SVGs ship with only a + // viewBox and no width/height, so `w-auto max-h-*` collapses their width to 0 + // and they render blank. A fixed height lets the browser derive width from the + // viewBox aspect ratio. Each tier also caps width proportional to its height - + // otherwise wide wordmark logos hit a shared cap and shrink, flattening the + // size hierarchy so gold and silver end up looking the same. Steps down by + // prestige: gold biggest, bronze smallest. logoClass: string; + // Fallback name size when a sponsor has no usable logo. + nameType: TypographyType; } const tierStyles: Record = { [ContributionSponsorTier.Gold]: { - dotClass: 'bg-accent-cheese-default', labelClass: 'text-accent-cheese-default', - chipClass: 'px-4 py-2.5', - logoClass: 'h-8 tablet:h-9', + logoClass: 'h-14 max-w-[260px]', + nameType: TypographyType.Body, }, [ContributionSponsorTier.Silver]: { - dotClass: 'bg-text-quaternary', labelClass: 'text-text-secondary', - chipClass: 'px-3.5 py-2', - logoClass: 'h-6 tablet:h-7', + logoClass: 'h-8 max-w-[150px]', + nameType: TypographyType.Footnote, }, [ContributionSponsorTier.Bronze]: { - dotClass: 'bg-accent-bacon-default', - labelClass: 'text-accent-bacon-default', - chipClass: 'px-3 py-1.5', - logoClass: 'h-5 tablet:h-6', + labelClass: 'text-accent-burger-default', + logoClass: 'h-4 max-w-[80px]', + nameType: TypographyType.Caption1, }, }; -// The headline tier sits on its own row inside a warm glow; the lower two -// tiers share the row beneath it. -const HEADLINE_TIERS = [ContributionSponsorTier.Gold]; -const LOWER_TIERS = [ +// Gold first so the strip reads left-to-right by prestige. +const TIER_ORDER: ContributionSponsorTier[] = [ + ContributionSponsorTier.Gold, ContributionSponsorTier.Silver, ContributionSponsorTier.Bronze, ]; -// Brand logos rest on white "medal cards" so they stay legible on the dark page -// regardless of the logo's own colors; logo-less sponsors (e.g. individuals) -// fall back to a quiet name pill so the card is never empty. -const SponsorChip = ({ +// A sponsor logo, monochrome (light-tinted) at rest so the mixed brand logos +// read as one calm row; on hover the tile fills white and reveals the logo's +// true colors. The logo only counts once it actually decodes - until then +// (loading, hung, blocked, 404, zero-size, or no URL) the name shows so nothing +// is ever blank. +const SponsorLogo = ({ sponsor, style, }: { @@ -64,6 +69,10 @@ const SponsorChip = ({ style: TierStyle; }): ReactElement => { const { logEvent } = useLogContext(); + const [logoLoaded, setLogoLoaded] = useState(false); + const [logoFailed, setLogoFailed] = useState(false); + const hasLogo = Boolean(sponsor.logoUrl) && !logoFailed; + const showName = !hasLogo || !logoLoaded; const onClick = () => logEvent({ @@ -72,59 +81,42 @@ const SponsorChip = ({ extra: JSON.stringify({ name: sponsor.name, tier: sponsor.tier }), }); - if (!sponsor.logoUrl) { - const pillClass = - 'flex shrink-0 items-center rounded-12 border border-border-subtlest-tertiary bg-surface-float px-3 py-1.5 transition-transform duration-200 hover:-translate-y-0.5 motion-reduce:transform-none'; - const name = ( - - {sponsor.name} - - ); - - if (!sponsor.url) { - return {name}; - } - - return ( - - {name} - - ); - } - - const cardClass = classNames( - 'flex shrink-0 items-center rounded-12 bg-white shadow-[0_10px_28px_-12px_rgba(0,0,0,0.6)] transition-transform duration-200 hover:-translate-y-1 motion-reduce:transform-none', - style.chipClass, - ); - const logo = ( - {`${sponsor.name} + {hasLogo && ( + {`${sponsor.name} setLogoLoaded(true)} + onError={() => setLogoFailed(true)} + className={classNames( + 'w-auto object-contain transition duration-200 [filter:brightness(0)_invert(1)] group-hover:[filter:none]', + style.logoClass, + !logoLoaded && 'hidden', + )} + /> )} - /> + {showName && ( + + {sponsor.name} + + )} + ); if (!sponsor.url) { return ( - - {logo} + + {body} ); } @@ -135,42 +127,20 @@ const SponsorChip = ({ target="_blank" rel="noopener noreferrer" aria-label={sponsor.name} - className={cardClass} + className={tileClass} onClick={onClick} > - {logo} + {body} ); }; -const SponsorTierGroup = ({ - tier, - sponsors, -}: { - tier: ContributionSponsorTier; - sponsors: ContributionSponsor[]; -}): ReactElement => { - const style = tierStyles[tier]; - - return ( - - - - - {sponsorTierLabel[tier]} - - - {sponsors.map((sponsor) => ( - - ))} - - ); -}; +const Divider = (): ReactElement => ( + +); export const GivebackSponsorTiers = (): ReactElement | null => { const { sponsors } = useContributionSponsors(); @@ -179,30 +149,15 @@ export const GivebackSponsorTiers = (): ReactElement | null => { return null; } - const byTier = (tier: ContributionSponsorTier) => - sponsors.filter((sponsor) => sponsor.tier === tier); - - const headlineTiers = HEADLINE_TIERS.filter( - (tier) => byTier(tier).length > 0, - ); - const lowerTiers = LOWER_TIERS.filter((tier) => byTier(tier).length > 0); + const tierGroups = TIER_ORDER.map((tier) => ({ + tier, + sponsors: sponsors.filter((sponsor) => sponsor.tier === tier), + })).filter((group) => group.sponsors.length > 0); return ( -
- {/* Soft brand glows, echoing the hero, so the wall feels native to the - page rather than a hard panel dropped on top of it. */} -
-
-
-
- - +
+ + {/* Label above the strip, left aligned. */} { Sponsored by - - {headlineTiers.length > 0 && ( -
- {/* Warm halo so the headline tiers glow softly above the rest. */} -
-
-
- - {headlineTiers.map((tier) => ( - - ))} - -
- )} - - {lowerTiers.length > 0 && ( - <> - {headlineTiers.length > 0 && ( -
- )} - - {lowerTiers.map((tier) => ( - - ))} - - - )} - + {/* One compact bordered strip spanning the page column; a divider + separates each tier section. */} +
+ {tierGroups.map((group, index) => { + const style = tierStyles[group.tier]; + return ( + + {index > 0 && } + + + + + {sponsorTierLabel[group.tier]} + + + + {group.sponsors.map((sponsor) => ( + + ))} + + + + ); + })} +
); 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..688b8f439b8 100644 --- a/packages/shared/src/features/giveback/components/GivebackTabNav.spec.tsx +++ b/packages/shared/src/features/giveback/components/GivebackTabNav.spec.tsx @@ -6,15 +6,16 @@ it('renders the tabs from the shared tab list', () => { render(); expect(screen.getByText('Take action')).toBeInTheDocument(); - expect(screen.getByText('Impact')).toBeInTheDocument(); - expect(screen.getByText('Campaign')).toBeInTheDocument(); + expect(screen.getByText('Your impact')).toBeInTheDocument(); + expect(screen.getByText('Causes')).toBeInTheDocument(); + expect(screen.getByText('FAQ')).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..f6278a90f91 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' | 'faq'; interface GivebackTab { id: GivebackTabId; @@ -11,8 +11,9 @@ interface GivebackTab { export const givebackTabs: GivebackTab[] = [ { id: 'actions', label: 'Take action' }, - { id: 'impact', label: 'Impact' }, - { id: 'why', label: 'Campaign' }, + { id: 'impact', label: 'Your impact' }, + { id: 'causes', label: 'Causes' }, + { id: 'faq', label: 'FAQ' }, ]; interface GivebackTabNavProps { @@ -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 all four tabs stay 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/useContributionCausePicker.ts b/packages/shared/src/features/giveback/hooks/useContributionCausePicker.ts index b5f94c8ac94..b534477182a 100644 --- a/packages/shared/src/features/giveback/hooks/useContributionCausePicker.ts +++ b/packages/shared/src/features/giveback/hooks/useContributionCausePicker.ts @@ -17,7 +17,7 @@ const MAX_CAUSES = 100; // The cause catalog and the visitor's saved picks in one request. Auth + // eligibility gated on the backend, so only fetch once signed in and the picker -// is reachable (after a logged-in join) — otherwise the request 403s. Keyed by +// is reachable (after a logged-in join) - otherwise the request 403s. Keyed by // user since the saved picks are user-specific. export const useContributionCausePicker = ( enabled: boolean, diff --git a/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.spec.tsx b/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.spec.tsx index cff63557362..9fe82f3d409 100644 --- a/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.spec.tsx +++ b/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.spec.tsx @@ -1,4 +1,7 @@ +import type { ReactNode } from 'react'; +import React from 'react'; import { act, renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useGivebackCauseSelection } from './useGivebackCauseSelection'; import { useContributionCausePicker } from './useContributionCausePicker'; import { useUpdateContributionCausePreferences } from './useUpdateContributionCausePreferences'; @@ -23,6 +26,13 @@ const mockUseToast = useToastNotification as jest.MockedFunction< const saveCausePreferences = jest.fn().mockResolvedValue(undefined); const displayToast = jest.fn(); +// The hook reads the query client (to force-clear the save toast), so renders +// need a provider. +const queryClient = new QueryClient(); +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + beforeEach(() => { jest.clearAllMocks(); mockUseUpdate.mockReturnValue({ saveCausePreferences, isPending: false }); @@ -43,7 +53,9 @@ it('seeds the selection from saved preferences', () => { isPending: false, }); - const { result } = renderHook(() => useGivebackCauseSelection(true)); + const { result } = renderHook(() => useGivebackCauseSelection(true), { + wrapper, + }); expect(result.current.selectedCount).toBe(2); expect(result.current.selectedIds.has('c1')).toBe(true); @@ -51,7 +63,9 @@ it('seeds the selection from saved preferences', () => { }); it('reports no saved causes when the visitor has none', () => { - const { result } = renderHook(() => useGivebackCauseSelection(true)); + const { result } = renderHook(() => useGivebackCauseSelection(true), { + wrapper, + }); expect(result.current.hasSavedCauses).toBe(false); }); @@ -66,7 +80,7 @@ it('does not seed an empty selection while disabled, then seeds once enabled', ( const { result, rerender } = renderHook( ({ enabled }) => useGivebackCauseSelection(enabled), - { initialProps: { enabled: false } }, + { initialProps: { enabled: false }, wrapper }, ); expect(result.current.selectedCount).toBe(0); @@ -84,7 +98,9 @@ it('does not seed an empty selection while disabled, then seeds once enabled', ( }); it('toggles a cause on and off', () => { - const { result } = renderHook(() => useGivebackCauseSelection(true)); + const { result } = renderHook(() => useGivebackCauseSelection(true), { + wrapper, + }); act(() => result.current.toggleCause('c1')); expect(result.current.selectedIds.has('c1')).toBe(true); @@ -94,7 +110,9 @@ it('toggles a cause on and off', () => { }); it('saves the current selection and toasts', async () => { - const { result } = renderHook(() => useGivebackCauseSelection(true)); + const { result } = renderHook(() => useGivebackCauseSelection(true), { + wrapper, + }); act(() => result.current.toggleCause('c1')); let saved: boolean | undefined; @@ -105,13 +123,17 @@ it('saves the current selection and toasts', async () => { await waitFor(() => expect(saveCausePreferences).toHaveBeenCalledWith(['c1']), ); - expect(displayToast).toHaveBeenCalledWith('Your causes are saved'); + expect(displayToast).toHaveBeenCalledWith('Your causes are saved', { + timer: 3000, + }); expect(saved).toBe(true); }); it('toasts a generic error and reports failure when saving fails', async () => { saveCausePreferences.mockRejectedValueOnce(new Error('network')); - const { result } = renderHook(() => useGivebackCauseSelection(true)); + const { result } = renderHook(() => useGivebackCauseSelection(true), { + wrapper, + }); let saved: boolean | undefined; await act(async () => { diff --git a/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts b/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts index 3c9e779548c..0fd37dbea7a 100644 --- a/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts +++ b/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts @@ -1,5 +1,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { useToastNotification } from '../../../hooks/useToastNotification'; +import { useQueryClient } from '@tanstack/react-query'; +import { + TOAST_NOTIF_KEY, + useToastNotification, +} from '../../../hooks/useToastNotification'; import { labels } from '../../../lib/labels'; import { useContributionCausePicker } from './useContributionCausePicker'; import { useUpdateContributionCausePreferences } from './useUpdateContributionCausePreferences'; @@ -10,6 +14,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; @@ -24,12 +31,22 @@ export const useGivebackCauseSelection = ( enabled: boolean, ): UseGivebackCauseSelection => { const { displayToast } = useToastNotification(); + const queryClient = useQueryClient(); const { causes, selectedCauseIds, isPending } = useContributionCausePicker(enabled); const { saveCausePreferences, isPending: isSaving } = 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; + + // Tracks the pending toast force-clear timer so a quick unmount (or a second + // save) doesn't leave a stray timeout firing later. + const clearToastTimer = useRef>(); + useEffect(() => () => clearTimeout(clearToastTimer.current), []); // Seed from saved preferences once they resolve, so editing starts from the // visitor's current selection without stomping later in-picker toggles. Wait @@ -57,22 +74,59 @@ 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]); - displayToast('Your causes are saved'); + displayToast('Your causes are saved', { timer: 3000 }); + // Force-clear after the timer: the global toast only auto-dismisses when + // the user's "auto-dismiss notifications" setting is on, and a save + // confirmation should never sit there waiting to be closed manually. + // Guard on identity so a newer toast is never clobbered. + const shown = queryClient.getQueryData(TOAST_NOTIF_KEY); + clearTimeout(clearToastTimer.current); + clearToastTimer.current = setTimeout(() => { + if (queryClient.getQueryData(TOAST_NOTIF_KEY) === shown) { + queryClient.setQueryData(TOAST_NOTIF_KEY, null); + } + }, 3000); return true; } catch { displayToast(labels.error.generic); return false; } - }, [saveCausePreferences, selectedIds, displayToast]); + }, [saveCausePreferences, selectedIds, displayToast, queryClient]); return { causes, isLoading: isPending, selectedIds, toggleCause, + toggleAndSave, selectedCount: selectedIds.size, hasSavedCauses: selectedCauseIds.length > 0, save, diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index cfd1f0385e6..5e0513c814a 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -263,6 +263,10 @@ export const featurePublicSignupBanner = new Feature( false, ); +// Off at launch: the giveback campaign starts without sponsors. Flip on once +// real sponsors exist to show the "Sponsored by" wall. +export const featureGivebackSponsors = new Feature('giveback_sponsors', false); + export enum DailyPageVariant { None = 'none', V1 = 'v1.1', diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 34cd556d014..0360452ec9d 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -510,6 +510,10 @@ export enum LogEvent { ClickGivebackEditCauses = 'click giveback edit causes', ClickGivebackCause = 'click giveback cause', ClickGivebackFaq = 'click giveback faq', + StartGivebackFunnel = 'start giveback funnel', + ViewGivebackFunnelStep = 'view giveback funnel step', + CompleteGivebackFunnel = 'complete giveback funnel', + ClickGivebackHowItWorks = 'click giveback how it works', // Daily homepage DailyFeedback = 'daily feedback', } diff --git a/packages/shared/src/svg/ConfettiSvg.tsx b/packages/shared/src/svg/ConfettiSvg.tsx deleted file mode 100644 index bc7b615838b..00000000000 --- a/packages/shared/src/svg/ConfettiSvg.tsx +++ /dev/null @@ -1,428 +0,0 @@ -import type { HTMLAttributes, ReactElement } from 'react'; -import React from 'react'; - -export default function ConfettiSvg( - props: HTMLAttributes, -): ReactElement { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/packages/shared/tailwind.config.ts b/packages/shared/tailwind.config.ts index 7ea3ce23f76..7dca8eeda57 100644 --- a/packages/shared/tailwind.config.ts +++ b/packages/shared/tailwind.config.ts @@ -320,6 +320,32 @@ export default { '55%': { transform: 'scale(1.18)', opacity: '1' }, '100%': { transform: 'scale(1)', opacity: '1' }, }, + // Claim feedback: a ring of light bursts outward from the claim button + // and fades — the "level up / reward unlocked" beat, replacing confetti. + 'claim-ring': { + '0%': { transform: 'scale(0.65)', opacity: '0.85' }, + '100%': { transform: 'scale(1.9)', opacity: '0' }, + }, + // Signature "feel better" enter: rise + de-blur + fade in. Stagger + // children with animation-delay for a choreographed reveal. + 'funnel-step-in': { + '0%': { + opacity: '0', + transform: 'translateY(12px)', + filter: 'blur(8px)', + }, + '100%': { + opacity: '1', + transform: 'translateY(0)', + filter: 'blur(0)', + }, + }, + // A coin dropping into the community pot. + 'coin-drop': { + '0%': { opacity: '0', transform: 'translateY(-16px) scale(0.5)' }, + '60%': { opacity: '1' }, + '100%': { opacity: '1', transform: 'translateY(0) scale(1)' }, + }, 'mascot-bob': { '0%, 100%': { transform: 'translateY(0)' }, '50%': { transform: 'translateY(-6px)' }, @@ -390,6 +416,11 @@ export default { 'meter-shine': 'meter-shine 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite', 'glow-pulse': 'glow-pulse 3s ease-in-out infinite', 'reward-pop': 'reward-pop 480ms cubic-bezier(0.34, 1.56, 0.64, 1) both', + 'claim-ring': + 'claim-ring 640ms cubic-bezier(0.22, 0.61, 0.36, 1) forwards', + 'funnel-step-in': + 'funnel-step-in 600ms cubic-bezier(0.25, 0.46, 0.45, 0.94) both', + 'coin-drop': 'coin-drop 500ms cubic-bezier(0.34, 1.2, 0.64, 1) both', 'streak-fade': 'streak-fade 2.6s ease-in-out infinite', 'streak-pulse': 'streak-pulse 2.2s ease-in-out infinite', 'streak-border-pulse': 'streak-border-pulse 2.2s ease-in-out infinite', diff --git a/packages/storybook/stories/features/giveback/GivebackActionCatalog.stories.tsx b/packages/storybook/stories/features/giveback/GivebackActionCatalog.stories.tsx new file mode 100644 index 00000000000..8a3384a72fd --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackActionCatalog.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackActionCatalog } from '@dailydotdev/shared/src/features/giveback/components/GivebackActionCatalog'; +import { mockActions, withGiveback } from './giveback.mocks'; + +// The "Take action" grid: paid growth actions with category filter chips and a +// "Show more" expand, plus the voluntary "love" actions. Each card opens the +// submission modal. Reads actions + categories from the actions query. +const meta: Meta = { + title: 'Features/Giveback/Action catalog', + component: GivebackActionCatalog, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Filter by category, open a card to launch the submission modal. Shows the full catalog and an empty state.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + decorators: [withGiveback()], +}; + +export const SingleCategory: Story = { + parameters: { + docs: { description: { story: 'Only content actions — no category chips.' } }, + }, + decorators: [ + withGiveback({ + actions: mockActions().filter((a) => a.categoryId === 'cat-content'), + categories: [{ id: 'cat-content', title: 'Content' }], + }), + ], +}; + +export const Empty: Story = { + decorators: [withGiveback({ actions: [], categories: [] })], +}; diff --git a/packages/storybook/stories/features/giveback/GivebackActionSubmissionModal.stories.tsx b/packages/storybook/stories/features/giveback/GivebackActionSubmissionModal.stories.tsx new file mode 100644 index 00000000000..4c9b28a7191 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackActionSubmissionModal.stories.tsx @@ -0,0 +1,113 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackActionSubmissionModal } from '@dailydotdev/shared/src/features/giveback/components/GivebackActionSubmissionModal'; +import type { ContributionAction } from '@dailydotdev/shared/src/features/giveback/types'; +import { withGiveback } from './giveback.mocks'; + +// The proof-submission modal opened from an action card. The form adapts to the +// action's `evidence`: a link field, a screenshot upload, and/or a note. Love +// actions skip the reward and just say thanks. +const meta: Meta = { + title: 'Features/Giveback/Submission modal', + component: GivebackActionSubmissionModal, + args: { onClose: () => undefined }, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'The pop-up that collects proof for an action. Variants show the link-only, screenshot, full (link + screenshot + note), and the love-action (no reward) layouts.', + }, + }, + }, + decorators: [withGiveback()], +}; + +export default meta; + +type Story = StoryObj; + +const makeAction = ( + overrides: Partial, +): ContributionAction => ({ + id: 'a-modal', + categoryId: 'cat-content', + title: 'Post about daily.dev on X', + description: 'A quick post about what you like helps more developers find us.', + points: 120, + evidence: { url: { required: true } }, + metadata: { + platform: 'x', + instructions: + 'Write a short post about what you like in daily.dev.\nInclude a link to daily.dev so people can find it.\nCopy the link to your post and paste it below.', + externalUrl: 'https://x.com/compose/post', + isLoveAction: false, + }, + cooldownSeconds: null, + maxPerUser: null, + userCooldownEndsAt: null, + userCompletions: 0, + latestUserSubmission: null, + ...overrides, +}); + +export const LinkOnly: Story = { + parameters: { + docs: { + description: { + story: + 'Branded action: platform logo, the ask, the reward, a numbered how-to, and a "Go to X" button — then the proof link field.', + }, + }, + }, + args: { action: makeAction({}) }, +}; + +export const Screenshot: Story = { + args: { + action: makeAction({ + title: 'Host a daily.dev meetup', + description: 'Bring developers together in person around daily.dev.', + points: 250, + evidence: { screenshot: { required: true } }, + metadata: { + platform: 'event', + instructions: 'Upload a photo from the meetup.', + externalUrl: null, + isLoveAction: false, + }, + }), + }, +}; + +export const FullProof: Story = { + args: { + action: makeAction({ + title: 'Speak about daily.dev at an event', + points: 200, + evidence: { + url: { required: true }, + screenshot: { required: false }, + note: { required: false }, + }, + }), + }, +}; + +export const LoveAction: Story = { + parameters: { + docs: { description: { story: 'A voluntary thank-you — no reward attached.' } }, + }, + args: { + action: makeAction({ + title: 'Leave us a kind word', + points: 0, + evidence: { note: { required: true } }, + metadata: { + platform: null, + instructions: null, + externalUrl: null, + isLoveAction: true, + }, + }), + }, +}; diff --git a/packages/storybook/stories/features/giveback/GivebackCampaignPieces.stories.tsx b/packages/storybook/stories/features/giveback/GivebackCampaignPieces.stories.tsx new file mode 100644 index 00000000000..626fe922a14 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackCampaignPieces.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackFaq } from '@dailydotdev/shared/src/features/giveback/components/GivebackFaq'; +import { withGiveback } from './giveback.mocks'; + +// The building blocks of the FAQ tab. The campaign's "why" headline now lives in +// the page hero, so this is the FAQ on its own. +const meta: Meta = { + title: 'Features/Giveback/Campaign pieces', + parameters: { layout: 'padded' }, + decorators: [withGiveback({ selectedCauseIds: ['c-oss', 'c-access', 'c-docs'] })], +}; + +export default meta; + +type Story = StoryObj; + +export const Faq: Story = { + render: () => , +}; diff --git a/packages/storybook/stories/features/giveback/GivebackCauseCard.stories.tsx b/packages/storybook/stories/features/giveback/GivebackCauseCard.stories.tsx new file mode 100644 index 00000000000..ad2fb18614a --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackCauseCard.stories.tsx @@ -0,0 +1,54 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackCauseCard } from '@dailydotdev/shared/src/features/giveback/components/GivebackCauseCard'; +import { mockCauses } from './giveback.mocks'; + +const cause = mockCauses()[0]; + +// The rich, selectable cause card used in the onboarding picker and the "more +// causes to explore" grid: emblem, name, category, full description (clamped), +// a "learn more" link to the cause site, and the select tick. +const meta: Meta = { + title: 'Features/Giveback/Cause card', + component: GivebackCauseCard, + args: { cause, index: 0, onToggle: () => undefined }, + parameters: { layout: 'padded' }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Unselected: Story = { args: { selected: false } }; + +export const Selected: Story = { args: { selected: true } }; + +export const Interactive: Story = { + parameters: { + docs: { description: { story: 'Click the card to toggle selection.' } }, + }, + render: (args) => { + const [selected, setSelected] = useState(false); + return ( + setSelected((value) => !value)} + /> + ); + }, +}; + +export const WithoutLinkOrDescription: Story = { + args: { + selected: false, + cause: { ...cause, url: null, description: null }, + }, +}; diff --git a/packages/storybook/stories/features/giveback/GivebackCauseSelection.stories.tsx b/packages/storybook/stories/features/giveback/GivebackCauseSelection.stories.tsx new file mode 100644 index 00000000000..9a97f144375 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackCauseSelection.stories.tsx @@ -0,0 +1,70 @@ +import React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackCauseSelection } from '@dailydotdev/shared/src/features/giveback/components/GivebackCauseSelection'; +import { mockCauses, withGiveback } from './giveback.mocks'; + +// The cause picker grid + category filter chips. Prop-driven: pass the causes, +// the selected ids set, and a toggle handler. These stories wire an interactive +// selection so you can click cards and filter. +const meta: Meta = { + title: 'Features/Giveback/Cause selection', + component: GivebackCauseSelection, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Interactive: click cards to select, use the category chips to filter. Covers the loaded grid, a pre-selected state, the loading skeleton, and the empty state.', + }, + }, + }, + decorators: [withGiveback()], +}; + +export default meta; + +type Story = StoryObj; + +const Interactive = ({ + preset = [], + isLoading = false, + empty = false, +}: { + preset?: string[]; + isLoading?: boolean; + empty?: boolean; +}) => { + const [selectedIds, setSelectedIds] = useState>( + () => new Set(preset), + ); + return ( +
+ + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }) + } + /> +
+ ); +}; + +export const Default: Story = { render: () => }; + +export const WithPreselected: Story = { + render: () => , +}; + +export const Loading: Story = { render: () => }; + +export const Empty: Story = { render: () => }; diff --git a/packages/storybook/stories/features/giveback/GivebackCausesPanel.stories.tsx b/packages/storybook/stories/features/giveback/GivebackCausesPanel.stories.tsx new file mode 100644 index 00000000000..7565f4c0dd6 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackCausesPanel.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackCausesPanel } from '@dailydotdev/shared/src/features/giveback/components/GivebackCausesPanel'; +import { withGiveback } from './giveback.mocks'; + +// The "Causes" management tab: every cause as a pickable card, the ones you +// already back up top, everything else below. Toggle cards to see the "Save +// changes" bar appear (it only shows when the working set differs from what's +// saved). +const meta: Meta = { + title: 'Features/Giveback/Causes tab', + component: GivebackCausesPanel, + parameters: { layout: 'padded' }, +}; + +export default meta; + +type Story = StoryObj; + +export const WithSelection: Story = { + decorators: [ + withGiveback({ selectedCauseIds: ['c-oss', 'c-access', 'c-docs'] }), + ], +}; + +export const NothingPicked: Story = { + parameters: { + docs: { + description: { + story: 'Empty "Your causes" state, with everything available below.', + }, + }, + }, + decorators: [withGiveback({ selectedCauseIds: [] })], +}; diff --git a/packages/storybook/stories/features/giveback/GivebackContributionSummary.stories.tsx b/packages/storybook/stories/features/giveback/GivebackContributionSummary.stories.tsx new file mode 100644 index 00000000000..aaca2c60d7a --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackContributionSummary.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackContributionSummary } from '@dailydotdev/shared/src/features/giveback/components/GivebackContributionSummary'; +import { mockStatus, withGiveback } from './giveback.mocks'; + +// The personal recap above the action catalog: square avatar + level badge, +// amount unlocked for your causes, actions taken, and the next reward. Reads +// your points, reward tiers and completed actions. +const meta: Meta = { + title: 'Features/Giveback/Contribution summary', + component: GivebackContributionSummary, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Personal progress recap. The "to go" figure is green; the avatar is a rounded square with a level badge. Shown at a few point levels.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + decorators: [withGiveback({ status: mockStatus({ userPoints: 320 }) })], +}; + +export const FreshContributor: Story = { + decorators: [withGiveback({ status: mockStatus({ userPoints: 0 }) })], +}; + +export const AllRewardsUnlocked: Story = { + decorators: [withGiveback({ status: mockStatus({ userPoints: 2500 }) })], +}; diff --git a/packages/storybook/stories/features/giveback/GivebackFaqPanel.stories.tsx b/packages/storybook/stories/features/giveback/GivebackFaqPanel.stories.tsx new file mode 100644 index 00000000000..562094d0f07 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackFaqPanel.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackFaqPanel } from '@dailydotdev/shared/src/features/giveback/components/GivebackFaqPanel'; +import { withGiveback } from './giveback.mocks'; + +// The "FAQ" tab: the campaign's questions and answers (the "why" headline now +// lives in the page hero). +const meta: Meta = { + title: 'Features/Giveback/FAQ tab', + component: GivebackFaqPanel, + parameters: { layout: 'padded' }, + decorators: [withGiveback({ selectedCauseIds: ['c-oss', 'c-access'] })], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/storybook/stories/features/giveback/GivebackFilterChip.stories.tsx b/packages/storybook/stories/features/giveback/GivebackFilterChip.stories.tsx new file mode 100644 index 00000000000..16cdb97081b --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackFilterChip.stories.tsx @@ -0,0 +1,54 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackFilterChip } from '@dailydotdev/shared/src/features/giveback/components/GivebackFilterChip'; + +// The tag-style category filter used across the cause picker, the Causes tab, +// and the action catalog. Bordered surface by default, brand fill when active. +const meta: Meta = { + title: 'Features/Giveback/Filter chip', + component: GivebackFilterChip, + parameters: { layout: 'padded' }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const States: Story = { + render: () => ( +
+ undefined} /> + undefined} + /> +
+ ), +}; + +export const FilterRow: Story = { + render: () => { + const filters = ['All', 'Open source', 'Education', 'Climate', 'Accessibility']; + const [active, setActive] = useState('All'); + return ( +
+ {filters.map((label) => ( + setActive(label)} + /> + ))} +
+ ); + }, +}; diff --git a/packages/storybook/stories/features/giveback/GivebackFundingSummary.stories.tsx b/packages/storybook/stories/features/giveback/GivebackFundingSummary.stories.tsx new file mode 100644 index 00000000000..fe437b27977 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackFundingSummary.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackFundingSummary } from '@dailydotdev/shared/src/features/giveback/components/GivebackFundingSummary'; +import { mockStatus, withGiveback } from './giveback.mocks'; + +// The hero funding meter: raised vs goal, percent funded, backers — the +// crowdfunding "pledge panel". Drives the cover progress bar. +const meta: Meta = { + title: 'Features/Giveback/Funding summary', + component: GivebackFundingSummary, + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'Live funding meter shown on the page cover. Seeded from the contribution overview query. Try the states below: partial funding, near-goal, fully funded, empty (nothing pledged yet) and the loading skeleton (no goal configured).', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const PartiallyFunded: Story = { + decorators: [withGiveback({ status: mockStatus() })], +}; + +export const NearGoal: Story = { + decorators: [ + withGiveback({ + status: mockStatus({ currentCyclePoints: 11400, contributorsCount: 2600 }), + }), + ], +}; + +export const FullyFunded: Story = { + decorators: [ + withGiveback({ + status: mockStatus({ + currentCyclePoints: 12000, + contributorsCount: 3120, + }), + }), + ], +}; + +export const Empty: Story = { + parameters: { + docs: { + description: { + story: 'Nothing pledged yet — leads with the goal and a shimmering track.', + }, + }, + }, + decorators: [ + withGiveback({ + status: mockStatus({ currentCyclePoints: 0, contributorsCount: 0 }), + }), + ], +}; + +export const LoadingSkeleton: Story = { + parameters: { + docs: { + description: { + story: 'No goal configured / data still loading — quiet skeleton.', + }, + }, + }, + decorators: [ + withGiveback({ status: mockStatus({ currentCycleTargetPoints: 0 }) }), + ], +}; diff --git a/packages/storybook/stories/features/giveback/GivebackFunnel.stories.tsx b/packages/storybook/stories/features/giveback/GivebackFunnel.stories.tsx new file mode 100644 index 00000000000..efca3ec30a0 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackFunnel.stories.tsx @@ -0,0 +1,100 @@ +import React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackFunnel } from '@dailydotdev/shared/src/features/giveback/components/GivebackFunnel'; +import { mockCauses, withGiveback } from './giveback.mocks'; + +// The full-screen warm-up funnel shown once before the campaign: intro (with the +// floating explainer video) → how it works → pick causes → impact. `selection` +// is the cause-picker state; here it's an interactive mock so you can toggle +// causes and walk all four steps. Use the toolbar light/dark switch too. +const meta: Meta = { + title: 'Features/Giveback/Funnel', + component: GivebackFunnel, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Forced once for everyone, then replayable from a "How it works" button. The default story is the forced run (no close); the Replayable story shows the dismissible variant. Click through the CTAs to see the staggered step animations and the milestone track.', + }, + }, + }, + decorators: [withGiveback()], +}; + +export default meta; + +type Story = StoryObj; + +const useMockSelection = (preset: string[] = []) => { + const [selectedIds, setSelectedIds] = useState>( + () => new Set(preset), + ); + const toggleCause = (id: string) => + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + return { + causes: mockCauses(), + isLoading: false, + selectedIds, + selectedCount: selectedIds.size, + hasSavedCauses: false, + isSaving: false, + save: async () => true, + toggleCause, + toggleAndSave: toggleCause, + }; +}; + +export const Forced: Story = { + render: () => { + // Nothing pre-selected — matches a brand-new user picking causes for the + // first time. Click through to the finale to see the value-prop step. + const selection = useMockSelection(); + return undefined} />; + }, +}; + +export const ForcedWithCauses: Story = { + parameters: { + docs: { + description: { + story: + 'Causes already picked, so the finale shows the personalised gratitude line. Walk Got it → Sounds good → Continue → Let’s start.', + }, + }, + }, + render: () => { + const selection = useMockSelection(['c-oss', 'c-scholarships', 'c-access']); + return undefined} />; + }, +}; + +export const Replayable: Story = { + parameters: { + docs: { + description: { + story: + 'Opened from "How it works" — shows the close button so it can be dismissed.', + }, + }, + }, + render: () => { + const selection = useMockSelection(); + return ( + undefined} + onComplete={() => undefined} + /> + ); + }, +}; diff --git a/packages/storybook/stories/features/giveback/GivebackHero.stories.tsx b/packages/storybook/stories/features/giveback/GivebackHero.stories.tsx new file mode 100644 index 00000000000..638a041f603 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackHero.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackHero } from '@dailydotdev/shared/src/features/giveback/components/GivebackHero'; +import { mockStatus, withGiveback } from './giveback.mocks'; + +// The page cover: brand + "How it works" across the top, the "Ad budgets buy +// clicks. Ours funds real causes." headline with the funding meter on the left, +// and charm on the right. +const meta: Meta = { + title: 'Features/Giveback/Page cover (hero)', + component: GivebackHero, + args: { onHowItWorks: () => undefined }, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Headline + funding meter (with milestone markers) on the left, charm on the right. Shown at a few funding levels.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const PartiallyFunded: Story = { + decorators: [withGiveback({ status: mockStatus() })], +}; + +export const Empty: Story = { + decorators: [ + withGiveback({ status: mockStatus({ currentCyclePoints: 0, contributorsCount: 0 }) }), + ], +}; + +export const NearGoal: Story = { + decorators: [ + withGiveback({ status: mockStatus({ currentCyclePoints: 11600 }) }), + ], +}; diff --git a/packages/storybook/stories/features/giveback/GivebackImpactPanel.stories.tsx b/packages/storybook/stories/features/giveback/GivebackImpactPanel.stories.tsx new file mode 100644 index 00000000000..5aadfc1e062 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackImpactPanel.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackImpactPanel } from '@dailydotdev/shared/src/features/giveback/components/GivebackImpactPanel'; +import { mockStatus, withGiveback } from './giveback.mocks'; + +// The "Impact" tab: the visitor's journey roadmap (the funding-progress section +// was intentionally removed). Wraps GivebackPersonalRoadmap. +const meta: Meta = { + title: 'Features/Giveback/Impact tab', + component: GivebackImpactPanel, + args: { onTakeAction: () => undefined }, + parameters: { layout: 'padded' }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + decorators: [withGiveback({ status: mockStatus({ userPoints: 320 }) })], +}; diff --git a/packages/storybook/stories/features/giveback/GivebackPersonalRoadmap.stories.tsx b/packages/storybook/stories/features/giveback/GivebackPersonalRoadmap.stories.tsx new file mode 100644 index 00000000000..103a582fb6d --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackPersonalRoadmap.stories.tsx @@ -0,0 +1,83 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackPersonalRoadmap } from '@dailydotdev/shared/src/features/giveback/components/GivebackPersonalRoadmap'; +import { mockStatus, withGiveback } from './giveback.mocks'; + +// The visitor's reward-ladder "journey": a battle-pass rail of every reward +// tier, with the level you're on highlighted, claimed perks marked, the next +// milestone's progress, and the connector track. Driven by your points +// (status.userPoints) and the reward tiers. +const meta: Meta = { + title: 'Features/Giveback/Journey roadmap', + component: GivebackPersonalRoadmap, + args: { onTakeAction: () => undefined }, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Vary your points to move along the ladder. States: early (nothing unlocked), mid-journey with claimable rewards, some already claimed, near the top, and the empty "journey starts soon" state when no tiers exist. In any state with a claimable tier, click "Claim" to see the reward celebration (the ring burst + sparkles + button pop).', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const ClaimAReward: Story = { + parameters: { + docs: { + description: { + story: + 'One tier just unlocked and unclaimed. Click "Claim" to trigger the celebration animation.', + }, + }, + }, + decorators: [withGiveback({ status: mockStatus({ userPoints: 120 }) })], +}; + +export const MidJourney: Story = { + parameters: { + docs: { + description: { + story: '320 pts: two tiers unlocked and claimable, next at 500.', + }, + }, + }, + decorators: [withGiveback({ status: mockStatus({ userPoints: 320 }) })], +}; + +export const SomeClaimed: Story = { + decorators: [ + withGiveback({ + status: mockStatus({ userPoints: 320 }), + claimedRewardIds: ['t-cores'], + }), + ], +}; + +export const EarlyJourney: Story = { + parameters: { + docs: { description: { story: '0 pts: level 1, working toward the first tier.' } }, + }, + decorators: [withGiveback({ status: mockStatus({ userPoints: 0 }) })], +}; + +export const NearTheTop: Story = { + decorators: [ + withGiveback({ + status: mockStatus({ userPoints: 2100 }), + claimedRewardIds: ['t-cores', 't-plus', 't-call'], + }), + ], +}; + +export const EmptyJourney: Story = { + parameters: { + docs: { description: { story: 'No reward tiers configured yet.' } }, + }, + decorators: [ + withGiveback({ status: mockStatus({ userPoints: 0 }), rewardTiers: [] }), + ], +}; diff --git a/packages/storybook/stories/features/giveback/GivebackScreenshotField.stories.tsx b/packages/storybook/stories/features/giveback/GivebackScreenshotField.stories.tsx new file mode 100644 index 00000000000..bf2509a5f34 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackScreenshotField.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackScreenshotField } from '@dailydotdev/shared/src/features/giveback/components/GivebackScreenshotField'; + +// The screenshot uploader used inside the submission modal. These cover the +// three states you hit when adding proof: empty drop zone, mid-upload, and a +// chosen preview ready to clear. +const meta: Meta = { + title: 'Features/Giveback/Screenshot field', + component: GivebackScreenshotField, + args: { + inputId: 'sb-screenshot', + onSelect: () => undefined, + onClear: () => undefined, + }, + parameters: { layout: 'padded' }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Empty: Story = { + args: { previewSrc: undefined, isUploading: false }, +}; + +export const Uploading: Story = { + args: { previewSrc: undefined, isUploading: true }, +}; + +export const WithPreview: Story = { + args: { + isUploading: false, + previewSrc: + 'https://media.daily.dev/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile', + }, +}; diff --git a/packages/storybook/stories/features/giveback/GivebackSponsorTiers.stories.tsx b/packages/storybook/stories/features/giveback/GivebackSponsorTiers.stories.tsx new file mode 100644 index 00000000000..a938372debb --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackSponsorTiers.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackSponsorTiers } from '@dailydotdev/shared/src/features/giveback/components/GivebackSponsorTiers'; +import { mockSponsors, withGiveback } from './giveback.mocks'; +import type { ContributionSponsor } from '@dailydotdev/shared/src/features/giveback/types'; +import { ContributionSponsorTier } from '@dailydotdev/shared/src/features/giveback/types'; + +// The "Sponsored by" wall: brand logos grouped into gold / silver / bronze +// columns split by dividers. Logos are monochrome at rest and light up to full +// colour on hover; logo-less sponsors fall back to their name. +const meta: Meta = { + title: 'Features/Giveback/Sponsor tiers', + component: GivebackSponsorTiers, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Hover a logo to reveal its real colours. Covers the full multi-tier wall, a gold-only wall, the logo-less fallback, and the empty state (renders nothing).', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const AllTiers: Story = { + decorators: [withGiveback({ sponsors: mockSponsors() })], +}; + +export const GoldOnly: Story = { + decorators: [ + withGiveback({ + sponsors: mockSponsors().filter( + (s) => s.tier === ContributionSponsorTier.Gold, + ), + }), + ], +}; + +export const LogoLess: Story = { + parameters: { + docs: { description: { story: 'Sponsors without a logo show their name.' } }, + }, + decorators: [ + withGiveback({ + sponsors: [ + { + id: 's-a', + name: 'Acme Corp', + amountCents: 200000, + url: 'https://acme.test', + logoUrl: null, + tier: ContributionSponsorTier.Gold, + }, + { + id: 's-b', + name: 'Dana K.', + amountCents: 5000, + url: null, + logoUrl: null, + tier: ContributionSponsorTier.Bronze, + }, + ] as ContributionSponsor[], + }), + ], +}; + +export const Empty: Story = { + parameters: { + docs: { description: { story: 'No sponsors yet — the section renders nothing.' } }, + }, + decorators: [withGiveback({ sponsors: [] })], +}; diff --git a/packages/storybook/stories/features/giveback/GivebackTabNav.stories.tsx b/packages/storybook/stories/features/giveback/GivebackTabNav.stories.tsx new file mode 100644 index 00000000000..dfe058d3748 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackTabNav.stories.tsx @@ -0,0 +1,33 @@ +import React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackTabNav } from '@dailydotdev/shared/src/features/giveback/components/GivebackTabNav'; +import type { GivebackTabId } from '@dailydotdev/shared/src/features/giveback/components/GivebackTabNav'; +import { withGiveback } from './giveback.mocks'; + +// The sticky tab navigation that switches between Take action / Impact / +// Campaign. Prop-driven (activeTab + onSelect); this story keeps the active tab +// in local state so the tabs are clickable. +const meta: Meta = { + title: 'Features/Giveback/Tab nav', + component: GivebackTabNav, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Click between the three campaign tabs to see the active state.', + }, + }, + }, + decorators: [withGiveback()], +}; + +export default meta; + +type Story = StoryObj; + +export const Interactive: Story = { + render: () => { + const [activeTab, setActiveTab] = useState('actions'); + return ; + }, +}; diff --git a/packages/storybook/stories/features/giveback/giveback.mocks.tsx b/packages/storybook/stories/features/giveback/giveback.mocks.tsx new file mode 100644 index 00000000000..42658a4582f --- /dev/null +++ b/packages/storybook/stories/features/giveback/giveback.mocks.tsx @@ -0,0 +1,434 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useMemo } from 'react'; +import type { Decorator } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AuthContextProvider } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; +import { generateQueryKey, RequestKey } from '@dailydotdev/shared/src/lib/query'; +import type { + ContributionAction, + ContributionActionCategory, + ContributionCause, + ContributionRewardTier, + ContributionSponsor, + ContributionStatus, +} from '@dailydotdev/shared/src/features/giveback/types'; +import { + ContributionRewardType, + ContributionSponsorTier, + ContributionSubmissionStatus, +} from '@dailydotdev/shared/src/features/giveback/types'; + +// A single signed-in user so every giveback query key resolves to the same id. +export const MOCK_USER = { + id: 'sb-user', + name: 'Dev Dana', + username: 'devdana', + image: + 'https://media.daily.dev/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile', + permalink: 'https://app.daily.dev/devdana', + bio: null, + createdAt: '2021-01-01T00:00:00.000Z', + reputation: 42, + providers: ['github'], +} as const; + +const noop = (): void => undefined; + +// --------------------------------------------------------------------------- +// Mock data builders. Every builder takes overrides so a story can tweak one +// field (e.g. userPoints) without restating the whole object. +// --------------------------------------------------------------------------- + +export const mockStatus = ( + overrides: Partial = {}, +): ContributionStatus => ({ + enabled: true, + eligible: true, + currentCyclePoints: 8200, + currentCycleTargetPoints: 12000, + lifetimePoints: 41000, + lifetimeAmountCents: 4100000, + contributorsCount: 1840, + userPoints: 320, + ...overrides, +}); + +export const mockSponsors = (): ContributionSponsor[] => [ + { + id: 's-vercel', + name: 'Vercel', + amountCents: 500000, + url: 'https://vercel.com', + logoUrl: 'https://svgl.app/library/vercel_wordmark.svg', + tier: ContributionSponsorTier.Gold, + }, + { + id: 's-stripe', + name: 'Stripe', + amountCents: 400000, + url: 'https://stripe.com', + logoUrl: 'https://svgl.app/library/stripe_wordmark.svg', + tier: ContributionSponsorTier.Gold, + }, + { + id: 's-sentry', + name: 'Sentry', + amountCents: 250000, + url: 'https://sentry.io', + logoUrl: 'https://svgl.app/library/sentry.svg', + tier: ContributionSponsorTier.Silver, + }, + { + id: 's-prisma', + name: 'Prisma', + amountCents: 120000, + url: 'https://prisma.io', + logoUrl: 'https://svgl.app/library/prisma.svg', + tier: ContributionSponsorTier.Bronze, + }, + { + // Logo-less sponsor: exercises the name-fallback path. + id: 's-dana', + name: 'Dana K.', + amountCents: 5000, + url: null, + logoUrl: null, + tier: ContributionSponsorTier.Bronze, + }, +]; + +export const mockCauses = (): ContributionCause[] => [ + { + id: 'c-oss', + title: 'Open-source maintainers', + description: + 'Keeps the maintainers behind the libraries you ship every day paid, so the tools you rely on stay alive and secure.', + url: 'https://opencollective.com', + category: 'Open source', + logoUrl: null, + }, + { + id: 'c-scholarships', + title: 'Dev scholarships', + description: + 'Puts students from underrepresented groups through the training that lands them their first job in tech.', + url: 'https://www.codeyourfuture.io', + category: 'Education', + logoUrl: null, + }, + { + id: 'c-access', + title: 'Access to tech', + description: + 'Gets laptops and internet to developers who otherwise could not get online to learn, build, and earn.', + url: 'https://www.codepath.org', + category: 'Accessibility', + logoUrl: null, + }, + { + id: 'c-climate', + title: 'Climate tech', + description: + 'Funds open tools that measure, cut, and fight carbon emissions with transparent, auditable data.', + url: 'https://www.climatebase.org', + category: 'Climate', + logoUrl: null, + }, + { + id: 'c-mentorship', + title: 'Mentorship programs', + description: + 'Pairs early-career devs with experienced mentors who help them grow and break into the industry faster.', + url: 'https://adplist.org', + category: 'Education', + logoUrl: null, + }, + { + id: 'c-docs', + title: 'Better docs', + description: + 'Pays technical writers to turn dense open-source docs into guides people can actually learn from.', + url: 'https://www.writethedocs.org', + category: 'Open source', + logoUrl: null, + }, +]; + +export const mockCategories = (): ContributionActionCategory[] => [ + { id: 'cat-events', title: 'Events' }, + { id: 'cat-content', title: 'Content' }, +]; + +const action = (overrides: Partial): ContributionAction => ({ + id: 'a-default', + categoryId: 'cat-content', + title: 'Action', + description: null, + points: 100, + evidence: { url: { required: true }, screenshot: {}, note: {} }, + metadata: { + platform: null, + instructions: null, + externalUrl: null, + isLoveAction: false, + }, + cooldownSeconds: null, + maxPerUser: null, + userCooldownEndsAt: null, + userCompletions: 0, + latestUserSubmission: null, + ...overrides, +}); + +export const mockActions = (): ContributionAction[] => [ + action({ + id: 'a-meetup', + categoryId: 'cat-events', + title: 'Host a daily.dev meetup', + description: 'Organize a local dev meetup that features daily.dev.', + points: 250, + metadata: { + platform: 'Events', + instructions: 'Share photos and the event link.', + externalUrl: null, + isLoveAction: false, + }, + }), + action({ + id: 'a-speak', + categoryId: 'cat-events', + title: 'Speak about daily.dev at an event', + description: 'Give a talk that features daily.dev in your slides or demo.', + points: 200, + }), + action({ + id: 'a-video', + categoryId: 'cat-content', + title: 'Make a video about daily.dev', + description: 'Create a video or short featuring daily.dev and post it.', + points: 150, + metadata: { + platform: 'YouTube', + instructions: null, + externalUrl: null, + isLoveAction: false, + }, + }), + action({ + id: 'a-write', + categoryId: 'cat-content', + title: 'Write about daily.dev', + description: 'Publish an article or blog post featuring daily.dev.', + points: 120, + metadata: { + platform: 'Blog', + instructions: null, + externalUrl: null, + isLoveAction: false, + }, + // An in-review submission so the catalog shows the "in review" state. + userCompletions: 1, + latestUserSubmission: { + id: 'sub-1', + actionId: 'a-write', + status: ContributionSubmissionStatus.Flagged, + awardedPoints: 0, + createdAt: '2026-06-20T10:00:00.000Z', + reviewedAt: null, + }, + }), + action({ + id: 'a-podcast', + categoryId: 'cat-content', + title: 'Talk about daily.dev on a podcast', + description: 'Host or guest on a podcast and mention daily.dev.', + points: 150, + // Approved: shows the "done" state. + userCompletions: 1, + latestUserSubmission: { + id: 'sub-2', + actionId: 'a-podcast', + status: ContributionSubmissionStatus.Approved, + awardedPoints: 150, + createdAt: '2026-06-18T10:00:00.000Z', + reviewedAt: '2026-06-19T10:00:00.000Z', + }, + }), + action({ + id: 'a-love', + categoryId: null, + title: 'Leave us a kind word', + description: 'A voluntary thank-you. No reward attached.', + points: 0, + evidence: { note: { required: true } }, + metadata: { + platform: null, + instructions: null, + externalUrl: null, + isLoveAction: true, + }, + }), +]; + +export const mockRewardTiers = (): ContributionRewardTier[] => [ + { + id: 't-cores', + title: '500 Cores', + description: 'Spend them on the daily.dev store.', + thresholdPoints: 100, + rewardType: ContributionRewardType.Cores, + }, + { + id: 't-plus', + title: '1 month of Plus', + description: 'Unlock the full daily.dev experience.', + thresholdPoints: 250, + rewardType: ContributionRewardType.PlusDays, + }, + { + id: 't-call', + title: 'A call with the team', + description: 'Shape the product with a 1:1.', + thresholdPoints: 500, + rewardType: ContributionRewardType.Call, + }, + { + id: 't-privilege', + title: 'Founding contributor badge', + description: 'A permanent mark on your profile.', + thresholdPoints: 1000, + rewardType: ContributionRewardType.Privilege, + }, + { + id: 't-custom', + title: 'Limited-edition swag', + description: 'The drop only contributors get.', + thresholdPoints: 2000, + rewardType: ContributionRewardType.Custom, + }, +]; + +// --------------------------------------------------------------------------- +// Providers + query-cache seeding. Pass `null` for a slice to leave it unseeded +// (e.g. status: null + goal 0 drives skeletons). Everything is seeded fresh so +// no real network request is ever made. +// --------------------------------------------------------------------------- + +export interface GivebackMockOptions { + status?: ContributionStatus | null; + sponsors?: ContributionSponsor[]; + causes?: ContributionCause[]; + selectedCauseIds?: string[]; + actions?: ContributionAction[]; + categories?: ContributionActionCategory[]; + rewardTiers?: ContributionRewardTier[]; + claimedRewardIds?: string[]; +} + +const GivebackProviders = ({ + children, + status = mockStatus(), + sponsors = mockSponsors(), + causes = mockCauses(), + selectedCauseIds = [], + actions = mockActions(), + categories = mockCategories(), + rewardTiers = mockRewardTiers(), + claimedRewardIds = [], +}: GivebackMockOptions & { children: ReactNode }): ReactElement => { + const queryClient = useMemo(() => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + staleTime: Infinity, + gcTime: Infinity, + }, + }, + }); + + client.setQueryData( + generateQueryKey(RequestKey.ContributionOverview, MOCK_USER), + { + contributionStatus: status ?? mockStatus({ currentCycleTargetPoints: 0 }), + contributionSponsors: { + pageInfo: { hasNextPage: false, endCursor: null }, + edges: sponsors.map((node) => ({ node })), + }, + }, + ); + + client.setQueryData( + generateQueryKey(RequestKey.ContributionCausePicker, MOCK_USER), + { causes, selectedCauseIds }, + ); + + client.setQueryData( + generateQueryKey(RequestKey.ContributionActions, MOCK_USER), + { actions, categories, rewardTiers, claimedRewardIds }, + ); + + return client; + // Rebuild only when the story's mock inputs change. + }, [ + status, + sponsors, + causes, + selectedCauseIds, + actions, + categories, + rewardTiers, + claimedRewardIds, + ]); + + const LogContext = getLogContextStatic(); + + return ( + + ''} + updateUser={noop as never} + refetchBoot={noop as never} + visit={{ visitId: 'sb', sessionId: 'sb' } as never} + accessToken={null as never} + squads={[]} + feeds={undefined} + geo={{} as never} + isAndroidApp={false} + > + +
+ {children} +
+
+
+
+ ); +}; + +// A decorator factory: `decorators: [withGiveback({ status: ... })]`. +export const withGiveback = + (options: GivebackMockOptions = {}): Decorator => + (Story) => + ( + + + + ); diff --git a/packages/webapp/pages/giveback/index.tsx b/packages/webapp/pages/giveback/index.tsx index 0b46171a0af..58d014bf678 100644 --- a/packages/webapp/pages/giveback/index.tsx +++ b/packages/webapp/pages/giveback/index.tsx @@ -21,7 +21,7 @@ const seo: NextSeoProps = { }, ...defaultSeo, description: - 'Help daily.dev grow and we will fund good causes. Complete community actions to help unlock donations toward a shared goal.', + 'daily.dev would rather fund real-world causes than pay for ads. Take small actions to help us grow, and we turn that budget into donations to the causes you choose, at no cost to you.', nofollow: true, noindex: true, };