From f559a51b7d1e385267bb7a7c66dc31c94471ed58 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 17 Jun 2026 23:56:53 +0300 Subject: [PATCH 01/89] feat(giveback): redesign impact journey, sponsor badges, and claim feedback Impact tab: - Drop the funding-progress section; the tab now shows only the personal reward-ladder journey, retitled "Your impact". - Rework the journey rail: rounded-square nodes (matching the level badge), a consistent green "completed" trail across nodes and connectors, and a glowing halo on the next target instead of dimming the node. - Replace confetti with a claim celebration on the Claim button (expanding claim-ring + sparkle burst, node pops into its checkmark). Sponsors: - Redesign sponsor cards to be self-describing: flat surface card, white logo tile for contrast, and an explicit colored tier pill (Gold/Silver/ Bronze sponsor) so the level is spelled out. Contribution summary: - Square (rounded) profile image instead of a circle. - Recolor "to go" progress text green (here and in the roadmap). Co-Authored-By: Claude Opus 4.8 --- .../GivebackCommunityGoalProgress.tsx | 211 --------- .../GivebackContributionSummary.tsx | 4 +- .../components/GivebackImpactPanel.spec.tsx | 34 +- .../components/GivebackImpactPanel.tsx | 11 +- .../components/GivebackPersonalRoadmap.tsx | 87 ++-- .../components/GivebackSponsorTiers.spec.tsx | 6 +- .../components/GivebackSponsorTiers.tsx | 235 +++------- packages/shared/src/svg/ConfettiSvg.tsx | 428 ------------------ packages/shared/tailwind.config.ts | 7 + 9 files changed, 147 insertions(+), 876 deletions(-) delete mode 100644 packages/shared/src/features/giveback/components/GivebackCommunityGoalProgress.tsx delete mode 100644 packages/shared/src/svg/ConfettiSvg.tsx 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..0ea498cf944 100644 --- a/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx +++ b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx @@ -50,7 +50,7 @@ export const GivebackContributionSummary = (): ReactElement => { @@ -141,7 +141,7 @@ export const GivebackContributionSummary = (): ReactElement => { tag={TypographyTag.Span} bold type={TypographyType.Caption1} - className="tabular-nums text-accent-cabbage-default" + className="tabular-nums text-status-success" > {formatDonationAmount(pointsToNext)} to go diff --git a/packages/shared/src/features/giveback/components/GivebackImpactPanel.spec.tsx b/packages/shared/src/features/giveback/components/GivebackImpactPanel.spec.tsx index 46e342a90b2..cf0c55626ae 100644 --- a/packages/shared/src/features/giveback/components/GivebackImpactPanel.spec.tsx +++ b/packages/shared/src/features/giveback/components/GivebackImpactPanel.spec.tsx @@ -1,8 +1,6 @@ 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'; @@ -11,8 +9,6 @@ import { useLogContext } from '../../../contexts/LogContext'; 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'); @@ -26,12 +22,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 >; @@ -73,20 +63,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], @@ -107,18 +83,10 @@ beforeEach(() => { >); }); -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(); -}); - it('renders the reward-ladder journey with the current level', () => { render(); - expect(screen.getByText('Your journey')).toBeInTheDocument(); + expect(screen.getByText('Your impact')).toBeInTheDocument(); expect(screen.getByText('Sticker pack')).toBeInTheDocument(); expect(screen.getByText('One month of Plus')).toBeInTheDocument(); expect(screen.getByText('Hoodie')).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/GivebackPersonalRoadmap.tsx b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx index 2cec2b7eb08..84e66e60e13 100644 --- a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx @@ -23,7 +23,6 @@ import { StarIcon, VIcon, } from '../../../components/icons'; -import ConfettiSvg from '../../../svg/ConfettiSvg'; import { useLogContext } from '../../../contexts/LogContext'; import { LogEvent } from '../../../lib/log'; import { useGivebackContribution } from '../hooks/useGivebackContribution'; @@ -64,13 +63,16 @@ interface RoadmapLevel { // "locked" never disagree (RPG / battle-pass clarity). type NodeState = 'claimed' | 'summit' | 'current' | 'unlocked' | 'locked'; +// Every step you've already cleared shares one "completed" green so the trail +// reads as a single continuous path. "Current" stays purple to mark where you +// are, the summit keeps its gold treatment, and locked steps stay muted. 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', + unlocked: 'bg-accent-avocado-default text-white', locked: 'border border-border-subtlest-tertiary bg-surface-float text-text-quaternary', }; @@ -94,7 +96,7 @@ const Connector = ({ fill }: { fill: ConnectorFill }): ReactElement => (
{fill.type === 'full' && ( -
+
)} {fill.type === 'partial' && (
- + {icon} {connectorBelow && } @@ -151,6 +153,17 @@ const RailToggle = ({ ); +// Directions the celebration sparkles fly when a reward is claimed, fed to the +// reaction-burst keyframe via CSS custom properties. +const claimSparkles: ReadonlyArray<{ tx: string; ty: string; delay: string }> = + [ + { tx: '-20px', ty: '-18px', delay: '0ms' }, + { tx: '18px', ty: '-22px', delay: '40ms' }, + { tx: '26px', ty: '2px', delay: '20ms' }, + { tx: '-24px', ty: '6px', delay: '60ms' }, + { tx: '4px', ty: '-26px', delay: '0ms' }, + ]; + interface NodeRowProps { node: RoadmapNode; amountToNext: number; @@ -213,24 +226,24 @@ const NodeRow = ({ return (
- {celebrate && ( - - )} {isCurrent && ( + )} + {isNext && ( + )} {getNodeIcon()} @@ -304,19 +317,39 @@ const NodeRow = ({ {canClaim ? ( - + +
) : ( !isReached && !isNext && ( @@ -344,7 +377,7 @@ const NodeRow = ({ {formatDonationAmount(amountToNext)} to go @@ -544,7 +577,7 @@ export const GivebackPersonalRoadmap = ({ bold className="uppercase tracking-wider" > - Your journey + Your impact diff --git a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.spec.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.spec.tsx index bb488474b5f..d98979f56f0 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 }), @@ -59,8 +59,8 @@ it('groups sponsors under their tier labels and renders brand logo cards', () => render(); expect(screen.getByText('Sponsored by')).toBeInTheDocument(); - expect(screen.getByText('Gold')).toBeInTheDocument(); - expect(screen.getByText('Bronze')).toBeInTheDocument(); + expect(screen.getByText('Gold sponsor')).toBeInTheDocument(); + expect(screen.getByText('Bronze sponsor')).toBeInTheDocument(); const link = screen.getByRole('link', { name: 'Vercel' }); expect(link).toHaveAttribute('href', 'https://acme.test'); diff --git a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx index a36dc4ce76b..838956bc447 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,62 +8,45 @@ 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'; -import { sponsorTierLabel } from '../utils'; +import { getSponsorInitials, sponsorTierLabel } from '../utils'; import type { ContributionSponsor } from '../types'; import { ContributionSponsorTier } from '../types'; -interface TierStyle { - // Tier marker dot + label color (soft, on-brand accent tints). - dotClass: string; - labelClass: string; - // White-card padding + logo height, stepping down platinum → backer. - chipClass: string; - logoClass: string; -} - -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', - }, - [ContributionSponsorTier.Silver]: { - dotClass: 'bg-text-quaternary', - labelClass: 'text-text-secondary', - chipClass: 'px-3.5 py-2', - logoClass: 'h-6 tablet:h-7', - }, - [ContributionSponsorTier.Bronze]: { - dotClass: 'bg-accent-bacon-default', - labelClass: 'text-accent-bacon-default', - chipClass: 'px-3 py-1.5', - logoClass: 'h-5 tablet:h-6', - }, +// Each tier gets its own colored pill so the sponsor level is spelled out on the +// card itself — no decoding a small dot. The colors map to the brand accents +// (gold = cheese, bronze = bacon); silver stays a quiet neutral. +const tierPillClass: Record = { + [ContributionSponsorTier.Gold]: + 'bg-accent-cheese-flat text-accent-cheese-default', + [ContributionSponsorTier.Silver]: + 'bg-background-subtle text-text-secondary', + [ContributionSponsorTier.Bronze]: + 'bg-accent-bacon-flat text-accent-bacon-default', }; -// 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 = [ +// Headline tiers first so the wall reads top-down 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 self-describing sponsor card: the brand logo sits on its own white tile so +// it stays legible regardless of the page theme (the high-contrast half), while +// the card body carries the name and a colored tier pill (the colorful half). +// The card itself rests on a flat surface rather than a bare white block. +const SponsorCard = ({ sponsor, - style, }: { sponsor: ContributionSponsor; - style: TierStyle; }): ReactElement => { const { logEvent } = useLogContext(); + const [failed, setFailed] = useState(false); + const showLogo = Boolean(sponsor.logoUrl) && !failed; const onClick = () => logEvent({ @@ -72,59 +55,52 @@ 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} + const cardClass = + 'flex min-w-0 items-center gap-3 rounded-14 border border-border-subtlest-tertiary bg-surface-float p-3 transition-transform duration-200 hover:-translate-y-1 motion-reduce:transform-none'; + + const body = ( + <> + + {showLogo ? ( + {`${sponsor.name} setFailed(true)} + /> + ) : ( + + {getSponsorInitials(sponsor.name)} + + )} + + + + {sponsor.name} + + + + {sponsorTierLabel[sponsor.tier]} sponsor + + + ); if (!sponsor.url) { return ( - {logo} + {body} ); } @@ -138,40 +114,11 @@ const SponsorChip = ({ className={cardClass} onClick={onClick} > - {logo} + {body} ); }; -const SponsorTierGroup = ({ - tier, - sponsors, -}: { - tier: ContributionSponsorTier; - sponsors: ContributionSponsor[]; -}): ReactElement => { - const style = tierStyles[tier]; - - return ( - - - - - {sponsorTierLabel[tier]} - - - {sponsors.map((sponsor) => ( - - ))} - - ); -}; - export const GivebackSponsorTiers = (): ReactElement | null => { const { sponsors } = useContributionSponsors(); @@ -179,13 +126,9 @@ 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 ordered = [...sponsors].sort( + (a, b) => TIER_ORDER.indexOf(a.tier) - TIER_ORDER.indexOf(b.tier), ); - const lowerTiers = LOWER_TIERS.filter((tier) => byTier(tier).length > 0); return (
@@ -213,45 +156,11 @@ export const GivebackSponsorTiers = (): ReactElement | null => { 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) => ( - - ))} - - - )} - + + {ordered.map((sponsor) => ( + + ))} +
); 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 1e1f0bffbd4..5e7803de3ef 100644 --- a/packages/shared/tailwind.config.ts +++ b/packages/shared/tailwind.config.ts @@ -310,6 +310,12 @@ 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' }, + }, 'mascot-bob': { '0%, 100%': { transform: 'translateY(0)' }, '50%': { transform: 'translateY(-6px)' }, @@ -379,6 +385,7 @@ 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', '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 c02c34bfb7b8cf571ae32e00eb2739b773312ded Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 18 Jun 2026 00:03:20 +0300 Subject: [PATCH 02/89] style(giveback): fix prettier formatting and tailwind class order Co-Authored-By: Claude Opus 4.8 --- .../features/giveback/components/GivebackPersonalRoadmap.tsx | 5 ++++- .../features/giveback/components/GivebackSponsorTiers.tsx | 3 +-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx index 84e66e60e13..dcb6237baa7 100644 --- a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx @@ -319,7 +319,10 @@ const NodeRow = ({ {canClaim ? (
{celebrate && ( - + {claimSparkles.map((sparkle) => ( = { [ContributionSponsorTier.Gold]: 'bg-accent-cheese-flat text-accent-cheese-default', - [ContributionSponsorTier.Silver]: - 'bg-background-subtle text-text-secondary', + [ContributionSponsorTier.Silver]: 'bg-background-subtle text-text-secondary', [ContributionSponsorTier.Bronze]: 'bg-accent-bacon-flat text-accent-bacon-default', }; From c0976d6ebcf244499c149e6e71efa049f46e257d Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 18 Jun 2026 00:11:22 +0300 Subject: [PATCH 03/89] style(giveback): fix prettier formatting in tailwind config Co-Authored-By: Claude Opus 4.8 --- packages/shared/tailwind.config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/shared/tailwind.config.ts b/packages/shared/tailwind.config.ts index 5e7803de3ef..b161fe8fd3d 100644 --- a/packages/shared/tailwind.config.ts +++ b/packages/shared/tailwind.config.ts @@ -385,7 +385,8 @@ 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', + 'claim-ring': + 'claim-ring 640ms cubic-bezier(0.22, 0.61, 0.36, 1) forwards', '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 b42a4517e12285896f78088f667f994298d78839 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 18 Jun 2026 00:19:20 +0300 Subject: [PATCH 04/89] chore: re-run CI (flaky useFeeds test, unrelated to changes) Co-Authored-By: Claude Opus 4.8 From a2ce6b34ecdcaf4febfc804ae5d8c36447f13314 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 18 Jun 2026 00:29:23 +0300 Subject: [PATCH 05/89] feat(giveback): size sponsor cards by tier and use brown for bronze Gold cards read biggest and step down to bronze (classic sponsor-wall hierarchy). Bronze pill switches from bacon (red) to burger (brown). Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackSponsorTiers.tsx | 59 ++++++++++++++----- 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx index 5d4411b2181..87f23f9b685 100644 --- a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx +++ b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx @@ -16,15 +16,36 @@ import { getSponsorInitials, sponsorTierLabel } from '../utils'; import type { ContributionSponsor } from '../types'; import { ContributionSponsorTier } from '../types'; -// Each tier gets its own colored pill so the sponsor level is spelled out on the -// card itself — no decoding a small dot. The colors map to the brand accents -// (gold = cheese, bronze = bacon); silver stays a quiet neutral. -const tierPillClass: Record = { - [ContributionSponsorTier.Gold]: - 'bg-accent-cheese-flat text-accent-cheese-default', - [ContributionSponsorTier.Silver]: 'bg-background-subtle text-text-secondary', - [ContributionSponsorTier.Bronze]: - 'bg-accent-bacon-flat text-accent-bacon-default', +interface TierStyle { + // Colored tier pill so the level is spelled out on the card (gold = cheese, + // bronze = brown/burger; silver stays a quiet neutral). + pillClass: string; + // Card + logo size step down by prestige — the classic sponsor-wall hierarchy + // where gold reads biggest and bronze smallest. + cardClass: string; + tileClass: string; + nameType: TypographyType; +} + +const tierStyles: Record = { + [ContributionSponsorTier.Gold]: { + pillClass: 'bg-accent-cheese-flat text-accent-cheese-default', + cardClass: 'gap-4 p-4', + tileClass: 'size-16', + nameType: TypographyType.Body, + }, + [ContributionSponsorTier.Silver]: { + pillClass: 'bg-background-subtle text-text-secondary', + cardClass: 'gap-3 p-3', + tileClass: 'size-12', + nameType: TypographyType.Callout, + }, + [ContributionSponsorTier.Bronze]: { + pillClass: 'bg-accent-burger-flat text-accent-burger-default', + cardClass: 'gap-2.5 p-2.5', + tileClass: 'size-10', + nameType: TypographyType.Footnote, + }, }; // Headline tiers first so the wall reads top-down by prestige. @@ -46,6 +67,7 @@ const SponsorCard = ({ const { logEvent } = useLogContext(); const [failed, setFailed] = useState(false); const showLogo = Boolean(sponsor.logoUrl) && !failed; + const style = tierStyles[sponsor.tier]; const onClick = () => logEvent({ @@ -54,12 +76,19 @@ const SponsorCard = ({ extra: JSON.stringify({ name: sponsor.name, tier: sponsor.tier }), }); - const cardClass = - 'flex min-w-0 items-center gap-3 rounded-14 border border-border-subtlest-tertiary bg-surface-float p-3 transition-transform duration-200 hover:-translate-y-1 motion-reduce:transform-none'; + const cardClass = classNames( + 'flex min-w-0 items-center rounded-14 border border-border-subtlest-tertiary bg-surface-float transition-transform duration-200 hover:-translate-y-1 motion-reduce:transform-none', + style.cardClass, + ); const body = ( <> - + {showLogo ? ( @@ -86,7 +115,7 @@ const SponsorCard = ({ @@ -155,7 +184,7 @@ export const GivebackSponsorTiers = (): ReactElement | null => { Sponsored by - + {ordered.map((sponsor) => ( ))} From 60914acfc008af2f3021255003864fe661dd9b03 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 18 Jun 2026 00:41:14 +0300 Subject: [PATCH 06/89] feat(giveback): group sponsor cards into per-tier rows, drop card fill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One row per tier keeps cards uniform within a row and reads the gold→bronze hierarchy top-down instead of mixed sizes wrapping raggedly. Cards are now border-only (no surface fill). Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackSponsorTiers.tsx | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx index 87f23f9b685..6cea88bfc54 100644 --- a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx +++ b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx @@ -77,7 +77,7 @@ const SponsorCard = ({ }); const cardClass = classNames( - 'flex min-w-0 items-center rounded-14 border border-border-subtlest-tertiary bg-surface-float transition-transform duration-200 hover:-translate-y-1 motion-reduce:transform-none', + 'flex min-w-0 items-center rounded-14 border border-border-subtlest-tertiary transition-transform duration-200 hover:-translate-y-1 motion-reduce:transform-none', style.cardClass, ); @@ -154,9 +154,13 @@ export const GivebackSponsorTiers = (): ReactElement | null => { return null; } - const ordered = [...sponsors].sort( - (a, b) => TIER_ORDER.indexOf(a.tier) - TIER_ORDER.indexOf(b.tier), - ); + // Each tier becomes its own row so cards within a row are the same size and + // the hierarchy reads top-down (gold → bronze) instead of mixed sizes + // wrapping into a ragged grid. + const tierRows = TIER_ORDER.map((tier) => ({ + tier, + sponsors: sponsors.filter((sponsor) => sponsor.tier === tier), + })).filter((row) => row.sponsors.length > 0); return (
@@ -184,11 +188,15 @@ export const GivebackSponsorTiers = (): ReactElement | null => { Sponsored by - - {ordered.map((sponsor) => ( - + + {tierRows.map((row) => ( + + {row.sponsors.map((sponsor) => ( + + ))} + ))} - +
); From 406870014dea02c8f3f52e336d3bc2ab46895c39 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 18 Jun 2026 00:52:15 +0300 Subject: [PATCH 07/89] feat(giveback): compact single-line sponsor chips Replace the two-line cards with content-width chips: small logo tile, name, and an inline tier marker (medal icon + short tier word) at the end of the name. Cuts the section's footprint while keeping the per-tier rows and gold>silver>bronze size hierarchy. Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackSponsorTiers.spec.tsx | 4 +- .../components/GivebackSponsorTiers.tsx | 88 +++++++++---------- 2 files changed, 42 insertions(+), 50 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.spec.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.spec.tsx index d98979f56f0..6d9eda34d19 100644 --- a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.spec.tsx +++ b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.spec.tsx @@ -59,8 +59,8 @@ it('renders a brand logo card with an explicit tier pill per sponsor', () => { render(); expect(screen.getByText('Sponsored by')).toBeInTheDocument(); - expect(screen.getByText('Gold sponsor')).toBeInTheDocument(); - expect(screen.getByText('Bronze sponsor')).toBeInTheDocument(); + expect(screen.getByText('Gold')).toBeInTheDocument(); + expect(screen.getByText('Bronze')).toBeInTheDocument(); const link = screen.getByRole('link', { name: 'Vercel' }); expect(link).toHaveAttribute('href', 'https://acme.test'); diff --git a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx index 6cea88bfc54..86add239ccb 100644 --- a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx +++ b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx @@ -17,33 +17,29 @@ import type { ContributionSponsor } from '../types'; import { ContributionSponsorTier } from '../types'; interface TierStyle { - // Colored tier pill so the level is spelled out on the card (gold = cheese, - // bronze = brown/burger; silver stays a quiet neutral). - pillClass: string; - // Card + logo size step down by prestige — the classic sponsor-wall hierarchy - // where gold reads biggest and bronze smallest. - cardClass: string; + // Tint for the inline tier marker (icon + short label) — gold = cheese, + // bronze = brown/burger; silver stays a quiet neutral. + tierClass: string; + // Logo tile size steps down by prestige so gold reads biggest, bronze + // smallest — the sponsor-wall hierarchy, kept compact. tileClass: string; nameType: TypographyType; } const tierStyles: Record = { [ContributionSponsorTier.Gold]: { - pillClass: 'bg-accent-cheese-flat text-accent-cheese-default', - cardClass: 'gap-4 p-4', - tileClass: 'size-16', - nameType: TypographyType.Body, + tierClass: 'text-accent-cheese-default', + tileClass: 'size-10', + nameType: TypographyType.Callout, }, [ContributionSponsorTier.Silver]: { - pillClass: 'bg-background-subtle text-text-secondary', - cardClass: 'gap-3 p-3', - tileClass: 'size-12', - nameType: TypographyType.Callout, + tierClass: 'text-text-secondary', + tileClass: 'size-8', + nameType: TypographyType.Footnote, }, [ContributionSponsorTier.Bronze]: { - pillClass: 'bg-accent-burger-flat text-accent-burger-default', - cardClass: 'gap-2.5 p-2.5', - tileClass: 'size-10', + tierClass: 'text-accent-burger-default', + tileClass: 'size-7', nameType: TypographyType.Footnote, }, }; @@ -55,10 +51,9 @@ const TIER_ORDER: ContributionSponsorTier[] = [ ContributionSponsorTier.Bronze, ]; -// A self-describing sponsor card: the brand logo sits on its own white tile so -// it stays legible regardless of the page theme (the high-contrast half), while -// the card body carries the name and a colored tier pill (the colorful half). -// The card itself rests on a flat surface rather than a bare white block. +// A compact, content-width sponsor chip: a small white logo tile, the name, and +// an inline tier marker (medal icon + short tier word) tucked at the end of the +// name. Border-only and sized to its text so a row of them stays tight. const SponsorCard = ({ sponsor, }: { @@ -76,16 +71,14 @@ const SponsorCard = ({ extra: JSON.stringify({ name: sponsor.name, tier: sponsor.tier }), }); - const cardClass = classNames( - 'flex min-w-0 items-center rounded-14 border border-border-subtlest-tertiary transition-transform duration-200 hover:-translate-y-1 motion-reduce:transform-none', - style.cardClass, - ); + const cardClass = + 'inline-flex max-w-full items-center gap-2 rounded-12 border border-border-subtlest-tertiary px-2.5 py-1.5 transition-transform duration-200 hover:-translate-y-0.5 motion-reduce:transform-none'; const body = ( <> @@ -94,34 +87,33 @@ const SponsorCard = ({ src={sponsor.logoUrl ?? undefined} alt={`${sponsor.name} logo`} loading="lazy" - className="size-full object-contain p-1.5" + className="size-full object-contain p-1" onError={() => setFailed(true)} /> ) : ( - + {getSponsorInitials(sponsor.name)} )} - - - {sponsor.name} - - - - {sponsorTierLabel[sponsor.tier]} sponsor - - + + {sponsor.name} + + + + {sponsorTierLabel[sponsor.tier]} + ); @@ -190,7 +182,7 @@ export const GivebackSponsorTiers = (): ReactElement | null => { {tierRows.map((row) => ( - + {row.sponsors.map((sponsor) => ( ))} From f66f6d31b52970c29aa23fe7c8a56cc158da16d1 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 18 Jun 2026 01:25:27 +0300 Subject: [PATCH 08/89] chore: re-trigger stuck Vercel preview deploy Co-Authored-By: Claude Opus 4.8 From 57c811f03838b0fd6b0c02b6d3acb8ebb4cccf45 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 18 Jun 2026 01:34:43 +0300 Subject: [PATCH 09/89] feat(giveback): logo-forward sponsor wall, drop name text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sponsors now render as big brand logos on white cards (no company name — the logo carries the brand). One row per tier with a small tier label; card/logo size steps down gold -> silver -> bronze. Name shows only as a fallback when a sponsor has no logo. Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackSponsorTiers.tsx | 154 +++++++++--------- 1 file changed, 80 insertions(+), 74 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx index 86add239ccb..f2af726c299 100644 --- a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx +++ b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx @@ -12,35 +12,35 @@ import { MedalBadgeIcon } from '../../../components/icons'; import { useLogContext } from '../../../contexts/LogContext'; import { LogEvent } from '../../../lib/log'; import { useContributionSponsors } from '../hooks/useContributionSponsors'; -import { getSponsorInitials, sponsorTierLabel } from '../utils'; +import { sponsorTierLabel } from '../utils'; import type { ContributionSponsor } from '../types'; import { ContributionSponsorTier } from '../types'; interface TierStyle { - // Tint for the inline tier marker (icon + short label) — gold = cheese, - // bronze = brown/burger; silver stays a quiet neutral. - tierClass: string; - // Logo tile size steps down by prestige so gold reads biggest, bronze - // smallest — the sponsor-wall hierarchy, kept compact. - tileClass: string; - nameType: TypographyType; + // Tint for the tier label that sits beside each row. + labelClass: string; + // White logo card height + horizontal padding, stepping down by prestige so + // gold logos read largest and bronze smallest. + cardClass: string; + // Max logo height inside the card. + logoClass: string; } const tierStyles: Record = { [ContributionSponsorTier.Gold]: { - tierClass: 'text-accent-cheese-default', - tileClass: 'size-10', - nameType: TypographyType.Callout, + labelClass: 'text-accent-cheese-default', + cardClass: 'h-20 px-6', + logoClass: 'max-h-12', }, [ContributionSponsorTier.Silver]: { - tierClass: 'text-text-secondary', - tileClass: 'size-8', - nameType: TypographyType.Footnote, + labelClass: 'text-text-secondary', + cardClass: 'h-16 px-5', + logoClass: 'max-h-9', }, [ContributionSponsorTier.Bronze]: { - tierClass: 'text-accent-burger-default', - tileClass: 'size-7', - nameType: TypographyType.Footnote, + labelClass: 'text-accent-burger-default', + cardClass: 'h-14 px-4', + logoClass: 'max-h-7', }, }; @@ -51,9 +51,10 @@ const TIER_ORDER: ContributionSponsorTier[] = [ ContributionSponsorTier.Bronze, ]; -// A compact, content-width sponsor chip: a small white logo tile, the name, and -// an inline tier marker (medal icon + short tier word) tucked at the end of the -// name. Border-only and sized to its text so a row of them stays tight. +// A logo-forward sponsor card: the brand logo fills a white card (legible on the +// dark page) with no name text — the logo carries the brand. Logo-less sponsors +// (individuals, fresh sponsors) fall back to their name so the card is never +// empty. Card size is driven by the sponsor's tier. const SponsorCard = ({ sponsor, }: { @@ -71,50 +72,32 @@ const SponsorCard = ({ extra: JSON.stringify({ name: sponsor.name, tier: sponsor.tier }), }); - const cardClass = - 'inline-flex max-w-full items-center gap-2 rounded-12 border border-border-subtlest-tertiary px-2.5 py-1.5 transition-transform duration-200 hover:-translate-y-0.5 motion-reduce:transform-none'; + const cardClass = classNames( + 'inline-flex max-w-full shrink-0 items-center justify-center rounded-12 bg-white shadow-2 transition-transform duration-200 hover:-translate-y-1 motion-reduce:transform-none', + style.cardClass, + ); - const body = ( - <> - - {showLogo ? ( - {`${sponsor.name} setFailed(true)} - /> - ) : ( - - {getSponsorInitials(sponsor.name)} - - )} - - - {sponsor.name} - - - - {sponsorTierLabel[sponsor.tier]} - - + const body = showLogo ? ( + {`${sponsor.name} setFailed(true)} + /> + ) : ( + + {sponsor.name} + ); if (!sponsor.url) { @@ -146,9 +129,8 @@ export const GivebackSponsorTiers = (): ReactElement | null => { return null; } - // Each tier becomes its own row so cards within a row are the same size and - // the hierarchy reads top-down (gold → bronze) instead of mixed sizes - // wrapping into a ragged grid. + // Each tier is its own row — a small tier label, then the row of logo cards — + // so the hierarchy reads top-down (gold → bronze) by both label and size. const tierRows = TIER_ORDER.map((tier) => ({ tier, sponsors: sponsors.filter((sponsor) => sponsor.tier === tier), @@ -180,14 +162,38 @@ export const GivebackSponsorTiers = (): ReactElement | null => { Sponsored by - - {tierRows.map((row) => ( - - {row.sponsors.map((sponsor) => ( - - ))} - - ))} + + {tierRows.map((row) => { + const style = tierStyles[row.tier]; + return ( + + + + + {sponsorTierLabel[row.tier]} + + + + {row.sponsors.map((sponsor) => ( + + ))} + + + ); + })} From d423cad3a88d38e45f6c75e1b8a772bd89adfa3a Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 18 Jun 2026 21:13:08 +0300 Subject: [PATCH 10/89] feat(giveback): flat monochrome sponsor wall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the white logo cards for flat hairline-border chips. Logos render monochrome (forced light tint) at rest and reveal full color on hover — the standard dark-theme sponsor-wall treatment (Vite/Astro/Tailwind). Keeps per-tier rows and gold>silver>bronze size hierarchy. Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackSponsorTiers.tsx | 43 ++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx index f2af726c299..cd0dc671657 100644 --- a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx +++ b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx @@ -19,28 +19,31 @@ import { ContributionSponsorTier } from '../types'; interface TierStyle { // Tint for the tier label that sits beside each row. labelClass: string; - // White logo card height + horizontal padding, stepping down by prestige so - // gold logos read largest and bronze smallest. - cardClass: string; - // Max logo height inside the card. + // Chip padding + logo height step down by prestige so gold reads largest. + chipClass: string; logoClass: string; + // Fallback name size for logo-less sponsors. + nameType: TypographyType; } const tierStyles: Record = { [ContributionSponsorTier.Gold]: { labelClass: 'text-accent-cheese-default', - cardClass: 'h-20 px-6', - logoClass: 'max-h-12', + chipClass: 'px-4 py-2.5', + logoClass: 'max-h-8', + nameType: TypographyType.Callout, }, [ContributionSponsorTier.Silver]: { labelClass: 'text-text-secondary', - cardClass: 'h-16 px-5', - logoClass: 'max-h-9', + chipClass: 'px-3.5 py-2', + logoClass: 'max-h-6', + nameType: TypographyType.Footnote, }, [ContributionSponsorTier.Bronze]: { labelClass: 'text-accent-burger-default', - cardClass: 'h-14 px-4', - logoClass: 'max-h-7', + chipClass: 'px-3 py-1.5', + logoClass: 'max-h-5', + nameType: TypographyType.Footnote, }, }; @@ -51,10 +54,10 @@ const TIER_ORDER: ContributionSponsorTier[] = [ ContributionSponsorTier.Bronze, ]; -// A logo-forward sponsor card: the brand logo fills a white card (legible on the -// dark page) with no name text — the logo carries the brand. Logo-less sponsors -// (individuals, fresh sponsors) fall back to their name so the card is never -// empty. Card size is driven by the sponsor's tier. +// A flat, logo-forward sponsor chip: no fill, just a hairline border. The logo +// is forced to a single light tint at rest (so wildly different brand logos read +// as one calm wall on the dark page) and reveals its true colors on hover. +// Logo-less sponsors fall back to their name so the chip is never empty. const SponsorCard = ({ sponsor, }: { @@ -73,8 +76,8 @@ const SponsorCard = ({ }); const cardClass = classNames( - 'inline-flex max-w-full shrink-0 items-center justify-center rounded-12 bg-white shadow-2 transition-transform duration-200 hover:-translate-y-1 motion-reduce:transform-none', - style.cardClass, + 'group inline-flex max-w-full shrink-0 items-center justify-center rounded-10 border border-border-subtlest-tertiary transition-colors duration-200 hover:border-border-subtlest-secondary', + style.chipClass, ); const body = showLogo ? ( @@ -83,7 +86,7 @@ const SponsorCard = ({ alt={`${sponsor.name} logo`} loading="lazy" className={classNames( - 'w-auto max-w-[160px] object-contain', + 'opacity-70 w-auto max-w-[140px] object-contain transition duration-200 [filter:brightness(0)_invert(1)] group-hover:opacity-100 group-hover:[filter:none]', style.logoClass, )} onError={() => setFailed(true)} @@ -91,10 +94,10 @@ const SponsorCard = ({ ) : ( {sponsor.name} @@ -129,7 +132,7 @@ export const GivebackSponsorTiers = (): ReactElement | null => { return null; } - // Each tier is its own row — a small tier label, then the row of logo cards — + // Each tier is its own row — a small tier label, then the row of logo chips — // so the hierarchy reads top-down (gold → bronze) by both label and size. const tierRows = TIER_ORDER.map((tier) => ({ tier, From e6662761823e58cad97a0c1d3e43621eae905278 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 18 Jun 2026 21:24:33 +0300 Subject: [PATCH 11/89] fix(giveback): never render a blank sponsor chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sponsor logo only counts once it actually decodes (onLoad with non-zero naturalWidth); until then — still loading, hung, CSP-blocked, 404, zero-size, or no URL — the sponsor name shows instead. Previously a logo that failed to fire onError (e.g. hung/blocked) left an empty chip. Also drops loading=lazy so the hidden-until-loaded image still loads. Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackSponsorTiers.tsx | 59 ++++++++++++------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx index cd0dc671657..337aecf56d7 100644 --- a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx +++ b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx @@ -64,8 +64,13 @@ const SponsorCard = ({ sponsor: ContributionSponsor; }): ReactElement => { const { logEvent } = useLogContext(); - const [failed, setFailed] = useState(false); - const showLogo = Boolean(sponsor.logoUrl) && !failed; + // The logo only counts as usable once it actually decodes with real pixels. + // Until then (still loading, hung, CSP-blocked, 404, or no URL at all) we show + // the sponsor's name so a chip is never blank. + const [logoLoaded, setLogoLoaded] = useState(false); + const [logoFailed, setLogoFailed] = useState(false); + const hasLogo = Boolean(sponsor.logoUrl) && !logoFailed; + const showName = !hasLogo || !logoLoaded; const style = tierStyles[sponsor.tier]; const onClick = () => @@ -80,27 +85,37 @@ const SponsorCard = ({ style.chipClass, ); - const body = showLogo ? ( - {`${sponsor.name} + {hasLogo && ( + {`${sponsor.name} + event.currentTarget.naturalWidth === 0 + ? setLogoFailed(true) + : setLogoLoaded(true) + } + onError={() => setLogoFailed(true)} + className={classNames( + 'opacity-70 w-auto max-w-[140px] object-contain transition duration-200 [filter:brightness(0)_invert(1)] group-hover:opacity-100 group-hover:[filter:none]', + style.logoClass, + !logoLoaded && 'hidden', + )} + /> )} - onError={() => setFailed(true)} - /> - ) : ( - - {sponsor.name} - + {showName && ( + + {sponsor.name} + + )} + ); if (!sponsor.url) { From 0a3d35d90d282cc1a8ebed66f7c7e0a0cf3997aa Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 18 Jun 2026 21:27:16 +0300 Subject: [PATCH 12/89] feat(giveback): three-column sponsor cards with white logo tiles Per-tier columns (gold/silver/bronze) as equal cards in a centered 3-up grid (stacks on mobile). Logos sit on white tiles so brand colors stay visible; name fallback still covers any logo that fails or hangs. Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackSponsorTiers.tsx | 131 +++++++----------- 1 file changed, 48 insertions(+), 83 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx index 337aecf56d7..6c2d1429b27 100644 --- a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx +++ b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx @@ -16,62 +16,34 @@ import { sponsorTierLabel } from '../utils'; import type { ContributionSponsor } from '../types'; import { ContributionSponsorTier } from '../types'; -interface TierStyle { - // Tint for the tier label that sits beside each row. - labelClass: string; - // Chip padding + logo height step down by prestige so gold reads largest. - chipClass: string; - logoClass: string; - // Fallback name size for logo-less sponsors. - nameType: TypographyType; -} - -const tierStyles: Record = { - [ContributionSponsorTier.Gold]: { - labelClass: 'text-accent-cheese-default', - chipClass: 'px-4 py-2.5', - logoClass: 'max-h-8', - nameType: TypographyType.Callout, - }, - [ContributionSponsorTier.Silver]: { - labelClass: 'text-text-secondary', - chipClass: 'px-3.5 py-2', - logoClass: 'max-h-6', - nameType: TypographyType.Footnote, - }, - [ContributionSponsorTier.Bronze]: { - labelClass: 'text-accent-burger-default', - chipClass: 'px-3 py-1.5', - logoClass: 'max-h-5', - nameType: TypographyType.Footnote, - }, +// Tint for each tier's column heading. +const tierLabelClass: Record = { + [ContributionSponsorTier.Gold]: 'text-accent-cheese-default', + [ContributionSponsorTier.Silver]: 'text-text-secondary', + [ContributionSponsorTier.Bronze]: 'text-accent-burger-default', }; -// Headline tiers first so the wall reads top-down by prestige. +// Gold first so the columns read left-to-right by prestige. const TIER_ORDER: ContributionSponsorTier[] = [ ContributionSponsorTier.Gold, ContributionSponsorTier.Silver, ContributionSponsorTier.Bronze, ]; -// A flat, logo-forward sponsor chip: no fill, just a hairline border. The logo -// is forced to a single light tint at rest (so wildly different brand logos read -// as one calm wall on the dark page) and reveals its true colors on hover. -// Logo-less sponsors fall back to their name so the chip is never empty. -const SponsorCard = ({ +// A sponsor logo on a white tile so the brand's real colors stay visible on the +// dark page. The logo only counts once it actually decodes (onLoad with real +// pixels); until then — loading, hung, blocked, 404, zero-size, or no URL — the +// sponsor name shows instead, so a tile is never blank. +const SponsorLogo = ({ sponsor, }: { sponsor: ContributionSponsor; }): ReactElement => { const { logEvent } = useLogContext(); - // The logo only counts as usable once it actually decodes with real pixels. - // Until then (still loading, hung, CSP-blocked, 404, or no URL at all) we show - // the sponsor's name so a chip is never blank. const [logoLoaded, setLogoLoaded] = useState(false); const [logoFailed, setLogoFailed] = useState(false); const hasLogo = Boolean(sponsor.logoUrl) && !logoFailed; const showName = !hasLogo || !logoLoaded; - const style = tierStyles[sponsor.tier]; const onClick = () => logEvent({ @@ -80,10 +52,8 @@ const SponsorCard = ({ extra: JSON.stringify({ name: sponsor.name, tier: sponsor.tier }), }); - const cardClass = classNames( - 'group inline-flex max-w-full shrink-0 items-center justify-center rounded-10 border border-border-subtlest-tertiary transition-colors duration-200 hover:border-border-subtlest-secondary', - style.chipClass, - ); + const tileClass = + 'inline-flex h-12 min-w-[88px] max-w-full items-center justify-center rounded-10 bg-white px-3 transition-transform duration-200 hover:-translate-y-0.5 motion-reduce:transform-none'; const body = ( <> @@ -98,8 +68,7 @@ const SponsorCard = ({ } onError={() => setLogoFailed(true)} className={classNames( - 'opacity-70 w-auto max-w-[140px] object-contain transition duration-200 [filter:brightness(0)_invert(1)] group-hover:opacity-100 group-hover:[filter:none]', - style.logoClass, + 'max-h-7 w-auto max-w-[120px] object-contain', !logoLoaded && 'hidden', )} /> @@ -107,10 +76,10 @@ const SponsorCard = ({ {showName && ( {sponsor.name} @@ -120,7 +89,7 @@ const SponsorCard = ({ if (!sponsor.url) { return ( - + {body} ); @@ -132,7 +101,7 @@ const SponsorCard = ({ target="_blank" rel="noopener noreferrer" aria-label={sponsor.name} - className={cardClass} + className={tileClass} onClick={onClick} > {body} @@ -147,12 +116,11 @@ export const GivebackSponsorTiers = (): ReactElement | null => { return null; } - // Each tier is its own row — a small tier label, then the row of logo chips — - // so the hierarchy reads top-down (gold → bronze) by both label and size. - const tierRows = TIER_ORDER.map((tier) => ({ + // One card per tier that has sponsors, laid out as up-to-three equal columns. + const tierColumns = TIER_ORDER.map((tier) => ({ tier, sponsors: sponsors.filter((sponsor) => sponsor.tier === tier), - })).filter((row) => row.sponsors.length > 0); + })).filter((column) => column.sponsors.length > 0); return (
@@ -169,7 +137,7 @@ export const GivebackSponsorTiers = (): ReactElement | null => { />
- + { Sponsored by - - {tierRows.map((row) => { - const style = tierStyles[row.tier]; - return ( +
+ {tierColumns.map((column) => ( + - + - - - {sponsorTierLabel[row.tier]} - - - - {row.sponsors.map((sponsor) => ( - - ))} - + {sponsorTierLabel[column.tier]} + + + + {column.sponsors.map((sponsor) => ( + + ))} - ); - })} - + + ))} +
); From 2cf930b991198f9e6abe1e62cb1118f6213caf9f Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 18 Jun 2026 21:37:38 +0300 Subject: [PATCH 13/89] feat(giveback): divider columns + hover-lit monochrome sponsor chips Drop the per-tier card borders for divider lines between the three columns. Each logo chip is a flat bordered tile with a monochrome logo at rest and fills white with the full-color logo on hover. Logo size steps down gold > silver > bronze. Layout stays horizontal (stacks on mobile). Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackSponsorTiers.tsx | 124 ++++++++++++------ 1 file changed, 84 insertions(+), 40 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx index 6c2d1429b27..83643836718 100644 --- a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx +++ b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx @@ -16,11 +16,36 @@ import { sponsorTierLabel } from '../utils'; import type { ContributionSponsor } from '../types'; import { ContributionSponsorTier } from '../types'; -// Tint for each tier's column heading. -const tierLabelClass: Record = { - [ContributionSponsorTier.Gold]: 'text-accent-cheese-default', - [ContributionSponsorTier.Silver]: 'text-text-secondary', - [ContributionSponsorTier.Bronze]: 'text-accent-burger-default', +interface TierStyle { + // Tint for the tier's column heading. + labelClass: string; + // Chip height + logo size step down by prestige: gold biggest, bronze + // smallest. + chipClass: string; + logoClass: string; + // Fallback name size when a sponsor has no usable logo. + nameType: TypographyType; +} + +const tierStyles: Record = { + [ContributionSponsorTier.Gold]: { + labelClass: 'text-accent-cheese-default', + chipClass: 'h-14 px-4', + logoClass: 'max-h-9', + nameType: TypographyType.Callout, + }, + [ContributionSponsorTier.Silver]: { + labelClass: 'text-text-secondary', + chipClass: 'h-12 px-3.5', + logoClass: 'max-h-7', + nameType: TypographyType.Footnote, + }, + [ContributionSponsorTier.Bronze]: { + labelClass: 'text-accent-burger-default', + chipClass: 'h-10 px-3', + logoClass: 'max-h-5', + nameType: TypographyType.Footnote, + }, }; // Gold first so the columns read left-to-right by prestige. @@ -30,14 +55,18 @@ const TIER_ORDER: ContributionSponsorTier[] = [ ContributionSponsorTier.Bronze, ]; -// A sponsor logo on a white tile so the brand's real colors stay visible on the -// dark page. The logo only counts once it actually decodes (onLoad with real -// pixels); until then — loading, hung, blocked, 404, zero-size, or no URL — the -// sponsor name shows instead, so a tile is never blank. +// A sponsor chip that lights up on hover: at rest it's a flat bordered tile with +// a monochrome (light-tinted) logo so the wall reads calm; on hover it fills +// white and reveals the logo's true colors. The logo only counts once it +// actually decodes (onLoad with real pixels) — until then (loading, hung, +// blocked, 404, zero-size, or no URL) the sponsor name shows so a tile is never +// blank. const SponsorLogo = ({ sponsor, + style, }: { sponsor: ContributionSponsor; + style: TierStyle; }): ReactElement => { const { logEvent } = useLogContext(); const [logoLoaded, setLogoLoaded] = useState(false); @@ -52,8 +81,10 @@ const SponsorLogo = ({ extra: JSON.stringify({ name: sponsor.name, tier: sponsor.tier }), }); - const tileClass = - 'inline-flex h-12 min-w-[88px] max-w-full items-center justify-center rounded-10 bg-white px-3 transition-transform duration-200 hover:-translate-y-0.5 motion-reduce:transform-none'; + const tileClass = classNames( + 'group inline-flex max-w-full items-center justify-center rounded-10 border border-border-subtlest-tertiary transition-colors duration-200 hover:border-transparent hover:bg-white', + style.chipClass, + ); const body = ( <> @@ -68,7 +99,8 @@ const SponsorLogo = ({ } onError={() => setLogoFailed(true)} className={classNames( - 'max-h-7 w-auto max-w-[120px] object-contain', + 'opacity-70 w-auto max-w-[120px] object-contain transition duration-200 [filter:brightness(0)_invert(1)] group-hover:opacity-100 group-hover:[filter:none]', + style.logoClass, !logoLoaded && 'hidden', )} /> @@ -76,10 +108,10 @@ const SponsorLogo = ({ {showName && ( {sponsor.name} @@ -116,7 +148,8 @@ export const GivebackSponsorTiers = (): ReactElement | null => { return null; } - // One card per tier that has sponsors, laid out as up-to-three equal columns. + // One column per tier that has sponsors, side by side, split by a divider + // rather than wrapped in cards. const tierColumns = TIER_ORDER.map((tier) => ({ tier, sponsors: sponsors.filter((sponsor) => sponsor.tier === tier), @@ -148,35 +181,46 @@ export const GivebackSponsorTiers = (): ReactElement | null => { Sponsored by -
- {tierColumns.map((column) => ( - - + {tierColumns.map((column, index) => { + const style = tierStyles[column.tier]; + return ( + 0 && + 'border-t border-border-subtlest-tertiary tablet:border-l tablet:border-t-0', )} > - - - {sponsorTierLabel[column.tier]} - - - - {column.sponsors.map((sponsor) => ( - - ))} - - - ))} + + + {sponsorTierLabel[column.tier]} + + + + {column.sponsors.map((sponsor) => ( + + ))} + + + ); + })}
From c80a3de47c3b2e3700665653a3642124bb68f389 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 18 Jun 2026 21:59:04 +0300 Subject: [PATCH 14/89] fix(giveback): robust sponsor logo render + stronger tier sizing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the unreliable naturalWidth===0 onLoad check (browsers report 0 for viewBox-only SVGs, which wrongly blanked valid logos) — rely on onError. Render monochrome logos at full strength instead of dimmed. Bump gold logo larger and bronze smaller for a more prominent hierarchy (silver unchanged). Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackSponsorTiers.tsx | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx index 83643836718..1c5894f774b 100644 --- a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx +++ b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx @@ -30,8 +30,8 @@ interface TierStyle { const tierStyles: Record = { [ContributionSponsorTier.Gold]: { labelClass: 'text-accent-cheese-default', - chipClass: 'h-14 px-4', - logoClass: 'max-h-9', + chipClass: 'h-16 px-5', + logoClass: 'max-h-10', nameType: TypographyType.Callout, }, [ContributionSponsorTier.Silver]: { @@ -42,9 +42,9 @@ const tierStyles: Record = { }, [ContributionSponsorTier.Bronze]: { labelClass: 'text-accent-burger-default', - chipClass: 'h-10 px-3', - logoClass: 'max-h-5', - nameType: TypographyType.Footnote, + chipClass: 'h-9 px-2.5', + logoClass: 'max-h-4', + nameType: TypographyType.Caption1, }, }; @@ -92,14 +92,10 @@ const SponsorLogo = ({ {`${sponsor.name} - event.currentTarget.naturalWidth === 0 - ? setLogoFailed(true) - : setLogoLoaded(true) - } + onLoad={() => setLogoLoaded(true)} onError={() => setLogoFailed(true)} className={classNames( - 'opacity-70 w-auto max-w-[120px] object-contain transition duration-200 [filter:brightness(0)_invert(1)] group-hover:opacity-100 group-hover:[filter:none]', + 'w-auto max-w-[120px] object-contain transition duration-200 [filter:brightness(0)_invert(1)] group-hover:[filter:none]', style.logoClass, !logoLoaded && 'hidden', )} @@ -111,7 +107,7 @@ const SponsorLogo = ({ type={style.nameType} bold truncate - className="max-w-[120px] text-text-secondary transition-colors group-hover:text-black" + className="max-w-[120px] text-text-primary transition-colors group-hover:text-black" > {sponsor.name} From dcc9fcf32a0cd53529fac72af13461e5ac7ffdca Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Fri, 19 Jun 2026 07:52:33 +0300 Subject: [PATCH 15/89] fix(giveback): render viewBox-only sponsor logos (fixed logo height) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: brand SVGs that ship with only a viewBox (no width/height) — e.g. GitHub, Supabase, Datadog, Algolia from svgl.app — collapse to zero width under 'w-auto max-h-*' and render blank. Use a fixed logo height so the browser derives width from the viewBox aspect ratio; every logo now shows. Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackSponsorTiers.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx index 1c5894f774b..168a5274e4a 100644 --- a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx +++ b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx @@ -22,6 +22,10 @@ interface TierStyle { // Chip height + logo size step down by prestige: gold biggest, bronze // smallest. chipClass: string; + // A FIXED 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 so every logo shows. logoClass: string; // Fallback name size when a sponsor has no usable logo. nameType: TypographyType; @@ -31,19 +35,19 @@ const tierStyles: Record = { [ContributionSponsorTier.Gold]: { labelClass: 'text-accent-cheese-default', chipClass: 'h-16 px-5', - logoClass: 'max-h-10', + logoClass: 'h-10', nameType: TypographyType.Callout, }, [ContributionSponsorTier.Silver]: { labelClass: 'text-text-secondary', chipClass: 'h-12 px-3.5', - logoClass: 'max-h-7', + logoClass: 'h-7', nameType: TypographyType.Footnote, }, [ContributionSponsorTier.Bronze]: { labelClass: 'text-accent-burger-default', chipClass: 'h-9 px-2.5', - logoClass: 'max-h-4', + logoClass: 'h-5', nameType: TypographyType.Caption1, }, }; @@ -95,7 +99,7 @@ const SponsorLogo = ({ onLoad={() => setLogoLoaded(true)} onError={() => setLogoFailed(true)} className={classNames( - 'w-auto max-w-[120px] object-contain transition duration-200 [filter:brightness(0)_invert(1)] group-hover:[filter:none]', + 'w-auto max-w-[160px] object-contain transition duration-200 [filter:brightness(0)_invert(1)] group-hover:[filter:none]', style.logoClass, !logoLoaded && 'hidden', )} From 5b4383af0237a3730635ea51d3583c283a838e6c Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Fri, 19 Jun 2026 10:10:45 +0300 Subject: [PATCH 16/89] feat(giveback): widen sponsor wall beyond the page column Render the sponsor strip in a near-full-width container (max-w-[120rem]) instead of the standard page column so the tier columns get more room and logos only wrap when there are genuinely many. Co-Authored-By: Claude Opus 4.8 --- .../shared/src/features/giveback/components/GivebackPage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx index 25fb260cf8d..0b03d52dbee 100644 --- a/packages/shared/src/features/giveback/components/GivebackPage.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx @@ -125,7 +125,9 @@ export const GivebackPage = (): ReactElement => { />
-
+ {/* The sponsor wall spans wider than the page column so logos get room + to breathe and only wrap when there are genuinely many. */} +
From a42e7e78d3c8389388981cbf2b2d5b98463f18b6 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Fri, 19 Jun 2026 11:10:47 +0300 Subject: [PATCH 17/89] chore: re-trigger failed Vercel deploy (infra flake, unrelated to changes) Co-Authored-By: Claude Opus 4.8 From ccd88c35ebdd88e5d793c516df5f23d976a6d75c Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Fri, 19 Jun 2026 17:45:04 +0300 Subject: [PATCH 18/89] feat(giveback): compact single bordered sponsor strip Collapse the tall three-column cards into one compact, content-width bordered box. 'Sponsored by' moves inline at the start; a divider separates each tier section. Smaller logos, monochrome at rest, white + full color on hover. Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackSponsorTiers.tsx | 115 +++++++----------- 1 file changed, 47 insertions(+), 68 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx index 168a5274e4a..4eff26ca94b 100644 --- a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx +++ b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx @@ -1,7 +1,7 @@ import type { ReactElement } from 'react'; import React, { useState } from 'react'; import classNames from 'classnames'; -import { FlexCol, FlexRow } from '../../../components/utilities'; +import { FlexRow } from '../../../components/utilities'; import { Typography, TypographyColor, @@ -17,15 +17,12 @@ import type { ContributionSponsor } from '../types'; import { ContributionSponsorTier } from '../types'; interface TierStyle { - // Tint for the tier's column heading. + // Tint for the inline tier label. labelClass: string; - // Chip height + logo size step down by prestige: gold biggest, bronze - // smallest. - chipClass: string; - // A FIXED 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 so every logo shows. + // 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. Steps down by prestige: gold biggest, bronze smallest. logoClass: string; // Fallback name size when a sponsor has no usable logo. nameType: TypographyType; @@ -34,37 +31,33 @@ interface TierStyle { const tierStyles: Record = { [ContributionSponsorTier.Gold]: { labelClass: 'text-accent-cheese-default', - chipClass: 'h-16 px-5', - logoClass: 'h-10', - nameType: TypographyType.Callout, + logoClass: 'h-7', + nameType: TypographyType.Footnote, }, [ContributionSponsorTier.Silver]: { labelClass: 'text-text-secondary', - chipClass: 'h-12 px-3.5', - logoClass: 'h-7', + logoClass: 'h-6', nameType: TypographyType.Footnote, }, [ContributionSponsorTier.Bronze]: { labelClass: 'text-accent-burger-default', - chipClass: 'h-9 px-2.5', logoClass: 'h-5', nameType: TypographyType.Caption1, }, }; -// Gold first so the columns read left-to-right by prestige. +// Gold first so the strip reads left-to-right by prestige. const TIER_ORDER: ContributionSponsorTier[] = [ ContributionSponsorTier.Gold, ContributionSponsorTier.Silver, ContributionSponsorTier.Bronze, ]; -// A sponsor chip that lights up on hover: at rest it's a flat bordered tile with -// a monochrome (light-tinted) logo so the wall reads calm; on hover it fills -// white and reveals the logo's true colors. The logo only counts once it -// actually decodes (onLoad with real pixels) — until then (loading, hung, -// blocked, 404, zero-size, or no URL) the sponsor name shows so a tile is never -// blank. +// 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, @@ -85,10 +78,8 @@ const SponsorLogo = ({ extra: JSON.stringify({ name: sponsor.name, tier: sponsor.tier }), }); - const tileClass = classNames( - 'group inline-flex max-w-full items-center justify-center rounded-10 border border-border-subtlest-tertiary transition-colors duration-200 hover:border-transparent hover:bg-white', - style.chipClass, - ); + const tileClass = + 'group inline-flex items-center justify-center rounded-8 px-2 py-1 transition-colors duration-200 hover:bg-white'; const body = ( <> @@ -99,7 +90,7 @@ const SponsorLogo = ({ onLoad={() => setLogoLoaded(true)} onError={() => setLogoFailed(true)} className={classNames( - 'w-auto max-w-[160px] object-contain transition duration-200 [filter:brightness(0)_invert(1)] group-hover:[filter:none]', + 'w-auto max-w-[120px] object-contain transition duration-200 [filter:brightness(0)_invert(1)] group-hover:[filter:none]', style.logoClass, !logoLoaded && 'hidden', )} @@ -141,6 +132,13 @@ const SponsorLogo = ({ ); }; +const Divider = (): ReactElement => ( + +); + export const GivebackSponsorTiers = (): ReactElement | null => { const { sponsors } = useContributionSponsors(); @@ -148,29 +146,16 @@ export const GivebackSponsorTiers = (): ReactElement | null => { return null; } - // One column per tier that has sponsors, side by side, split by a divider - // rather than wrapped in cards. - const tierColumns = TIER_ORDER.map((tier) => ({ + const tierGroups = TIER_ORDER.map((tier) => ({ tier, sponsors: sponsors.filter((sponsor) => sponsor.tier === tier), - })).filter((column) => column.sponsors.length > 0); + })).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. */} -
-
-
-
- - +
+ {/* One compact bordered strip; "Sponsored by" sits inline at the start and + a divider separates each tier section. */} +
{ Sponsored by -
- {tierColumns.map((column, index) => { - const style = tierStyles[column.tier]; - return ( - 0 && - 'border-t border-border-subtlest-tertiary tablet:border-l tablet:border-t-0', - )} - > + {tierGroups.map((group) => { + const style = tierStyles[group.tier]; + return ( + + + - {sponsorTierLabel[column.tier]} + {sponsorTierLabel[group.tier]} - - {column.sponsors.map((sponsor) => ( + + {group.sponsors.map((sponsor) => ( { /> ))} - - ); - })} -
- + + + ); + })} +
); }; From ee8b1f5a06f0dec033390c9b524451f1c50a2796 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Fri, 19 Jun 2026 18:13:15 +0300 Subject: [PATCH 19/89] feat(giveback): larger logos with stronger per-tier size difference Bump tier logo sizes (gold h-10, silver h-7, bronze h-5) for a clearer hierarchy and a slightly bigger strip; grow box padding and divider to match. Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackSponsorTiers.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx index 4eff26ca94b..ba47ef72cf2 100644 --- a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx +++ b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx @@ -31,12 +31,12 @@ interface TierStyle { const tierStyles: Record = { [ContributionSponsorTier.Gold]: { labelClass: 'text-accent-cheese-default', - logoClass: 'h-7', - nameType: TypographyType.Footnote, + logoClass: 'h-10', + nameType: TypographyType.Callout, }, [ContributionSponsorTier.Silver]: { labelClass: 'text-text-secondary', - logoClass: 'h-6', + logoClass: 'h-7', nameType: TypographyType.Footnote, }, [ContributionSponsorTier.Bronze]: { @@ -135,7 +135,7 @@ const SponsorLogo = ({ const Divider = (): ReactElement => ( ); @@ -155,7 +155,7 @@ export const GivebackSponsorTiers = (): ReactElement | null => {
{/* One compact bordered strip; "Sponsored by" sits inline at the start and a divider separates each tier section. */} -
+
Date: Fri, 19 Jun 2026 19:40:11 +0300 Subject: [PATCH 20/89] feat(giveback): stronger size spread across sponsor tiers Widen the per-tier logo step (gold h-14, silver h-8, bronze h-4) for a much clearer hierarchy; divider grows to match. Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackSponsorTiers.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx index ba47ef72cf2..e3bfbf5f098 100644 --- a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx +++ b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx @@ -31,17 +31,17 @@ interface TierStyle { const tierStyles: Record = { [ContributionSponsorTier.Gold]: { labelClass: 'text-accent-cheese-default', - logoClass: 'h-10', - nameType: TypographyType.Callout, + logoClass: 'h-14', + nameType: TypographyType.Body, }, [ContributionSponsorTier.Silver]: { labelClass: 'text-text-secondary', - logoClass: 'h-7', + logoClass: 'h-8', nameType: TypographyType.Footnote, }, [ContributionSponsorTier.Bronze]: { labelClass: 'text-accent-burger-default', - logoClass: 'h-5', + logoClass: 'h-4', nameType: TypographyType.Caption1, }, }; @@ -135,7 +135,7 @@ const SponsorLogo = ({ const Divider = (): ReactElement => ( ); From c8cdc8d6162c82fd840697cecd043349d79a436a Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 20 Jun 2026 11:46:01 +0300 Subject: [PATCH 21/89] fix(giveback): per-tier logo max-width so gold actually reads bigger Wide wordmark logos were all hitting a shared max-w-[120px] and shrinking, which flattened the height hierarchy (gold and silver looked the same). Scale each tier's width cap with its height (gold 260px, silver 150px, bronze 80px) so gold renders large. Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackSponsorTiers.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx index e3bfbf5f098..b8d70880969 100644 --- a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx +++ b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx @@ -22,7 +22,10 @@ interface TierStyle { // 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. Steps down by prestige: gold biggest, bronze smallest. + // 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; @@ -31,17 +34,17 @@ interface TierStyle { const tierStyles: Record = { [ContributionSponsorTier.Gold]: { labelClass: 'text-accent-cheese-default', - logoClass: 'h-14', + logoClass: 'h-14 max-w-[260px]', nameType: TypographyType.Body, }, [ContributionSponsorTier.Silver]: { labelClass: 'text-text-secondary', - logoClass: 'h-8', + logoClass: 'h-8 max-w-[150px]', nameType: TypographyType.Footnote, }, [ContributionSponsorTier.Bronze]: { labelClass: 'text-accent-burger-default', - logoClass: 'h-4', + logoClass: 'h-4 max-w-[80px]', nameType: TypographyType.Caption1, }, }; @@ -90,7 +93,7 @@ const SponsorLogo = ({ onLoad={() => setLogoLoaded(true)} onError={() => setLogoFailed(true)} className={classNames( - 'w-auto max-w-[120px] object-contain transition duration-200 [filter:brightness(0)_invert(1)] group-hover:[filter:none]', + 'w-auto object-contain transition duration-200 [filter:brightness(0)_invert(1)] group-hover:[filter:none]', style.logoClass, !logoLoaded && 'hidden', )} From c86b130d03ad4bfdd62bd1cbcce90496b1899b84 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 20 Jun 2026 22:33:52 +0300 Subject: [PATCH 22/89] feat(giveback): align sponsor strip to the page column width Render the sponsor strip in the standard page column (like the hero/video/ action sections) instead of an extra-wide container, and let the bordered box fill that width so it lines up with the rest of the page. Co-Authored-By: Claude Opus 4.8 --- .../src/features/giveback/components/GivebackPage.tsx | 4 +--- .../features/giveback/components/GivebackSponsorTiers.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx index 0b03d52dbee..25fb260cf8d 100644 --- a/packages/shared/src/features/giveback/components/GivebackPage.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx @@ -125,9 +125,7 @@ export const GivebackPage = (): ReactElement => { />
- {/* The sponsor wall spans wider than the page column so logos get room - to breathe and only wrap when there are genuinely many. */} -
+
diff --git a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx index b8d70880969..90dcb640fbb 100644 --- a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx +++ b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx @@ -155,10 +155,10 @@ export const GivebackSponsorTiers = (): ReactElement | null => { })).filter((group) => group.sponsors.length > 0); return ( -
- {/* One compact bordered strip; "Sponsored by" sits inline at the start and - a divider separates each tier section. */} -
+
+ {/* One compact bordered strip spanning the page column; "Sponsored by" + sits inline at the start and a divider separates each tier section. */} +
Date: Sat, 20 Jun 2026 22:50:19 +0300 Subject: [PATCH 23/89] feat(giveback): move 'Sponsored by' label above the strip, left aligned Pull the label out of the bordered box and place it above, left aligned. Dividers now separate only the tier sections inside the box. Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackSponsorTiers.tsx | 79 ++++++++++--------- 1 file changed, 41 insertions(+), 38 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx index 90dcb640fbb..7db09daccb7 100644 --- a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx +++ b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx @@ -1,7 +1,7 @@ import type { ReactElement } from 'react'; import React, { useState } from 'react'; import classNames from 'classnames'; -import { FlexRow } from '../../../components/utilities'; +import { FlexCol, FlexRow } from '../../../components/utilities'; import { Typography, TypographyColor, @@ -156,9 +156,8 @@ export const GivebackSponsorTiers = (): ReactElement | null => { return (
- {/* One compact bordered strip spanning the page column; "Sponsored by" - sits inline at the start and a divider separates each tier section. */} -
+ + {/* Label above the strip, left aligned. */} { Sponsored by - {tierGroups.map((group) => { - const style = tierStyles[group.tier]; - return ( - - - - - - + {tierGroups.map((group, index) => { + const style = tierStyles[group.tier]; + return ( + + {index > 0 && } + + - {sponsorTierLabel[group.tier]} - + + + {sponsorTierLabel[group.tier]} + + + + {group.sponsors.map((sponsor) => ( + + ))} + - - {group.sponsors.map((sponsor) => ( - - ))} - - - - ); - })} -
+ + ); + })} +
+
); }; From 041a1d22824ab139cd0419fb4ad6379eab466530 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 20 Jun 2026 23:13:35 +0300 Subject: [PATCH 24/89] feat(giveback): left-align sponsor logos inside the strip Co-Authored-By: Claude Opus 4.8 --- .../src/features/giveback/components/GivebackSponsorTiers.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx index 7db09daccb7..9a75731ca12 100644 --- a/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx +++ b/packages/shared/src/features/giveback/components/GivebackSponsorTiers.tsx @@ -170,7 +170,7 @@ export const GivebackSponsorTiers = (): ReactElement | null => { {/* One compact bordered strip spanning the page column; a divider separates each tier section. */} -
+
{tierGroups.map((group, index) => { const style = tierStyles[group.tier]; return ( From 3c2771807da6a086e0b23b25619da4c22e010727 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 21 Jun 2026 00:01:06 +0300 Subject: [PATCH 25/89] fix(giveback): rectangular journey connector + polish Replace the pill-shaped (rounded-full) step connector with a crisp 3px rectangular track so it no longer reads as an oval. Also constrain the 'ready to claim' gift icon size so it stays proportional in its pill. Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackPersonalRoadmap.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx index dcb6237baa7..167c4999898 100644 --- a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx @@ -92,15 +92,17 @@ interface RoadmapNode { connector?: ConnectorFill; } +// A straight rectangular track between nodes — no rounded "pill" ends. Width is +// a crisp 3px line; the fill colors echo the node states (green = cleared). const Connector = ({ fill }: { fill: ConnectorFill }): ReactElement => ( -
-
+
+
{fill.type === 'full' && ( -
+
)} {fill.type === 'partial' && (
)} @@ -633,7 +635,7 @@ export const GivebackPersonalRoadmap = ({ {claimableCount > 0 && ( - + {claimableCount} ready to claim From 94fbc293cc8bab55348cf0ec1122a0fdacd3351f Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 14:18:15 +0300 Subject: [PATCH 26/89] feat(giveback): onboarding warm-up funnel before the baseline page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New users were confused about what the campaign is, whether it costs them, and where the money goes. Add a full-screen 6-step funnel (what it is → how it works → pick causes → your impact → see it work → start) that answers each question one beat at a time, with an animated community-pot 'aha'. - Reuses the existing cause picker for the 'pick causes' step and saves on completion, so it replaces the standalone first-run picker. - Forced once for everyone via a persisted seen-flag (usePersistentContext), no skip; relaunchable any time from a new 'How it works' button. - Cause step never traps the user (only blocks when causes exist and none picked). New analytics events for funnel start/step/complete. Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackFunnel.spec.tsx | 133 +++++++ .../giveback/components/GivebackFunnel.tsx | 361 ++++++++++++++++++ .../giveback/components/GivebackPage.tsx | 67 +++- packages/shared/src/lib/log.ts | 4 + 4 files changed, 564 insertions(+), 1 deletion(-) create mode 100644 packages/shared/src/features/giveback/components/GivebackFunnel.spec.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackFunnel.tsx 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..7a0546b840d --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.spec.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { GivebackFunnel } from './GivebackFunnel'; +import type { useGivebackCauseSelection } from '../hooks/useGivebackCauseSelection'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent } from '../../../lib/log'; + +jest.mock('../../../contexts/LogContext'); + +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(), + 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( + , + ); + + expect( + screen.getByText('We give our ad budget to good causes'), + ).toBeInTheDocument(); + + advance('Got it'); + expect( + screen.getByText("It's a team effort — and free for you"), + ).toBeInTheDocument(); + + // No causes available → Continue is not blocked. + advance('Sounds good'); + advance('Continue'); + expect(screen.getByText('Beautiful choices')).toBeInTheDocument(); + + advance('Love it'); + advance("I'm in"); + 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( + , + ); + + 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( + , + ); + 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..93a2aafc132 --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -0,0 +1,361 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect, useMemo, 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 { + ArrowIcon, + CoinIcon, + EarthIcon, + UpvoteIcon, + VIcon, +} from '../../../components/icons'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent } from '../../../lib/log'; +import type { useGivebackCauseSelection } from '../hooks/useGivebackCauseSelection'; +import { GivebackBackground } from './GivebackBackground'; +import { GivebackMascot } from './GivebackMascot'; +import { GivebackCauseSelection } from './GivebackCauseSelection'; + +type CauseSelection = ReturnType; + +// Step keys double as analytics labels and drive the progress bar. +const STEP_KEYS = [ + 'intro', + 'how', + 'causes', + 'impact', + 'example', + 'start', +] 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; +} + +// A soft, on-brand pillar block behind each step's hero icon/illustration. +const Stage = ({ children }: { children: ReactNode }): ReactElement => ( + {children} +); + +// The "aha" moment: an action drops a coin into the community pot, which fills +// toward the goal. Animates from empty on mount (when its step is reached). +const FundingPot = (): ReactElement => { + const [filled, setFilled] = useState(false); + + useEffect(() => { + const frame = requestAnimationFrame(() => setFilled(true)); + return () => cancelAnimationFrame(frame); + }, []); + + return ( + + + + + + + Take an action + + + + + + + + +
+
+
+ + Community pot + + + + ); +}; + +const Eyebrow = ({ children }: { children: ReactNode }): ReactElement => ( + + {children} + +); + +interface StepLayoutProps { + stage: ReactNode; + eyebrow: string; + title: string; + body: string; +} + +const StepLayout = ({ + stage, + eyebrow, + title, + body, +}: StepLayoutProps): ReactElement => ( + + {stage} + + {eyebrow} + + {title} + + + {body} + + + +); + +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; + + 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: 'Love it', + example: "I'm in", + start: "Let's start", + }), + [], + ); + + const renderStep = (): ReactElement => { + switch (stepKey) { + case 'how': + return ( + } + eyebrow="How it works" + title="It's a team effort — and free for you" + body="Every small action you take adds money to a shared community pot. We donate it together when we hit the goal. You never pay a cent." + /> + ); + case 'causes': + return ( + + + Pick your causes + + Choose what we fund together + + + + + ); + case 'impact': + return ( + } + eyebrow="Your impact" + title="Beautiful choices" + body={ + selection.selectedCount > 0 + ? `Your ${selection.selectedCount} ${ + selection.selectedCount === 1 ? 'cause' : 'causes' + } help fund real things — open source, scholarships, and access to tech for people who need it. Chosen by you.` + : 'These causes fund real things — open source, scholarships, and access to tech for people who need it. Chosen by the community.' + } + /> + ); + case 'example': + return ( + } + eyebrow="See it work" + title="Take action → the pot grows" + body="Upvote, share, comment — each action drops money into the pot. When the community reaches the goal, daily.dev sends every cent to your causes automatically. No effort, no cost." + /> + ); + case 'start': + return ( + } + eyebrow="You're all set" + title="Let's grow something good" + body="Jump in, take your first action, and watch the community pot climb toward the goal. The more we grow, the more we give." + /> + ); + case 'intro': + default: + return ( + + + + } + eyebrow="daily.dev giveback" + title="We give our ad budget to good causes" + body="Instead of paying ad giants to grow, daily.dev takes that budget and donates it to real-world causes. That's Giveback — and the community decides where it goes." + /> + ); + } + }; + + return ( +
+ + +
+ {!isFirst ? ( +
+ +
+ {renderStep()} +
+ +
+ +
+
+ ); +}; diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx index 25fb260cf8d..0de5e0f8d5c 100644 --- a/packages/shared/src/features/giveback/components/GivebackPage.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx @@ -1,13 +1,21 @@ import type { ReactElement } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { FlexCol } from '../../../components/utilities'; +import { FlexCol, FlexRow } from '../../../components/utilities'; import { Typography, TypographyColor, TypographyTag, TypographyType, } from '../../../components/typography/Typography'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { InfoIcon } from '../../../components/icons'; +import usePersistentContext from '../../../hooks/usePersistentContext'; import { GivebackBackground } from './GivebackBackground'; +import { GivebackFunnel } from './GivebackFunnel'; import { GivebackHero } from './GivebackHero'; import { GivebackSponsorTiers } from './GivebackSponsorTiers'; import { GivebackCauseSelection } from './GivebackCauseSelection'; @@ -50,8 +58,16 @@ export const GivebackPage = (): ReactElement => { 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 @@ -102,6 +118,35 @@ export const GivebackPage = (): ReactElement => { } }, [selection, goToActions, logEvent]); + 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({ + event_name: LogEvent.SaveGivebackCauses, + extra: JSON.stringify({ + cause_count: selection.selectedIds.size, + cause_ids: [...selection.selectedIds], + origin: 'funnel', + }), + }); + } + if (wasReplay) { + setReplayFunnel(false); + return; + } + setCompletedOnboarding(true); + goToActions(); + }, [replayFunnel, setFunnelSeen, selection, logEvent, goToActions]); + // Reveal the picker, then bring it into view as it mounts. useEffect(() => { if (showPicker) { @@ -123,6 +168,17 @@ export const GivebackPage = (): ReactElement => { onJoin={() => setStartedPicker(true)} onTakeAction={goToActions} /> + + +
@@ -197,6 +253,15 @@ export const GivebackPage = (): ReactElement => { )} {showTabs && } + + {showFunnel && ( + setReplayFunnel(false)} + onComplete={handleFunnelComplete} + /> + )}
); }; diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 2d069a4d8e5..e6c95cc0e03 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -508,6 +508,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', } export enum TargetType { From e55cd6a600110c74d44694e343d85dcf7980a544 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 14:34:05 +0300 Subject: [PATCH 27/89] feat(giveback): choreographed motion + cohesive visuals in the funnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply interface-craft principles (jakub.kr / Interfaces): signature enter animation (rise + de-blur + fade, staggered 90ms per element), replayed on every step; living glow behind each stage; coin drops into the community pot; text-wrap balance/pretty; will-change — all motion-safe guarded. Makes the flow feel like a serious, considered campaign. Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackFunnel.tsx | 146 ++++++++++++------ packages/shared/tailwind.config.ts | 23 +++ 2 files changed, 124 insertions(+), 45 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 93a2aafc132..24f15049ec3 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -50,9 +50,38 @@ interface GivebackFunnelProps { onComplete: () => void; } -// A soft, on-brand pillar block behind each step's hero icon/illustration. +// 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} +
+ +
{children}
+
); // The "aha" moment: an action drops a coin into the community pot, which fills @@ -81,8 +110,10 @@ const FundingPot = (): ReactElement => { @@ -133,20 +164,33 @@ const StepLayout = ({ body, }: StepLayoutProps): ReactElement => ( - {stage} + + {stage} + - {eyebrow} - - {title} - - - {body} - + + {eyebrow} + + + + {title} + + + + + {body} + + ); @@ -223,22 +267,27 @@ export const GivebackFunnel = ({ case 'causes': return ( - - Pick your causes - - Choose what we fund together - - - + + + Pick your causes + + Choose what we fund together + + + + + + ); case 'impact': @@ -340,21 +389,28 @@ export const GivebackFunnel = ({
- {renderStep()} + {/* Keyed by step so the choreographed enter replays on every advance. */} +
+ {renderStep()} +
- + + +
); diff --git a/packages/shared/tailwind.config.ts b/packages/shared/tailwind.config.ts index b161fe8fd3d..5b44eeb7ce8 100644 --- a/packages/shared/tailwind.config.ts +++ b/packages/shared/tailwind.config.ts @@ -316,6 +316,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)' }, @@ -387,6 +407,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 23de462deb7b799b3f928cd895df6d457eda58f0 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 14:39:35 +0300 Subject: [PATCH 28/89] feat(giveback): wider funnel, punchier copy, bigger visuals, real action demo - Widen the funnel to max-w-4xl so content/causes are easier to read and select. - Rewrite step copy in a confident, explicit voice (Nikita-Bier-style clarity): 'You act. We pay. Causes win.', 'Your activity funds real causes', etc. - Enlarge the hero visuals (globe, mascot, community pot) so each step stands out. - Replace the abstract 'see it work' step with a real, interactive example from the take-action list: 'Share daily.dev on X' -> Try it -> a mock posted result + '+50 dropped into the pot' so the mechanic is concrete. Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackFunnel.spec.tsx | 8 +- .../giveback/components/GivebackFunnel.tsx | 186 +++++++++++++++--- 2 files changed, 157 insertions(+), 37 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.spec.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.spec.tsx index 7a0546b840d..f10d4b9fd85 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.spec.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.spec.tsx @@ -43,18 +43,16 @@ it('walks every step and completes on the final CTA', () => { ); expect( - screen.getByText('We give our ad budget to good causes'), + screen.getByText('Your activity funds real causes'), ).toBeInTheDocument(); advance('Got it'); - expect( - screen.getByText("It's a team effort — and free for you"), - ).toBeInTheDocument(); + 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('Beautiful choices')).toBeInTheDocument(); + expect(screen.getByText('Real causes. Real impact.')).toBeInTheDocument(); advance('Love it'); advance("I'm in"); diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 24f15049ec3..b51a6f9b3ba 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -17,7 +17,9 @@ import CloseButton from '../../../components/CloseButton'; import { ArrowIcon, CoinIcon, + DiscussIcon, EarthIcon, + TwitterIcon, UpvoteIcon, VIcon, } from '../../../components/icons'; @@ -75,10 +77,10 @@ const Reveal = ({ // 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}
@@ -95,13 +97,13 @@ const FundingPot = (): ReactElement => { }, []); return ( - + - + Take an action @@ -110,7 +112,7 @@ const FundingPot = (): ReactElement => { { -
+
{ />
Community pot @@ -138,6 +140,114 @@ const FundingPot = (): ReactElement => { ); }; +// A real, concrete example pulled from the take-action list: the visitor "tries" +// an action and immediately sees the result — a posted share + money landing in +// the pot. Makes the abstract mechanic tangible. +const GivebackActionDemo = (): ReactElement => { + const [done, setDone] = useState(false); + + return ( + + + + + + + + Share daily.dev on X + + + Post about daily.dev to your followers + + + {done ? ( + + + Done + + ) : ( + + )} + + + {done && ( + + + + + Y + + + + You + + + @you · now + + + + + + + + Just found @dailydotdev — the home for developers. The feed + actually gets me. Worth a look 👀 + + + + + 12 + + + + 148 + + + + + + + + + +50 dropped into the pot + + + + Goal 64% + + + + + That's every action. One move, real money. + + + )} + + ); +}; + const Eyebrow = ({ children }: { children: ReactNode }): ReactElement => ( {body} @@ -260,8 +370,8 @@ export const GivebackFunnel = ({ } eyebrow="How it works" - title="It's a team effort — and free for you" - body="Every small action you take adds money to a shared community pot. We donate it together when we hit the goal. You never pay a cent." + title="You act. We pay. Causes win." + body="Every action you take drops money into one shared pot. Hit the goal together and we donate it — automatically. It never costs you a cent." /> ); case 'causes': @@ -293,34 +403,46 @@ export const GivebackFunnel = ({ case 'impact': return ( } - eyebrow="Your impact" - title="Beautiful choices" + stage={} + eyebrow="Nice picks" + title="Real causes. Real impact." body={ selection.selectedCount > 0 ? `Your ${selection.selectedCount} ${ - selection.selectedCount === 1 ? 'cause' : 'causes' - } help fund real things — open source, scholarships, and access to tech for people who need it. Chosen by you.` - : 'These causes fund real things — open source, scholarships, and access to tech for people who need it. Chosen by the community.' + selection.selectedCount === 1 ? 'pick goes' : 'picks go' + } straight to the people behind them — open-source maintainers, students, and devs who can't afford access. No middlemen.` + : 'This money goes straight to the people behind these causes — open-source maintainers, students, and devs who can’t afford access. No middlemen.' } /> ); case 'example': return ( - } - eyebrow="See it work" - title="Take action → the pot grows" - body="Upvote, share, comment — each action drops money into the pot. When the community reaches the goal, daily.dev sends every cent to your causes automatically. No effort, no cost." - /> + + + See it in action + + + + One action. Watch what happens. + + + + + + ); case 'start': return ( } - eyebrow="You're all set" - title="Let's grow something good" - body="Jump in, take your first action, and watch the community pot climb toward the goal. The more we grow, the more we give." + stage={} + eyebrow="You're in" + title="Let's fund something real" + body="Take your first action now. The more we move together, the more we give. Your causes are counting on it." /> ); case 'intro': @@ -328,13 +450,13 @@ export const GivebackFunnel = ({ return ( + } eyebrow="daily.dev giveback" - title="We give our ad budget to good causes" - body="Instead of paying ad giants to grow, daily.dev takes that budget and donates it to real-world causes. That's Giveback — and the community decides where it goes." + title="Your activity funds real causes" + body="daily.dev would rather back developers than ad networks. So we take our marketing budget and donate it — and you decide where it goes." /> ); } @@ -388,14 +510,14 @@ export const GivebackFunnel = ({ )} -
+
{/* Keyed by step so the choreographed enter replays on every advance. */}
{renderStep()}
-
+
- )} + + + + + Gave a talk on dev tooling today and put @dailydotdev front and center. + The room ate it up 🔥 + + + + + 24 + + + + 312 + + + + - {done && ( - - - - - Y - - - - You - - - @you · now - - - - - - - - Just found @dailydotdev, the home for developers. The feed - actually gets me. Worth a look 👀 - - - - - 12 - - - - 148 - - - - - - - - - +50 dropped into the pot - - - - Goal 64% - - + +
+ + + +
+ + + Why daily.dev is my homepage now + + + Sam Codes · 18K views + + + +
+ + + + + + + + lena.dev + - That's every action. One move, real money. + 6 min read -
- )} + + + + How I finally fixed my dev feed + + + The setup that keeps me current without the endless noise. + + - ); -}; +
+); const Eyebrow = ({ children }: { children: ReactNode }): ReactElement => ( } - eyebrow="How it works" - title="You act. We pay. Causes win." - body="Every action you take drops money into one shared pot. Hit the goal together and we donate it automatically. It never costs you a cent." - /> + + + How it works + + + + You act. We pay. Causes win. + + + + + + + + You never pay a cent. daily.dev funds every dollar. + + + ); case 'causes': return ( @@ -419,7 +496,7 @@ export const GivebackFunnel = ({ return ( - See it in action + Real people, real actions - One action. Watch what happens. + This is what taking action looks like - - + + + + + + Every post here sent real money to the community's causes. + ); @@ -449,11 +534,7 @@ export const GivebackFunnel = ({ default: return ( - - - } + stage={} eyebrow="daily.dev giveback" title="Your activity funds real causes" body="daily.dev would rather back developers than ad networks. So we take our marketing budget and donate it, and you decide where it goes." From 00f1ce23d7fa4efda4ffa1ecc337e2804275a466 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 15:08:01 +0300 Subject: [PATCH 31/89] feat(giveback): persistent floating campaign video in the funnel The campaign explainer plays inline on step 1, then docks to a floating bottom-right player for the rest of the funnel. A single mounted instance is repositioned (over an in-flow slot on step 1, pinned to the corner after) so playback never restarts when it moves, with a close button once docked. Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackFunnel.tsx | 163 ++++++++++++++---- 1 file changed, 126 insertions(+), 37 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index bc45a4227a2..1b5de49ef3e 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -1,5 +1,5 @@ -import type { ReactElement, ReactNode } from 'react'; -import React, { useEffect, useMemo, useState } from 'react'; +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 { @@ -30,6 +30,7 @@ import type { useGivebackCauseSelection } from '../hooks/useGivebackCauseSelecti import { GivebackBackground } from './GivebackBackground'; import { GivebackMascot } from './GivebackMascot'; import { GivebackCauseSelection } from './GivebackCauseSelection'; +import { GivebackCampaignVideo } from './GivebackCampaignVideo'; type CauseSelection = ReturnType; @@ -87,37 +88,85 @@ const Stage = ({ children }: { children: ReactNode }): ReactElement => (
); -// Step 1 hero: the whole idea as one picture. Ad budget (muted, struck through) -// is redirected into real causes (bright, prominent) so the value lands fast. -const BudgetRedirect = (): ReactElement => ( - - - - - - - Ad spend - - +// 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); - - - - - - Real causes - - - -); + 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 }); + }; + update(); + window.addEventListener('resize', update); + window.addEventListener('scroll', update, true); + let observer: ResizeObserver | undefined; + if (typeof ResizeObserver !== 'undefined' && slotRef.current) { + observer = new ResizeObserver(update); + observer.observe(slotRef.current); + } + return () => { + window.removeEventListener('resize', update); + window.removeEventListener('scroll', update, true); + observer?.disconnect(); + }; + }, [docked, slotRef]); + + if (!style) { + return null; + } + + return ( +
+
+ + {docked && ( + + )} +
+
+ ); +}; // Step 2: the whole campaign at a glance, so the flow is never a mystery. const FLOW_STEPS: ReadonlyArray<{ @@ -374,6 +423,11 @@ export const GivebackFunnel = ({ 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); + useEffect(() => { logEvent({ event_name: LogEvent.StartGivebackFunnel, @@ -533,12 +587,39 @@ export const GivebackFunnel = ({ case 'intro': default: return ( - } - eyebrow="daily.dev giveback" - title="Your activity funds real causes" - body="daily.dev would rather back developers than ad networks. So we take our marketing budget and donate it, and you decide where it goes." - /> + + {/* The floating player overlays this slot while on step 1. */} +
+ + daily.dev giveback + + + + Your activity funds real causes + + + + + daily.dev would rather back developers than ad networks. So we + take our marketing budget and donate it, and you decide where it + goes. + + + ); } }; @@ -615,6 +696,14 @@ export const GivebackFunnel = ({ + + {!videoClosed && ( + 0} + onClose={() => setVideoClosed(true)} + /> + )}
); }; From 3f29242853f0fd832b1a0fff9490920c088e05cf Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 15:11:44 +0300 Subject: [PATCH 32/89] feat(giveback): make the action ask explicit on the proof step Each community example now leads with a clear header: the action we're asking for, a one-line description of what to do, and the dollar payout to causes, with the real post shown below as 'A real example'. Adds a subtitle clarifying these are actions the user can pick. Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackFunnel.tsx | 210 +++++++++++------- 1 file changed, 128 insertions(+), 82 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 1b5de49ef3e..243ccc74798 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -239,118 +239,153 @@ const RewardTag = ({ amount }: { amount: number }): ReactElement => (
); -const ProofFooter = ({ +// An explicit "here's the ask" header above each example, so it's obvious what +// we want the user to do, what it gives, and what it looks like in the wild. +const ProofItem = ({ action, + request, amount, + children, }: { action: string; + request: string; amount: number; + children: ReactNode; }): ReactElement => ( - + + + + + {action} + + + + + {request} + + - {action} + A real example - - + {children} + ); // Real community proof: three believable posts from real action types, so the // visitor can picture exactly what to do and what it gives back. const CommunityProof = (): ReactElement => ( -
- - - - MR - - - - Maya Rivera +
+ + + + + MR + + + + Maya Rivera + + + @maya.builds + + + + + + + + Gave a talk on dev tooling today and put @dailydotdev front and + center. The room ate it up 🔥 + + + + + 24 + + + + 312 + + + + + + + +
+ + + +
+ + + Why daily.dev is my homepage now - @maya.builds + Sam Codes · 18K views - - - - - - Gave a talk on dev tooling today and put @dailydotdev front and center. - The room ate it up 🔥 - - - - - 24 - - - - 312 - - - -
+ +
- -
- - - -
- + + + + + + + + + lena.dev + + + 6 min read + + + - Why daily.dev is my homepage now + How I finally fixed my dev feed - Sam Codes · 18K views + The setup that keeps me current without the endless noise. - - - - - - - - - - - lena.dev - - - 6 min read - - - - - How I finally fixed my dev feed - - - The setup that keeps me current without the endless noise. - - - +
); @@ -562,10 +597,21 @@ export const GivebackFunnel = ({ This is what taking action looks like
- + + + Pick anything you'd enjoy. Here's the ask, what it + pays your causes, and a real example of someone doing it. + + + - + Date: Wed, 24 Jun 2026 15:50:23 +0300 Subject: [PATCH 33/89] feat(giveback): restructure the proof step into uniform action cards The mismatched tweet/video/blog cards read as visual noise at different heights. Replace with three equal-height, consistently structured cards (color-coded action icon + reward, the ask, then a short real example pinned to the bottom so dividers align). Cleaner and easier to scan. Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackFunnel.tsx | 220 +++++++----------- 1 file changed, 80 insertions(+), 140 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 243ccc74798..846e2aef808 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -17,9 +17,9 @@ import CloseButton from '../../../components/CloseButton'; import { ArrowIcon, CoinIcon, - DiscussIcon, EarthIcon, GiftIcon, + PlayIcon, TwitterIcon, UpvoteIcon, VIcon, @@ -239,153 +239,101 @@ const RewardTag = ({ amount }: { amount: number }): ReactElement => (
); -// An explicit "here's the ask" header above each example, so it's obvious what -// we want the user to do, what it gives, and what it looks like in the wild. -const ProofItem = ({ - action, - request, - amount, - children, -}: { +// Each action a visitor can take, as one uniform card: the ask, the cause +// payout, and a short real example pinned to the bottom so the three cards line +// up and scan cleanly instead of reading as mismatched posts. +const PROOF_ACTIONS: ReadonlyArray<{ action: string; request: string; amount: number; - children: ReactNode; -}): ReactElement => ( - - - - - {action} - - - - - {request} - - - - A real example - - {children} - -); + tint: string; + icon: ReactElement; + quote: string; + attribution: string; +}> = [ + { + action: 'Speak at an event', + request: 'Give a talk and feature daily.dev in your slides.', + amount: 200, + tint: 'bg-accent-cabbage-flat text-accent-cabbage-default', + icon: , + quote: 'Gave a talk on dev tooling and put daily.dev front and center.', + attribution: 'Maya Rivera · 312 likes', + }, + { + action: 'Make a video', + request: 'Post a video or short featuring daily.dev.', + amount: 150, + tint: 'bg-accent-ketchup-flat text-accent-ketchup-default', + icon: , + quote: 'Why daily.dev is my homepage now.', + attribution: 'Sam Codes · 18K views', + }, + { + action: 'Write a post', + request: 'Publish an article featuring daily.dev.', + amount: 120, + tint: 'bg-accent-blueCheese-flat text-accent-blueCheese-default', + icon: , + quote: 'How I finally fixed my dev feed.', + attribution: 'lena.dev · 6 min read', + }, +]; -// Real community proof: three believable posts from real action types, so the -// visitor can picture exactly what to do and what it gives back. const CommunityProof = (): ReactElement => ( -
- - - - - MR - - - - Maya Rivera - - - @maya.builds - - - - +
+ {PROOF_ACTIONS.map((item) => ( + + + + {item.icon} + + {item.action} + + - - Gave a talk on dev tooling today and put @dailydotdev front and - center. The room ate it up 🔥 + + + {item.request} - - - - 24 - - - - 312 - - - - - - -
- - - -
- - - Why daily.dev is my homepage now - + + A real example + + + “{item.quote}” + + - Sam Codes · 18K views + {item.attribution} -
- - - - - - - - - - lena.dev - - - 6 min read - - - - - How I finally fixed my dev feed - - - The setup that keeps me current without the endless noise. - - - + ))}
); @@ -611,14 +559,6 @@ export const GivebackFunnel = ({ - - - Every post here sent real money to the community's causes. - -
); case 'start': From 9f29ac50053bfe4c2e4a10bc3ddd7619281d914e Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 15:54:28 +0300 Subject: [PATCH 34/89] feat(giveback): redesign How it works into a connected milestone track Replace the four identical purple gradient tiles with a connected milestone track: per-step accent gradients on circular badges, a gradient connector line showing momentum, and a glowing gold 'unlock the goal' focal point. Reframe the copy around the community raising the bar together to unlock the budget for causes. Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackFunnel.tsx | 84 ++++++++++++------- 1 file changed, 54 insertions(+), 30 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 846e2aef808..76afdef8d34 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -19,6 +19,7 @@ import { CoinIcon, EarthIcon, GiftIcon, + MedalBadgeIcon, PlayIcon, TwitterIcon, UpvoteIcon, @@ -168,65 +169,88 @@ const GivebackFunnelVideo = ({ ); }; -// Step 2: the whole campaign at a glance, so the flow is never a mystery. +// The campaign as a connected milestone track: the community acts together, +// raises the bar, unlocks the goal (the glowing focal point), and the budget is +// released to causes. Each node has its own accent so it reads as a journey, not +// four identical tiles. const FLOW_STEPS: ReadonlyArray<{ icon: ReactElement; title: string; sub: string; + gradient: string; + isGoal?: boolean; }> = [ { icon: , - title: 'You take an action', + title: 'Everyone takes action', sub: 'Post, talk, write, or host', + gradient: 'from-accent-cabbage-default to-accent-onion-default', }, { icon: , - title: 'daily.dev adds money', - sub: 'Into the shared community pot', + title: 'We raise the bar together', + sub: 'Every action grows the pot', + gradient: 'from-accent-cabbage-default to-accent-avocado-default', }, { - icon: , - title: 'We hit the goal together', - sub: 'The whole community chips in', + icon: , + title: 'We unlock the goal', + sub: 'Hit the milestone, release the budget', + gradient: 'from-accent-cheese-default to-accent-bacon-default', + isGoal: true, }, { icon: , - title: 'We fund your causes', - sub: 'Automatically, every cent', + title: 'Causes get funded', + sub: 'Every dollar, automatically', + gradient: 'from-accent-avocado-default to-accent-lettuce-default', }, ]; const FlowSequence = (): ReactElement => ( - - {FLOW_STEPS.map((flowStep, index) => ( - - - - {flowStep.icon} +
+ {/* The connecting track, gradient-filled to read as momentum to the goal. */} +
+ + {FLOW_STEPS.map((step) => ( + + + {step.isGoal && ( + + )} + + {step.icon} + - + - {flowStep.title} + {step.title} - {flowStep.sub} + {step.sub} - - {index < FLOW_STEPS.length - 1 && ( - - - - )} - - ))} - + + ))} + +
); // The cause payout for an action, in plain dollars. From 04682da89ec521c8552853f13cf1338687dc2df9 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 20:22:41 +0300 Subject: [PATCH 35/89] feat(giveback): trim funnel to four steps with a personal finale Drop the community-proof and standalone start steps so the funnel only highlights what matters: what it is, how it works, pick causes, and the impact. Impact becomes the finale, surfacing the visitor's own picked causes as chips and completing the funnel. Remove now-dead components. Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackFunnel.spec.tsx | 3 +- .../giveback/components/GivebackFunnel.tsx | 247 ++++-------------- 2 files changed, 47 insertions(+), 203 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.spec.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.spec.tsx index f10d4b9fd85..c26afa7236b 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.spec.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.spec.tsx @@ -54,8 +54,7 @@ it('walks every step and completes on the final CTA', () => { advance('Continue'); expect(screen.getByText('Real causes. Real impact.')).toBeInTheDocument(); - advance('Love it'); - advance("I'm in"); + // Impact is now the final step; its CTA completes the funnel. advance("Let's start"); expect(onComplete).toHaveBeenCalledTimes(1); diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 76afdef8d34..cc4d1689150 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -17,11 +17,8 @@ import CloseButton from '../../../components/CloseButton'; import { ArrowIcon, CoinIcon, - EarthIcon, GiftIcon, MedalBadgeIcon, - PlayIcon, - TwitterIcon, UpvoteIcon, VIcon, } from '../../../components/icons'; @@ -35,15 +32,10 @@ import { GivebackCampaignVideo } from './GivebackCampaignVideo'; type CauseSelection = ReturnType; -// Step keys double as analytics labels and drive the progress bar. -const STEP_KEYS = [ - 'intro', - 'how', - 'causes', - 'impact', - 'example', - 'start', -] as const; +// 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 { @@ -253,114 +245,6 @@ const FlowSequence = (): ReactElement => (
); -// The cause payout for an action, in plain dollars. -const RewardTag = ({ amount }: { amount: number }): ReactElement => ( - - - - +${amount} - - -); - -// Each action a visitor can take, as one uniform card: the ask, the cause -// payout, and a short real example pinned to the bottom so the three cards line -// up and scan cleanly instead of reading as mismatched posts. -const PROOF_ACTIONS: ReadonlyArray<{ - action: string; - request: string; - amount: number; - tint: string; - icon: ReactElement; - quote: string; - attribution: string; -}> = [ - { - action: 'Speak at an event', - request: 'Give a talk and feature daily.dev in your slides.', - amount: 200, - tint: 'bg-accent-cabbage-flat text-accent-cabbage-default', - icon: , - quote: 'Gave a talk on dev tooling and put daily.dev front and center.', - attribution: 'Maya Rivera · 312 likes', - }, - { - action: 'Make a video', - request: 'Post a video or short featuring daily.dev.', - amount: 150, - tint: 'bg-accent-ketchup-flat text-accent-ketchup-default', - icon: , - quote: 'Why daily.dev is my homepage now.', - attribution: 'Sam Codes · 18K views', - }, - { - action: 'Write a post', - request: 'Publish an article featuring daily.dev.', - amount: 120, - tint: 'bg-accent-blueCheese-flat text-accent-blueCheese-default', - icon: , - quote: 'How I finally fixed my dev feed.', - attribution: 'lena.dev · 6 min read', - }, -]; - -const CommunityProof = (): ReactElement => ( -
- {PROOF_ACTIONS.map((item) => ( - - - - {item.icon} - - - {item.action} - - - - - - {item.request} - - - - - A real example - - - “{item.quote}” - - - {item.attribution} - - - - ))} -
-); - const Eyebrow = ({ children }: { children: ReactNode }): ReactElement => ( ( ); -interface StepLayoutProps { - stage: ReactNode; - eyebrow: string; - title: string; - body: string; -} - -const StepLayout = ({ - stage, - eyebrow, - title, - body, -}: StepLayoutProps): ReactElement => ( - - - {stage} - - - - {eyebrow} - - - - {title} - - - - - {body} - - - - -); - export const GivebackFunnel = ({ selection, canClose = false, @@ -435,6 +274,12 @@ export const GivebackFunnel = ({ const videoSlotRef = useRef(null); const [videoClosed, setVideoClosed] = useState(false); + // The visitor's own picks, surfaced on the finale so it feels personal. + const selectedCauseNames = selection.causes + .filter((cause) => selection.selectedIds.has(cause.id)) + .map((cause) => cause.title) + .slice(0, 4); + useEffect(() => { logEvent({ event_name: LogEvent.StartGivebackFunnel, @@ -474,9 +319,7 @@ export const GivebackFunnel = ({ intro: 'Got it', how: 'Sounds good', causes: 'Continue', - impact: 'Love it', - example: "I'm in", - start: "Let's start", + impact: "Let's start", }), [], ); @@ -539,61 +382,63 @@ export const GivebackFunnel = ({
); case 'impact': - return ( - } - eyebrow="Nice picks" - title="Real causes. Real impact." - body={ - selection.selectedCount > 0 - ? `Your ${selection.selectedCount} ${ - selection.selectedCount === 1 ? 'pick goes' : 'picks go' - } straight to the people behind them: open-source maintainers, students, and devs who can't afford access. No middlemen.` - : "This money goes straight to the people behind these causes: open-source maintainers, students, and devs who can't afford access. No middlemen." - } - /> - ); - case 'example': return ( - Real people, real actions + + + + Nice picks + + - This is what taking action looks like + Real causes. Real impact. - + - Pick anything you'd enjoy. Here's the ask, what it - pays your causes, and a real example of someone doing it. + {selection.selectedCount > 0 + ? `Your ${selection.selectedCount} ${ + selection.selectedCount === 1 ? 'pick goes' : 'picks go' + } straight to the people behind them: open-source maintainers, students, and devs who can't afford access. No middlemen.` + : "This money goes straight to the people behind these causes: open-source maintainers, students, and devs who can't afford access. No middlemen."} - - - + {selectedCauseNames.length > 0 && ( + + + {selectedCauseNames.map((name) => ( + + + + {name} + + + ))} + + + )} ); - case 'start': - return ( - } - eyebrow="You're in" - title="Let's fund something real" - body="Take your first action now. The more we move together, the more we give. Your causes are counting on it." - /> - ); case 'intro': default: return ( From 0fcc36456d2417ca066dff5f96d43579e6409033 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 20:59:11 +0300 Subject: [PATCH 36/89] feat(giveback): slim the page cover - drop video and CTA, fold the meter into the headline Remove the hero campaign video and the right-side join button (onboarding now lives in the warm-up funnel), and fold the funding progress meter directly under the headline. Delete the now-dead picker/onboarding-bar path on the page. The cover is compact, so the action tabs sit higher and more central. Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackHero.tsx | 150 +++++++----------- .../giveback/components/GivebackPage.tsx | 90 +---------- 2 files changed, 63 insertions(+), 177 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackHero.tsx b/packages/shared/src/features/giveback/components/GivebackHero.tsx index f2cef2e6355..a709adccca2 100644 --- a/packages/shared/src/features/giveback/components/GivebackHero.tsx +++ b/packages/shared/src/features/giveback/components/GivebackHero.tsx @@ -8,104 +8,72 @@ import { TypographyTag, TypographyType, } from '../../../components/typography/Typography'; -import { GivebackStartPanel } from './GivebackStartPanel'; -import { GivebackCampaignVideo } from './GivebackCampaignVideo'; import { GivebackFundingSummary } from './GivebackFundingSummary'; -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; -} - -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. */} +// The page cover: brand, headline, and the live funding meter folded into one +// block. No video or CTA here — the warm-up funnel handles onboarding — so the +// cover stays compact and the action tabs sit higher on the page. +export const GivebackHero = (): 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 - - -
- + - - -
- - -
+ + 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. +
-
- ); -}; + +
+ +
+ +
+); diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx index 1d50cad882f..e0a3cc8ce9c 100644 --- a/packages/shared/src/features/giveback/components/GivebackPage.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx @@ -1,12 +1,6 @@ import type { ReactElement } from 'react'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import { FlexCol, FlexRow } from '../../../components/utilities'; -import { - Typography, - TypographyColor, - TypographyTag, - TypographyType, -} from '../../../components/typography/Typography'; import { Button, ButtonSize, @@ -18,8 +12,6 @@ import { GivebackBackground } from './GivebackBackground'; import { GivebackFunnel } from './GivebackFunnel'; import { GivebackHero } from './GivebackHero'; import { GivebackSponsorTiers } from './GivebackSponsorTiers'; -import { GivebackCauseSelection } from './GivebackCauseSelection'; -import { GivebackOnboardingBar } from './GivebackOnboardingBar'; import { GivebackLegalFooter } from './GivebackLegalFooter'; import { GivebackTabNav, givebackTabs } from './GivebackTabNav'; import { GivebackActionCatalog } from './GivebackActionCatalog'; @@ -52,9 +44,8 @@ export const GivebackPage = (): ReactElement => { 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'); @@ -66,16 +57,8 @@ export const GivebackPage = (): ReactElement => { 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); - - const causesRef = useRef(null); const tabsRef = useRef(null); // The tab section can mount in the same tick we ask to scroll (right after @@ -103,21 +86,6 @@ export const GivebackPage = (): ReactElement => { [logEvent], ); - const handleContinue = useCallback(async () => { - const saved = await selection.save(); - if (saved) { - logEvent({ - event_name: LogEvent.SaveGivebackCauses, - extra: JSON.stringify({ - cause_count: selection.selectedIds.size, - cause_ids: [...selection.selectedIds], - }), - }); - setCompletedOnboarding(true); - goToActions(); - } - }, [selection, goToActions, logEvent]); - const handleHowItWorks = useCallback(() => { logEvent({ event_name: LogEvent.ClickGivebackHowItWorks }); setReplayFunnel(true); @@ -147,13 +115,6 @@ export const GivebackPage = (): ReactElement => { goToActions(); }, [replayFunnel, setFunnelSeen, selection, logEvent, goToActions]); - // Reveal the picker, then bring it into view as it mounts. - useEffect(() => { - if (showPicker) { - scrollIntoView(causesRef.current); - } - }, [showPicker]); - const activeLabel = givebackTabs.find((tab) => tab.id === activeTab)?.label; return ( @@ -162,12 +123,7 @@ 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 && (
@@ -244,14 +170,6 @@ export const GivebackPage = (): ReactElement => {
- {showPicker && ( - - )} - {showTabs && } {showFunnel && ( From 2383c97179e5db827e3818237cacca68e7d226bb Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 22:34:19 +0300 Subject: [PATCH 37/89] feat(giveback): gate sponsors behind a flag, move How it works into the tab strip Sponsors are off at launch (no sponsors yet) behind a giveback_sponsors flag. The How it works button moves from under the hero to the right of the tab nav. Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackPage.tsx | 41 +++++++++---------- .../giveback/components/GivebackTabNav.tsx | 23 ++++++++++- packages/shared/src/lib/featureManagement.ts | 4 ++ 3 files changed, 45 insertions(+), 23 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx index e0a3cc8ce9c..38f6817034e 100644 --- a/packages/shared/src/features/giveback/components/GivebackPage.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx @@ -1,13 +1,9 @@ import type { ReactElement } from 'react'; import React, { useCallback, useRef, useState } from 'react'; -import { FlexCol, FlexRow } from '../../../components/utilities'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '../../../components/buttons/Button'; -import { InfoIcon } from '../../../components/icons'; +import { FlexCol } from '../../../components/utilities'; import usePersistentContext from '../../../hooks/usePersistentContext'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { featureGivebackSponsors } from '../../../lib/featureManagement'; import { GivebackBackground } from './GivebackBackground'; import { GivebackFunnel } from './GivebackFunnel'; import { GivebackHero } from './GivebackHero'; @@ -59,6 +55,12 @@ export const GivebackPage = (): ReactElement => { const showTabs = selection.hasSavedCauses || completedOnboarding; const showFunnel = replayFunnel || (funnelLoaded && funnelSeen === false); + // Sponsors are gated off for launch (no sponsors yet); flip the flag on later. + const { value: showSponsors } = useConditionalFeature({ + feature: featureGivebackSponsors, + shouldEvaluate: true, + }); + const tabsRef = useRef(null); // The tab section can mount in the same tick we ask to scroll (right after @@ -124,26 +126,21 @@ export const GivebackPage = (): ReactElement => {
- - -
-
- -
+ {showSponsors && ( +
+ +
+ )} {showTabs && (
- +
void; + // Re-opens the warm-up funnel; rendered as a button on the right of the strip. + onHowItWorks?: () => void; } // Sticky section nav for the onboarded experience. Spans the full content width @@ -27,6 +35,7 @@ interface GivebackTabNavProps { export const GivebackTabNav = ({ activeTab, onSelect, + onHowItWorks, }: GivebackTabNavProps): ReactElement => { const activeLabel = givebackTabs.find((tab) => tab.id === activeTab)?.label ?? ''; @@ -37,7 +46,7 @@ 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" /> -
+
({ label: tab.label }))} active={activeLabel} @@ -49,6 +58,18 @@ export const GivebackTabNav = ({ } }} /> + {onHowItWorks && ( + + )}
); diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index b81735f849b..fef08acd4b5 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -245,3 +245,7 @@ export const featurePublicSignupBanner = new Feature( 'public_signup_banner', 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); From 5aef1ebe4ad223d894e71d71b7378ce33089653a Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 22:34:28 +0300 Subject: [PATCH 38/89] feat(giveback): lighter funnel chrome, roomier causes, celebratory finale Replace the funnel's heavy top progress bar with carousel dots + Back/Next buttons. Causes show full descriptions, two per row. The impact finale celebrates the visitor's specific picks as prominent cards with a thank-you. Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackCauseCard.tsx | 2 +- .../components/GivebackCauseSelection.tsx | 4 +- .../giveback/components/GivebackFunnel.tsx | 182 ++++++++++-------- 3 files changed, 109 insertions(+), 79 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackCauseCard.tsx b/packages/shared/src/features/giveback/components/GivebackCauseCard.tsx index 73b265badc7..a9b6c972971 100644 --- a/packages/shared/src/features/giveback/components/GivebackCauseCard.tsx +++ b/packages/shared/src/features/giveback/components/GivebackCauseCard.tsx @@ -80,7 +80,7 @@ export const GivebackCauseCard = ({ {cause.description} diff --git a/packages/shared/src/features/giveback/components/GivebackCauseSelection.tsx b/packages/shared/src/features/giveback/components/GivebackCauseSelection.tsx index 12d647587ae..d3fe0cccee0 100644 --- a/packages/shared/src/features/giveback/components/GivebackCauseSelection.tsx +++ b/packages/shared/src/features/giveback/components/GivebackCauseSelection.tsx @@ -52,7 +52,7 @@ export const GivebackCauseSelection = ({ if (isLoading) { return ( -
+
{Array.from({ length: 6 }).map((_, index) => (
)} -
+
{visibleCauses.map(({ cause, index }) => ( (null); const [videoClosed, setVideoClosed] = useState(false); - // The visitor's own picks, surfaced on the finale so it feels personal. - const selectedCauseNames = selection.causes + // 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 .filter((cause) => selection.selectedIds.has(cause.id)) - .map((cause) => cause.title) - .slice(0, 4); + .slice(0, 3); useEffect(() => { logEvent({ @@ -386,11 +385,11 @@ export const GivebackFunnel = ({ - + - Nice picks + Your impact - Real causes. Real impact. + {selectedCauses.length > 0 + ? 'Look what you just set in motion' + : 'Real causes. Real impact.'} - - - {selection.selectedCount > 0 - ? `Your ${selection.selectedCount} ${ - selection.selectedCount === 1 ? 'pick goes' : 'picks go' - } straight to the people behind them: open-source maintainers, students, and devs who can't afford access. No middlemen.` - : "This money goes straight to the people behind these causes: open-source maintainers, students, and devs who can't afford access. No middlemen."} - - - {selectedCauseNames.length > 0 && ( - - - {selectedCauseNames.map((name) => ( - - - 0 ? ( + <> + + + Because you chose{' '} + {selectedCauses.length === 1 ? 'this' : 'these'}, every + action you take sends real money straight to: + + + +
+ {selectedCauses.map((cause) => ( + - {name} - - - ))} - + + + + + {cause.title} + + {cause.description && ( + + {cause.description} + + )} + + ))} +
+
+ + + Thank you for giving back. The community is better because + of you. 💚 + + + + ) : ( + + + Your actions send real money straight to the people behind + these causes: open-source maintainers, students, and devs who + can't afford access. No middlemen. + )}
@@ -488,59 +520,57 @@ export const GivebackFunnel = ({ > -
- {!isFirst ? ( - + )} - + {!videoClosed && ( From f4af8d066c1932713669ff1bc44b89b5718b7f0d Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 22:34:28 +0300 Subject: [PATCH 39/89] docs(giveback): Storybook coverage for the giveback feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stories + mock providers (seeded query cache + auth/log contexts) for the funnel, journey roadmap, cause selection, funding meter/bar, sponsor tiers, contribution summary, action catalog, submission + edit-causes modals, tab nav, page cover, and campaign pieces — every state, for fast design iteration. Co-Authored-By: Claude Opus 4.8 --- .../GivebackActionCatalog.stories.tsx | 44 ++ .../GivebackActionSubmissionModal.stories.tsx | 103 +++++ .../GivebackCampaignPanel.stories.tsx | 33 ++ .../GivebackCampaignPieces.stories.tsx | 41 ++ .../GivebackCauseSelection.stories.tsx | 70 +++ .../GivebackContributionSummary.stories.tsx | 36 ++ .../GivebackEditCausesModal.stories.tsx | 32 ++ .../giveback/GivebackFundingBar.stories.tsx | 37 ++ .../GivebackFundingSummary.stories.tsx | 74 +++ .../giveback/GivebackFunnel.stories.tsx | 81 ++++ .../giveback/GivebackHero.stories.tsx | 39 ++ .../giveback/GivebackImpactPanel.stories.tsx | 20 + .../GivebackPersonalRoadmap.stories.tsx | 83 ++++ .../giveback/GivebackSponsorTiers.stories.tsx | 75 +++ .../giveback/GivebackTabNav.stories.tsx | 33 ++ .../features/giveback/giveback.mocks.tsx | 428 ++++++++++++++++++ 16 files changed, 1229 insertions(+) create mode 100644 packages/storybook/stories/features/giveback/GivebackActionCatalog.stories.tsx create mode 100644 packages/storybook/stories/features/giveback/GivebackActionSubmissionModal.stories.tsx create mode 100644 packages/storybook/stories/features/giveback/GivebackCampaignPanel.stories.tsx create mode 100644 packages/storybook/stories/features/giveback/GivebackCampaignPieces.stories.tsx create mode 100644 packages/storybook/stories/features/giveback/GivebackCauseSelection.stories.tsx create mode 100644 packages/storybook/stories/features/giveback/GivebackContributionSummary.stories.tsx create mode 100644 packages/storybook/stories/features/giveback/GivebackEditCausesModal.stories.tsx create mode 100644 packages/storybook/stories/features/giveback/GivebackFundingBar.stories.tsx create mode 100644 packages/storybook/stories/features/giveback/GivebackFundingSummary.stories.tsx create mode 100644 packages/storybook/stories/features/giveback/GivebackFunnel.stories.tsx create mode 100644 packages/storybook/stories/features/giveback/GivebackHero.stories.tsx create mode 100644 packages/storybook/stories/features/giveback/GivebackImpactPanel.stories.tsx create mode 100644 packages/storybook/stories/features/giveback/GivebackPersonalRoadmap.stories.tsx create mode 100644 packages/storybook/stories/features/giveback/GivebackSponsorTiers.stories.tsx create mode 100644 packages/storybook/stories/features/giveback/GivebackTabNav.stories.tsx create mode 100644 packages/storybook/stories/features/giveback/giveback.mocks.tsx 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..3112528e506 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackActionSubmissionModal.stories.tsx @@ -0,0 +1,103 @@ +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: 'Write about daily.dev', + description: 'Publish an article or blog post featuring daily.dev.', + points: 120, + evidence: { url: { required: true } }, + metadata: { + platform: 'Blog', + instructions: 'Paste the public link to your post.', + externalUrl: null, + isLoveAction: false, + }, + cooldownSeconds: null, + maxPerUser: null, + userCooldownEndsAt: null, + userCompletions: 0, + latestUserSubmission: null, + ...overrides, +}); + +export const LinkOnly: Story = { + args: { action: makeAction({}) }, +}; + +export const Screenshot: Story = { + args: { + action: makeAction({ + title: 'Host a daily.dev meetup', + points: 250, + evidence: { screenshot: { required: true } }, + metadata: { + platform: 'Events', + 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/GivebackCampaignPanel.stories.tsx b/packages/storybook/stories/features/giveback/GivebackCampaignPanel.stories.tsx new file mode 100644 index 00000000000..b88df5ac735 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackCampaignPanel.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackCampaignPanel } from '@dailydotdev/shared/src/features/giveback/components/GivebackCampaignPanel'; +import { withGiveback } from './giveback.mocks'; + +// The "Campaign" (why) tab: the emotional budget story, the visitor's selected +// causes, and the FAQ. Reads the cause picker (causes + saved picks) and status. +const meta: Meta = { + title: 'Features/Giveback/Campaign tab', + component: GivebackCampaignPanel, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'The "why" tab. Shows the budget story, your picked causes, and the FAQ.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const WithPickedCauses: Story = { + decorators: [ + withGiveback({ selectedCauseIds: ['c-oss', 'c-access', 'c-docs'] }), + ], +}; + +export const NoPicksYet: Story = { + decorators: [withGiveback({ selectedCauseIds: [] })], +}; 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..3bfd01ef462 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackCampaignPieces.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackBudgetStory } from '@dailydotdev/shared/src/features/giveback/components/GivebackBudgetStory'; +import { GivebackSelectedCauses } from '@dailydotdev/shared/src/features/giveback/components/GivebackSelectedCauses'; +import { GivebackFaq } from '@dailydotdev/shared/src/features/giveback/components/GivebackFaq'; +import { withGiveback } from './giveback.mocks'; + +// The building blocks of the Campaign ("why") tab, each on its own so you can +// refine them in isolation: the emotional budget story, the editable list of +// your picked causes (with the edit pop-up), and the FAQ. +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 BudgetStory: Story = { + render: () => ( + + ), +}; + +export const SelectedCauses: Story = { + parameters: { + docs: { + description: { + story: 'Your picked causes with an "Edit" button that opens the modal.', + }, + }, + }, + render: () => , +}; + +export const Faq: Story = { + render: () => , +}; 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/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/GivebackEditCausesModal.stories.tsx b/packages/storybook/stories/features/giveback/GivebackEditCausesModal.stories.tsx new file mode 100644 index 00000000000..4c17aa7dd69 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackEditCausesModal.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackEditCausesModal } from '@dailydotdev/shared/src/features/giveback/components/GivebackEditCausesModal'; +import { withGiveback } from './giveback.mocks'; + +// The "edit your causes" pop-up, opened from the Campaign tab. Reuses the picker +// grid, seeded with the saved selection so it opens with the current picks +// ready to toggle, and saves on confirm. +const meta: Meta = { + title: 'Features/Giveback/Edit causes modal', + component: GivebackEditCausesModal, + args: { onClose: () => undefined }, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'The pop-up for changing causes after onboarding. Opens with the saved picks pre-selected; toggle and save.', + }, + }, + }, + decorators: [withGiveback({ selectedCauseIds: ['c-oss', 'c-access'] })], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const NothingPicked: Story = { + decorators: [withGiveback({ selectedCauseIds: [] })], +}; diff --git a/packages/storybook/stories/features/giveback/GivebackFundingBar.stories.tsx b/packages/storybook/stories/features/giveback/GivebackFundingBar.stories.tsx new file mode 100644 index 00000000000..52def970bff --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackFundingBar.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GivebackFundingBar } from '@dailydotdev/shared/src/features/giveback/components/GivebackFundingBar'; +import { mockStatus, withGiveback } from './giveback.mocks'; + +// The sticky bottom progress bar shown on the campaign tabs: your points, the +// next reward, segment progress, and a "Take action" CTA. Driven by your points +// + reward tiers (useGivebackContribution). +const meta: Meta = { + title: 'Features/Giveback/Funding bar (sticky)', + component: GivebackFundingBar, + args: { onTakeAction: () => undefined }, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Pinned to the bottom of the viewport. Shows progress toward the next reward, or a "top of the ladder" state when everything is unlocked.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const TowardNextReward: Story = { + decorators: [withGiveback({ status: mockStatus({ userPoints: 320 }) })], +}; + +export const AllRewardsUnlocked: Story = { + decorators: [withGiveback({ status: mockStatus({ userPoints: 2500 }) })], +}; + +export const JustStarted: Story = { + decorators: [withGiveback({ status: mockStatus({ userPoints: 0 }) })], +}; 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..ff5e63d563c --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackFunnel.stories.tsx @@ -0,0 +1,81 @@ +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), + ); + return { + causes: mockCauses(), + isLoading: false, + selectedIds, + selectedCount: selectedIds.size, + hasSavedCauses: false, + isSaving: false, + save: async () => true, + toggleCause: (id: string) => + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }), + }; +}; + +export const Forced: Story = { + render: () => { + const selection = useMockSelection(['c-oss', 'c-scholarships']); + 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..7a6705c811d --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackHero.stories.tsx @@ -0,0 +1,39 @@ +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, headline, subtitle, and the live funding meter folded +// into one block (no video / CTA — onboarding lives in the funnel). +const meta: Meta = { + title: 'Features/Giveback/Page cover (hero)', + component: GivebackHero, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Compact cover so the tabs sit higher. The funding meter sits under the headline. 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/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..4991821b9fd --- /dev/null +++ b/packages/storybook/stories/features/giveback/giveback.mocks.tsx @@ -0,0 +1,428 @@ +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: 'Fund the maintainers behind the tools we use every day.', + url: null, + category: 'Open source', + logoUrl: null, + }, + { + id: 'c-scholarships', + title: 'Dev scholarships', + description: 'Help students from underrepresented groups learn to code.', + url: null, + category: 'Education', + logoUrl: null, + }, + { + id: 'c-access', + title: 'Access to tech', + description: 'Get hardware and connectivity to devs who lack it.', + url: null, + category: 'Accessibility', + logoUrl: null, + }, + { + id: 'c-climate', + title: 'Climate tech', + description: 'Back open tools fighting the climate crisis.', + url: null, + category: 'Climate', + logoUrl: null, + }, + { + id: 'c-mentorship', + title: 'Mentorship programs', + description: 'Pair early-career devs with experienced mentors.', + url: null, + category: 'Education', + logoUrl: null, + }, + { + id: 'c-docs', + title: 'Better docs', + description: 'Pay technical writers to improve open-source docs.', + url: null, + 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) => + ( + + + + ); From 780d26a0690ab7550ee82b481c5f59c23c60101d Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 22:35:43 +0300 Subject: [PATCH 40/89] docs(giveback): start the funnel story with no causes pre-selected Match a brand-new user's first-time pick (nothing selected by default). Co-Authored-By: Claude Opus 4.8 --- .../stories/features/giveback/GivebackFunnel.stories.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/storybook/stories/features/giveback/GivebackFunnel.stories.tsx b/packages/storybook/stories/features/giveback/GivebackFunnel.stories.tsx index ff5e63d563c..e4cbd8d4126 100644 --- a/packages/storybook/stories/features/giveback/GivebackFunnel.stories.tsx +++ b/packages/storybook/stories/features/giveback/GivebackFunnel.stories.tsx @@ -53,7 +53,9 @@ const useMockSelection = (preset: string[] = []) => { export const Forced: Story = { render: () => { - const selection = useMockSelection(['c-oss', 'c-scholarships']); + // Nothing pre-selected — matches a brand-new user picking causes for the + // first time. + const selection = useMockSelection(); return undefined} />; }, }; From 9239418c09798a88511cedd5164f08797493fdd8 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 22:42:14 +0300 Subject: [PATCH 41/89] fix(giveback): funnel stepper uses rounded-rectangle indicators, not ovals Co-Authored-By: Claude Opus 4.8 --- .../shared/src/features/giveback/components/GivebackFunnel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 130dc2a8a5c..8a480371bbf 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -545,7 +545,7 @@ export const GivebackFunnel = ({ Date: Wed, 24 Jun 2026 23:29:38 +0300 Subject: [PATCH 42/89] feat(giveback): explicit action brief, personal journey, tab split + polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Submission modal: redesign the top into an explicit "action brief" — platform logo, the ask, the reward, full numbered how-to, and a "Go to " button — so it's obvious what to do before the proof fields. - Impact: rebuild the "levels" header into a proud impact statement and put the user's profile picture on the trail as the "you are here" marker; calm the palette to one meaning-per-color system (green done / cabbage you+next / gold summit-once / neutral locked), circles not squares. - Tabs: split Campaign into Causes (manage all causes, yours on top) and FAQ (the "Big tech buys ads. We fund developers." story + FAQ); delete the dead Campaign panel, selected-causes recap, and edit-causes modal. - Hero + copy: make the why and the ask explicit and proud across surfaces. - Funnel: reimagine "how it works" as a vertical numbered timeline. - Background: fade the brand sweep vertically so it fills the top corners and no longer reads as "cut" gaps inside the app's rounded content card. - Extract GivebackPlatformLogo so the card and modal share one logo renderer. Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackActionCard.tsx | 46 +- .../GivebackActionSubmissionModal.tsx | 275 +++++++---- .../components/GivebackBackground.tsx | 10 +- .../components/GivebackCampaignPanel.spec.tsx | 119 ----- .../components/GivebackCampaignPanel.tsx | 22 - .../components/GivebackCausesPanel.tsx | 223 +++++++++ .../components/GivebackEditCausesModal.tsx | 130 ------ .../giveback/components/GivebackFaqPanel.tsx | 19 + .../giveback/components/GivebackFunnel.tsx | 113 ++--- .../giveback/components/GivebackHero.tsx | 11 +- .../components/GivebackImpactPanel.spec.tsx | 30 +- .../giveback/components/GivebackPage.tsx | 6 +- .../components/GivebackPersonalRoadmap.tsx | 431 +++++++++++------- .../components/GivebackPlatformLogo.tsx | 52 +++ .../components/GivebackSelectedCauses.tsx | 137 ------ .../components/GivebackTabNav.spec.tsx | 9 +- .../giveback/components/GivebackTabNav.tsx | 7 +- .../GivebackActionSubmissionModal.stories.tsx | 22 +- .../GivebackCampaignPanel.stories.tsx | 33 -- .../GivebackCampaignPieces.stories.tsx | 17 +- .../giveback/GivebackCausesPanel.stories.tsx | 34 ++ .../GivebackEditCausesModal.stories.tsx | 32 -- .../giveback/GivebackFaqPanel.stories.tsx | 18 + 23 files changed, 924 insertions(+), 872 deletions(-) delete mode 100644 packages/shared/src/features/giveback/components/GivebackCampaignPanel.spec.tsx delete mode 100644 packages/shared/src/features/giveback/components/GivebackCampaignPanel.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx delete mode 100644 packages/shared/src/features/giveback/components/GivebackEditCausesModal.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackFaqPanel.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackPlatformLogo.tsx delete mode 100644 packages/shared/src/features/giveback/components/GivebackSelectedCauses.tsx delete mode 100644 packages/storybook/stories/features/giveback/GivebackCampaignPanel.stories.tsx create mode 100644 packages/storybook/stories/features/giveback/GivebackCausesPanel.stories.tsx delete mode 100644 packages/storybook/stories/features/giveback/GivebackEditCausesModal.stories.tsx create mode 100644 packages/storybook/stories/features/giveback/GivebackFaqPanel.stories.tsx diff --git a/packages/shared/src/features/giveback/components/GivebackActionCard.tsx b/packages/shared/src/features/giveback/components/GivebackActionCard.tsx index 01220eee4de..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,47 +40,6 @@ 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 // 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. @@ -206,7 +166,7 @@ export const GivebackActionCard = ({ : 'shadow-1 bg-white text-black group-hover:scale-105', )} > - 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 = ({ @@ -173,57 +334,35 @@ export const GivebackActionSubmissionModal = ({ 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)} - - - - 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 && (
-
- - ); -}; 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..22f98fe30c0 --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackFaqPanel.tsx @@ -0,0 +1,19 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { FlexCol } from '../../../components/utilities'; +import { GivebackBudgetStory } from './GivebackBudgetStory'; +import { GivebackFaq } from './GivebackFaq'; + +// The FAQ tab: the short, proud reason for the whole campaign up top (the +// headline rides beside the charm), then the answers to everything people ask. +const headline = { + title: 'Big tech buys ads.', + highlight: 'We fund developers.', +}; + +export const GivebackFaqPanel = (): ReactElement => ( + + + + +); diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 8a480371bbf..8027e4e43e5 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -14,13 +14,7 @@ import { ButtonVariant, } from '../../../components/buttons/Button'; import CloseButton from '../../../components/CloseButton'; -import { - CoinIcon, - GiftIcon, - MedalBadgeIcon, - UpvoteIcon, - VIcon, -} from '../../../components/icons'; +import { GiftIcon, VIcon } from '../../../components/icons'; import { useLogContext } from '../../../contexts/LogContext'; import { LogEvent } from '../../../lib/log'; import type { useGivebackCauseSelection } from '../hooks/useGivebackCauseSelection'; @@ -160,88 +154,63 @@ const GivebackFunnelVideo = ({ ); }; -// The campaign as a connected milestone track: the community acts together, -// raises the bar, unlocks the goal (the glowing focal point), and the budget is -// released to causes. Each node has its own accent so it reads as a journey, not -// four identical tiles. -const FLOW_STEPS: ReadonlyArray<{ - icon: ReactElement; - title: string; - sub: string; - gradient: string; - isGoal?: boolean; -}> = [ - { - icon: , - title: 'Everyone takes action', - sub: 'Post, talk, write, or host', - gradient: 'from-accent-cabbage-default to-accent-onion-default', - }, +// "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 }> = [ { - icon: , - title: 'We raise the bar together', - sub: 'Every action grows the pot', - gradient: 'from-accent-cabbage-default to-accent-avocado-default', + title: 'You take an action', + sub: 'Upvote, post, share, talk, write. Anything counts.', }, { - icon: , - title: 'We unlock the goal', - sub: 'Hit the milestone, release the budget', - gradient: 'from-accent-cheese-default to-accent-bacon-default', - isGoal: true, + title: 'The pot grows toward the goal', + sub: 'Every action drops real money in. You never pay a cent.', }, { - icon: , - title: 'Causes get funded', - sub: 'Every dollar, automatically', - gradient: 'from-accent-avocado-default to-accent-lettuce-default', + title: 'We donate it to your causes', + sub: 'Hit the goal together and it’s sent automatically.', }, ]; const FlowSequence = (): ReactElement => ( -
- {/* The connecting track, gradient-filled to read as momentum to the goal. */} -
- - {FLOW_STEPS.map((step) => ( - - - {step.isGoal && ( + + {FLOW_STEPS.map((step, index) => { + const isLast = index === FLOW_STEPS.length - 1; + return ( + + + + + {index + 1} + + + {!isLast && ( )} - + + - {step.icon} - - - - {step.title} {step.sub} - - ))} - -
+ + ); + })} + ); const Eyebrow = ({ children }: { children: ReactNode }): ReactElement => ( @@ -344,14 +313,6 @@ export const GivebackFunnel = ({ - - - You never pay a cent. daily.dev funds every dollar. - - ); case 'causes': diff --git a/packages/shared/src/features/giveback/components/GivebackHero.tsx b/packages/shared/src/features/giveback/components/GivebackHero.tsx index a709adccca2..ce310b839bd 100644 --- a/packages/shared/src/features/giveback/components/GivebackHero.tsx +++ b/packages/shared/src/features/giveback/components/GivebackHero.tsx @@ -53,11 +53,11 @@ export const GivebackHero = (): ReactElement => ( tag={TypographyTag.H1} type={TypographyType.Title1} bold - className="max-w-3xl" + className="max-w-3xl [text-wrap:balance]" > - Grow the community. Redirect the budget. + Do what you already do. - Fund good causes. + Fund what you actually care about. ( color={TypographyColor.Secondary} className="max-w-2xl" > - 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. + Every action you take on daily.dev unlocks real money for good causes. + We pay for all of it, you choose where it goes. No catch, no cost, + just developers giving back together. diff --git a/packages/shared/src/features/giveback/components/GivebackImpactPanel.spec.tsx b/packages/shared/src/features/giveback/components/GivebackImpactPanel.spec.tsx index cf0c55626ae..16740ba3827 100644 --- a/packages/shared/src/features/giveback/components/GivebackImpactPanel.spec.tsx +++ b/packages/shared/src/features/giveback/components/GivebackImpactPanel.spec.tsx @@ -5,7 +5,10 @@ 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'; @@ -13,7 +16,10 @@ 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. @@ -34,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[] = [ @@ -78,9 +91,24 @@ 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 >); + mockAuth.mockReturnValue({ user: null } as unknown as ReturnType< + typeof useAuthContext + >); }); it('renders the reward-ladder journey with the current level', () => { @@ -91,7 +119,7 @@ it('renders the reward-ladder journey with the current level', () => { 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 One month of Plus')).toBeInTheDocument(); expect(screen.getByText('$60 to go')).toBeInTheDocument(); }); diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx index 38f6817034e..5fb5b2d479a 100644 --- a/packages/shared/src/features/giveback/components/GivebackPage.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx @@ -13,7 +13,8 @@ import { GivebackTabNav, givebackTabs } from './GivebackTabNav'; import { GivebackActionCatalog } from './GivebackActionCatalog'; import { GivebackContributionSummary } from './GivebackContributionSummary'; import { GivebackImpactPanel } from './GivebackImpactPanel'; -import { GivebackCampaignPanel } from './GivebackCampaignPanel'; +import { GivebackCausesPanel } from './GivebackCausesPanel'; +import { GivebackFaqPanel } from './GivebackFaqPanel'; import { GivebackFundingBar } from './GivebackFundingBar'; import type { GivebackTabId } from './GivebackTabNav'; import { useLogContext } from '../../../contexts/LogContext'; @@ -157,7 +158,8 @@ export const GivebackPage = (): ReactElement => { {activeTab === 'impact' && ( )} - {activeTab === 'why' && } + {activeTab === 'causes' && } + {activeTab === 'faq' && }
)} diff --git a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx index 92864a27bb8..7f802253939 100644 --- a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx @@ -21,18 +21,43 @@ import { LockIcon, MedalBadgeIcon, StarIcon, + UserIcon, VIcon, } from '../../../components/icons'; +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'; +// 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. const DEFAULT_UPCOMING = 4; @@ -59,24 +84,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'; - -// Every step you've already cleared shares one "completed" green so the trail -// reads as a single continuous path. "Current" stays purple to mark where you -// are, the summit keeps its gold treatment, and locked steps stay muted. -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-avocado-default text-white', - locked: - 'border border-border-subtlest-tertiary bg-surface-float text-text-quaternary', -}; - +// Disciplined palette so color carries meaning instead of decoration: +// • green = done (cleared / claimed) +// • cabbage (brand) = you + your next goal — the only "live" accent +// • gold = the summit prize, used exactly once so it reads as "the big one" +// • neutral = still locked +// No multi-hue gradients on the markers; the trail stays calm and legible. type ConnectorFill = | { type: 'full' } | { type: 'partial'; progress: number } @@ -92,8 +105,9 @@ interface RoadmapNode { connector?: ConnectorFill; } -// A straight rectangular track between nodes - no rounded "pill" ends. Width is -// a crisp 3px line; the fill colors echo the node states (green = cleared). +// 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 => (
@@ -102,7 +116,7 @@ const Connector = ({ fill }: { fill: ConnectorFill }): ReactElement => ( )} {fill.type === 'partial' && (
)} @@ -168,6 +182,7 @@ const claimSparkles: ReadonlyArray<{ tx: string; ty: string; delay: string }> = interface NodeRowProps { node: RoadmapNode; + user: LoggedUser | null; amountToNext: number; segmentProgress: number; isClaiming: boolean; @@ -175,8 +190,12 @@ interface NodeRowProps { onTakeAction: () => void; } +const markerBase = + 'flex size-10 items-center justify-center rounded-full [&_svg]:size-5'; + const NodeRow = ({ node, + user, amountToNext, segmentProgress, isClaiming, @@ -194,30 +213,89 @@ const NodeRow = ({ onClaim(reward.id); }; - const getNodeState = (): NodeState => { - if (isReached && isSummit) { - return 'summit'; - } + // 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'; + return user ? ( + + ) : ( + + + + ); + } + if (isSummit) { + return ( + + {isClaimed ? : } + + ); } if (isClaimed) { - return 'claimed'; + return ( + + + + ); } if (isReached) { - return 'unlocked'; - } - return 'locked'; - }; - - const getNodeIcon = (): ReactElement => { - if (isClaimed) { - return ; + return ( + + {rewardIconByType[reward.type]} + + ); } - if (isReached || isNext) { - return rewardIconByType[reward.type]; + if (isNext) { + return ( + + {rewardIconByType[reward.type]} + + ); } - return ; + return ( + + + + ); }; const requirementLabel = @@ -229,34 +307,19 @@ const NodeRow = ({
- {isCurrent && ( + {(isCurrent || isNext) && ( - )} - {isNext && ( - )} - {getNodeIcon()} - - - {level.levelNumber} + {renderMarker()} {!isLast && } @@ -369,7 +432,7 @@ const NodeRow = ({
{ const { logEvent } = useLogContext(); + const { user } = useAuthContext(); const { earnedPoints, currentLevel, 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); @@ -474,6 +541,15 @@ 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( @@ -573,134 +649,159 @@ export const GivebackPersonalRoadmap = ({ return (
- - - - Your impact - - - - - Level - + + + + + Your impact + + {hasImpact ? ( - {focusIndex + 1} + You turned {actionsTaken}{' '} + {actionsTaken === 1 ? 'action' : 'actions'} into{' '} + + {formatDonationAmount(approved)} + {' '} + for good causes - - - + ) : ( - {nextLevel - ? `Next up: ${nextLevel.reward.title}` - : "You've unlocked every reward"} - - - {nextLevel - ? `${formatDonationAmount(amountToNext)} to go.` - : "You've reached the top of the ladder."} - - - Level {focusIndex + 1} of {total} ·{' '} - {formatDonationAmount(approved)} unlocked + Turn your everyday actions into{' '} + + real donations + - + )} + + {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.'} + + + + {claimableCount > 0 && ( - + - {claimableCount} ready to claim + {claimableCount} {claimableCount === 1 ? 'reward' : 'rewards'}{' '} + ready to claim )} + + {nextLevel + ? `${formatDonationAmount(amountToNext)} to ${ + nextLevel.reward.title + }` + : 'Every reward unlocked'} + - - {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/GivebackSelectedCauses.tsx b/packages/shared/src/features/giveback/components/GivebackSelectedCauses.tsx deleted file mode 100644 index 315ec8b0ab4..00000000000 --- a/packages/shared/src/features/giveback/components/GivebackSelectedCauses.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useState } from 'react'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '../../../components/buttons/Button'; -import { - Typography, - TypographyColor, - TypographyTag, - TypographyType, -} from '../../../components/typography/Typography'; -import { EditIcon, OpenLinkIcon } from '../../../components/icons'; -import { IconSize } from '../../../components/Icon'; -import { FlexCol, FlexRow } from '../../../components/utilities'; -import { anchorDefaultRel } from '../../../lib/strings'; -import { useLogContext } from '../../../contexts/LogContext'; -import { LogEvent } from '../../../lib/log'; -import { useContributionCausePicker } from '../hooks/useContributionCausePicker'; -import { GivebackSection } from './GivebackSection'; -import { CauseEmblem } from './CauseEmblem'; -import { GivebackEditCausesModal } from './GivebackEditCausesModal'; - -// "Your causes" recap on the Campaign tab. Shows only the causes the visitor -// picked, with a quick action to open the picker and edit. Editing lives here -// now instead of a gear button in the tab bar. -export const GivebackSelectedCauses = (): ReactElement => { - const { logEvent } = useLogContext(); - const { causes, selectedCauseIds } = useContributionCausePicker(true); - const [isEditOpen, setIsEditOpen] = useState(false); - - // Keep each cause's position in the full list so the fallback emblem tint - // stays stable between the picker and this recap. - const selectedCauses = causes - .map((cause, index) => ({ cause, index })) - .filter(({ cause }) => selectedCauseIds.includes(cause.id)); - - const openEdit = () => { - logEvent({ - event_name: LogEvent.ClickGivebackEditCauses, - extra: JSON.stringify({ has_causes: selectedCauses.length > 0 }), - }); - setIsEditOpen(true); - }; - - const onCauseClick = (causeId: string) => - logEvent({ event_name: LogEvent.ClickGivebackCause, target_id: causeId }); - - return ( - - {selectedCauses.length > 0 ? ( - -
- {selectedCauses.map(({ cause, index }) => ( - - - - - {cause.title} - - {cause.category && ( - - {cause.category} - - )} - - {cause.url && ( - onCauseClick(cause.id)} - className="flex size-8 shrink-0 items-center justify-center rounded-10 text-text-tertiary transition-colors hover:bg-surface-hover hover:text-text-primary" - > - - - )} - - ))} -
- - - -
- ) : ( - - - You haven't picked any causes yet. Choose where your actions - send the money. - - - - )} - - {isEditOpen && ( - setIsEditOpen(false)} /> - )} -
- ); -}; diff --git a/packages/shared/src/features/giveback/components/GivebackTabNav.spec.tsx b/packages/shared/src/features/giveback/components/GivebackTabNav.spec.tsx index 173527625ac..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 3080d0a60bc..c65ff2a1c20 100644 --- a/packages/shared/src/features/giveback/components/GivebackTabNav.tsx +++ b/packages/shared/src/features/giveback/components/GivebackTabNav.tsx @@ -8,7 +8,7 @@ import { } from '../../../components/buttons/Button'; import { InfoIcon } from '../../../components/icons'; -export type GivebackTabId = 'actions' | 'impact' | 'why'; +export type GivebackTabId = 'actions' | 'impact' | 'causes' | 'faq'; interface GivebackTab { id: GivebackTabId; @@ -17,8 +17,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 { diff --git a/packages/storybook/stories/features/giveback/GivebackActionSubmissionModal.stories.tsx b/packages/storybook/stories/features/giveback/GivebackActionSubmissionModal.stories.tsx index 3112528e506..4c9b28a7191 100644 --- a/packages/storybook/stories/features/giveback/GivebackActionSubmissionModal.stories.tsx +++ b/packages/storybook/stories/features/giveback/GivebackActionSubmissionModal.stories.tsx @@ -31,14 +31,15 @@ const makeAction = ( ): ContributionAction => ({ id: 'a-modal', categoryId: 'cat-content', - title: 'Write about daily.dev', - description: 'Publish an article or blog post featuring daily.dev.', + 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: 'Blog', - instructions: 'Paste the public link to your post.', - externalUrl: null, + 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, @@ -50,6 +51,14 @@ const makeAction = ( }); 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({}) }, }; @@ -57,10 +66,11 @@ 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: 'Events', + platform: 'event', instructions: 'Upload a photo from the meetup.', externalUrl: null, isLoveAction: false, diff --git a/packages/storybook/stories/features/giveback/GivebackCampaignPanel.stories.tsx b/packages/storybook/stories/features/giveback/GivebackCampaignPanel.stories.tsx deleted file mode 100644 index b88df5ac735..00000000000 --- a/packages/storybook/stories/features/giveback/GivebackCampaignPanel.stories.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { GivebackCampaignPanel } from '@dailydotdev/shared/src/features/giveback/components/GivebackCampaignPanel'; -import { withGiveback } from './giveback.mocks'; - -// The "Campaign" (why) tab: the emotional budget story, the visitor's selected -// causes, and the FAQ. Reads the cause picker (causes + saved picks) and status. -const meta: Meta = { - title: 'Features/Giveback/Campaign tab', - component: GivebackCampaignPanel, - parameters: { - layout: 'padded', - docs: { - description: { - component: - 'The "why" tab. Shows the budget story, your picked causes, and the FAQ.', - }, - }, - }, -}; - -export default meta; - -type Story = StoryObj; - -export const WithPickedCauses: Story = { - decorators: [ - withGiveback({ selectedCauseIds: ['c-oss', 'c-access', 'c-docs'] }), - ], -}; - -export const NoPicksYet: Story = { - decorators: [withGiveback({ selectedCauseIds: [] })], -}; diff --git a/packages/storybook/stories/features/giveback/GivebackCampaignPieces.stories.tsx b/packages/storybook/stories/features/giveback/GivebackCampaignPieces.stories.tsx index 3bfd01ef462..a900b474354 100644 --- a/packages/storybook/stories/features/giveback/GivebackCampaignPieces.stories.tsx +++ b/packages/storybook/stories/features/giveback/GivebackCampaignPieces.stories.tsx @@ -1,12 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { GivebackBudgetStory } from '@dailydotdev/shared/src/features/giveback/components/GivebackBudgetStory'; -import { GivebackSelectedCauses } from '@dailydotdev/shared/src/features/giveback/components/GivebackSelectedCauses'; import { GivebackFaq } from '@dailydotdev/shared/src/features/giveback/components/GivebackFaq'; import { withGiveback } from './giveback.mocks'; -// The building blocks of the Campaign ("why") tab, each on its own so you can -// refine them in isolation: the emotional budget story, the editable list of -// your picked causes (with the edit pop-up), and the FAQ. +// The building blocks of the FAQ tab, each on its own so you can refine them in +// isolation: the emotional budget story (the "why") and the FAQ. const meta: Meta = { title: 'Features/Giveback/Campaign pieces', parameters: { layout: 'padded' }, @@ -25,17 +23,6 @@ export const BudgetStory: Story = { ), }; -export const SelectedCauses: Story = { - parameters: { - docs: { - description: { - story: 'Your picked causes with an "Edit" button that opens the modal.', - }, - }, - }, - render: () => , -}; - export const Faq: 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/GivebackEditCausesModal.stories.tsx b/packages/storybook/stories/features/giveback/GivebackEditCausesModal.stories.tsx deleted file mode 100644 index 4c17aa7dd69..00000000000 --- a/packages/storybook/stories/features/giveback/GivebackEditCausesModal.stories.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { GivebackEditCausesModal } from '@dailydotdev/shared/src/features/giveback/components/GivebackEditCausesModal'; -import { withGiveback } from './giveback.mocks'; - -// The "edit your causes" pop-up, opened from the Campaign tab. Reuses the picker -// grid, seeded with the saved selection so it opens with the current picks -// ready to toggle, and saves on confirm. -const meta: Meta = { - title: 'Features/Giveback/Edit causes modal', - component: GivebackEditCausesModal, - args: { onClose: () => undefined }, - parameters: { - layout: 'fullscreen', - docs: { - description: { - component: - 'The pop-up for changing causes after onboarding. Opens with the saved picks pre-selected; toggle and save.', - }, - }, - }, - decorators: [withGiveback({ selectedCauseIds: ['c-oss', 'c-access'] })], -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = {}; - -export const NothingPicked: Story = { - decorators: [withGiveback({ selectedCauseIds: [] })], -}; 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..640b15b5d9a --- /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 proud "Big tech buys ads. We fund developers." reason up +// top beside the charm, then the full FAQ. +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 = {}; From e86e6edb6fd4205da34b8fa20f9597d35a853f38 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 23:44:28 +0300 Subject: [PATCH 43/89] feat(giveback): rework funnel finale + journey roadmap for clarity, contrast, brand Funnel: - Move the carousel dots above each step's title and drop the eyebrow labels; every step is now a clear title + subtitle. - Rebuild the impact finale: two-line message + the user's chosen causes as high-contrast, branded CauseEmblem cards (no more low-contrast green-on-green). Journey roadmap: - Markers are rounded-rect squircles, not circles, to match daily.dev branding; the "you are here" avatar is a rounded square too. - Contrast-first palette: calm surface tiles by default, green only as a "done" check accent, cabbage as the single live/goal accent, summit gets the one brand-gradient tile. No washed-out green fills or low-contrast glyphs. - Summit reward uses a gift icon instead of a medal. - The current-goal card is a tight, clearly-bounded card so its action button stands out instead of a wide sprawly box. - Stronger claim celebration: flash + expanding ring + brand-colored confetti, bigger "Claim reward" button. Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackFunnel.tsx | 169 +++++++----------- .../components/GivebackImpactPanel.spec.tsx | 2 +- .../components/GivebackPersonalRoadmap.tsx | 107 +++++++---- 3 files changed, 141 insertions(+), 137 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 8027e4e43e5..ef96201849a 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -14,7 +14,7 @@ import { ButtonVariant, } from '../../../components/buttons/Button'; import CloseButton from '../../../components/CloseButton'; -import { GiftIcon, VIcon } from '../../../components/icons'; +import { VIcon } from '../../../components/icons'; import { useLogContext } from '../../../contexts/LogContext'; import { LogEvent } from '../../../lib/log'; import type { useGivebackCauseSelection } from '../hooks/useGivebackCauseSelection'; @@ -22,6 +22,7 @@ import { GivebackBackground } from './GivebackBackground'; import { GivebackMascot } from './GivebackMascot'; import { GivebackCauseSelection } from './GivebackCauseSelection'; import { GivebackCampaignVideo } from './GivebackCampaignVideo'; +import { CauseEmblem } from './CauseEmblem'; type CauseSelection = ReturnType; @@ -213,18 +214,6 @@ const FlowSequence = (): ReactElement => ( ); -const Eyebrow = ({ children }: { children: ReactNode }): ReactElement => ( - - {children} - -); - export const GivebackFunnel = ({ selection, canClose = false, @@ -243,9 +232,11 @@ export const GivebackFunnel = ({ const [videoClosed, setVideoClosed] = useState(false); // The visitor's own picks, surfaced front-and-center on the finale so the - // moment celebrates exactly what they chose to fund. + // 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 - .filter((cause) => selection.selectedIds.has(cause.id)) + .map((cause, index) => ({ cause, index })) + .filter(({ cause }) => selection.selectedIds.has(cause.id)) .slice(0, 3); useEffect(() => { @@ -298,9 +289,6 @@ export const GivebackFunnel = ({ return ( - How it works - - - + @@ -320,14 +308,21 @@ export const GivebackFunnel = ({ - Pick your causes - Choose what we fund together + Pick the causes we'll fund together + + + Choose as many as you like. You can change them anytime. @@ -343,52 +338,47 @@ export const GivebackFunnel = ({ ); case 'impact': return ( - + - - Your impact - - - - {selectedCauses.length > 0 - ? 'Look what you just set in motion' - : 'Real causes. Real impact.'} - + + + + {selectedCauses.length > 0 + ? 'Your giving is now in motion' + : 'Real causes. Real impact.'} + + + {selectedCauses.length > 0 + ? 'From here, every action you take sends real money to the causes you picked. daily.dev funds it all, so it never costs you a thing.' + : "Your actions send real money straight to open-source maintainers, students, and devs who can't afford access. daily.dev funds it all, no cost to you."} + + - {selectedCauses.length > 0 ? ( - <> - - - Because you chose{' '} - {selectedCauses.length === 1 ? 'this' : 'these'}, every - action you take sends real money straight to: - - - -
- {selectedCauses.map((cause) => ( - - - - + {selectedCauses.length > 0 && ( + +
+ {selectedCauses.map(({ cause, index }) => ( + + + {cause.title} @@ -396,38 +386,15 @@ export const GivebackFunnel = ({ {cause.description} )} - ))} -
-
- - - Thank you for giving back. The community is better because - of you. 💚 - - - - ) : ( - - - Your actions send real money straight to the people behind - these causes: open-source maintainers, students, and devs who - can't afford access. No middlemen. - +
+ ))} +
)}
@@ -442,10 +409,7 @@ export const GivebackFunnel = ({ aria-hidden className="aspect-video w-full max-w-xl" /> - - daily.dev giveback - - + - +
- {/* Keyed by step so the choreographed enter replays on every advance. */} -
- {renderStep()} -
-
- -
- {/* Lightweight carousel dots: a quick "where am I" without a long bar. */} - + {/* 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()} +
+ + +
{!isFirst && ( ); -// Directions the celebration sparkles fly when a reward is claimed, fed to the -// reaction-burst keyframe via CSS custom properties. -const claimSparkles: ReadonlyArray<{ tx: string; ty: string; delay: string }> = - [ - { tx: '-20px', ty: '-18px', delay: '0ms' }, - { tx: '18px', ty: '-22px', delay: '40ms' }, - { tx: '26px', ty: '2px', delay: '20ms' }, - { tx: '-24px', ty: '6px', delay: '60ms' }, - { tx: '4px', ty: '-26px', delay: '0ms' }, - ]; +// 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; @@ -190,8 +208,11 @@ 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-full [&_svg]:size-5'; + 'flex size-10 items-center justify-center rounded-12 [&_svg]:size-5'; const NodeRow = ({ node, @@ -218,11 +239,13 @@ const NodeRow = ({ // trail never shows two competing highlights. const renderMarker = (): ReactElement => { if (isCurrent) { + // Your own face marks where you stand - a rounded square (not a circle) to + // match daily.dev's square avatars. return user ? ( ) : ( @@ -237,25 +260,29 @@ const NodeRow = ({ ); } 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 ? : } + {isClaimed ? : } ); } if (isClaimed) { + // Done = a calm surface tile with a green check accent, not a saturated + // green fill (which washed out the icon). return ( @@ -263,11 +290,13 @@ const NodeRow = ({ ); } if (isReached) { + // Unlocked, claim pending: surface tile with the reward icon in brand + // cabbage so it stays high-contrast and clearly actionable. return ( {rewardIconByType[reward.type]} @@ -275,11 +304,12 @@ const NodeRow = ({ ); } if (isNext) { + // The immediate goal: the one filled brand tile, white on cabbage. return ( {rewardIconByType[reward.type]} @@ -290,7 +320,7 @@ const NodeRow = ({ @@ -310,7 +340,7 @@ const NodeRow = ({ {(isCurrent || isNext) && ( )} @@ -386,13 +418,19 @@ const NodeRow = ({ {celebrate && ( - + {/* A bright flash + expanding ring read as a real "pop", and + the confetti fans out in brand colors. */} + + {claimSparkles.map((sparkle) => ( : undefined} className={classNames( 'relative', celebrate && 'motion-safe:animate-reward-pop', )} > - Claim + Claim reward
) : ( @@ -445,13 +484,13 @@ const NodeRow = ({ {formatDonationAmount(amountToNext)} to go + +); + // Manage-your-causes tab. Unlike the recap on the old Campaign tab, this shows // every cause as a pickable card: the ones you already back sit up top so you // can see your line-up at a glance, and everything else is right below ready to @@ -168,14 +212,13 @@ export const GivebackCausesPanel = (): ReactElement => { {selectedCauses.length > 0 ? ( -
+
{selectedCauses.map(({ cause, index }) => ( - ))}
@@ -203,7 +246,7 @@ export const GivebackCausesPanel = (): ReactElement => { bold className="uppercase tracking-wider" > - More causes to back + More causes to explore
{otherCauses.map(({ cause, index }) => ( diff --git a/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx index 8e770ae2570..004dd097a70 100644 --- a/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx +++ b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx @@ -9,25 +9,23 @@ import { } from '../../../components/typography/Typography'; import { GiftIcon, InfoIcon } from '../../../components/icons'; import { IconSize } from '../../../components/Icon'; -import { - ProfilePicture, - ProfileImageSize, -} from '../../../components/ProfilePicture'; -import { useAuthContext } from '../../../contexts/AuthContext'; +import ProgressCircle from '../../../components/ProgressCircle'; 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 recap above the action catalog. Stat-first: the money you've unlocked +// is the hero number, with a quiet progress ring toward your next reward. No +// level badge or avatar - the campaign is about the donation, not a game rank. export const GivebackContributionSummary = (): ReactElement => { - const { user } = useAuthContext(); - const { earnedPoints, nextReward, pointsToNext, 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. + const { + earnedPoints, + nextReward, + pointsToNext, + progressPercentage, + isPending, + } = useGivebackContribution(true); + // 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,32 +33,13 @@ export const GivebackContributionSummary = (): ReactElement => { ); if (isPending) { - return ( - -
-
- - ); + return
; } return ( - - {user && ( -
- - - Lvl {currentLevel} - -
- )} - - - + + + { - + + {formatDonationAmount(earnedPoints)} unlocked for your causes + {!isActionsPending && ( { {nextReward && ( - - - Next reward - - - + + + + + + + Next reward + + {nextReward.title} - - - {formatDonationAmount(pointsToNext)} to go - - + + {formatDonationAmount(pointsToNext)} to go + + +
)} ); diff --git a/packages/shared/src/features/giveback/components/GivebackFaqPanel.tsx b/packages/shared/src/features/giveback/components/GivebackFaqPanel.tsx index 22f98fe30c0..6d4c28ec52f 100644 --- a/packages/shared/src/features/giveback/components/GivebackFaqPanel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFaqPanel.tsx @@ -1,19 +1,7 @@ import type { ReactElement } from 'react'; import React from 'react'; -import { FlexCol } from '../../../components/utilities'; -import { GivebackBudgetStory } from './GivebackBudgetStory'; import { GivebackFaq } from './GivebackFaq'; -// The FAQ tab: the short, proud reason for the whole campaign up top (the -// headline rides beside the charm), then the answers to everything people ask. -const headline = { - title: 'Big tech buys ads.', - highlight: 'We fund developers.', -}; - -export const GivebackFaqPanel = (): ReactElement => ( - - - - -); +// 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/GivebackFundingSummary.tsx b/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx index 7c3a709980d..fe2d32274a3 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,16 +30,16 @@ const Meter = ({ percentage: number; empty?: boolean; }): ReactElement => ( - // The track owns the styling: a dark (primary background) fill with a hairline - // border so the meter reads as a crisp, contained gauge rather than a flat bar. + // The track owns the styling: a taller, dark (primary background) fill with a + // hairline border so the meter reads as a crisp, contained gauge.
@@ -43,16 +48,33 @@ const Meter = ({ // "ready to fill" rather than broken.
) : ( )} + {MILESTONES.map((milestone) => ( + + = milestone + ? 'bg-white' + : 'bg-border-subtlest-secondary', + )} + /> + + ))}
); @@ -75,7 +97,7 @@ export const GivebackFundingSummary = (): ReactElement => { return (
-
+
); diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index e29e4f11a06..77e90ff218f 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -370,32 +370,45 @@ export const GivebackFunnel = ({ {selectedCauses.length > 0 && ( - -
- {selectedCauses.map(({ cause, index }) => ( - - - - - {cause.title} - - {cause.description && ( - - {cause.description} + <> + +
+ {selectedCauses.map(({ cause, index }) => ( + + + + + {cause.title} - )} + {cause.description && ( + + {cause.description} + + )} + - - ))} -
-
+ ))} +
+
+ + + Thank you for choosing who to back. From now on, your + everyday actions turn into real support for them. 💜 + + + )} ); diff --git a/packages/shared/src/features/giveback/components/GivebackHero.tsx b/packages/shared/src/features/giveback/components/GivebackHero.tsx index ce310b839bd..a0c4e2b4b7b 100644 --- a/packages/shared/src/features/giveback/components/GivebackHero.tsx +++ b/packages/shared/src/features/giveback/components/GivebackHero.tsx @@ -2,18 +2,33 @@ 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 { InfoIcon } from '../../../components/icons'; import { Typography, TypographyColor, TypographyTag, TypographyType, } from '../../../components/typography/Typography'; +import { GivebackHeadline } from './GivebackHeadline'; import { GivebackFundingSummary } from './GivebackFundingSummary'; +import { GivebackMascot } from './GivebackMascot'; + +interface GivebackHeroProps { + // Re-opens the warm-up funnel; rendered top-right next to the brand. + onHowItWorks?: () => void; +} -// The page cover: brand, headline, and the live funding meter folded into one -// block. No video or CTA here — the warm-up funnel handles onboarding — so the -// cover stays compact and the action tabs sit higher on the page. -export const GivebackHero = (): ReactElement => ( +// 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 = ({ + onHowItWorks, +}: GivebackHeroProps): ReactElement => (
{/* Clip only the decorative glows, not the content. */}
( 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' }} /> -
- - + + ( - - Do what you already do. - - Fund what you actually care about. - - - - Every action you take on daily.dev unlocks real money for good causes. - We pay for all of it, you choose where it goes. No catch, no cost, - just developers giving back together. - - + {onHowItWorks && ( + + )} + + + + + + + + Every action you take on daily.dev unlocks real money for good + causes. We pay for all of it, you choose where it goes. No catch, + no cost, just developers giving back together. + + + +
+ +
+
-
- -
+ +
); diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx index 5fb5b2d479a..6ff296e3117 100644 --- a/packages/shared/src/features/giveback/components/GivebackPage.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx @@ -126,7 +126,7 @@ export const GivebackPage = (): ReactElement => {
- +
{showSponsors && ( @@ -137,11 +137,7 @@ export const GivebackPage = (): ReactElement => { {showTabs && (
- +
diff --git a/packages/shared/src/features/giveback/components/GivebackTabNav.tsx b/packages/shared/src/features/giveback/components/GivebackTabNav.tsx index c65ff2a1c20..a9662a1c80c 100644 --- a/packages/shared/src/features/giveback/components/GivebackTabNav.tsx +++ b/packages/shared/src/features/giveback/components/GivebackTabNav.tsx @@ -1,12 +1,6 @@ import type { ReactElement } from 'react'; import React from 'react'; import TabList, { TabListVariant } from '../../../components/tabs/TabList'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '../../../components/buttons/Button'; -import { InfoIcon } from '../../../components/icons'; export type GivebackTabId = 'actions' | 'impact' | 'causes' | 'faq'; @@ -25,8 +19,6 @@ export const givebackTabs: GivebackTab[] = [ interface GivebackTabNavProps { activeTab: GivebackTabId; onSelect: (tab: GivebackTabId) => void; - // Re-opens the warm-up funnel; rendered as a button on the right of the strip. - onHowItWorks?: () => void; } // Sticky section nav for the onboarded experience. Spans the full content width @@ -36,7 +28,6 @@ interface GivebackTabNavProps { export const GivebackTabNav = ({ activeTab, onSelect, - onHowItWorks, }: GivebackTabNavProps): ReactElement => { const activeLabel = givebackTabs.find((tab) => tab.id === activeTab)?.label ?? ''; @@ -47,7 +38,7 @@ 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" /> -
+
({ label: tab.label }))} active={activeLabel} @@ -59,18 +50,6 @@ export const GivebackTabNav = ({ } }} /> - {onHowItWorks && ( - - )}
); diff --git a/packages/storybook/stories/features/giveback/GivebackCampaignPieces.stories.tsx b/packages/storybook/stories/features/giveback/GivebackCampaignPieces.stories.tsx index a900b474354..626fe922a14 100644 --- a/packages/storybook/stories/features/giveback/GivebackCampaignPieces.stories.tsx +++ b/packages/storybook/stories/features/giveback/GivebackCampaignPieces.stories.tsx @@ -1,10 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { GivebackBudgetStory } from '@dailydotdev/shared/src/features/giveback/components/GivebackBudgetStory'; import { GivebackFaq } from '@dailydotdev/shared/src/features/giveback/components/GivebackFaq'; import { withGiveback } from './giveback.mocks'; -// The building blocks of the FAQ tab, each on its own so you can refine them in -// isolation: the emotional budget story (the "why") and the FAQ. +// 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' }, @@ -15,14 +14,6 @@ export default meta; type Story = StoryObj; -export const BudgetStory: Story = { - render: () => ( - - ), -}; - export const Faq: Story = { render: () => , }; diff --git a/packages/storybook/stories/features/giveback/GivebackHero.stories.tsx b/packages/storybook/stories/features/giveback/GivebackHero.stories.tsx index 7a6705c811d..1ea85c40538 100644 --- a/packages/storybook/stories/features/giveback/GivebackHero.stories.tsx +++ b/packages/storybook/stories/features/giveback/GivebackHero.stories.tsx @@ -2,17 +2,19 @@ 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, headline, subtitle, and the live funding meter folded -// into one block (no video / CTA — onboarding lives in the funnel). +// The page cover: brand + "How it works" across the top, the "Big tech buys ads. +// We fund developers." headline with the live funding meter on the left, and the +// charm on the right. const meta: Meta = { title: 'Features/Giveback/Page cover (hero)', component: GivebackHero, + args: { onHowItWorks: () => undefined }, parameters: { layout: 'padded', docs: { description: { component: - 'Compact cover so the tabs sit higher. The funding meter sits under the headline. Shown at a few funding levels.', + 'Headline + funding meter (with milestone markers) on the left, charm on the right. Shown at a few funding levels.', }, }, }, diff --git a/packages/storybook/stories/features/giveback/giveback.mocks.tsx b/packages/storybook/stories/features/giveback/giveback.mocks.tsx index 4991821b9fd..9e3297d38f2 100644 --- a/packages/storybook/stories/features/giveback/giveback.mocks.tsx +++ b/packages/storybook/stories/features/giveback/giveback.mocks.tsx @@ -102,7 +102,8 @@ export const mockCauses = (): ContributionCause[] => [ { id: 'c-oss', title: 'Open-source maintainers', - description: 'Fund the maintainers behind the tools we use every day.', + description: + 'Keeps the maintainers behind the libraries you ship every day paid, so the tools you rely on stay alive and secure.', url: null, category: 'Open source', logoUrl: null, @@ -110,7 +111,8 @@ export const mockCauses = (): ContributionCause[] => [ { id: 'c-scholarships', title: 'Dev scholarships', - description: 'Help students from underrepresented groups learn to code.', + description: + 'Puts students from underrepresented groups through the training that lands them their first job in tech.', url: null, category: 'Education', logoUrl: null, @@ -118,7 +120,8 @@ export const mockCauses = (): ContributionCause[] => [ { id: 'c-access', title: 'Access to tech', - description: 'Get hardware and connectivity to devs who lack it.', + description: + 'Gets laptops and internet to developers who otherwise could not get online to learn, build, and earn.', url: null, category: 'Accessibility', logoUrl: null, @@ -126,7 +129,8 @@ export const mockCauses = (): ContributionCause[] => [ { id: 'c-climate', title: 'Climate tech', - description: 'Back open tools fighting the climate crisis.', + description: + 'Funds open tools that measure, cut, and fight carbon emissions with transparent, auditable data.', url: null, category: 'Climate', logoUrl: null, @@ -134,7 +138,8 @@ export const mockCauses = (): ContributionCause[] => [ { id: 'c-mentorship', title: 'Mentorship programs', - description: 'Pair early-career devs with experienced mentors.', + description: + 'Pairs early-career devs with experienced mentors who help them grow and break into the industry faster.', url: null, category: 'Education', logoUrl: null, @@ -142,7 +147,8 @@ export const mockCauses = (): ContributionCause[] => [ { id: 'c-docs', title: 'Better docs', - description: 'Pay technical writers to improve open-source docs.', + description: + 'Pays technical writers to turn dense open-source docs into guides people can actually learn from.', url: null, category: 'Open source', logoUrl: null, From 158549850f89551da30933e3df2bac516ebc255a Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 25 Jun 2026 08:52:54 +0300 Subject: [PATCH 47/89] fix(giveback): solid sticky funnel bar, rounded-rect progress bars - Funnel: the Back/Next controls now sit in a solid glass sticky bar (top border + blur) so they stay visible and centered over busy steps like the cause grid, instead of floating transparently on top of the content. - Progress bars use rounded-rectangle corners, not pill/oval shapes: the funding meter (rounded-8 track, rounded-6 fill) with rounded-square milestone markers, and the roadmap's next-reward bar (rounded-6). Also de-pill the flow rail. Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackFundingSummary.tsx | 17 +++++++---------- .../giveback/components/GivebackFunnel.tsx | 13 ++++++++++--- .../components/GivebackPersonalRoadmap.tsx | 6 +++--- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx b/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx index fe2d32274a3..4396c93c1de 100644 --- a/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx @@ -34,12 +34,12 @@ const Meter = ({ // hairline border so the meter reads as a crisp, contained gauge.
@@ -48,26 +48,23 @@ const Meter = ({ // "ready to fill" rather than broken.
) : ( - + )} {MILESTONES.map((milestone) => ( = milestone ? 'bg-white' : 'bg-border-subtlest-secondary', @@ -97,7 +94,7 @@ export const GivebackFundingSummary = (): ReactElement => { return (
-
+
); diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 77e90ff218f..638dc99d609 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -189,7 +189,7 @@ const FlowSequence = (): ReactElement => ( {!isLast && ( )} @@ -492,8 +492,15 @@ export const GivebackFunnel = ({
-
- + {/* A solid, glass sticky bar so the controls never get lost over a busy + step (e.g. the cause grid). Full-bleed background with the buttons + centered in the page column. */} +
+
+ {!isFirst && ( ); -// Manage-your-causes tab. Unlike the recap on the old Campaign tab, this shows -// every cause as a pickable card: the ones you already back sit up top so you -// can see your line-up at a glance, and everything else is right below ready to -// add. Toggles update an in-memory working set; "Save" persists it (the bar only -// lights up when you've actually changed something). +// Manage-your-causes tab: the ones you back sit up top, everything else is right +// below ready to add. Toggles update an in-memory working set; "Save changes" +// persists it (the button only lights up when the set actually differs). export const GivebackCausesPanel = (): ReactElement => { const { logEvent } = useLogContext(); const { @@ -121,6 +142,9 @@ export const GivebackCausesPanel = (): ReactElement => { ({ cause }) => !selectedIds.has(cause.id) && matchesFilter(cause), ); + const onLearnMore = (causeId: string) => + logEvent({ event_name: LogEvent.ClickGivebackCause, target_id: causeId }); + const onSave = async () => { const saved = await save(); if (!saved) { @@ -138,12 +162,12 @@ export const GivebackCausesPanel = (): ReactElement => { if (isLoading) { return ( -
+
{Array.from({ length: 6 }).map((_, index) => (
))}
@@ -151,7 +175,7 @@ export const GivebackCausesPanel = (): ReactElement => { } return ( - + Your causes, your call @@ -186,7 +210,7 @@ export const GivebackCausesPanel = (): ReactElement => { )} - + { {selectedCauses.length > 0 ? (
{selectedCauses.map(({ cause, index }) => ( - ))}
@@ -238,7 +264,7 @@ export const GivebackCausesPanel = (): ReactElement => {
{otherCauses.length > 0 && ( - + { > More causes to explore -
+
{otherCauses.map(({ cause, index }) => ( - ))}
diff --git a/packages/shared/src/features/giveback/components/GivebackHero.tsx b/packages/shared/src/features/giveback/components/GivebackHero.tsx index a0c4e2b4b7b..0d3b0b25c61 100644 --- a/packages/shared/src/features/giveback/components/GivebackHero.tsx +++ b/packages/shared/src/features/giveback/components/GivebackHero.tsx @@ -98,7 +98,10 @@ export const GivebackHero = ({
- +
diff --git a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx index 6c7e37ac46e..ee5962852b4 100644 --- a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx @@ -328,6 +328,84 @@ const NodeRow = ({ ); }; + // 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 renderAction = (): ReactElement => { + if (canClaim) { + return ( +
+ {celebrate && ( + + + + {claimSparkles.map((sparkle) => ( + + ))} + + )} + +
+ ); + } + if (isNext) { + return ( + + ); + } + if (isReached) { + return ( + + + + Done + + + ); + } + return ( + + + + ); + }; + const requirementLabel = level.requiredApprovedAmount > 0 ? formatDonationAmount(level.requiredApprovedAmount) @@ -358,11 +436,11 @@ const NodeRow = ({
@@ -382,14 +460,6 @@ const NodeRow = ({ You're here )} - {isClaimed && !isCurrent && ( - - - - Claimed - - - )} - {canClaim ? ( -
- {celebrate && ( - - {/* A bright flash + expanding ring read as a real "pop", and - the confetti fans out in brand colors. */} - - - {claimSparkles.map((sparkle) => ( - - ))} - - )} - -
- ) : ( - !isReached && - !isNext && ( - - - - ) - )} + {/* One right-hand action slot, so claim / take-action / done / lock + line up in a single column down the whole ladder. */} +
+ {renderAction()} +
{isNext && ( - -
+ +
- - - {formatDonationAmount(amountToNext)} to go - - - - + + {formatDonationAmount(amountToNext)} to go + +
)}
@@ -772,7 +785,7 @@ export const GivebackPersonalRoadmap = ({
- + Date: Thu, 25 Jun 2026 09:27:51 +0300 Subject: [PATCH 49/89] feat(giveback): funnel polish, status-ring contribution, clearer impact actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Funnel: - "How it works" is centered as a unit; number tiles are now a brand-gradient fill with a dark numeral for contrast. - Footer spans the page-width sticky bar (max-w-4xl) instead of a narrow strip. - Impact finale cards lead with the cause's impact (description), name demoted to a small label, so the moment is about value, not the org's name. Funding meter: milestone markers are outline-stroke circles by default, filling in white as the goal passes them. Contribution summary: rethought around the user — avatar back, wrapped in a progress ring to the next level with a level badge (status you can feel); dropped the redundant "next reward" block (it lives on the sticky footer). Impact roadmap: the "ready to claim" note is a plain status line, not a button; "$X to your next reward" replaces the confusing "$X to "; the active card leads with the reward title as its hero. Causes save toast auto-dismisses (3s) instead of waiting to be closed. Co-Authored-By: Claude Opus 4.8 --- .../GivebackContributionSummary.tsx | 77 ++++++++----------- .../components/GivebackFundingSummary.tsx | 18 ++--- .../giveback/components/GivebackFunnel.tsx | 43 ++++++----- .../components/GivebackImpactPanel.spec.tsx | 2 +- .../components/GivebackPersonalRoadmap.tsx | 54 ++++++------- .../hooks/useGivebackCauseSelection.spec.tsx | 4 +- .../hooks/useGivebackCauseSelection.ts | 3 +- 7 files changed, 98 insertions(+), 103 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx index 004dd097a70..58ef859e97b 100644 --- a/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx +++ b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx @@ -7,24 +7,26 @@ 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, + ProfileImageSize, +} from '../../../components/ProfilePicture'; import ProgressCircle from '../../../components/ProgressCircle'; +import { useAuthContext } from '../../../contexts/AuthContext'; import { useGivebackContribution } from '../hooks/useGivebackContribution'; import { useContributionActions } from '../hooks/useContributionActions'; import { formatDonationAmount } from '../utils'; -// Personal recap above the action catalog. Stat-first: the money you've unlocked -// is the hero number, with a quiet progress ring toward your next reward. No -// level badge or avatar - the campaign is about the donation, not a game rank. +// Personal status banner above the action catalog. Your face anchors it (this is +// about you), wrapped in a progress ring toward your next level with a level +// badge — status you can feel — and the money you've unlocked is the hero stat. +// The "next reward" detail lives on the sticky footer, so it's not repeated here. export const GivebackContributionSummary = (): ReactElement => { - const { - earnedPoints, - nextReward, - pointsToNext, - progressPercentage, - isPending, - } = useGivebackContribution(true); + const { user } = useAuthContext(); + const { earnedPoints, currentLevel, progressPercentage, isPending } = + useGivebackContribution(true); // Shares the catalog's query key, so this adds no extra request. const { actions, isPending: isActionsPending } = useContributionActions(true); const actionsTaken = actions.reduce( @@ -37,8 +39,26 @@ export const GivebackContributionSummary = (): ReactElement => { } return ( - - + + {user && ( +
+ {/* Progress ring toward your next level wraps the avatar, so status + and momentum read in one glance. */} +
+ +
+ + + Lvl {currentLevel} + +
+ )} + + { )} - - {nextReward && ( - - - - - - - Next reward - - - - {nextReward.title} - - - {formatDonationAmount(pointsToNext)} to go - - - - )}
); }; diff --git a/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx b/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx index 4396c93c1de..7a5910ee4cb 100644 --- a/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx @@ -59,18 +59,14 @@ const Meter = ({ = milestone + ? 'border-white bg-white' + : 'border-border-subtlest-secondary bg-background-default', + )} style={{ left: `${milestone}%` }} - > - = milestone - ? 'bg-white' - : 'bg-border-subtlest-secondary', - )} - /> - + /> ))}
); diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 638dc99d609..06c57e4beba 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -175,14 +175,14 @@ const FLOW_STEPS: ReadonlyArray<{ title: string; sub: string }> = [ ]; const FlowSequence = (): ReactElement => ( - + {FLOW_STEPS.map((step, index) => { const isLast = index === FLOW_STEPS.length - 1; return ( - - + + {index + 1} @@ -287,7 +287,7 @@ export const GivebackFunnel = ({ switch (stepKey) { case 'how': return ( - + - + @@ -378,21 +378,26 @@ export const GivebackFunnel = ({ key={cause.id} className="hover:border-accent-cabbage-default/40 h-full items-start gap-3 rounded-16 border border-border-subtlest-tertiary bg-surface-float p-4 text-left transition-colors" > - - - + + + {cause.title} - {cause.description && ( - - {cause.description} - - )} - + + {cause.description && ( + + {cause.description} + + )} ))}
@@ -500,7 +505,7 @@ export const GivebackFunnel = ({ aria-hidden className="via-accent-cabbage-default/40 pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent to-transparent" /> - + {!isFirst && ( + + + + + {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} {claimableCount === 1 ? 'reward' : 'rewards'}{' '} - ready to claim + ready to claim below )} - - {nextLevel - ? `${formatDonationAmount(amountToNext)} to ${ - nextLevel.reward.title - }` - : 'Every reward unlocked'} - - + diff --git a/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.spec.tsx b/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.spec.tsx index cff63557362..ddecb69569a 100644 --- a/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.spec.tsx +++ b/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.spec.tsx @@ -105,7 +105,9 @@ 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); }); diff --git a/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts b/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts index 3c9e779548c..9da18df638a 100644 --- a/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts +++ b/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts @@ -60,7 +60,8 @@ export const useGivebackCauseSelection = ( const save = useCallback(async () => { try { await saveCausePreferences([...selectedIds]); - displayToast('Your causes are saved'); + // Auto-dismiss; a confirmation shouldn't linger waiting to be closed. + displayToast('Your causes are saved', { timer: 3000 }); return true; } catch { displayToast(labels.error.generic); From 0df2e446f91a1c5bb1c00c4a894f94e15030693a Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 25 Jun 2026 11:52:15 +0300 Subject: [PATCH 50/89] feat(giveback): flat contribution status, funnel footer as funding-bar card, polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Contribution summary: flat (no card box). Avatar in a progress ring with the level as a quiet label beneath — dropped the loud gradient "LVL" pill. - Funnel footer: contained rounded-top bordered glass card mirroring the main page's funding bar, instead of a full-bleed strip. - Impact hero: removed the "Your impact" eyebrow above the headline. - Active reward card: soft brand-tinted border, no heavy glow. Co-Authored-By: Claude Opus 4.8 --- .../GivebackContributionSummary.tsx | 37 +++++++----- .../giveback/components/GivebackFunnel.tsx | 60 ++++++++++--------- .../components/GivebackImpactPanel.spec.tsx | 2 +- .../components/GivebackPersonalRoadmap.tsx | 14 +---- 4 files changed, 57 insertions(+), 56 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx index 58ef859e97b..d0da36ab176 100644 --- a/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx +++ b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx @@ -39,23 +39,30 @@ export const GivebackContributionSummary = (): ReactElement => { } return ( - + {user && ( -
- {/* Progress ring toward your next level wraps the avatar, so status - and momentum read in one glance. */} -
- + // Flat, no card: the avatar in a progress ring (momentum to next level) + // with the level as a quiet label beneath - status without a loud pill. + +
+
+ +
+
- - - Lvl {currentLevel} - -
+ + Level {currentLevel} + + )} diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 06c57e4beba..e3d319e103b 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -497,40 +497,42 @@ export const GivebackFunnel = ({
- {/* A solid, glass sticky bar so the controls never get lost over a busy - step (e.g. the cause grid). Full-bleed background with the buttons - centered in the page column. */} -
-
- - {!isFirst && ( + {/* A contained, glass sticky bar mirroring the main page's funding bar: + a rounded-top bordered card so the controls never get lost over a busy + step (e.g. the cause grid). */} +
+
+
+ + {!isFirst && ( + + )} - )} - - + +
{!videoClosed && ( diff --git a/packages/shared/src/features/giveback/components/GivebackImpactPanel.spec.tsx b/packages/shared/src/features/giveback/components/GivebackImpactPanel.spec.tsx index 63afe87c606..9c1d8519b2f 100644 --- a/packages/shared/src/features/giveback/components/GivebackImpactPanel.spec.tsx +++ b/packages/shared/src/features/giveback/components/GivebackImpactPanel.spec.tsx @@ -114,7 +114,7 @@ beforeEach(() => { it('renders the reward-ladder journey with the current level', () => { render(); - expect(screen.getByText('Your impact')).toBeInTheDocument(); + expect(screen.getByText(/for good causes/)).toBeInTheDocument(); expect(screen.getByText('Sticker pack')).toBeInTheDocument(); expect(screen.getByText('One month of Plus')).toBeInTheDocument(); expect(screen.getByText('Hoodie')).toBeInTheDocument(); diff --git a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx index 18e16810d69..b632830729c 100644 --- a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx @@ -438,9 +438,10 @@ const NodeRow = ({ className={classNames( 'gap-3', // The current goal is a tight, clearly-bounded card so the eye lands - // straight on its action; every other row stays a plain line. + // straight on its action; every other row stays a plain line. Soft + // brand-tinted border, no heavy glow. isNext && - 'ring-accent-cabbage-default/60 rounded-16 bg-surface-float p-4 shadow-2-cabbage ring-1', + 'border-accent-cabbage-default/30 rounded-16 border bg-surface-float p-4', )} > @@ -704,15 +705,6 @@ export const GivebackPersonalRoadmap = ({ - - Your impact - {hasImpact ? ( Date: Thu, 25 Jun 2026 12:02:51 +0300 Subject: [PATCH 51/89] fix(giveback): compact funnel footer buttons Back pinned left, the primary CTA pinned right with a capped width (min-w-40, max-w-18rem) so neither button sprawls across the wide sticky bar. Co-Authored-By: Claude Opus 4.8 --- .../src/features/giveback/components/GivebackFunnel.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index e3d319e103b..98b50f3eb90 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -506,13 +506,15 @@ export const GivebackFunnel = ({ aria-hidden className="via-accent-cabbage-default/40 pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent to-transparent" /> + {/* Two compact buttons: Back pinned left, the primary CTA pinned right + with a capped width so neither sprawls across the wide bar. */} {!isFirst && ( ); @@ -162,7 +166,7 @@ export const GivebackCausesPanel = (): ReactElement => { if (isLoading) { return ( -
+
{Array.from({ length: 6 }).map((_, index) => (
{ } return ( - + Your causes, your call @@ -236,7 +240,7 @@ export const GivebackCausesPanel = (): ReactElement => { {selectedCauses.length > 0 ? ( -
+
{selectedCauses.map(({ cause, index }) => ( { > More causes to explore -
+
{otherCauses.map(({ cause, index }) => ( = [ + { + icon: , + title: 'You chose well', + sub: 'Real, vetted nonprofits, picked by you.', + }, + { + icon: , + title: 'Costs you nothing', + sub: 'daily.dev funds every single dollar.', + }, + { + icon: , + title: 'Real, lasting impact', + sub: 'Everyday actions become real support.', + }, +]; type CauseSelection = ReturnType; @@ -369,51 +393,41 @@ export const GivebackFunnel = ({ - {selectedCauses.length > 0 && ( - <> - -
- {selectedCauses.map(({ cause, index }) => ( - - - - - {cause.title} - - - {cause.description && ( - - {cause.description} - - )} - - ))} -
-
- - +
+ {IMPACT_VALUES.map((value) => ( + - Thank you for choosing who to back. From now on, your - everyday actions turn into real support for them. 💜 - - - + + {value.icon} + + + {value.title} + + + {value.sub} + + + ))} +
+
+ {selectedCauses.length > 0 && ( + + + Thank you for choosing who to back. From now on, your everyday + actions turn into real support for them. 💜 + + )} ); From 980c98bea17e6384ca8eb77782c5ef5f5b6cd2af Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 25 Jun 2026 13:02:28 +0300 Subject: [PATCH 53/89] feat(giveback): tag-style filters, section-title sizing, take-action header - Filter chips read as real tags now: every chip has a border + surface, the active one fills with the brand color (no more loose floating text). Applies to the funnel picker, the Causes tab, and the action catalog. - Impact hero title sized to match the other section titles (Title2). - Take action: added a "Your contribution" section title (matching the other tabs), removed the now-redundant eyebrow from the stat component, and reorganised it to lead with the dollar amount + a cleaner actions/info line. Co-Authored-By: Claude Opus 4.8 --- .../GivebackContributionSummary.tsx | 74 ++++++++----------- .../components/GivebackFilterChip.tsx | 11 ++- .../giveback/components/GivebackPage.tsx | 24 ++++++ .../components/GivebackPersonalRoadmap.tsx | 4 +- 4 files changed, 65 insertions(+), 48 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx index d0da36ab176..925f4c7ccea 100644 --- a/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx +++ b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx @@ -66,41 +66,7 @@ export const GivebackContributionSummary = (): ReactElement => { )} - - - Your contribution - - - - - - Counts the moment you act, because we trust you. If a submission - is rejected, we'll subtract it. - - - - - - + { {!isActionsPending && ( - - {actionsTaken} {actionsTaken === 1 ? 'action' : 'actions'} taken - + + + {actionsTaken} {actionsTaken === 1 ? 'action' : 'actions'} taken + + + + + + Counts the moment you act, because we trust you. If a + submission is rejected, we'll subtract it. + + + + )} diff --git a/packages/shared/src/features/giveback/components/GivebackFilterChip.tsx b/packages/shared/src/features/giveback/components/GivebackFilterChip.tsx index 6d57ec35dc7..833d790496e 100644 --- a/packages/shared/src/features/giveback/components/GivebackFilterChip.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFilterChip.tsx @@ -8,7 +8,10 @@ interface GivebackFilterChipProps { onClick: () => void; } -// Pill used for the cause-picker category filters. +// Tag-style pill for the category filters. Every chip carries a visible border +// and surface so the row reads clearly as a set of filters/tags (not loose +// text); the active one fills with the brand color so the current filter is +// unmistakable. export const GivebackFilterChip = ({ isSelected, label, @@ -18,10 +21,10 @@ export const GivebackFilterChip = ({ type="button" aria-pressed={isSelected} className={classNames( - 'h-8 shrink-0 rounded-10 px-3 font-medium transition-colors typo-footnote', + 'h-8 shrink-0 rounded-10 border px-3.5 font-bold transition-colors typo-footnote', isSelected - ? 'bg-accent-cabbage-default text-white' - : 'bg-transparent text-text-tertiary hover:bg-surface-float hover:text-text-primary', + ? 'border-accent-cabbage-default bg-accent-cabbage-default text-white' + : 'border-border-subtlest-tertiary bg-surface-float text-text-secondary hover:border-accent-cabbage-default hover:text-text-primary', )} onClick={onClick} > diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx index 6ff296e3117..3fed7824990 100644 --- a/packages/shared/src/features/giveback/components/GivebackPage.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx @@ -1,6 +1,12 @@ import type { ReactElement } 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 { useConditionalFeature } from '../../../hooks/useConditionalFeature'; import { featureGivebackSponsors } from '../../../lib/featureManagement'; @@ -147,6 +153,24 @@ export const GivebackPage = (): ReactElement => { > {activeTab === 'actions' && ( + + + Your contribution + + + Every action you take unlocks real money for the causes + you back. Pick one below and your contribution grows. + + diff --git a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx index b632830729c..ffab123d5f7 100644 --- a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx @@ -708,7 +708,7 @@ export const GivebackPersonalRoadmap = ({ {hasImpact ? ( @@ -722,7 +722,7 @@ export const GivebackPersonalRoadmap = ({ ) : ( From 76a9d56b0e610ec8de0e7fcb53d4d8af77a3fea4 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 25 Jun 2026 13:10:41 +0300 Subject: [PATCH 54/89] feat(giveback): compact floating funnel bar, detailed explore cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Funnel footer: a small floating glass pill that just wraps the two buttons (Back + Next), centered and elevated with a shadow, instead of a full-width bar. - Causes tab "More causes to explore": richer detail cards (description, learn more) like the funnel, so there's enough context to decide — "Your causes" stays a compact list. Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackCausesPanel.tsx | 8 ++- .../giveback/components/GivebackFunnel.tsx | 57 ++++++++----------- 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx b/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx index f66df186156..85186c31b9f 100644 --- a/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx @@ -26,6 +26,7 @@ import { useGivebackCauseSelection } from '../hooks/useGivebackCauseSelection'; import { useContributionCausePicker } from '../hooks/useContributionCausePicker'; import type { ContributionCause } from '../types'; import { GivebackFilterChip } from './GivebackFilterChip'; +import { GivebackCauseCard } from './GivebackCauseCard'; import { CauseEmblem } from './CauseEmblem'; const ALL_FILTER = 'all'; @@ -278,15 +279,16 @@ export const GivebackCausesPanel = (): ReactElement => { > More causes to explore -
+ {/* Richer detail cards here (like the funnel) so people have enough + context to decide what to add — your picks above stay compact. */} +
{otherCauses.map(({ cause, index }) => ( - ))}
diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 26e8cf61811..5d0786dbbe7 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -511,44 +511,35 @@ export const GivebackFunnel = ({
- {/* A contained, glass sticky bar mirroring the main page's funding bar: - a rounded-top bordered card so the controls never get lost over a busy - step (e.g. the cause grid). */} -
-
-
- {/* Two compact buttons: Back pinned left, the primary CTA pinned right - with a capped width so neither sprawls across the wide bar. */} - - {!isFirst && ( - - )} + {/* A small floating control bar: just the two buttons in a glass pill that + hovers above the page (shadow for depth), centered, so it never gets + lost over a busy step but stays out of the way. */} +
+ + {!isFirst && ( - -
+ )} + +
{!videoClosed && ( From 30d43804ae3898d06e1199dac735f6e430bd7b77 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 25 Jun 2026 13:27:56 +0300 Subject: [PATCH 55/89] fix(giveback): fixed-width funnel footer, buttons flex to fill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floating footer bar is a fixed width; the buttons flex inside it — a lone CTA spans the full bar, Back + Next split it evenly — so the bar stays the same width on every step. Co-Authored-By: Claude Opus 4.8 --- .../features/giveback/components/GivebackFunnel.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 5d0786dbbe7..3fa600d666c 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -511,16 +511,18 @@ export const GivebackFunnel = ({
- {/* A small floating control bar: just the two buttons in a glass pill that - hovers above the page (shadow for depth), centered, so it never gets - lost over a busy step but stays out of the way. */} + {/* 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. */}
- + {!isFirst && (
From d9dbbe948701ac7847bdffd3c4faac98a9247903 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 25 Jun 2026 13:50:15 +0300 Subject: [PATCH 57/89] feat(giveback): nested-radius footer, yellow claim, modal scroll, story coverage - Funnel footer corner radius follows the nested formula: inner button radius (rounded-14) + bar padding (8px) = rounded-22, so the curves stay concentric. - Claiming is consistently yellow: the Claim button uses the cheese color and the claimable marker switches to a cheese accent (matching "ready to claim"). - Submission modal: body scrolls while the action bar stays pinned at the bottom, so Submit is always reachable on tall forms. - Cause URLs added to mocks so the "learn more" / visit-site links render in the cards (More causes) and rows (Your causes). - Storybook: new stories for the screenshot field (empty/uploading/preview), the cause card (selected/unselected/interactive/bare), the filter chip states, and a funnel run with causes pre-picked (shows the personalised finale). Co-Authored-By: Claude Opus 4.8 --- .../GivebackActionSubmissionModal.tsx | 56 ++++++++++--------- .../giveback/components/GivebackFunnel.tsx | 5 +- .../components/GivebackPersonalRoadmap.tsx | 10 ++-- .../giveback/GivebackCauseCard.stories.tsx | 54 ++++++++++++++++++ .../giveback/GivebackFilterChip.stories.tsx | 54 ++++++++++++++++++ .../giveback/GivebackFunnel.stories.tsx | 17 +++++- .../GivebackScreenshotField.stories.tsx | 43 ++++++++++++++ .../features/giveback/giveback.mocks.tsx | 12 ++-- 8 files changed, 212 insertions(+), 39 deletions(-) create mode 100644 packages/storybook/stories/features/giveback/GivebackCauseCard.stories.tsx create mode 100644 packages/storybook/stories/features/giveback/GivebackFilterChip.stories.tsx create mode 100644 packages/storybook/stories/features/giveback/GivebackScreenshotField.stories.tsx diff --git a/packages/shared/src/features/giveback/components/GivebackActionSubmissionModal.tsx b/packages/shared/src/features/giveback/components/GivebackActionSubmissionModal.tsx index 35875644d81..85437953e19 100644 --- a/packages/shared/src/features/giveback/components/GivebackActionSubmissionModal.tsx +++ b/packages/shared/src/features/giveback/components/GivebackActionSubmissionModal.tsx @@ -333,7 +333,7 @@ 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" /> - + {!isSubmitted && ( )} + - - {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/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 091eda1e519..b9caa66bc5a 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -520,8 +520,11 @@ export const GivebackFunnel = ({ 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. */}
-); + {cause.url && ( + event.stopPropagation()} + className="group/learn relative z-1 inline-flex w-fit items-center gap-1 font-bold text-text-link underline-offset-2 typo-footnote hover:underline focus-visible:underline" + > + Learn more + + + )} +
+ ); +}; diff --git a/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx b/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx index 85186c31b9f..c4e90ad2f61 100644 --- a/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx @@ -146,6 +146,11 @@ export const GivebackCausesPanel = (): ReactElement => { 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 }); @@ -197,24 +202,6 @@ export const GivebackCausesPanel = (): ReactElement => { - {categories.length > 0 && ( - - setActiveFilter(ALL_FILTER)} - /> - {categories.map((category) => ( - setActiveFilter(category)} - /> - ))} - - )} - { )} - {otherCauses.length > 0 && ( + {/* Filters sit just above the discovery grid they control (and below your + own causes), so the connection is obvious. */} + {hasOtherCauses && ( { > More causes to explore - {/* Richer detail cards here (like the funnel) so people have enough - context to decide what to add — your picks above stay compact. */} -
- {otherCauses.map(({ cause, index }) => ( - 0 && ( + + setActiveFilter(ALL_FILTER)} /> - ))} -
+ {categories.map((category) => ( + setActiveFilter(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/GivebackContributionSummary.tsx b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx index 925f4c7ccea..b3f2949529a 100644 --- a/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx +++ b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx @@ -13,19 +13,18 @@ import { ProfilePicture, ProfileImageSize, } from '../../../components/ProfilePicture'; -import ProgressCircle from '../../../components/ProgressCircle'; import { useAuthContext } from '../../../contexts/AuthContext'; import { useGivebackContribution } from '../hooks/useGivebackContribution'; import { useContributionActions } from '../hooks/useContributionActions'; import { formatDonationAmount } from '../utils'; -// Personal status banner above the action catalog. Your face anchors it (this is -// about you), wrapped in a progress ring toward your next level with a level -// badge — status you can feel — and the money you've unlocked is the hero stat. -// The "next reward" detail lives on the sticky footer, so it's not repeated here. +// 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, currentLevel, progressPercentage, isPending } = + const { earnedPoints, currentLevel, isPending } = useGivebackContribution(true); // Shares the catalog's query key, so this adds no extra request. const { actions, isPending: isActionsPending } = useContributionActions(true); @@ -39,30 +38,19 @@ export const GivebackContributionSummary = (): ReactElement => { } return ( - + {user && ( - // Flat, no card: the avatar in a progress ring (momentum to next level) - // with the level as a quiet label beneath - status without a loud pill. - -
-
- -
- -
- - Level {currentLevel} - -
+
+ + + Lvl {currentLevel} + +
)} 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 a34eb952835..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.tsx b/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx index 7a5910ee4cb..cb57ab257b8 100644 --- a/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx @@ -34,7 +34,7 @@ const Meter = ({ // hairline border so the meter reads as a crisp, contained gauge.
- + @@ -408,12 +416,12 @@ export const GivebackFunnel = ({ {value.icon} - + {value.title} {value.sub} @@ -493,7 +501,15 @@ export const GivebackFunnel = ({ )} -
+
{/* Lightweight carousel dots sit above the step content as a quick "where am I" cue, just over each step's title. */} @@ -524,7 +540,7 @@ export const GivebackFunnel = ({ (Large = rounded-14) + the bar's padding (p-2 = 8px) => rounded-22, so the inner and outer curves stay concentric. */}
- + {!isFirst && (
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 => (
{
- {showTabs && } - {showFunnel && ( = { - title: 'Features/Giveback/Funding bar (sticky)', - component: GivebackFundingBar, - args: { onTakeAction: () => undefined }, - parameters: { - layout: 'fullscreen', - docs: { - description: { - component: - 'Pinned to the bottom of the viewport. Shows progress toward the next reward, or a "top of the ladder" state when everything is unlocked.', - }, - }, - }, -}; - -export default meta; - -type Story = StoryObj; - -export const TowardNextReward: Story = { - decorators: [withGiveback({ status: mockStatus({ userPoints: 320 }) })], -}; - -export const AllRewardsUnlocked: Story = { - decorators: [withGiveback({ status: mockStatus({ userPoints: 2500 }) })], -}; - -export const JustStarted: Story = { - decorators: [withGiveback({ status: mockStatus({ userPoints: 0 }) })], -}; From d4b55411da1308a66a3701d1b3b5b9bf6682fc13 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 25 Jun 2026 14:50:18 +0300 Subject: [PATCH 61/89] =?UTF-8?q?feat(giveback):=20responsive=20pass=20?= =?UTF-8?q?=E2=80=94=20scrollable=20tabs,=20mobile-safe=20funnel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tab nav scrolls horizontally on narrow screens (with auto-scroll to the active tab) so all four tabs stay reachable instead of overflowing. - Funnel: "how it works" list is a centered, width-capped block whose rows wrap (min-w-0) instead of a w-fit block that could overflow; tighter horizontal padding on mobile. Co-Authored-By: Claude Opus 4.8 --- .../src/features/giveback/components/GivebackFunnel.tsx | 8 +++++--- .../src/features/giveback/components/GivebackTabNav.tsx | 5 ++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 4fc6657e996..9c22446fe5e 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -206,7 +206,7 @@ const FLOW_STEPS: ReadonlyArray<{ title: string; sub: string }> = [ ]; const FlowSequence = (): ReactElement => ( - + {FLOW_STEPS.map((step, index) => { const isLast = index === FLOW_STEPS.length - 1; return ( @@ -224,7 +224,9 @@ const FlowSequence = (): ReactElement => ( /> )} - + -
+ {/* 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) { From e8da2683795300d3dce591f6b8a6da44da06d15d Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 25 Jun 2026 15:01:22 +0300 Subject: [PATCH 62/89] fix(giveback): tighten the gap above the tabs bar Co-Authored-By: Claude Opus 4.8 --- .../shared/src/features/giveback/components/GivebackPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx index 73f109c5cc3..d47eb86067e 100644 --- a/packages/shared/src/features/giveback/components/GivebackPage.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx @@ -129,7 +129,7 @@ export const GivebackPage = (): ReactElement => {
- +
From c716c6f9d4e8a52c25a8fa1168ab4c17d518d469 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 25 Jun 2026 17:52:33 +0300 Subject: [PATCH 63/89] fix(giveback): correct first-segment progress on the reward ladder When no tier is reached yet, the segment baseline used the next tier's own threshold (Math.max(0, reachedCount-1) -> levels[0]), collapsing the denominator to 0 so the 'to go' bar stuck at 0%. Start the first segment at 0 instead. Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackPersonalRoadmap.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx index b6cbfa5e61e..7ee9a38c8ca 100644 --- a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx @@ -622,9 +622,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; From eaeb31623b1f84b80d576e55d225d70e33cc6143 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 25 Jun 2026 19:35:47 +0300 Subject: [PATCH 64/89] fix(giveback): sharpen and correct the campaign messaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The program funds the causes developers pick, not "developers" — align the copy so it's true, specific, and unconfusing: - Hero: "Big tech buys ads. We fund the causes you pick." + a precise subtitle (our marketing budget → real donations to the nonprofits you choose; you spend minutes, we cover the dollars). - Funnel intro: budget goes into good causes (not "back developers"); you pick which causes get the donations. - Funding meter: "unlocked of goal" (matches how the meter fills) and "contributors" instead of "pledged"/"backers". Spec updated to match. Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackFundingSummary.spec.tsx | 14 +++++++------- .../giveback/components/GivebackFundingSummary.tsx | 4 ++-- .../giveback/components/GivebackFunnel.tsx | 6 +++--- .../features/giveback/components/GivebackHero.tsx | 8 ++++---- .../features/giveback/GivebackFaqPanel.stories.tsx | 4 ++-- .../features/giveback/GivebackHero.stories.tsx | 2 +- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackFundingSummary.spec.tsx b/packages/shared/src/features/giveback/components/GivebackFundingSummary.spec.tsx index 6f30d8a8ca7..3f03a184eb7 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, @@ -61,9 +61,9 @@ it('shows a goal-forward empty state when nothing is pledged yet', () => { screen.getByText('goal to unlock for good causes'), ).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 cb57ab257b8..57753b8fec0 100644 --- a/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx @@ -154,7 +154,7 @@ export const GivebackFundingSummary = (): ReactElement => { color={TypographyColor.Tertiary} className="pb-1" > - pledged of {formatDonationAmount(goal)} goal + unlocked of {formatDonationAmount(goal)} goal @@ -174,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.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 9c22446fe5e..e684c3937b8 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -473,9 +473,9 @@ export const GivebackFunnel = ({ color={TypographyColor.Secondary} className="max-w-xl [text-wrap:pretty]" > - daily.dev would rather back developers than ad networks. So we - take our marketing budget and donate it, and you decide where it - goes. + daily.dev would rather put its marketing budget into good causes + than ad networks. You take small actions, we turn them into + donations, and you pick which causes get them.
diff --git a/packages/shared/src/features/giveback/components/GivebackHero.tsx b/packages/shared/src/features/giveback/components/GivebackHero.tsx index 0d3b0b25c61..b581f8c74c0 100644 --- a/packages/shared/src/features/giveback/components/GivebackHero.tsx +++ b/packages/shared/src/features/giveback/components/GivebackHero.tsx @@ -79,7 +79,7 @@ export const GivebackHero = ({ - Every action you take on daily.dev unlocks real money for good - causes. We pay for all of it, you choose where it goes. No catch, - no cost, just developers giving back together. + Every action you take on daily.dev turns our marketing budget into + real donations to the nonprofits you choose. You spend a few + minutes, we cover every dollar. diff --git a/packages/storybook/stories/features/giveback/GivebackFaqPanel.stories.tsx b/packages/storybook/stories/features/giveback/GivebackFaqPanel.stories.tsx index 640b15b5d9a..562094d0f07 100644 --- a/packages/storybook/stories/features/giveback/GivebackFaqPanel.stories.tsx +++ b/packages/storybook/stories/features/giveback/GivebackFaqPanel.stories.tsx @@ -2,8 +2,8 @@ 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 proud "Big tech buys ads. We fund developers." reason up -// top beside the charm, then the full FAQ. +// 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, diff --git a/packages/storybook/stories/features/giveback/GivebackHero.stories.tsx b/packages/storybook/stories/features/giveback/GivebackHero.stories.tsx index 1ea85c40538..73b68051bf1 100644 --- a/packages/storybook/stories/features/giveback/GivebackHero.stories.tsx +++ b/packages/storybook/stories/features/giveback/GivebackHero.stories.tsx @@ -3,7 +3,7 @@ import { GivebackHero } from '@dailydotdev/shared/src/features/giveback/componen import { mockStatus, withGiveback } from './giveback.mocks'; // The page cover: brand + "How it works" across the top, the "Big tech buys ads. -// We fund developers." headline with the live funding meter on the left, and the +// We fund the causes you pick." headline with the funding meter on the left, and // charm on the right. const meta: Meta = { title: 'Features/Giveback/Page cover (hero)', From fc7da787ead50e6fbf6b55d0ff683dd051f57de0 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 25 Jun 2026 20:45:09 +0300 Subject: [PATCH 65/89] feat(giveback): auto-save causes, scroll/contrast/mobile polish - Causes tab: adding/removing a cause auto-saves (no "Save changes" button); "Your causes" always shows every pick regardless of the active filter, which now only narrows "more causes to explore". - Funnel: prevent scroll-chaining (overscroll-contain) so the page behind never shows through; smaller finale charm. - Funding meter: keep the filled background, with the subtle (tertiary) border used by the locked level tiles. - Submission modal: smaller footer + tighter padding. - Hero: ~15% smaller headline (Title1) and smaller charm/brand on mobile; tighter gap above the tabs. Co-Authored-By: Claude Opus 4.8 --- .../GivebackActionSubmissionModal.tsx | 4 +- .../components/GivebackCausesPanel.tsx | 79 +++++-------------- .../components/GivebackFundingSummary.tsx | 2 +- .../components/GivebackFunnel.spec.tsx | 1 + .../giveback/components/GivebackFunnel.tsx | 4 +- .../giveback/components/GivebackHeadline.tsx | 2 +- .../giveback/components/GivebackHero.tsx | 12 ++- .../giveback/components/GivebackPage.tsx | 2 +- .../hooks/useGivebackCauseSelection.ts | 22 ++++++ .../giveback/GivebackFunnel.stories.tsx | 22 +++--- 10 files changed, 71 insertions(+), 79 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackActionSubmissionModal.tsx b/packages/shared/src/features/giveback/components/GivebackActionSubmissionModal.tsx index 85437953e19..cdbeb990aac 100644 --- a/packages/shared/src/features/giveback/components/GivebackActionSubmissionModal.tsx +++ b/packages/shared/src/features/giveback/components/GivebackActionSubmissionModal.tsx @@ -333,7 +333,7 @@ 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" /> - + {!isSubmitted && ( + {isLove ? ( - )} - + + Your causes · {selectedCount} + {selectedCauses.length > 0 ? (
@@ -235,7 +198,7 @@ export const GivebackCausesPanel = (): ReactElement => { cause={cause} index={index} selected - onToggle={toggleCause} + onToggle={onToggle} onLearnMore={onLearnMore} /> ))} @@ -295,7 +258,7 @@ export const GivebackCausesPanel = (): ReactElement => { cause={cause} index={index} selected={false} - onToggle={toggleCause} + onToggle={onToggle} buttonToggle /> ))} diff --git a/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx b/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx index 57753b8fec0..55760cfc091 100644 --- a/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx @@ -34,7 +34,7 @@ const Meter = ({ // hairline border so the meter reads as a crisp, contained gauge.
= {}): Selection => ({ isLoading: false, selectedIds: new Set(), toggleCause: jest.fn(), + toggleAndSave: jest.fn(), selectedCount: 0, hasSavedCauses: false, save: jest.fn().mockResolvedValue(true), diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index e684c3937b8..3c604f987f8 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -375,7 +375,7 @@ export const GivebackFunnel = ({ diff --git a/packages/shared/src/features/giveback/components/GivebackHeadline.tsx b/packages/shared/src/features/giveback/components/GivebackHeadline.tsx index 831039218dd..7921094aeac 100644 --- a/packages/shared/src/features/giveback/components/GivebackHeadline.tsx +++ b/packages/shared/src/features/giveback/components/GivebackHeadline.tsx @@ -22,7 +22,7 @@ export const GivebackHeadline = ({ }: GivebackHeadlineProps): ReactElement => ( diff --git a/packages/shared/src/features/giveback/components/GivebackHero.tsx b/packages/shared/src/features/giveback/components/GivebackHero.tsx index b581f8c74c0..e8fd972bf4f 100644 --- a/packages/shared/src/features/giveback/components/GivebackHero.tsx +++ b/packages/shared/src/features/giveback/components/GivebackHero.tsx @@ -44,17 +44,21 @@ export const GivebackHero = ({ - + + - Giveback @@ -100,7 +104,7 @@ export const GivebackHero = ({ diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx index d47eb86067e..f120bb82a45 100644 --- a/packages/shared/src/features/giveback/components/GivebackPage.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx @@ -129,7 +129,7 @@ export const GivebackPage = (): ReactElement => {
- +
diff --git a/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts b/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts index dcc8afa9fe2..9c0655e4170 100644 --- a/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts +++ b/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts @@ -14,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; @@ -62,6 +65,24 @@ 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. + const toggleAndSave = useCallback( + (id: string) => { + const next = new Set(selectedIds); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + setSelectedIds(next); + saveCausePreferences([...next]).catch(() => + displayToast(labels.error.generic), + ); + }, + [selectedIds, saveCausePreferences, displayToast], + ); + const save = useCallback(async () => { try { await saveCausePreferences([...selectedIds]); @@ -88,6 +109,7 @@ export const useGivebackCauseSelection = ( isLoading: isPending, selectedIds, toggleCause, + toggleAndSave, selectedCount: selectedIds.size, hasSavedCauses: selectedCauseIds.length > 0, save, diff --git a/packages/storybook/stories/features/giveback/GivebackFunnel.stories.tsx b/packages/storybook/stories/features/giveback/GivebackFunnel.stories.tsx index 255ebf7fbb4..efca3ec30a0 100644 --- a/packages/storybook/stories/features/giveback/GivebackFunnel.stories.tsx +++ b/packages/storybook/stories/features/giveback/GivebackFunnel.stories.tsx @@ -30,6 +30,16 @@ 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, @@ -38,16 +48,8 @@ const useMockSelection = (preset: string[] = []) => { hasSavedCauses: false, isSaving: false, save: async () => true, - toggleCause: (id: string) => - setSelectedIds((prev) => { - const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - return next; - }), + toggleCause, + toggleAndSave: toggleCause, }; }; From 104f13081c355e13246c477e3c5a95726e893c8f Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 25 Jun 2026 21:25:29 +0300 Subject: [PATCH 66/89] style(giveback): balanced titles + pretty body copy across the feature Apply the jakub.kr text-wrap guidance everywhere: - text-wrap: balance on headings/titles (hero, section shells, panel titles, value-prop and reward titles) so lines split evenly with no lone last word. - text-wrap: pretty on body copy (subtitles, descriptions, FAQ answers, instructions, cause descriptions) to avoid orphans/widows. Centralised via GivebackSection and GivebackHeadline so most panels inherit it. Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackActionSubmissionModal.tsx | 7 ++++++- .../features/giveback/components/GivebackCauseCard.tsx | 2 +- .../features/giveback/components/GivebackCausesPanel.tsx | 9 +++++++-- .../src/features/giveback/components/GivebackFaq.tsx | 2 +- .../src/features/giveback/components/GivebackFunnel.tsx | 8 +++++++- .../features/giveback/components/GivebackHeadline.tsx | 2 +- .../src/features/giveback/components/GivebackHero.tsx | 2 +- .../src/features/giveback/components/GivebackPage.tsx | 3 ++- .../giveback/components/GivebackPersonalRoadmap.tsx | 4 +++- .../src/features/giveback/components/GivebackSection.tsx | 2 ++ 10 files changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackActionSubmissionModal.tsx b/packages/shared/src/features/giveback/components/GivebackActionSubmissionModal.tsx index cdbeb990aac..e5dc5c0be38 100644 --- a/packages/shared/src/features/giveback/components/GivebackActionSubmissionModal.tsx +++ b/packages/shared/src/features/giveback/components/GivebackActionSubmissionModal.tsx @@ -125,6 +125,7 @@ const ActionBrief = ({ {action.description} @@ -180,6 +181,7 @@ const InstructionsBlock = ({ tag={TypographyTag.Span} type={TypographyType.Callout} color={TypographyColor.Secondary} + className="[text-wrap:pretty]" > {step} @@ -190,6 +192,7 @@ const InstructionsBlock = ({ {instructions} @@ -349,6 +352,7 @@ export const GivebackActionSubmissionModal = ({ This one's a voluntary thank-you. No reward or donation is attached, we just genuinely appreciate it. @@ -367,7 +371,7 @@ export const GivebackActionSubmissionModal = ({ It already counts toward your contribution. If it's rejected, we'll subtract it. @@ -380,6 +384,7 @@ export const GivebackActionSubmissionModal = ({ Done it? Add your proof below. It counts toward your contribution the moment you submit. diff --git a/packages/shared/src/features/giveback/components/GivebackCauseCard.tsx b/packages/shared/src/features/giveback/components/GivebackCauseCard.tsx index 1ecb7ed9f1d..31ff4f6cb9f 100644 --- a/packages/shared/src/features/giveback/components/GivebackCauseCard.tsx +++ b/packages/shared/src/features/giveback/components/GivebackCauseCard.tsx @@ -117,7 +117,7 @@ export const GivebackCauseCard = ({ type={TypographyType.Footnote} color={TypographyColor.Secondary} className={classNames( - 'relative z-1', + 'relative z-1 [text-wrap:pretty]', cardClickable && 'pointer-events-none', )} > diff --git a/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx b/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx index fad4b9e8abc..67b53ce8651 100644 --- a/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx @@ -164,14 +164,19 @@ export const GivebackCausesPanel = (): ReactElement => { return ( - + Your causes, your call Every action you take sends real money to the causes you pick here. Back as many as you like, change them whenever you want. daily.dev diff --git a/packages/shared/src/features/giveback/components/GivebackFaq.tsx b/packages/shared/src/features/giveback/components/GivebackFaq.tsx index 8f42a0b3d1e..1958dbe68d3 100644 --- a/packages/shared/src/features/giveback/components/GivebackFaq.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFaq.tsx @@ -121,7 +121,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/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 3c604f987f8..ac9fd081f8f 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -237,6 +237,7 @@ const FlowSequence = (): ReactElement => ( {step.sub} @@ -418,12 +419,17 @@ export const GivebackFunnel = ({ {value.icon} - + {value.title} {value.sub} diff --git a/packages/shared/src/features/giveback/components/GivebackHeadline.tsx b/packages/shared/src/features/giveback/components/GivebackHeadline.tsx index 7921094aeac..4b0c665e503 100644 --- a/packages/shared/src/features/giveback/components/GivebackHeadline.tsx +++ b/packages/shared/src/features/giveback/components/GivebackHeadline.tsx @@ -24,7 +24,7 @@ export const GivebackHeadline = ({ tag={TypographyTag.H2} type={TypographyType.Title1} bold - className={classNames('max-w-3xl', className)} + className={classNames('max-w-3xl [text-wrap:balance]', className)} > {title} diff --git a/packages/shared/src/features/giveback/components/GivebackHero.tsx b/packages/shared/src/features/giveback/components/GivebackHero.tsx index e8fd972bf4f..843f4b1f9b6 100644 --- a/packages/shared/src/features/giveback/components/GivebackHero.tsx +++ b/packages/shared/src/features/giveback/components/GivebackHero.tsx @@ -89,7 +89,7 @@ export const GivebackHero = ({ tag={TypographyTag.P} type={TypographyType.Callout} color={TypographyColor.Secondary} - className="max-w-2xl" + className="max-w-2xl [text-wrap:pretty]" > Every action you take on daily.dev turns our marketing budget into real donations to the nonprofits you choose. You spend a few diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx index f120bb82a45..f26544c8e0e 100644 --- a/packages/shared/src/features/giveback/components/GivebackPage.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx @@ -157,6 +157,7 @@ export const GivebackPage = (): ReactElement => { tag={TypographyTag.H2} type={TypographyType.Title2} bold + className="[text-wrap:balance]" > Your contribution @@ -164,7 +165,7 @@ export const GivebackPage = (): ReactElement => { tag={TypographyTag.P} type={TypographyType.Callout} color={TypographyColor.Secondary} - className="max-w-2xl" + className="max-w-2xl [text-wrap:pretty]" > Every action you take unlocks real money for the causes you back. Pick one below and your contribution grows. diff --git a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx index 7ee9a38c8ca..6f2d6804449 100644 --- a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx @@ -480,6 +480,7 @@ const NodeRow = ({ {reward.description} @@ -588,6 +589,7 @@ export const GivebackPersonalRoadmap = ({ Reward milestones are on the way. Keep taking action to unlock them. @@ -740,7 +742,7 @@ export const GivebackPersonalRoadmap = ({ tag={TypographyTag.P} type={TypographyType.Callout} color={TypographyColor.Secondary} - className="max-w-2xl" + className="max-w-2xl [text-wrap:pretty]" > {hasImpact && causeNames ? `Headed to ${causeNames}. Every action you take adds more, and it never costs you a thing.` 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} From 2764072f00c15bcbb6236b4ff84e274e9a668ccd Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 25 Jun 2026 21:35:44 +0300 Subject: [PATCH 67/89] fix(giveback): tab scroll-to-top, flat contribution, fixed funnel bg, mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switching tabs snaps the tab strip to the top so the new tab's content (causes or actions) starts in view. - "Your contribution" is now flat (no card border/background); level badge ring blends with the page. - Funnel: background moved outside the scroll container so it stays fixed — the page behind can never show through, however far you scroll. - Hero: "How it works" is icon-only on mobile so it never crowds the brand; funding meter ~35% narrower (max-w-xl -> max-w-sm); smaller "move to" CTA icon. Co-Authored-By: Claude Opus 4.8 --- .../GivebackContributionSummary.tsx | 4 +- .../giveback/components/GivebackFunnel.tsx | 142 +++++++++--------- .../giveback/components/GivebackHero.tsx | 6 +- .../giveback/components/GivebackPage.tsx | 5 +- 4 files changed, 85 insertions(+), 72 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx index b3f2949529a..a07a7b648e7 100644 --- a/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx +++ b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx @@ -38,7 +38,7 @@ export const GivebackContributionSummary = (): ReactElement => { } return ( - + {user && (
{ rounded={ProfileImageSize.XXLarge} className="ring-1 ring-border-subtlest-tertiary" /> - + Lvl {currentLevel}
diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index ac9fd081f8f..fd517a423f6 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -494,91 +494,99 @@ export const GivebackFunnel = ({ role="dialog" aria-modal aria-label="How daily.dev Giveback works" - className="fixed inset-0 z-modal flex flex-col overflow-y-auto overscroll-contain bg-background-default" + className="fixed inset-0 z-modal bg-background-default" > + {/* 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 && ( - - )} -
+
+ {/* 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 +
+ {/* 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) => ( - - ))} - + + {STEP_KEYS.map((key, index) => ( + + ))} + - {/* Keyed by step so the choreographed enter replays on every advance. */} -
- {renderStep()} -
-
+ {/* 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 + {/* 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 + {/* 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 && ( +
+ + {!isFirst && ( + + )} - )} - - -
+ {isLast && ( + + )} + +
+
+
{!videoClosed && ( } className="shrink-0" + aria-label="How it works" onClick={onHowItWorks} > - How it works + {/* Icon-only on mobile so it never crowds the brand. */} + How it works )}
@@ -97,7 +99,7 @@ export const GivebackHero = ({
-
+
diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx index f26544c8e0e..e473186ed2b 100644 --- a/packages/shared/src/features/giveback/components/GivebackPage.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx @@ -90,8 +90,11 @@ export const GivebackPage = (): ReactElement => { extra: JSON.stringify({ tab }), }); setActiveTab(tab); + // Snap the tab strip to the top so the freshly-switched content starts in + // view instead of mid-scroll from the previous tab. + scrollToTabs(); }, - [logEvent], + [logEvent, scrollToTabs], ); const handleHowItWorks = useCallback(() => { From 7cc5235bb72773714d2c64d732ff441b877528f5 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 25 Jun 2026 21:47:45 +0300 Subject: [PATCH 68/89] fix(giveback): move the current-goal Take action below the progress bar The action sat in the top-right slot and overflowed the highlighted card on narrow widths. For the current goal it now renders full-width below the progress bar (claim/done/lock still use the right-hand slot for other rows). Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackPersonalRoadmap.tsx | 73 ++++++++++--------- 1 file changed, 39 insertions(+), 34 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx index 6f2d6804449..1051033d5f2 100644 --- a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx @@ -332,7 +332,7 @@ const NodeRow = ({ // 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 renderAction = (): ReactElement => { + const buildActionSlot = (): ReactElement | null => { if (canClaim) { return (
@@ -380,16 +380,9 @@ const NodeRow = ({ ); } if (isNext) { - return ( - - ); + // 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; } if (isReached) { return ( @@ -412,6 +405,7 @@ const NodeRow = ({ level.requiredApprovedAmount > 0 ? formatDonationAmount(level.requiredApprovedAmount) : 'Free'; + const action = buildActionSlot(); return ( @@ -487,34 +481,45 @@ const NodeRow = ({ )} - {/* One right-hand action slot, so claim / take-action / done / lock - line up in a single column down the whole ladder. */} -
- {renderAction()} -
+ {/* 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 + + + + )}
From 530f1b302c3427eeaac40b5da4e152497b0984f2 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 25 Jun 2026 21:52:33 +0300 Subject: [PATCH 69/89] fix(giveback): responsive padding, mobile hero + funnel video - More horizontal breathing room at tablet/laptop widths (column + tab strip px scales up), so content isn't edge-tight around ~1140px. - Hero: "How it works" is fully visible again; the "Giveback" label shrinks on mobile (Body -> Title3 at tablet) so the button fits beside it. - Funnel: on mobile the explainer video only shows inline on step 1 and is dropped afterwards (no floating corner player overlapping the content/footer). Co-Authored-By: Claude Opus 4.8 --- .../src/features/giveback/components/GivebackFunnel.tsx | 6 +++++- .../src/features/giveback/components/GivebackHero.tsx | 8 +++----- .../src/features/giveback/components/GivebackPage.tsx | 2 +- .../src/features/giveback/components/GivebackTabNav.tsx | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index fd517a423f6..77f28fdd6de 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -21,6 +21,7 @@ import { 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'; @@ -264,6 +265,9 @@ export const GivebackFunnel = ({ // 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 @@ -588,7 +592,7 @@ export const GivebackFunnel = ({
- {!videoClosed && ( + {!videoClosed && !(isMobile && stepIndex > 0) && ( 0} diff --git a/packages/shared/src/features/giveback/components/GivebackHero.tsx b/packages/shared/src/features/giveback/components/GivebackHero.tsx index 316cee28154..d43d2dac2cb 100644 --- a/packages/shared/src/features/giveback/components/GivebackHero.tsx +++ b/packages/shared/src/features/giveback/components/GivebackHero.tsx @@ -55,10 +55,10 @@ export const GivebackHero = ({ /> Giveback @@ -71,11 +71,9 @@ export const GivebackHero = ({ variant={ButtonVariant.Float} icon={} className="shrink-0" - aria-label="How it works" onClick={onHowItWorks} > - {/* Icon-only on mobile so it never crowds the brand. */} - How it works + How it works )} diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx index e473186ed2b..6f853b3e25a 100644 --- a/packages/shared/src/features/giveback/components/GivebackPage.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx @@ -29,7 +29,7 @@ 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'; +const column = 'mx-auto w-full max-w-6xl px-4 tablet:px-6 laptop:px-8'; const scrollIntoView = (node: HTMLElement | null): void => { if (!node || typeof node.scrollIntoView !== 'function') { diff --git a/packages/shared/src/features/giveback/components/GivebackTabNav.tsx b/packages/shared/src/features/giveback/components/GivebackTabNav.tsx index 694241c819b..fd8e1e8ac7e 100644 --- a/packages/shared/src/features/giveback/components/GivebackTabNav.tsx +++ b/packages/shared/src/features/giveback/components/GivebackTabNav.tsx @@ -40,7 +40,7 @@ export const GivebackTabNav = ({ /> {/* Scrollable on narrow screens so all four tabs stay reachable instead of overflowing or wrapping. */} -
+
({ label: tab.label }))} active={activeLabel} From 35eb9898f59212089432c73f847c1e5a3d8fbcec Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 25 Jun 2026 22:00:36 +0300 Subject: [PATCH 70/89] feat(giveback): scroll filters to top on change (match tab behavior) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a category filter in the action catalog or causes tab now scrolls the tab strip back to the top (smooth), so the narrowed list always starts in view instead of the page jumping from the previous scroll position — same behavior as switching the main tabs. Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackActionCatalog.tsx | 11 +++++++++- .../components/GivebackCausesPanel.tsx | 22 ++++++++++++++----- .../giveback/components/GivebackPage.tsx | 6 +++-- 3 files changed, 31 insertions(+), 8 deletions(-) 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/GivebackCausesPanel.tsx b/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx index 67b53ce8651..4ed39555f90 100644 --- a/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx @@ -93,15 +93,27 @@ const CauseRow = ({ ); +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. Toggles update an in-memory working set; "Save changes" -// persists it (the button only lights up when the set actually differs). -export const GivebackCausesPanel = (): ReactElement => { +// 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( @@ -241,14 +253,14 @@ export const GivebackCausesPanel = (): ReactElement => { setActiveFilter(ALL_FILTER)} + onClick={() => selectFilter(ALL_FILTER)} /> {categories.map((category) => ( setActiveFilter(category)} + onClick={() => selectFilter(category)} /> ))} diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx index 6f853b3e25a..7048b4abb66 100644 --- a/packages/shared/src/features/giveback/components/GivebackPage.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx @@ -175,13 +175,15 @@ export const GivebackPage = (): ReactElement => { - + )} {activeTab === 'impact' && ( )} - {activeTab === 'causes' && } + {activeTab === 'causes' && ( + + )} {activeTab === 'faq' && }
From 8d3abcca3b27dfdaf3135b7646783470b545064b Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 25 Jun 2026 22:08:22 +0300 Subject: [PATCH 71/89] fix(giveback): flat loading skeleton for the contribution stat Matches the now-flat loaded layout so it no longer flashes from a card into a flat row. Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackContributionSummary.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx index a07a7b648e7..590d3b10057 100644 --- a/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx +++ b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx @@ -34,7 +34,17 @@ export const GivebackContributionSummary = (): ReactElement => { ); if (isPending) { - return
; + // 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 ( From 00f0a7c28abc829f0d2819638b0acaa06ed7c98d Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Fri, 26 Jun 2026 07:43:52 +0300 Subject: [PATCH 72/89] style(giveback): wider, uniform page gutter (hero, tabs, body match) The hero, tab content and footer already share one column const; bump its horizontal padding (tablet:px-8, laptop:px-12) and match the tab strip so the left/right gutter is consistent and less edge-tight at mid widths. Co-Authored-By: Claude Opus 4.8 --- .../shared/src/features/giveback/components/GivebackPage.tsx | 5 ++++- .../src/features/giveback/components/GivebackTabNav.tsx | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx index 7048b4abb66..a2b793767b7 100644 --- a/packages/shared/src/features/giveback/components/GivebackPage.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx @@ -29,7 +29,10 @@ 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 tablet:px-6 laptop:px-8'; +// 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') { diff --git a/packages/shared/src/features/giveback/components/GivebackTabNav.tsx b/packages/shared/src/features/giveback/components/GivebackTabNav.tsx index fd8e1e8ac7e..f6278a90f91 100644 --- a/packages/shared/src/features/giveback/components/GivebackTabNav.tsx +++ b/packages/shared/src/features/giveback/components/GivebackTabNav.tsx @@ -40,7 +40,7 @@ export const GivebackTabNav = ({ /> {/* Scrollable on narrow screens so all four tabs stay reachable instead of overflowing or wrapping. */} -
+
({ label: tab.label }))} active={activeLabel} From 4f7667d030c0e3d84e097d6b4acf562be8dc350d Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Fri, 26 Jun 2026 07:52:45 +0300 Subject: [PATCH 73/89] fix(giveback): mobile hero polish + subtle level badge - Hero: "How it works" is text-only (dropped the icon to save header space); the charm is smaller on mobile (h-32) and hangs to the left instead of centered (logo/label already shrink on mobile). - Contribution level badge is now flat: purple text on a subtle bordered surface, instead of the solid gradient pill. Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackContributionSummary.tsx | 2 +- .../src/features/giveback/components/GivebackHero.tsx | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx index 590d3b10057..c2e73bc76dc 100644 --- a/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx +++ b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx @@ -57,7 +57,7 @@ export const GivebackContributionSummary = (): ReactElement => { rounded={ProfileImageSize.XXLarge} className="ring-1 ring-border-subtlest-tertiary" /> - + Lvl {currentLevel}
diff --git a/packages/shared/src/features/giveback/components/GivebackHero.tsx b/packages/shared/src/features/giveback/components/GivebackHero.tsx index d43d2dac2cb..d18724367be 100644 --- a/packages/shared/src/features/giveback/components/GivebackHero.tsx +++ b/packages/shared/src/features/giveback/components/GivebackHero.tsx @@ -7,7 +7,6 @@ import { ButtonSize, ButtonVariant, } from '../../../components/buttons/Button'; -import { InfoIcon } from '../../../components/icons'; import { Typography, TypographyColor, @@ -69,7 +68,6 @@ export const GivebackHero = ({ type="button" size={ButtonSize.Small} variant={ButtonVariant.Float} - icon={} className="shrink-0" onClick={onHowItWorks} > @@ -78,7 +76,7 @@ export const GivebackHero = ({ )} - + From 12e85f4aeb07512dbe0522dd5a04d6d2a05538f4 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Fri, 26 Jun 2026 12:37:59 +0300 Subject: [PATCH 74/89] fix(giveback): solid dark background for the level badge Use the primary background instead of the translucent surface-float so the badge reads as a solid dark box, not see-through over the avatar. Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackContributionSummary.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx index c2e73bc76dc..29e84b68b1a 100644 --- a/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx +++ b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx @@ -57,7 +57,7 @@ export const GivebackContributionSummary = (): ReactElement => { rounded={ProfileImageSize.XXLarge} className="ring-1 ring-border-subtlest-tertiary" /> - + Lvl {currentLevel}
From f6c0abc5b6305bac63add744bc143c0cb6d3ca3c Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Fri, 26 Jun 2026 21:48:55 +0300 Subject: [PATCH 75/89] fix(giveback): no filter jump, subtle goal-card border, compact mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reserve a viewport-tall tab content area (min-h) so a short/filtered list can still scroll the sticky tabs to the top — no spring-back jump. - Current-goal roadmap card uses a plain subtle border (regular box), not the bright brand ring/glow. - Hero brand ~20% smaller on mobile (logo h-4, "Giveback" Footnote). - Funnel finale value props are horizontal (icon left, copy right) on mobile and only stack/center in the 3-up grid on tablet+, cutting the final page height. Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackFunnel.tsx | 40 ++++++++++--------- .../giveback/components/GivebackHero.tsx | 6 +-- .../giveback/components/GivebackPage.tsx | 5 ++- .../components/GivebackPersonalRoadmap.tsx | 6 +-- 4 files changed, 32 insertions(+), 25 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 77f28fdd6de..4439ce9e80e 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -416,28 +416,32 @@ export const GivebackFunnel = ({
{IMPACT_VALUES.map((value) => ( - - + {value.icon} - - {value.title} - - - {value.sub} - - + + + {value.title} + + + {value.sub} + + + ))}
diff --git a/packages/shared/src/features/giveback/components/GivebackHero.tsx b/packages/shared/src/features/giveback/components/GivebackHero.tsx index d18724367be..a41dcbe9a53 100644 --- a/packages/shared/src/features/giveback/components/GivebackHero.tsx +++ b/packages/shared/src/features/giveback/components/GivebackHero.tsx @@ -46,15 +46,15 @@ export const GivebackHero = ({ { key={activeTab} role="region" aria-label={activeLabel} - className={`${column} pt-8`} + // Reserve a viewport-tall content area so a short (e.g. filtered) + // list still leaves enough room to scroll the tab strip to the top + // — otherwise the sticky tabs spring back and the page "jumps". + className={`${column} min-h-[calc(100dvh-3.5rem)] pt-8`} > {activeTab === 'actions' && ( diff --git a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx index 1051033d5f2..01a6ff0c0cc 100644 --- a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx @@ -434,10 +434,10 @@ const NodeRow = ({ className={classNames( 'gap-3', // The current goal is a tight, clearly-bounded card so the eye lands - // straight on its action; every other row stays a plain line. Soft - // brand-tinted border, no heavy glow. + // straight on its action; every other row stays a plain line. Plain + // subtle border like any regular box. isNext && - 'border-accent-cabbage-default/30 rounded-16 border bg-surface-float p-4', + 'rounded-16 border border-border-subtlest-tertiary bg-surface-float p-4', )} > From bc1d274b226409b4129dca3321d47345815c27e4 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 27 Jun 2026 09:54:59 +0300 Subject: [PATCH 76/89] fix(giveback): compact funnel finale height on mobile Co-Authored-By: Claude Opus 4.8 --- .../src/features/giveback/components/GivebackFunnel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 4439ce9e80e..aa7ea427216 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -98,7 +98,7 @@ const Reveal = ({ // 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 => ( -
+
( )} Date: Sat, 27 Jun 2026 13:55:37 +0300 Subject: [PATCH 77/89] fix(giveback): align roadmap highlight, clean up toast timer, throttle funnel video - Roadmap 'You're here' now rides the active goal row (face + card + progress on one node) instead of splitting across last-cleared/next. - Force-clear toast timer is tracked and cleared on unmount / re-save. - Funnel docked-video reposition is coalesced to one measure per frame. Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackFunnel.tsx | 26 +++++++++++++++---- .../components/GivebackPersonalRoadmap.tsx | 9 ++++--- .../hooks/useGivebackCauseSelection.ts | 8 +++++- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index aa7ea427216..9adcd8a65f4 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -147,17 +147,33 @@ const GivebackFunnelVideo = ({ 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', update); - window.addEventListener('scroll', update, true); + window.addEventListener('resize', scheduleUpdate); + window.addEventListener('scroll', scheduleUpdate, true); let observer: ResizeObserver | undefined; if (typeof ResizeObserver !== 'undefined' && slotRef.current) { - observer = new ResizeObserver(update); + observer = new ResizeObserver(scheduleUpdate); observer.observe(slotRef.current); } return () => { - window.removeEventListener('resize', update); - window.removeEventListener('scroll', update, true); + if (frame !== null) { + window.cancelAnimationFrame(frame); + } + window.removeEventListener('resize', scheduleUpdate); + window.removeEventListener('scroll', scheduleUpdate, true); observer?.disconnect(); }; }, [docked, slotRef]); diff --git a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx index 01a6ff0c0cc..2444a79da78 100644 --- a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx @@ -540,8 +540,7 @@ export const GivebackPersonalRoadmap = ({ }: GivebackPersonalRoadmapProps): ReactElement => { const { logEvent } = useLogContext(); const { user } = useAuthContext(); - const { earnedPoints, currentLevel, isPending } = - useGivebackContribution(true); + const { earnedPoints, isPending } = useGivebackContribution(true); const { rewardTiers } = useContributionRewards(true); const { claimedRewardIds } = useContributionUserRewards(true); const { claim, isPending: isClaiming } = useClaimContributionReward(); @@ -613,10 +612,14 @@ export const GivebackPersonalRoadmap = ({ 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) diff --git a/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts b/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts index 9c0655e4170..e3c4e86aa99 100644 --- a/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts +++ b/packages/shared/src/features/giveback/hooks/useGivebackCauseSelection.ts @@ -39,6 +39,11 @@ export const useGivebackCauseSelection = ( const [selectedIds, setSelectedIds] = useState>(new Set()); + // 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 // for `enabled`: while the query is gated off it reports not-loading with an @@ -92,7 +97,8 @@ export const useGivebackCauseSelection = ( // 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); - setTimeout(() => { + clearTimeout(clearToastTimer.current); + clearToastTimer.current = setTimeout(() => { if (queryClient.getQueryData(TOAST_NOTIF_KEY) === shown) { queryClient.setQueryData(TOAST_NOTIF_KEY, null); } From dd5bf20a0898a12881ea89e70a357a0d753674dd Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 27 Jun 2026 13:56:38 +0300 Subject: [PATCH 78/89] fix(giveback): tab clicks swap content without scrolling to the tab strip Co-Authored-By: Claude Opus 4.8 --- .../src/features/giveback/components/GivebackPage.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx index 3f7b0f1b74a..016cb26f961 100644 --- a/packages/shared/src/features/giveback/components/GivebackPage.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx @@ -92,12 +92,12 @@ 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); - // Snap the tab strip to the top so the freshly-switched content starts in - // view instead of mid-scroll from the previous tab. - scrollToTabs(); }, - [logEvent, scrollToTabs], + [logEvent], ); const handleHowItWorks = useCallback(() => { From 2c2ad0afc965b8bbe6c35d49af4bbf16ecaec5e5 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 27 Jun 2026 14:00:04 +0300 Subject: [PATCH 79/89] fix(giveback): bleed gradient under card padding so the corner isn't cut Co-Authored-By: Claude Opus 4.8 --- .../features/giveback/components/GivebackBackground.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/features/giveback/components/GivebackBackground.tsx b/packages/shared/src/features/giveback/components/GivebackBackground.tsx index 51d232a5b33..aac9d339b87 100644 --- a/packages/shared/src/features/giveback/components/GivebackBackground.tsx +++ b/packages/shared/src/features/giveback/components/GivebackBackground.tsx @@ -44,9 +44,15 @@ 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 From 4a6875baae689a405ef70c94bbe6cd5f055ad934 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 27 Jun 2026 14:00:49 +0300 Subject: [PATCH 80/89] fix(giveback): drop 'unlocked for your causes' label from contribution stat Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackContributionSummary.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx index 29e84b68b1a..fc0ffe4505b 100644 --- a/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx +++ b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx @@ -72,13 +72,6 @@ export const GivebackContributionSummary = (): ReactElement => { > {formatDonationAmount(earnedPoints)} - - unlocked for your causes - {!isActionsPending && ( From d589749911b55af2f72af52bb1a5845d16b7164e Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 27 Jun 2026 14:12:44 +0300 Subject: [PATCH 81/89] copy(giveback): sharpen messaging across hero, funnel, FAQ and stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lead with the honest trade — devs help us grow, we redirect the ad budget to causes they pick. Why-framed, gain-framed, plain dev-brand voice. Updates text-asserting specs to match. Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackActionCatalog.tsx | 4 +- .../GivebackContributionSummary.tsx | 4 +- .../giveback/components/GivebackFaq.tsx | 6 +-- .../GivebackFundingSummary.spec.tsx | 4 +- .../components/GivebackFundingSummary.tsx | 6 +-- .../components/GivebackFunnel.spec.tsx | 2 +- .../giveback/components/GivebackFunnel.tsx | 39 ++++++++++--------- .../giveback/components/GivebackHero.tsx | 7 ++-- .../components/GivebackImpactPanel.spec.tsx | 2 +- .../giveback/components/GivebackPage.tsx | 5 ++- .../components/GivebackPersonalRoadmap.tsx | 2 +- packages/webapp/pages/giveback/index.tsx | 2 +- 12 files changed, 43 insertions(+), 40 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackActionCatalog.tsx b/packages/shared/src/features/giveback/components/GivebackActionCatalog.tsx index c2ed7eb71c4..337e85c0cba 100644 --- a/packages/shared/src/features/giveback/components/GivebackActionCatalog.tsx +++ b/packages/shared/src/features/giveback/components/GivebackActionCatalog.tsx @@ -212,8 +212,8 @@ export const GivebackActionCatalog = ({ 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/GivebackContributionSummary.tsx b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx index fc0ffe4505b..e01c17488e1 100644 --- a/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx +++ b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx @@ -100,8 +100,8 @@ export const GivebackContributionSummary = (): ReactElement => { type={TypographyType.Caption1} color={TypographyColor.Tertiary} > - Counts the moment you act, because we trust you. If a - submission is rejected, we'll subtract it. + It counts the moment you act — we trust you. If something gets + rejected, we just subtract it. diff --git a/packages/shared/src/features/giveback/components/GivebackFaq.tsx b/packages/shared/src/features/giveback/components/GivebackFaq.tsx index 1958dbe68d3..f5ea6b884b3 100644 --- a/packages/shared/src/features/giveback/components/GivebackFaq.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFaq.tsx @@ -24,13 +24,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 +54,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 — and send that budget to causes the community actually cares about. You help us grow, the world gets the money.', }, { id: 'geo', diff --git a/packages/shared/src/features/giveback/components/GivebackFundingSummary.spec.tsx b/packages/shared/src/features/giveback/components/GivebackFundingSummary.spec.tsx index 3f03a184eb7..040e3589635 100644 --- a/packages/shared/src/features/giveback/components/GivebackFundingSummary.spec.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFundingSummary.spec.tsx @@ -57,10 +57,10 @@ it('shows a goal-forward empty state when nothing is unlocked 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 contributors" zeros leak through. expect(screen.queryByText('$0')).not.toBeInTheDocument(); expect(screen.queryByText(/0 contributors/)).not.toBeInTheDocument(); diff --git a/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx b/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx index 55760cfc091..63647a6c0ab 100644 --- a/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFundingSummary.tsx @@ -115,7 +115,7 @@ export const GivebackFundingSummary = (): ReactElement => { color={TypographyColor.Tertiary} className="pb-1" > - goal to unlock for good causes + goal we'll fund together @@ -128,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. diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.spec.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.spec.tsx index 23616ea81ed..611ab45d7b8 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.spec.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.spec.tsx @@ -44,7 +44,7 @@ it('walks every step and completes on the final CTA', () => { ); expect( - screen.getByText('Your activity funds real causes'), + screen.getByText("We'd rather fund the world than pay for ads"), ).toBeInTheDocument(); advance('Got it'); diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 9adcd8a65f4..87feefad917 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -41,18 +41,18 @@ const IMPACT_VALUES: ReadonlyArray<{ }> = [ { icon: , - title: 'You chose well', - sub: 'Real, vetted nonprofits, picked by you.', + title: 'You call the shots', + sub: 'Real, vetted nonprofits — picked by you.', }, { icon: , title: 'Costs you nothing', - sub: 'daily.dev funds every single dollar.', + sub: 'We fund every single dollar.', }, { icon: , - title: 'Real, lasting impact', - sub: 'Everyday actions become real support.', + title: 'Real-world impact', + sub: 'Small actions add up to real support.', }, ]; @@ -210,15 +210,15 @@ const GivebackFunnelVideo = ({ const FLOW_STEPS: ReadonlyArray<{ title: string; sub: string }> = [ { title: 'You take an action', - sub: 'Upvote, post, share, talk, write. Anything counts.', + sub: 'Share us, post, leave a review, cast a vote — the small stuff that helps us grow.', }, { - title: 'The pot grows toward the goal', - sub: 'Every action drops real money in. You never pay a cent.', + title: 'The pot fills up', + sub: 'Each action drops real daily.dev money in. Never yours.', }, { - title: 'We donate it to your causes', - sub: 'Hit the goal together and it’s sent automatically.', + title: 'We fund your causes', + sub: 'Hit the goal together and the money goes out — to the causes you picked.', }, ]; @@ -413,7 +413,7 @@ export const GivebackFunnel = ({ className="[text-wrap:balance]" > {selectedCauses.length > 0 - ? 'Your giving is now in motion' + ? "You're in. Now every action funds them." : 'Real causes. Real impact.'} {selectedCauses.length > 0 - ? 'From here, every action you take sends real money to the causes you picked. daily.dev funds it all, so it never costs you a thing.' - : "Your actions send real money straight to open-source maintainers, students, and devs who can't afford access. daily.dev funds it all, no cost to you."} + ? '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."} @@ -469,8 +469,8 @@ export const GivebackFunnel = ({ bold className="max-w-xl [text-wrap:pretty]" > - Thank you for choosing who to back. From now on, your everyday - actions turn into real support for them. 💜 + Thanks for choosing who to back. From now on, your everyday + actions are working for them. 💜 )} @@ -493,7 +493,7 @@ export const GivebackFunnel = ({ bold className="[text-wrap:balance]" > - Your activity funds real causes + We'd rather fund the world than pay for ads @@ -503,9 +503,10 @@ export const GivebackFunnel = ({ color={TypographyColor.Secondary} className="max-w-xl [text-wrap:pretty]" > - daily.dev would rather put its marketing budget into good causes - than ad networks. You take small actions, we turn them into - donations, and you pick which causes get them. + Most companies spend their growth budget on ads. We'd + rather spend it on causes that matter. The deal is simple: you + help us grow, and we hand that budget to the causes you choose. + It never costs you a thing. diff --git a/packages/shared/src/features/giveback/components/GivebackHero.tsx b/packages/shared/src/features/giveback/components/GivebackHero.tsx index a41dcbe9a53..578e0a5054b 100644 --- a/packages/shared/src/features/giveback/components/GivebackHero.tsx +++ b/packages/shared/src/features/giveback/components/GivebackHero.tsx @@ -89,9 +89,10 @@ export const GivebackHero = ({ color={TypographyColor.Secondary} className="max-w-2xl [text-wrap:pretty]" > - Every action you take on daily.dev turns our marketing budget into - real donations to the nonprofits you choose. You spend a few - minutes, we cover every dollar. + Instead of pouring our growth budget into ad networks, we put it + toward causes that matter. You take a few small actions to help us + grow; we turn every one into a real donation — 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 9c1d8519b2f..19d0bbd11e4 100644 --- a/packages/shared/src/features/giveback/components/GivebackImpactPanel.spec.tsx +++ b/packages/shared/src/features/giveback/components/GivebackImpactPanel.spec.tsx @@ -114,7 +114,7 @@ beforeEach(() => { it('renders the reward-ladder journey with the current level', () => { render(); - expect(screen.getByText(/for good causes/)).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(); diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx index 016cb26f961..3f270809745 100644 --- a/packages/shared/src/features/giveback/components/GivebackPage.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx @@ -176,8 +176,9 @@ export const GivebackPage = (): ReactElement => { color={TypographyColor.Secondary} className="max-w-2xl [text-wrap:pretty]" > - Every action you take unlocks real money for the causes - you back. Pick one below and your contribution grows. + Each action unlocks real money for the causes you back — + funded by us, chosen by you. Take one and watch your + number climb. diff --git a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx index 2444a79da78..0a6f697a11e 100644 --- a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx @@ -731,7 +731,7 @@ export const GivebackPersonalRoadmap = ({ {formatDonationAmount(approved)} {' '} - for good causes + for causes you love ) : ( Date: Sat, 27 Jun 2026 14:52:44 +0300 Subject: [PATCH 82/89] fix(giveback): drop reserved min-height on impact/FAQ tabs to remove gap Co-Authored-By: Claude Opus 4.8 --- .../features/giveback/components/GivebackPage.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx index 3f270809745..937d3ee3577 100644 --- a/packages/shared/src/features/giveback/components/GivebackPage.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx @@ -154,10 +154,15 @@ export const GivebackPage = (): ReactElement => { key={activeTab} role="region" aria-label={activeLabel} - // Reserve a viewport-tall content area so a short (e.g. filtered) - // list still leaves enough room to scroll the tab strip to the top - // — otherwise the sticky tabs spring back and the page "jumps". - className={`${column} min-h-[calc(100dvh-3.5rem)] 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' && ( From 578e809e12c8a05a7e9b75adee28defc4cf69fa9 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 27 Jun 2026 14:56:37 +0300 Subject: [PATCH 83/89] copy(giveback): remove em dashes from user-facing copy Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackActionCatalog.tsx | 2 +- .../components/GivebackContributionSummary.tsx | 2 +- .../src/features/giveback/components/GivebackFaq.tsx | 4 ++-- .../features/giveback/components/GivebackFunnel.tsx | 10 +++++----- .../src/features/giveback/components/GivebackHero.tsx | 2 +- .../src/features/giveback/components/GivebackPage.tsx | 2 +- packages/webapp/pages/giveback/index.tsx | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackActionCatalog.tsx b/packages/shared/src/features/giveback/components/GivebackActionCatalog.tsx index 337e85c0cba..685300777b9 100644 --- a/packages/shared/src/features/giveback/components/GivebackActionCatalog.tsx +++ b/packages/shared/src/features/giveback/components/GivebackActionCatalog.tsx @@ -212,7 +212,7 @@ export const GivebackActionCatalog = ({ type={TypographyType.Footnote} color={TypographyColor.Tertiary} > - No donation rides on these — they just help us out. We'd love + 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/GivebackContributionSummary.tsx b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx index e01c17488e1..43a836aefa0 100644 --- a/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx +++ b/packages/shared/src/features/giveback/components/GivebackContributionSummary.tsx @@ -100,7 +100,7 @@ export const GivebackContributionSummary = (): ReactElement => { type={TypographyType.Caption1} color={TypographyColor.Tertiary} > - It counts the moment you act — we trust you. If something gets + It counts the moment you act. We trust you. If something gets rejected, we just subtract it. diff --git a/packages/shared/src/features/giveback/components/GivebackFaq.tsx b/packages/shared/src/features/giveback/components/GivebackFaq.tsx index f5ea6b884b3..67c4db11e4e 100644 --- a/packages/shared/src/features/giveback/components/GivebackFaq.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFaq.tsx @@ -24,7 +24,7 @@ const faqs: FaqItem[] = [ id: 'cost', question: 'Does this cost me anything?', answer: - 'Nothing. Not a cent. daily.dev funds every donation — you bring the actions, we bring the money.', + 'Nothing. Not a cent. daily.dev funds every donation. You bring the actions, we bring the money.', }, { id: 'how', @@ -54,7 +54,7 @@ const faqs: FaqItem[] = [ id: 'why', question: 'Why is daily.dev doing this?', answer: - 'Most companies buy growth with ads. We’d rather earn it with you — and send that budget to causes the community actually cares about. You help us grow, the world gets the money.', + 'Most companies buy growth with ads. We’d rather earn it with you and send that budget to causes the community actually cares about. You help us grow, the world gets the money.', }, { id: 'geo', diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 87feefad917..73a49ab37e1 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -42,7 +42,7 @@ const IMPACT_VALUES: ReadonlyArray<{ { icon: , title: 'You call the shots', - sub: 'Real, vetted nonprofits — picked by you.', + sub: 'Real, vetted nonprofits, picked by you.', }, { icon: , @@ -210,7 +210,7 @@ const GivebackFunnelVideo = ({ const FLOW_STEPS: ReadonlyArray<{ title: string; sub: string }> = [ { title: 'You take an action', - sub: 'Share us, post, leave a review, cast a vote — the small stuff that helps us grow.', + sub: 'Share us, post, leave a review, cast a vote. The small stuff that helps us grow.', }, { title: 'The pot fills up', @@ -218,7 +218,7 @@ const FLOW_STEPS: ReadonlyArray<{ title: string; sub: string }> = [ }, { title: 'We fund your causes', - sub: 'Hit the goal together and the money goes out — to the causes you picked.', + sub: 'Hit the goal together and the money goes out to the causes you picked.', }, ]; @@ -423,8 +423,8 @@ export const GivebackFunnel = ({ className="max-w-xl [text-wrap:pretty]" > {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."} + ? '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."} diff --git a/packages/shared/src/features/giveback/components/GivebackHero.tsx b/packages/shared/src/features/giveback/components/GivebackHero.tsx index 578e0a5054b..1508c2c1bc6 100644 --- a/packages/shared/src/features/giveback/components/GivebackHero.tsx +++ b/packages/shared/src/features/giveback/components/GivebackHero.tsx @@ -91,7 +91,7 @@ export const GivebackHero = ({ > Instead of pouring our growth budget into ad networks, we put it toward causes that matter. You take a few small actions to help us - grow; we turn every one into a real donation — to the causes you + grow; we turn every one into a real donation to the causes you choose. You never pay a cent. diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx index 937d3ee3577..f7ef5d52146 100644 --- a/packages/shared/src/features/giveback/components/GivebackPage.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx @@ -181,7 +181,7 @@ export const GivebackPage = (): ReactElement => { color={TypographyColor.Secondary} className="max-w-2xl [text-wrap:pretty]" > - Each action unlocks real money for the causes you back — + Each action unlocks real money for the causes you back, funded by us, chosen by you. Take one and watch your number climb. diff --git a/packages/webapp/pages/giveback/index.tsx b/packages/webapp/pages/giveback/index.tsx index 5194547c6c7..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: - '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.', + '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, }; From a4ade47de764091f6734a888ac4b1a92b1c6c25f Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 27 Jun 2026 15:09:31 +0300 Subject: [PATCH 84/89] fix(giveback): match FAQ title size to other tab headings (Title2) Co-Authored-By: Claude Opus 4.8 --- .../src/features/giveback/components/GivebackFaq.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/features/giveback/components/GivebackFaq.tsx b/packages/shared/src/features/giveback/components/GivebackFaq.tsx index 67c4db11e4e..072210a8ae9 100644 --- a/packages/shared/src/features/giveback/components/GivebackFaq.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFaq.tsx @@ -76,7 +76,15 @@ export const GivebackFaq = (): ReactElement => { }; return ( - + + + Frequently asked questions +
{faqs.map((faq) => { const isOpen = openId === faq.id; From bcdeea9b3bfebaff787427e53069c802438c4342 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 27 Jun 2026 15:10:36 +0300 Subject: [PATCH 85/89] copy(giveback): new hero headline 'Ad budgets buy clicks. Ours funds real causes.' Co-Authored-By: Claude Opus 4.8 --- .../src/features/giveback/components/GivebackHero.tsx | 4 ++-- .../stories/features/giveback/GivebackHero.stories.tsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackHero.tsx b/packages/shared/src/features/giveback/components/GivebackHero.tsx index 1508c2c1bc6..953b8eb2aa0 100644 --- a/packages/shared/src/features/giveback/components/GivebackHero.tsx +++ b/packages/shared/src/features/giveback/components/GivebackHero.tsx @@ -80,8 +80,8 @@ export const GivebackHero = ({ = { title: 'Features/Giveback/Page cover (hero)', component: GivebackHero, From 5b1cd791e81e7cb3f4ecf6ccf5581b773fea0c3b Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 27 Jun 2026 15:15:00 +0300 Subject: [PATCH 86/89] =?UTF-8?q?copy(giveback):=20make=20the=20deal=20exp?= =?UTF-8?q?licit=20=E2=80=94=20grow=20through=20devs,=20fund=20causes=20yo?= =?UTF-8?q?u=20pick?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace abstract 'help us grow' with the concrete ask (help more developers discover daily.dev) and the concrete why (we grow through devs, not ads) across hero, funnel intro, how-step and FAQ. Co-Authored-By: Claude Opus 4.8 --- .../src/features/giveback/components/GivebackFaq.tsx | 2 +- .../features/giveback/components/GivebackFunnel.tsx | 10 +++++----- .../src/features/giveback/components/GivebackHero.tsx | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackFaq.tsx b/packages/shared/src/features/giveback/components/GivebackFaq.tsx index 072210a8ae9..0cead25afb9 100644 --- a/packages/shared/src/features/giveback/components/GivebackFaq.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFaq.tsx @@ -54,7 +54,7 @@ const faqs: FaqItem[] = [ id: 'why', question: 'Why is daily.dev doing this?', answer: - 'Most companies buy growth with ads. We’d rather earn it with you and send that budget to causes the community actually cares about. You help us grow, the world gets the money.', + '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', diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 73a49ab37e1..1a97ee47a4e 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -210,7 +210,7 @@ const GivebackFunnelVideo = ({ const FLOW_STEPS: ReadonlyArray<{ title: string; sub: string }> = [ { title: 'You take an action', - sub: 'Share us, post, leave a review, cast a vote. The small stuff that helps us grow.', + sub: 'Share us, post, leave a review, cast a vote. Small things that help more devs find daily.dev.', }, { title: 'The pot fills up', @@ -503,10 +503,10 @@ export const GivebackFunnel = ({ color={TypographyColor.Secondary} className="max-w-xl [text-wrap:pretty]" > - Most companies spend their growth budget on ads. We'd - rather spend it on causes that matter. The deal is simple: you - help us grow, and we hand that budget to the causes you choose. - It never costs you a thing. + 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/GivebackHero.tsx b/packages/shared/src/features/giveback/components/GivebackHero.tsx index 953b8eb2aa0..143eb6a828d 100644 --- a/packages/shared/src/features/giveback/components/GivebackHero.tsx +++ b/packages/shared/src/features/giveback/components/GivebackHero.tsx @@ -89,10 +89,10 @@ export const GivebackHero = ({ color={TypographyColor.Secondary} className="max-w-2xl [text-wrap:pretty]" > - Instead of pouring our growth budget into ad networks, we put it - toward causes that matter. You take a few small actions to help us - grow; we turn every one into a real donation to the causes you - choose. You never pay a cent. + 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. From d27b5ee9a0f6fe79d2f6246cc767a198db8d1cae Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 27 Jun 2026 15:50:37 +0300 Subject: [PATCH 87/89] fix(giveback): portal funnel overlay to body so it covers the full viewport The funnel rendered inside the app content card (overflow-clip + rounded + sidebar offset), so the fixed overlay was confined to the card instead of the viewport. Portal it to document.body via RootPortal; wrap funnel spec renders in a QueryClientProvider (RootPortal reads request protocol). Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackFunnel.spec.tsx | 14 ++ .../giveback/components/GivebackFunnel.tsx | 179 +++++++++--------- 2 files changed, 105 insertions(+), 88 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.spec.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.spec.tsx index 611ab45d7b8..ab044243172 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.spec.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.spec.tsx @@ -1,5 +1,7 @@ +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'; @@ -7,6 +9,15 @@ 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 >; @@ -41,6 +52,7 @@ it('walks every step and completes on the final CTA', () => { const onComplete = jest.fn(); render( , + { wrapper }, ); expect( @@ -81,6 +93,7 @@ it('blocks the causes step until at least one cause is picked', () => { })} onComplete={jest.fn()} />, + { wrapper }, ); advance('Got it'); @@ -115,6 +128,7 @@ 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(); diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 1a97ee47a4e..13b20657968 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -14,6 +14,7 @@ import { ButtonVariant, } from '../../../components/buttons/Button'; import CloseButton from '../../../components/CloseButton'; +import { RootPortal } from '../../../components/tooltips/Portal'; import { CoinIcon, GiftIcon, @@ -515,111 +516,113 @@ export const GivebackFunnel = ({ }; return ( -
- {/* Background lives outside the scroll container, so it stays fixed and the + +
+ {/* 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 && ( - - )} -
+
+ {/* 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 +
+ {/* 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) => ( - - ))} - + + {STEP_KEYS.map((key, index) => ( + + ))} + - {/* Keyed by step so the choreographed enter replays on every advance. */} -
- {renderStep()} -
-
+ {/* 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 + {/* 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 + {/* 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 && ( +
+ + {!isFirst && ( + + )} - )} - - -
-
+ {isLast && ( + + )} + + + +
- {!videoClosed && !(isMobile && stepIndex > 0) && ( - 0} - onClose={() => setVideoClosed(true)} - /> - )} -
+ {!videoClosed && !(isMobile && stepIndex > 0) && ( + 0} + onClose={() => setVideoClosed(true)} + /> + )} +
+ ); }; From 29fe1826d03f9913579e6d4c6cf5271727d51e09 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 27 Jun 2026 15:55:09 +0300 Subject: [PATCH 88/89] refactor(giveback): shared GivebackTabHeading for consistent tab titles/subtitles Take action / Your impact / Causes / FAQ now render their heading through one component (Title2 bold, Callout/Secondary subtitle, gap-2, max-w-2xl), so size, color, gap and width can't drift between tabs. Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackCausesPanel.tsx | 25 ++------ .../giveback/components/GivebackFaq.tsx | 10 +-- .../giveback/components/GivebackPage.tsx | 31 ++------- .../components/GivebackPersonalRoadmap.tsx | 64 ++++++++----------- .../components/GivebackTabHeading.tsx | 43 +++++++++++++ 5 files changed, 81 insertions(+), 92 deletions(-) create mode 100644 packages/shared/src/features/giveback/components/GivebackTabHeading.tsx diff --git a/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx b/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx index 4ed39555f90..ec4d8b40288 100644 --- a/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackCausesPanel.tsx @@ -21,6 +21,7 @@ import { useGivebackCauseSelection } from '../hooks/useGivebackCauseSelection'; import type { ContributionCause } from '../types'; import { GivebackFilterChip } from './GivebackFilterChip'; import { GivebackCauseCard } from './GivebackCauseCard'; +import { GivebackTabHeading } from './GivebackTabHeading'; import { CauseEmblem } from './CauseEmblem'; const ALL_FILTER = 'all'; @@ -175,26 +176,10 @@ export const GivebackCausesPanel = ({ return ( - - - Your causes, your call - - - Every action you take sends real money to the causes you pick here. - Back as many as you like, change them whenever you want. daily.dev - funds every donation, so it never costs you a thing. - - + { return ( - - Frequently asked questions - +
{faqs.map((faq) => { const isOpen = openId === faq.id; diff --git a/packages/shared/src/features/giveback/components/GivebackPage.tsx b/packages/shared/src/features/giveback/components/GivebackPage.tsx index f7ef5d52146..2dc1ee9d7e3 100644 --- a/packages/shared/src/features/giveback/components/GivebackPage.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPage.tsx @@ -1,12 +1,6 @@ import type { ReactElement } 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 { useConditionalFeature } from '../../../hooks/useConditionalFeature'; import { featureGivebackSponsors } from '../../../lib/featureManagement'; @@ -18,6 +12,7 @@ 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 { GivebackFaqPanel } from './GivebackFaqPanel'; @@ -166,26 +161,10 @@ export const GivebackPage = (): ReactElement => { > {activeTab === 'actions' && ( - - - Your contribution - - - Each action unlocks real money for the causes you back, - funded by us, chosen by you. Take one and watch your - number climb. - - + diff --git a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx index 0a6f697a11e..beec3d82e7f 100644 --- a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx @@ -42,6 +42,7 @@ 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. @@ -718,45 +719,32 @@ export const GivebackPersonalRoadmap = ({
- - {hasImpact ? ( - - You turned {actionsTaken}{' '} - {actionsTaken === 1 ? 'action' : 'actions'} into{' '} - - {formatDonationAmount(approved)} - {' '} - for causes you love - - ) : ( - - Turn your everyday actions into{' '} - - real donations - - - )} - - {hasImpact && causeNames + + 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.'} - - + : '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.' + } + /> 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} + + )} + +); From cb3925352a27f8c332bfbf12af8c165bbb20dfa1 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 27 Jun 2026 16:18:48 +0300 Subject: [PATCH 89/89] fix(giveback): center the how-it-works steps in one column, wrap copy inside Heading and the numbered timeline now share a single centered max-w-md column (aligned left edges, no stray left gap), and each row's text uses min-w-0 flex-1 so the copy always wraps inside the container instead of overflowing. Tighter tile/gap reads cleaner on mobile. Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackFunnel.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx index 13b20657968..4e98093dad1 100644 --- a/packages/shared/src/features/giveback/components/GivebackFunnel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackFunnel.tsx @@ -224,14 +224,14 @@ const FLOW_STEPS: ReadonlyArray<{ title: string; sub: string }> = [ ]; const FlowSequence = (): ReactElement => ( - + {FLOW_STEPS.map((step, index) => { const isLast = index === FLOW_STEPS.length - 1; return ( - - - - + + + + {index + 1} @@ -243,12 +243,16 @@ const FlowSequence = (): ReactElement => ( )} {step.title} @@ -342,18 +346,18 @@ export const GivebackFunnel = ({ switch (stepKey) { case 'how': return ( - - + + You act. We pay. Causes win. - +