From 109b27bb72a88578d26cf249ce8bc6d1b875cf74 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:58:39 +0300 Subject: [PATCH 1/2] feat(giveback): warm-up funnel and reworked hero Replaces the inline cause-picker onboarding with a full-screen 4-step warm-up funnel (why -> how it works -> pick causes -> impact), shown once for everyone via a persisted giveback:funnel_seen flag and replayable from the hero's 'How it works'. A single mounted explainer video docks from inline to a floating corner player across steps; hidden on mobile after step 1. The hero is reworked around the new GivebackHeadline + charm mascot + live funding summary, with 'How it works' as its only onboarding entry (the funnel auto-shows for first-timers). Removes the old StartPanel join CTA, the inline picker, GivebackOnboardingBar and the sticky GivebackFundingBar. Adds the funnel lifecycle log events and the funnel-step-in / coin-drop keyframes. Sponsors stay unrendered (no flag), per the prior PR. Note: the docked video uses fixed positioning + scroll/resize listeners; worth a real-app smoke test (not just Storybook). --- .../components/GivebackActionCatalog.tsx | 11 +- .../components/GivebackBackground.tsx | 24 +- .../components/GivebackCampaignVideo.tsx | 4 +- .../components/GivebackCauseSelection.tsx | 15 +- .../components/GivebackFundingBar.tsx | 141 ---- .../GivebackFundingSummary.spec.tsx | 18 +- .../components/GivebackFundingSummary.tsx | 50 +- .../components/GivebackFunnel.spec.tsx | 145 ++++ .../giveback/components/GivebackFunnel.tsx | 632 ++++++++++++++++++ .../giveback/components/GivebackHeadline.tsx | 4 +- .../giveback/components/GivebackHero.tsx | 170 ++--- .../giveback/components/GivebackMascot.tsx | 8 +- .../components/GivebackOnboardingBar.spec.tsx | 32 - .../components/GivebackOnboardingBar.tsx | 71 -- .../giveback/components/GivebackPage.tsx | 142 ++-- .../components/GivebackStartPanel.spec.tsx | 100 --- .../components/GivebackStartPanel.tsx | 111 --- packages/shared/src/lib/log.ts | 4 + packages/shared/tailwind.config.ts | 23 + 19 files changed, 1033 insertions(+), 672 deletions(-) delete mode 100644 packages/shared/src/features/giveback/components/GivebackFundingBar.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackFunnel.spec.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackFunnel.tsx delete mode 100644 packages/shared/src/features/giveback/components/GivebackOnboardingBar.spec.tsx delete mode 100644 packages/shared/src/features/giveback/components/GivebackOnboardingBar.tsx delete mode 100644 packages/shared/src/features/giveback/components/GivebackStartPanel.spec.tsx delete mode 100644 packages/shared/src/features/giveback/components/GivebackStartPanel.tsx diff --git a/packages/shared/src/features/giveback/components/GivebackActionCatalog.tsx b/packages/shared/src/features/giveback/components/GivebackActionCatalog.tsx index 3a5c9e32753..c2ed7eb71c4 100644 --- a/packages/shared/src/features/giveback/components/GivebackActionCatalog.tsx +++ b/packages/shared/src/features/giveback/components/GivebackActionCatalog.tsx @@ -41,7 +41,15 @@ const ActionGrid = ({ ); -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 = () => { 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/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/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; -} - -// 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/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 => (
{ - render( - , - ); - - expect(screen.getByText('0 causes selected')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled(); -}); - -it('continues with a selection and uses singular copy for one cause', () => { - const onContinue = jest.fn(); - render( - , - ); - - expect(screen.getByText('1 cause selected')).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'Continue' })); - expect(onContinue).toHaveBeenCalled(); -}); diff --git a/packages/shared/src/features/giveback/components/GivebackOnboardingBar.tsx b/packages/shared/src/features/giveback/components/GivebackOnboardingBar.tsx deleted file mode 100644 index 918ab90f65b..00000000000 --- a/packages/shared/src/features/giveback/components/GivebackOnboardingBar.tsx +++ /dev/null @@ -1,71 +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 { - Button, - ButtonIconPosition, - ButtonSize, - ButtonVariant, -} from '../../../components/buttons/Button'; -import { ArrowIcon } from '../../../components/icons'; - -interface GivebackOnboardingBarProps { - selectedCount: number; - isSaving: boolean; - onContinue: () => void; -} - -// Sticky bottom bar that keeps the confirm CTA one tap away while scrolling the -// cause list. Rendered at the page root so it pins to the viewport bottom. -export const GivebackOnboardingBar = ({ - selectedCount, - isSaving, - onContinue, -}: GivebackOnboardingBarProps): ReactElement => { - return ( -
-
- - - - {selectedCount} {selectedCount === 1 ? 'cause' : 'causes'}{' '} - selected - - - daily.dev funds every donation. Pick at least one to continue. - - - - - -
-
- ); -}; diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx index 178e88b0fd9..bae520641b6 100644 --- a/packages/shared/src/features/giveback/components/GivebackPage.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx @@ -1,33 +1,28 @@ import type { ReactElement } from 'react'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import { FlexCol } from '../../../components/utilities'; -import { - Typography, - TypographyColor, - TypographyTag, - TypographyType, -} from '../../../components/typography/Typography'; +import usePersistentContext from '../../../hooks/usePersistentContext'; import { GivebackBackground } from './GivebackBackground'; +import { GivebackFunnel } from './GivebackFunnel'; import { GivebackHero } from './GivebackHero'; -import { GivebackCauseSelection } from './GivebackCauseSelection'; -import { GivebackOnboardingBar } from './GivebackOnboardingBar'; import { GivebackLegalFooter } from './GivebackLegalFooter'; import { GivebackTabNav, givebackTabs } from './GivebackTabNav'; import { GivebackActionCatalog } from './GivebackActionCatalog'; import { GivebackContributionSummary } from './GivebackContributionSummary'; +import { GivebackTabHeading } from './GivebackTabHeading'; import { GivebackImpactPanel } from './GivebackImpactPanel'; import { GivebackCausesPanel } from './GivebackCausesPanel'; import { GivebackFaq } from './GivebackFaq'; -import { GivebackFundingBar } from './GivebackFundingBar'; import type { GivebackTabId } from './GivebackTabNav'; import { useLogContext } from '../../../contexts/LogContext'; import { LogEvent } from '../../../lib/log'; import { useContributionStatus } from '../hooks/useContributionStatus'; import { useGivebackCauseSelection } from '../hooks/useGivebackCauseSelection'; -// Centers a section to the page column. The tab nav lives outside this so its -// glass background can span the full content width. -const column = 'mx-auto w-full max-w-6xl px-4'; +// Single source of truth for the page gutter, shared by the hero, the tab +// content and the footer so every row lines up at the exact same left/right +// padding. Scales up on wider screens so content isn't edge-tight. +const column = 'mx-auto w-full max-w-6xl px-4 tablet:px-8 laptop:px-12'; const scrollIntoView = (node: HTMLElement | null): void => { if (!node || typeof node.scrollIntoView !== 'function') { @@ -40,26 +35,25 @@ 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'); - const showTabs = selection.hasSavedCauses || completedOnboarding; - const showPicker = startedPicker && !showTabs; + // 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); - // 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); + const showTabs = selection.hasSavedCauses || completedOnboarding; + const showFunnel = replayFunnel || (funnelLoaded && funnelSeen === false); - 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 +76,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 +101,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,46 +119,11 @@ 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. - - - - -
- )} - {showTabs && (
@@ -163,12 +132,24 @@ 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' && ( @@ -187,15 +168,14 @@ export const GivebackPage = (): ReactElement => {
- {showPicker && ( - setReplayFunnel(false)} + onComplete={handleFunnelComplete} /> )} - - {showTabs && }
); }; diff --git a/packages/shared/src/features/giveback/components/GivebackStartPanel.spec.tsx b/packages/shared/src/features/giveback/components/GivebackStartPanel.spec.tsx deleted file mode 100644 index 5a199390c2d..00000000000 --- a/packages/shared/src/features/giveback/components/GivebackStartPanel.spec.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; -import { GivebackStartPanel } from './GivebackStartPanel'; -import { useAuthContext } from '../../../contexts/AuthContext'; -import { useLogContext } from '../../../contexts/LogContext'; -import { AuthTriggers } from '../../../lib/auth'; -import { LogEvent } from '../../../lib/log'; -import type { LoggedUser } from '../../../lib/user'; - -jest.mock('../../../contexts/AuthContext'); -jest.mock('../../../contexts/LogContext'); - -const mockUseAuthContext = useAuthContext as jest.MockedFunction< - typeof useAuthContext ->; -const mockUseLogContext = useLogContext as jest.MockedFunction< - typeof useLogContext ->; - -const showLogin = jest.fn(); -const logEvent = jest.fn(); -const onJoin = jest.fn(); -const onTakeAction = jest.fn(); - -beforeEach(() => { - jest.clearAllMocks(); - mockUseLogContext.mockReturnValue({ logEvent } as unknown as ReturnType< - typeof useLogContext - >); -}); - -const renderPanel = ( - user: LoggedUser | null, - { hasSelectedCauses = false, isResolving = false } = {}, -) => { - mockUseAuthContext.mockReturnValue({ - user, - showLogin, - } as unknown as ReturnType); - - return render( - , - ); -}; - -it('prompts login for logged-out visitors without joining or logging', () => { - renderPanel(null); - - fireEvent.click(screen.getByRole('button', { name: 'Join the campaign' })); - - expect(showLogin).toHaveBeenCalledWith({ trigger: AuthTriggers.Giveback }); - // Neither the join event nor the reveal should fire when login opens instead. - expect(logEvent).not.toHaveBeenCalled(); - expect(onJoin).not.toHaveBeenCalled(); -}); - -it('logs the join event and reveals causes for authenticated visitors', () => { - renderPanel({ id: 'u1' } as LoggedUser); - - fireEvent.click(screen.getByRole('button', { name: 'Join the campaign' })); - - expect(showLogin).not.toHaveBeenCalled(); - expect(logEvent).toHaveBeenCalledWith({ - event_name: LogEvent.ClickJoinGiveback, - }); - expect(onJoin).toHaveBeenCalled(); -}); - -it('jumps onboarded visitors to the action tab without re-joining', () => { - renderPanel({ id: 'u1' } as LoggedUser, { hasSelectedCauses: true }); - - fireEvent.click(screen.getByRole('button', { name: 'Take action' })); - - expect(onTakeAction).toHaveBeenCalled(); - expect(onJoin).not.toHaveBeenCalled(); - expect(logEvent).toHaveBeenCalledWith({ - event_name: LogEvent.ClickGivebackTakeAction, - extra: JSON.stringify({ origin: 'hero' }), - }); -}); - -it('renders an empty CTA while resolving so its copy cannot flip mid-click', () => { - renderPanel({ id: 'u1' } as LoggedUser, { - hasSelectedCauses: true, - isResolving: true, - }); - - // No copy is shown until the onboarding state resolves, and acting on the - // empty button does nothing yet. - expect(screen.queryByText('Take action')).not.toBeInTheDocument(); - expect(screen.queryByText('Join the campaign')).not.toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button')); - expect(onTakeAction).not.toHaveBeenCalled(); -}); diff --git a/packages/shared/src/features/giveback/components/GivebackStartPanel.tsx b/packages/shared/src/features/giveback/components/GivebackStartPanel.tsx deleted file mode 100644 index 1c4a81580fe..00000000000 --- a/packages/shared/src/features/giveback/components/GivebackStartPanel.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import { FlexCol } from '../../../components/utilities'; -import { - Typography, - TypographyColor, - TypographyTag, - TypographyType, -} from '../../../components/typography/Typography'; -import { - Button, - ButtonIconPosition, - ButtonSize, - ButtonVariant, -} from '../../../components/buttons/Button'; -import { MoveToIcon } from '../../../components/icons'; -import { IconSize } from '../../../components/Icon'; -import { useAuthContext } from '../../../contexts/AuthContext'; -import { useLogContext } from '../../../contexts/LogContext'; -import { AuthTriggers } from '../../../lib/auth'; -import { LogEvent } from '../../../lib/log'; - -interface GivebackStartPanelProps { - // While we're still loading whether the visitor has causes, the CTA renders - // empty so its copy doesn't flip after the data lands. - isResolving: boolean; - // True once the visitor has confirmed causes: the CTA becomes "Take action". - hasSelectedCauses: boolean; - // Fires once an authenticated visitor opts in, to reveal the cause picker. - onJoin: () => void; - // Fires for an already-onboarded visitor, to jump to the action tab. - onTakeAction: () => void; -} - -// Hero gateway: one clear decision above the fold. New visitors opt in (logged -// out get the login prompt first); returning visitors who already picked causes -// jump straight to taking action. -export const GivebackStartPanel = ({ - isResolving, - hasSelectedCauses, - onJoin, - onTakeAction, -}: GivebackStartPanelProps): ReactElement => { - const { user, showLogin } = useAuthContext(); - const { logEvent } = useLogContext(); - - const handleClick = () => { - if (hasSelectedCauses) { - logEvent({ - event_name: LogEvent.ClickGivebackTakeAction, - extra: JSON.stringify({ origin: 'hero' }), - }); - onTakeAction(); - return; - } - - if (!user) { - showLogin({ trigger: AuthTriggers.Giveback }); - return; - } - - // A logged-out click opens login instead, so the join event fires here only. - logEvent({ event_name: LogEvent.ClickJoinGiveback }); - onJoin(); - }; - - const ctaLabel = hasSelectedCauses ? 'Take action' : 'Join the campaign'; - const buttonClassName = - 'shadow-2-cabbage transition-transform duration-200 ease-out hover:scale-[1.02]'; - - return ( - - - Take small actions and we turn them into{' '} - - real donations - - . daily.dev funds every cent, so you never pay. - - - {/* While resolving, render an empty button (no icon, no spinner): it keeps - its fixed height and full width, so the copy fades in without a shift. */} - {isResolving ? ( - - )} - - ); -}; diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index af6e47d36e8..31d0126b615 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -509,6 +509,10 @@ export enum LogEvent { ClaimGivebackReward = 'claim giveback reward', 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/tailwind.config.ts b/packages/shared/tailwind.config.ts index 6633005e464..b0eddb4dda1 100644 --- a/packages/shared/tailwind.config.ts +++ b/packages/shared/tailwind.config.ts @@ -326,6 +326,26 @@ export default { '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)' }, @@ -398,6 +418,9 @@ export default { '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', From 1142df80ff56924ece2ff46a2196f6c5ea14c6a8 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:18:14 +0300 Subject: [PATCH 2/2] fix(giveback): auto-show funnel by saved causes, kill hero layout shift, split funnel Onboarding: - The funnel now shows automatically for any eligible visitor who hasn't saved causes (gated on selection.hasSavedCauses), not a local giveback:funnel_seen flag, so it keeps appearing until they pick. - Hold the whole page body until the campaign status and saved picks resolve. The funnel is a full-screen overlay on the same background, so revealing the hero/tabs first flashed them before it covered them and popped the tabs in once the picks landed. Now only the shared background shows while resolving: no flash, no shift. Hero layout shift: - The mascot had a fixed height but auto width and lazy loading, so it reflowed the hero row (and the tab bar/content below) when it loaded. Pinned the charm's 2812x2024 aspect ratio so the width is reserved up front. Funnel split (was ~630 lines): - givebackFunnelTypes.ts: STEP_KEYS / StepKey / CauseSelection - GivebackFunnelVideo.tsx: the docked explainer video - GivebackFunnelSteps.tsx: the per-step content - GivebackFunnel.tsx: the orchestrator shell (state, nav, footer, portal) --- .../giveback/components/GivebackFunnel.tsx | 448 +----------------- .../components/GivebackFunnelSteps.tsx | 335 +++++++++++++ .../components/GivebackFunnelVideo.tsx | 103 ++++ .../giveback/components/GivebackMascot.tsx | 7 +- .../giveback/components/GivebackPage.tsx | 129 ++--- .../components/givebackFunnelTypes.ts | 9 + 6 files changed, 536 insertions(+), 495 deletions(-) create mode 100644 packages/shared/src/features/giveback/components/GivebackFunnelSteps.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackFunnelVideo.tsx create mode 100644 packages/shared/src/features/giveback/components/givebackFunnelTypes.ts diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 4e98093dad1..afe0e0f6cc6 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -1,13 +1,7 @@ -import type { CSSProperties, ReactElement, ReactNode, RefObject } from 'react'; +import type { ReactElement } 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 { FlexRow } from '../../../components/utilities'; import { Button, ButtonSize, @@ -15,55 +9,16 @@ import { } from '../../../components/buttons/Button'; import CloseButton from '../../../components/CloseButton'; import { RootPortal } from '../../../components/tooltips/Portal'; -import { - CoinIcon, - GiftIcon, - MoveToIcon, - VIcon, -} from '../../../components/icons'; +import { MoveToIcon } 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]; +import { GivebackFunnelStep } from './GivebackFunnelSteps'; +import { GivebackFunnelVideo } from './GivebackFunnelVideo'; +import type { CauseSelection, StepKey } from './givebackFunnelTypes'; +import { STEP_KEYS } from './givebackFunnelTypes'; interface GivebackFunnelProps { selection: CauseSelection; @@ -74,202 +29,6 @@ interface GivebackFunnelProps { 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, @@ -290,14 +49,6 @@ export const GivebackFunnel = ({ // 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, @@ -342,183 +93,6 @@ export const GivebackFunnel = ({ [], ); - 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 (
- {/* Just a close affordance on replay — the heavy progress bar is gone. */} + {/* Just a close affordance on replay - the heavy progress bar is gone. */}
{canClose && ( - {renderStep()} +
diff --git a/packages/shared/src/features/giveback/components/GivebackFunnelSteps.tsx b/packages/shared/src/features/giveback/components/GivebackFunnelSteps.tsx new file mode 100644 index 00000000000..525ea56af70 --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackFunnelSteps.tsx @@ -0,0 +1,335 @@ +import type { ReactElement, ReactNode, RefObject } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { FlexCol, FlexRow } from '../../../components/utilities'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; +import { CoinIcon, GiftIcon, VIcon } from '../../../components/icons'; +import { cloudinaryCharmBookmarks } from '../../../lib/image'; +import { GivebackMascot } from './GivebackMascot'; +import { GivebackCauseSelection } from './GivebackCauseSelection'; +import type { CauseSelection, StepKey } from './givebackFunnelTypes'; + +// 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.', + }, +]; + +// 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}
+
+); + +// "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} + + + + ); + })} + +); + +interface GivebackFunnelStepProps { + stepKey: StepKey; + selection: CauseSelection; + // The intro step renders the in-flow slot the floating video docks over. + videoSlotRef: RefObject; +} + +export const GivebackFunnelStep = ({ + stepKey, + selection, + videoSlotRef, +}: GivebackFunnelStepProps): ReactElement => { + // The visitor's own picks, surfaced front-and-center on the finale so the + // moment celebrates exactly what they chose to fund. + const selectedCauses = selection.causes + .map((cause, index) => ({ cause, index })) + .filter(({ cause }) => selection.selectedIds.has(cause.id)) + .slice(0, 3); + + 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. + + + + ); + } +}; diff --git a/packages/shared/src/features/giveback/components/GivebackFunnelVideo.tsx b/packages/shared/src/features/giveback/components/GivebackFunnelVideo.tsx new file mode 100644 index 00000000000..0b532b439b8 --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackFunnelVideo.tsx @@ -0,0 +1,103 @@ +import type { CSSProperties, ReactElement, RefObject } from 'react'; +import React, { useEffect, useState } from 'react'; +import { ButtonSize, ButtonVariant } from '../../../components/buttons/Button'; +import CloseButton from '../../../components/CloseButton'; +import { GivebackCampaignVideo } from './GivebackCampaignVideo'; + +// 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; + +interface GivebackFunnelVideoProps { + slotRef: RefObject; + docked: boolean; + onClose: () => void; +} + +export const GivebackFunnelVideo = ({ + slotRef, + docked, + onClose, +}: GivebackFunnelVideoProps): 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 && ( + + )} +
+
+ ); +}; diff --git a/packages/shared/src/features/giveback/components/GivebackMascot.tsx b/packages/shared/src/features/giveback/components/GivebackMascot.tsx index de403451dc8..09f18493170 100644 --- a/packages/shared/src/features/giveback/components/GivebackMascot.tsx +++ b/packages/shared/src/features/giveback/components/GivebackMascot.tsx @@ -35,13 +35,16 @@ export const GivebackMascot = ({ className="bg-accent-cabbage-default/20 absolute inset-0 m-auto size-3/4 rounded-full blur-3xl motion-safe:animate-glow-pulse" /> {/* The render sits on solid black; `mix-blend-screen` drops the black so - the charm reads as floating on the dark page. */} + the charm reads as floating on the dark page. The charm artwork is + 2812x2024; pinning that aspect ratio reserves the width from the fixed + height before the image loads, so it never reflows the hero row (and + the tab bar/content below it) on load. */} {image.alt} diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx index bae520641b6..6c706f41209 100644 --- a/packages/shared/src/features/giveback/components/GivebackPage.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx @@ -1,7 +1,6 @@ import type { ReactElement } from 'react'; import React, { useCallback, useRef, useState } from 'react'; import { FlexCol } from '../../../components/utilities'; -import usePersistentContext from '../../../hooks/usePersistentContext'; import { GivebackBackground } from './GivebackBackground'; import { GivebackFunnel } from './GivebackFunnel'; import { GivebackHero } from './GivebackHero'; @@ -43,16 +42,22 @@ export const GivebackPage = (): ReactElement => { // 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); + // Resolved once we know the campaign status and, for eligible visitors, + // whether they've already saved causes. The whole body waits on this so the + // hero and tabs never flash on screen before a forced funnel covers them, and + // the tabs never pop in once the saved picks land. + const onboardingResolved = !!status && (!isEligible || !selection.isLoading); + + // First-timers (eligible, no saved causes) get the warm-up funnel + // automatically and it stays up until they save a pick; it's also replayable + // from the hero's "How it works". + const needsOnboarding = + isEligible && !selection.hasSavedCauses && !completedOnboarding; + const forcedFunnel = onboardingResolved && needsOnboarding; + const showFunnel = replayFunnel || forcedFunnel; const showTabs = selection.hasSavedCauses || completedOnboarding; - const showFunnel = replayFunnel || (funnelLoaded && funnelSeen === false); const tabsRef = useRef(null); @@ -89,11 +94,10 @@ export const GivebackPage = (): ReactElement => { 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. + // Finishing the funnel 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({ @@ -111,7 +115,7 @@ export const GivebackPage = (): ReactElement => { } setCompletedOnboarding(true); goToActions(); - }, [replayFunnel, setFunnelSeen, selection, logEvent, goToActions]); + }, [replayFunnel, selection, logEvent, goToActions]); const activeLabel = givebackTabs.find((tab) => tab.id === activeTab)?.label; @@ -119,54 +123,63 @@ export const GivebackPage = (): ReactElement => {
- -
- -
- - {showTabs && ( -
- - -
- {activeTab === 'actions' && ( - - - - - - )} - {activeTab === 'impact' && ( - - )} - {activeTab === 'causes' && ( - - )} - {activeTab === 'faq' && } -
+ {/* Hold the body until we know whether to force the funnel. The funnel is a + full-screen overlay on the same background, so revealing the hero/tabs + first would flash them on screen before it covers them. While resolving, + only the shared background shows, so there's no flash and no shift. */} + {onboardingResolved && !forcedFunnel && ( + +
+
- )} -
- -
-
+ {showTabs && ( +
+ + +
+ {activeTab === 'actions' && ( + + + + + + )} + {activeTab === 'impact' && ( + + )} + {activeTab === 'causes' && ( + + )} + {activeTab === 'faq' && } +
+
+ )} + +
+ +
+ + )} {showFunnel && ( ; + +// Step keys double as analytics labels and drive the progress dots. 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. +export const STEP_KEYS = ['intro', 'how', 'causes', 'impact'] as const; +export type StepKey = (typeof STEP_KEYS)[number];