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/GivebackImpactPanel.spec.tsx b/packages/shared/src/features/giveback/components/GivebackImpactPanel.spec.tsx index 46e342a90b2..19d0bbd11e4 100644 --- a/packages/shared/src/features/giveback/components/GivebackImpactPanel.spec.tsx +++ b/packages/shared/src/features/giveback/components/GivebackImpactPanel.spec.tsx @@ -1,23 +1,25 @@ import React from 'react'; import { act, fireEvent, render, screen } from '@testing-library/react'; import { GivebackImpactPanel } from './GivebackImpactPanel'; -import { useContributionStatus } from '../hooks/useContributionStatus'; -import { useContributionSponsors } from '../hooks/useContributionSponsors'; import { useGivebackContribution } from '../hooks/useGivebackContribution'; import { useContributionRewards } from '../hooks/useContributionRewards'; import { useContributionUserRewards } from '../hooks/useContributionUserRewards'; import { useClaimContributionReward } from '../hooks/useClaimContributionReward'; +import { useContributionCausePicker } from '../hooks/useContributionCausePicker'; +import { useContributionActions } from '../hooks/useContributionActions'; import { useLogContext } from '../../../contexts/LogContext'; +import { useAuthContext } from '../../../contexts/AuthContext'; import { LogEvent } from '../../../lib/log'; import { ContributionRewardType, type ContributionRewardTier } from '../types'; -jest.mock('../hooks/useContributionStatus'); -jest.mock('../hooks/useContributionSponsors'); jest.mock('../hooks/useGivebackContribution'); jest.mock('../hooks/useContributionRewards'); jest.mock('../hooks/useContributionUserRewards'); jest.mock('../hooks/useClaimContributionReward'); +jest.mock('../hooks/useContributionCausePicker'); +jest.mock('../hooks/useContributionActions'); jest.mock('../../../contexts/LogContext'); +jest.mock('../../../contexts/AuthContext'); // Resolve the reveal/count-up animations synchronously so assertions read final // values, not mid-animation frames. @@ -26,12 +28,6 @@ jest.mock('../useGivebackMotion', () => ({ useCountUp: (target: number) => target, })); -const mockStatus = useContributionStatus as jest.MockedFunction< - typeof useContributionStatus ->; -const mockSponsors = useContributionSponsors as jest.MockedFunction< - typeof useContributionSponsors ->; const mockContribution = useGivebackContribution as jest.MockedFunction< typeof useGivebackContribution >; @@ -44,7 +40,14 @@ const mockUserRewards = useContributionUserRewards as jest.MockedFunction< const mockClaim = useClaimContributionReward as jest.MockedFunction< typeof useClaimContributionReward >; +const mockCausePicker = useContributionCausePicker as jest.MockedFunction< + typeof useContributionCausePicker +>; +const mockActions = useContributionActions as jest.MockedFunction< + typeof useContributionActions +>; const mockLog = useLogContext as jest.MockedFunction; +const mockAuth = useAuthContext as jest.MockedFunction; const logEvent = jest.fn(); const tiers: ContributionRewardTier[] = [ @@ -73,20 +76,6 @@ const tiers: ContributionRewardTier[] = [ beforeEach(() => { jest.clearAllMocks(); - mockStatus.mockReturnValue({ - status: { - enabled: true, - eligible: true, - currentCyclePoints: 4000, - currentCycleTargetPoints: 10000, - lifetimePoints: 0, - lifetimeAmountCents: 0, - contributorsCount: 128, - userPoints: 40, - }, - isPending: false, - }); - mockSponsors.mockReturnValue({ sponsors: [], isPending: false }); mockContribution.mockReturnValue({ earnedPoints: 40, nextReward: tiers[1], @@ -102,28 +91,35 @@ beforeEach(() => { claim: jest.fn().mockResolvedValue(undefined), isPending: false, }); + mockCausePicker.mockReturnValue({ + causes: [], + selectedCauseIds: [], + isPending: false, + }); + mockActions.mockReturnValue({ + actions: [], + categories: [], + rewardTiers: [], + claimedRewardIds: [], + isPending: false, + }); mockLog.mockReturnValue({ logEvent } as unknown as ReturnType< typeof useLogContext >); -}); - -it('renders the funding progress section with live totals', () => { - render(); - - expect(screen.getByText('Funding progress')).toBeInTheDocument(); - expect(screen.getByText('$4,000')).toBeInTheDocument(); - expect(screen.getByText('128')).toBeInTheDocument(); + mockAuth.mockReturnValue({ user: null } as unknown as ReturnType< + typeof useAuthContext + >); }); it('renders the reward-ladder journey with the current level', () => { render(); - expect(screen.getByText('Your journey')).toBeInTheDocument(); + expect(screen.getByText(/for causes you love/)).toBeInTheDocument(); expect(screen.getByText('Sticker pack')).toBeInTheDocument(); expect(screen.getByText('One month of Plus')).toBeInTheDocument(); expect(screen.getByText('Hoodie')).toBeInTheDocument(); // $40 earned: the next milestone is the $100 tier. - expect(screen.getByText('Next up: One month of Plus')).toBeInTheDocument(); + expect(screen.getByText('$60 to your next reward')).toBeInTheDocument(); expect(screen.getByText('$60 to go')).toBeInTheDocument(); }); @@ -131,7 +127,7 @@ it('offers a claim for an unlocked, unclaimed tier and logs it', async () => { render(); // The $25 tier is reached at $40 and not yet claimed. - const claimButton = screen.getByRole('button', { name: 'Claim' }); + const claimButton = screen.getByRole('button', { name: /Claim reward/ }); expect(claimButton).toBeInTheDocument(); expect(screen.getByText(/ready to claim/)).toBeInTheDocument(); diff --git a/packages/shared/src/features/giveback/components/GivebackImpactPanel.tsx b/packages/shared/src/features/giveback/components/GivebackImpactPanel.tsx index 12d0b0b4e4c..035a4bc2e4b 100644 --- a/packages/shared/src/features/giveback/components/GivebackImpactPanel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackImpactPanel.tsx @@ -1,24 +1,17 @@ import type { ReactElement } from 'react'; import React from 'react'; import { FlexCol } from '../../../components/utilities'; -import { GivebackCommunityGoalProgress } from './GivebackCommunityGoalProgress'; import { GivebackPersonalRoadmap } from './GivebackPersonalRoadmap'; interface GivebackImpactPanelProps { onTakeAction: () => void; } -// The Impact tab: the campaign's funding progress (community pot + sponsors) -// followed by the visitor's own reward-ladder journey. The leaderboard and live -// community feed from the design are intentionally skipped — the campaign starts -// from scratch, so the social-proof half stays funding + personal progress. +// The Impact tab: the visitor's own reward-ladder journey through the campaign. export const GivebackImpactPanel = ({ onTakeAction, }: GivebackImpactPanelProps): ReactElement => ( - -
- -
+
); diff --git a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx index 2cec2b7eb08..a18ef909b84 100644 --- a/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx +++ b/packages/shared/src/features/giveback/components/GivebackPersonalRoadmap.tsx @@ -1,6 +1,5 @@ import type { ReactElement } from 'react'; import React, { useMemo, useState } from 'react'; -import classNames from 'classnames'; import { FlexCol, FlexRow } from '../../../components/utilities'; import { Typography, @@ -13,358 +12,46 @@ import { ButtonSize, ButtonVariant, } from '../../../components/buttons/Button'; -import { - ArrowIcon, - CoreIcon, - DevPlusIcon, - GiftIcon, - LockIcon, - MedalBadgeIcon, - StarIcon, - VIcon, -} from '../../../components/icons'; -import ConfettiSvg from '../../../svg/ConfettiSvg'; +import { ArrowIcon, GiftIcon } from '../../../components/icons'; +import { useAuthContext } from '../../../contexts/AuthContext'; 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 { ContributionRewardType } from '../types'; +import { useContributionCausePicker } from '../hooks/useContributionCausePicker'; +import { useContributionActions } from '../hooks/useContributionActions'; import { formatDonationAmount } from '../utils'; -import { GivebackMeterShine } from './GivebackMeterShine'; +import { GivebackTabHeading } from './GivebackTabHeading'; +import { RailToggle } from './GivebackRoadmapRail'; +import { NodeRow } from './GivebackRoadmapNode'; +import type { + ConnectorFill, + RoadmapLevel, + RoadmapNode, +} from './givebackRoadmapTypes'; + +// 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; -const rewardIconByType: Record = { - [ContributionRewardType.Cores]: , - [ContributionRewardType.PlusDays]: , - [ContributionRewardType.Call]: , - [ContributionRewardType.Privilege]: , - [ContributionRewardType.Custom]: , -}; - -// A reward tier reshaped into a roadmap node. Every tier grants a reward, so the -// node always has one. -interface RoadmapLevel { - id: string; - levelNumber: number; - requiredApprovedAmount: number; - reward: { - id: string; - type: ContributionRewardType; - title: string; - description: string | null; - }; -} - -// One state drives every visual cue on a node, so "done", "you are here", and -// "locked" never disagree (RPG / battle-pass clarity). -type NodeState = 'claimed' | 'summit' | 'current' | 'unlocked' | 'locked'; - -const nodeStyles: Record = { - claimed: 'bg-accent-avocado-default text-white', - summit: - 'bg-gradient-to-br from-accent-cheese-default to-accent-bacon-default text-white shadow-2', - current: - 'bg-gradient-to-br from-accent-cabbage-default to-accent-onion-default text-white shadow-2-cabbage', - unlocked: 'bg-accent-cabbage-default text-white', - locked: - 'border border-border-subtlest-tertiary bg-surface-float text-text-quaternary', -}; - -type ConnectorFill = - | { type: 'full' } - | { type: 'partial'; progress: number } - | { type: 'muted' }; - -interface RoadmapNode { - level: RoadmapLevel; - isLast: boolean; - isReached: boolean; - isCurrent: boolean; - isNext: boolean; - isClaimed: boolean; - connector?: ConnectorFill; -} - -const Connector = ({ fill }: { fill: ConnectorFill }): ReactElement => ( -
-
- {fill.type === 'full' && ( -
- )} - {fill.type === 'partial' && ( -
- )} -
-); - -interface RailToggleProps { - icon: ReactElement; - label: string; - onClick: () => void; - connectorBelow?: ConnectorFill; -} - -const RailToggle = ({ - icon, - label, - onClick, - connectorBelow, -}: RailToggleProps): ReactElement => ( - -); - -interface NodeRowProps { - node: RoadmapNode; - amountToNext: number; - segmentProgress: number; - isClaiming: boolean; - onClaim: (tierId: string) => void; - onTakeAction: () => void; -} - -const NodeRow = ({ - node, - amountToNext, - segmentProgress, - isClaiming, - onClaim, - onTakeAction, -}: NodeRowProps): ReactElement => { - const { level, isLast, isReached, isCurrent, isNext, isClaimed } = node; - const { reward } = level; - const isSummit = isLast; - const canClaim = isReached && !isClaimed; - const [celebrate, setCelebrate] = useState(false); - - const handleClaim = () => { - setCelebrate(true); - onClaim(reward.id); - }; - - const getNodeState = (): NodeState => { - if (isReached && isSummit) { - return 'summit'; - } - if (isCurrent) { - return 'current'; - } - if (isClaimed) { - return 'claimed'; - } - if (isReached) { - return 'unlocked'; - } - return 'locked'; - }; - - const getNodeIcon = (): ReactElement => { - if (isClaimed) { - return ; - } - if (isReached || isNext) { - return rewardIconByType[reward.type]; - } - return ; - }; - - const requirementLabel = - level.requiredApprovedAmount > 0 - ? formatDonationAmount(level.requiredApprovedAmount) - : 'Free'; - - return ( - -
- {celebrate && ( - - )} - - {isCurrent && ( - - )} - - {getNodeIcon()} - - - {level.levelNumber} - - - {!isLast && } -
- -
- - - - - - Level {level.levelNumber} · {requirementLabel} - - {isCurrent && ( - - You're here - - )} - {isClaimed && !isCurrent && ( - - - - Claimed - - - )} - - - {reward.title} - - {isNext && reward.description && ( - - {reward.description} - - )} - - - {canClaim ? ( - - ) : ( - !isReached && - !isNext && ( - - - - ) - )} - - - {isNext && ( - -
-
- -
-
- - - {formatDonationAmount(amountToNext)} to go - - - -
- )} -
-
-
- ); -}; - interface GivebackPersonalRoadmapProps { onTakeAction: () => void; } @@ -377,11 +64,14 @@ export const GivebackPersonalRoadmap = ({ onTakeAction, }: GivebackPersonalRoadmapProps): ReactElement => { const { logEvent } = useLogContext(); - const { earnedPoints, currentLevel, isPending } = - useGivebackContribution(true); + const { user } = useAuthContext(); + const { earnedPoints, isPending } = useGivebackContribution(true); const { rewardTiers } = useContributionRewards(true); const { claimedRewardIds } = useContributionUserRewards(true); const { claim, isPending: isClaiming } = useClaimContributionReward(); + const { causes: pickerCauses, selectedCauseIds } = + useContributionCausePicker(true); + const { actions } = useContributionActions(true); const [claimingId, setClaimingId] = useState(null); const [showCompleted, setShowCompleted] = useState(true); const [showAllUpcoming, setShowAllUpcoming] = useState(false); @@ -428,6 +118,7 @@ export const GivebackPersonalRoadmap = ({ Reward milestones are on the way. Keep taking action to unlock them. @@ -436,11 +127,24 @@ export const GivebackPersonalRoadmap = ({ } const approved = earnedPoints; + const actionsTaken = actions.reduce( + (sum, action) => sum + action.userCompletions, + 0, + ); + const selectedNames = pickerCauses + .filter((cause) => selectedCauseIds.includes(cause.id)) + .map((cause) => cause.title); + const causeNames = formatCauseNames(selectedNames); + const hasImpact = approved > 0; const total = levels.length; - const focusIndex = Math.min(total - 1, Math.max(0, currentLevel - 1)); const nextIndex = levels.findIndex( (level) => level.requiredApprovedAmount > approved, ); + // "You're here" rides the goal you're climbing toward (the next unreached + // level), so the face marker, the highlighted card and the progress bar all + // land on one row — not split across the last-cleared and next levels. Once + // every level is reached, focus the summit. + const focusIndex = nextIndex === -1 ? total - 1 : nextIndex; const nextLevel = nextIndex === -1 ? undefined : levels[nextIndex]; const amountToNext = nextLevel ? Math.max(0, nextLevel.requiredApprovedAmount - approved) @@ -453,9 +157,11 @@ export const GivebackPersonalRoadmap = ({ approved >= level.requiredApprovedAmount && !claimedIds.has(level.id), ).length; - // Progress within the current segment (last reached → next level). + // Progress within the current segment (last reached → next level). With + // nothing reached yet the segment starts at 0, not the first tier's threshold + // (otherwise the denominator collapses to 0 and the bar sticks at 0%). const previousAmount = - levels[Math.max(0, reachedCount - 1)].requiredApprovedAmount; + reachedCount > 0 ? levels[reachedCount - 1].requiredApprovedAmount : 0; const segmentDenominator = nextLevel ? nextLevel.requiredApprovedAmount - previousAmount : 1; @@ -535,134 +241,139 @@ export const GivebackPersonalRoadmap = ({ return (
- - - - Your journey - - - - - Level - - - {focusIndex + 1} - - + + + + You turned {actionsTaken}{' '} + {actionsTaken === 1 ? 'action' : 'actions'} into{' '} + + {formatDonationAmount(approved)} + {' '} + for causes you love + + ) : ( + <> + Turn your everyday actions into{' '} + + real donations + + + ) + } + description={ + hasImpact && causeNames + ? `Headed to ${causeNames}. Every action you take adds more, and it never costs you a thing.` + : 'Every action you take sends real money to the causes you back. daily.dev funds it all, so you never pay a cent. Take your first one.' + } + /> - - - {nextLevel - ? `Next up: ${nextLevel.reward.title}` - : "You've unlocked every reward"} - - + + - Level {focusIndex + 1} of {total} ·{' '} - {formatDonationAmount(approved)} unlocked + {nextLevel + ? `${formatDonationAmount(amountToNext)} to your next reward` + : 'Every reward unlocked'} - - + {claimableCount > 0 && ( - + // A status note, not a tappable control: no pill background, just + // the gift glyph + cheese text pointing down to the ladder. + - - {claimableCount} ready to claim + + {claimableCount} {claimableCount === 1 ? 'reward' : 'rewards'}{' '} + ready to claim below )} - + - - {focusIndex > 0 && ( - - } - label={ - showCompleted - ? 'Hide completed levels' - : `Show ${focusIndex} completed ${ - focusIndex === 1 ? 'level' : 'levels' - }` - } - onClick={() => setShowCompleted((value) => !value)} - connectorBelow={{ type: 'full' }} - /> - )} + + + Rewards you unlock along the way + + + + {focusIndex > 0 && ( + + } + label={ + showCompleted + ? 'Hide completed levels' + : `Show ${focusIndex} completed ${ + focusIndex === 1 ? 'level' : 'levels' + }` + } + onClick={() => setShowCompleted((value) => !value)} + connectorBelow={{ type: 'full' }} + /> + )} - {visibleNodes.map((node) => ( - - ))} + {visibleNodes.map((node) => ( + + ))} + + {hiddenUpcoming > 0 && ( + } + label={`Show ${hiddenUpcoming} more ${ + hiddenUpcoming === 1 ? 'level' : 'levels' + }`} + onClick={() => setShowAllUpcoming(true)} + /> + )} + - {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/GivebackRoadmapNode.tsx b/packages/shared/src/features/giveback/components/GivebackRoadmapNode.tsx new file mode 100644 index 00000000000..87ea10955ba --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackRoadmapNode.tsx @@ -0,0 +1,418 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { FlexCol, FlexRow } from '../../../components/utilities'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { + CoreIcon, + DevPlusIcon, + GiftIcon, + LockIcon, + MedalBadgeIcon, + StarIcon, + UserIcon, + VIcon, +} from '../../../components/icons'; +import { + ProfilePicture, + ProfileImageSize, +} from '../../../components/ProfilePicture'; +import type { LoggedUser } from '../../../lib/user'; +import { ContributionRewardType } from '../types'; +import { formatDonationAmount } from '../utils'; +import { GivebackMeterShine } from './GivebackMeterShine'; +import { Connector } from './GivebackRoadmapRail'; +import type { RoadmapNode } from './givebackRoadmapTypes'; + +const rewardIconByType: Record = { + [ContributionRewardType.Cores]: , + [ContributionRewardType.PlusDays]: , + [ContributionRewardType.Call]: , + [ContributionRewardType.Privilege]: , + [ContributionRewardType.Custom]: , +}; + +// Directions the celebration confetti flies when a reward is claimed, fed to the +// reaction-burst keyframe via CSS custom properties. A fuller spread of brand +// colors makes the claim feel like a genuine "you did it" moment, not a flicker. +const claimSparkles: ReadonlyArray<{ + tx: string; + ty: string; + delay: string; + color: string; +}> = [ + { + tx: '-34px', + ty: '-26px', + delay: '0ms', + color: 'bg-accent-cabbage-default', + }, + { tx: '32px', ty: '-30px', delay: '30ms', color: 'bg-accent-cheese-default' }, + { tx: '44px', ty: '-2px', delay: '60ms', color: 'bg-accent-avocado-default' }, + { tx: '-42px', ty: '4px', delay: '20ms', color: 'bg-accent-onion-default' }, + { tx: '8px', ty: '-40px', delay: '10ms', color: 'bg-accent-cheese-default' }, + { + tx: '-14px', + ty: '-38px', + delay: '50ms', + color: 'bg-accent-cabbage-default', + }, + { tx: '24px', ty: '26px', delay: '40ms', color: 'bg-accent-avocado-default' }, + { tx: '-26px', ty: '24px', delay: '70ms', color: 'bg-accent-cheese-default' }, +]; + +interface NodeRowProps { + node: RoadmapNode; + user: LoggedUser | null; + amountToNext: number; + segmentProgress: number; + isClaiming: boolean; + onClaim: (tierId: string) => void; + onTakeAction: () => void; +} + +// Contrast-first, branded palette so color carries meaning, not decoration: +// • markers are calm surface tiles by default (high contrast on the dark page) +// • green is only a "done" check accent, never a saturated fill +// • cabbage (brand) is the single live accent: you, your next goal, claimable +// • the summit alone gets a brand gradient fill so it reads as "the big one" +// • locked stays muted/dimmed +// +// Rounded rectangles, not circles - the squircle marker echoes daily.dev's +// branding (square avatars, rounded app tiles) and reads as custom-built rather +// than a generic battle-pass dot. +const markerBase = + 'flex size-10 items-center justify-center rounded-12 [&_svg]:size-5'; + +export const NodeRow = ({ + node, + user, + amountToNext, + segmentProgress, + isClaiming, + onClaim, + onTakeAction, +}: NodeRowProps): ReactElement => { + const { level, isLast, isReached, isCurrent, isNext, isClaimed } = node; + const { reward } = level; + const isSummit = isLast; + const canClaim = isReached && !isClaimed; + const [celebrate, setCelebrate] = useState(false); + + const handleClaim = () => { + setCelebrate(true); + onClaim(reward.id); + }; + + // Clear the celebration once it has played so it can replay on a retry (e.g. + // after a failed claim) and never leaves the reward-pop class stuck on. + useEffect(() => { + if (!celebrate) { + return undefined; + } + const timer = setTimeout(() => setCelebrate(false), 700); + return () => clearTimeout(timer); + }, [celebrate]); + + // The marker is the one cue that tells you, at a glance, what this stop is. + // Priority matters: "you" (your face) and the summit prize always win, so the + // trail never shows two competing highlights. + const renderMarker = (): ReactElement => { + if (isCurrent) { + // Your own face marks where you stand - a rounded square (not a circle) to + // match daily.dev's square avatars. + return user ? ( + + ) : ( + + + + ); + } + if (isSummit) { + // The grand prize: the single boldest tile, a brand gradient with a gift + // (white on cabbage→onion reads clearly, unlike a flat gold fill). + return ( + + {isClaimed ? : } + + ); + } + if (isClaimed) { + // Done = a calm surface tile with a green check accent, not a saturated + // green fill (which washed out the icon). + return ( + + + + ); + } + if (isReached) { + // Unlocked, claim pending: cheese (yellow) accent matches the "ready to + // claim" cue and the Claim button, so claimable reads consistently. + return ( + + {rewardIconByType[reward.type]} + + ); + } + if (isNext) { + // The immediate goal: the one filled brand tile, white on cabbage. + return ( + + {rewardIconByType[reward.type]} + + ); + } + return ( + + + + ); + }; + + // The single right-hand action for this level. Exactly one of: claim the + // reward, take action toward it, a "done" tick, or a lock - so the column of + // actions reads cleanly top to bottom. + const buildActionSlot = (): ReactElement | null => { + if (canClaim) { + return ( +
+ {celebrate && ( + + + + {claimSparkles.map((sparkle) => ( + + ))} + + )} + +
+ ); + } + if (isNext) { + // The current goal's action lives below the progress bar, not in the + // top-right slot, so it never overflows the card on narrow widths. + return null; + } + if (isReached) { + return ( + + + + Done + + + ); + } + return ( + + + + ); + }; + + const requirementLabel = + level.requiredApprovedAmount > 0 + ? formatDonationAmount(level.requiredApprovedAmount) + : 'Free'; + const action = buildActionSlot(); + + return ( + +
+ + {(isCurrent || isNext) && ( + + )} + + {renderMarker()} + + + {!isLast && } +
+ +
+ + + + + + Level {level.levelNumber} · {requirementLabel} + + {isCurrent && ( + + You're here + + )} + + + {reward.title} + + {isNext && reward.description && ( + + {reward.description} + + )} + + + {/* 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 + +
+ +
+ )} +
+
+
+ ); +}; diff --git a/packages/shared/src/features/giveback/components/GivebackRoadmapRail.tsx b/packages/shared/src/features/giveback/components/GivebackRoadmapRail.tsx new file mode 100644 index 00000000000..3653e08e645 --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackRoadmapRail.tsx @@ -0,0 +1,75 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import type { ConnectorFill } from './givebackRoadmapTypes'; + +// 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. +export const Connector = ({ fill }: { fill: ConnectorFill }): ReactElement => ( +
+
+ {fill.type === 'full' && ( +
+ )} + {fill.type === 'partial' && ( +
+ )} +
+); + +interface RailToggleProps { + icon: ReactElement; + label: string; + onClick: () => void; + connectorBelow?: ConnectorFill; +} + +// A dashed node that expands/collapses a stretch of the rail (completed levels +// above, upcoming levels below), styled like a node so it sits on the same track. +export const RailToggle = ({ + icon, + label, + onClick, + connectorBelow, +}: RailToggleProps): ReactElement => ( + +); diff --git a/packages/shared/src/features/giveback/components/GivebackSponsorBudgetBar.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorBudgetBar.tsx deleted file mode 100644 index d8c6d2f5c36..00000000000 --- a/packages/shared/src/features/giveback/components/GivebackSponsorBudgetBar.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useMemo, useState } from 'react'; -import classNames from 'classnames'; -import { FlexCol, FlexRow } from '../../../components/utilities'; -import { - Typography, - TypographyColor, - TypographyTag, - TypographyType, -} from '../../../components/typography/Typography'; -import { formatDonationAmount, getSponsorInitials } from '../utils'; -import { GivebackSponsorLogo } from './GivebackSponsorLogo'; - -// A sponsor reduced to what the budget bar needs (amount already in whole -// currency units). -export interface BudgetSponsor { - id: string; - name: string; - amount: number; - logoUrl?: string | null; -} - -// iOS "storage bar" style: one bar split into proportional, color-coded -// segments — one per top sponsor, with the long tail grouped into "Others". The -// logo above defaults to the biggest sponsor and swaps to whichever segment you -// hover or focus. -const SEGMENT_PALETTE = [ - { - seg: 'bg-accent-water-default', - tile: 'bg-accent-water-flat text-accent-water-default', - }, - { - seg: 'bg-accent-blueCheese-default', - tile: 'bg-accent-blueCheese-flat text-accent-blueCheese-default', - }, - { - seg: 'bg-accent-cheese-default', - tile: 'bg-accent-cheese-flat text-accent-cheese-default', - }, - { - seg: 'bg-accent-avocado-default', - tile: 'bg-accent-avocado-flat text-accent-avocado-default', - }, - { - seg: 'bg-accent-onion-default', - tile: 'bg-accent-onion-flat text-accent-onion-default', - }, - { - seg: 'bg-accent-bacon-default', - tile: 'bg-accent-bacon-flat text-accent-bacon-default', - }, -]; - -const OTHERS_STYLE = { - seg: 'bg-accent-salt-default', - tile: 'bg-surface-float text-text-secondary', -}; - -const MAX_SEGMENTS = 6; - -interface Segment { - id: string; - label: string; - amount: number; - share: number; - seg: string; - tile: string; - initials: string; - logoUrl?: string | null; - count?: number; -} - -interface GivebackSponsorBudgetBarProps { - sponsors: BudgetSponsor[]; -} - -export const GivebackSponsorBudgetBar = ({ - sponsors, -}: GivebackSponsorBudgetBarProps): ReactElement | null => { - const [hoveredId, setHoveredId] = useState(null); - - const segments = useMemo(() => { - const sorted = [...sponsors].sort((a, b) => b.amount - a.amount); - const total = sorted.reduce((sum, sponsor) => sum + sponsor.amount, 0) || 1; - const leading = sorted.slice(0, MAX_SEGMENTS); - const rest = sorted.slice(MAX_SEGMENTS); - - const result: Segment[] = leading.map((sponsor, index) => ({ - id: sponsor.id, - label: sponsor.name, - amount: sponsor.amount, - share: (sponsor.amount / total) * 100, - seg: SEGMENT_PALETTE[index % SEGMENT_PALETTE.length].seg, - tile: SEGMENT_PALETTE[index % SEGMENT_PALETTE.length].tile, - initials: getSponsorInitials(sponsor.name), - logoUrl: sponsor.logoUrl, - })); - - if (rest.length > 0) { - const restAmount = rest.reduce((sum, sponsor) => sum + sponsor.amount, 0); - result.push({ - id: 'others', - label: 'Other sponsors', - amount: restAmount, - share: (restAmount / total) * 100, - seg: OTHERS_STYLE.seg, - tile: OTHERS_STYLE.tile, - initials: `+${rest.length}`, - count: rest.length, - }); - } - - return result; - }, [sponsors]); - - if (segments.length === 0) { - return null; - } - - const topId = segments[0].id; - const active = - segments.find((segment) => segment.id === (hoveredId ?? topId)) ?? - segments[0]; - const isHovering = hoveredId !== null; - - return ( - - - - - - - {active.label} - - {!isHovering && active.id === topId && ( - - Top sponsor - - )} - - - {formatDonationAmount(active.amount)} - {active.count ? ` · ${active.count} sponsors` : ''} ·{' '} - {Math.round(active.share)}% - - - - - - {segments.map((segment) => ( - setHoveredId(segment.id)} - onMouseLeave={() => setHoveredId(null)} - style={{ width: `${segment.share}%` }} - className={classNames( - 'box-border h-full border-r-2 border-background-default transition-opacity duration-200 last:border-r-0', - segment.seg, - isHovering && hoveredId !== segment.id - ? 'opacity-40' - : 'opacity-100', - )} - /> - ))} - - - ); -}; diff --git a/packages/shared/src/features/giveback/components/GivebackSponsorLogo.tsx b/packages/shared/src/features/giveback/components/GivebackSponsorLogo.tsx deleted file mode 100644 index baa3a9b0515..00000000000 --- a/packages/shared/src/features/giveback/components/GivebackSponsorLogo.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useState } from 'react'; -import classNames from 'classnames'; -import { getSponsorInitials } from '../utils'; - -interface GivebackSponsorLogoProps { - name: string; - logoUrl?: string | null; - // Overrides the computed initials (e.g. "+3" for a grouped tile). - initials?: string; - className?: string; - tileClassName?: string; - initialsClassName?: string; -} - -// Renders a sponsor's real brand logo on a white chip, falling back to initials -// when there's no logo (individuals, fresh sponsors) or the image fails to load. -export const GivebackSponsorLogo = ({ - name, - logoUrl, - initials, - className, - tileClassName, - initialsClassName, -}: GivebackSponsorLogoProps): ReactElement => { - const [failed, setFailed] = useState(false); - const showLogo = Boolean(logoUrl) && !failed; - const label = initials ?? getSponsorInitials(name); - - return ( - - {showLogo ? ( - {`${name} setFailed(true)} - /> - ) : ( - label - )} - - ); -}; diff --git a/packages/shared/src/features/giveback/components/givebackRoadmapTypes.ts b/packages/shared/src/features/giveback/components/givebackRoadmapTypes.ts new file mode 100644 index 00000000000..0dd330e3e19 --- /dev/null +++ b/packages/shared/src/features/giveback/components/givebackRoadmapTypes.ts @@ -0,0 +1,32 @@ +import type { ContributionRewardType } from '../types'; + +// A reward tier reshaped into a roadmap node. Every tier grants a reward, so the +// node always has one. +export interface RoadmapLevel { + id: string; + levelNumber: number; + requiredApprovedAmount: number; + reward: { + id: string; + type: ContributionRewardType; + title: string; + description: string | null; + }; +} + +// How a connector segment between two nodes is filled: fully cleared (green), +// the live segment leading up to you (partial cabbage), or not yet reached. +export type ConnectorFill = + | { type: 'full' } + | { type: 'partial'; progress: number } + | { type: 'muted' }; + +export interface RoadmapNode { + level: RoadmapLevel; + isLast: boolean; + isReached: boolean; + isCurrent: boolean; + isNext: boolean; + isClaimed: boolean; + connector?: ConnectorFill; +} diff --git a/packages/shared/src/svg/ConfettiSvg.tsx b/packages/shared/src/svg/ConfettiSvg.tsx deleted file mode 100644 index bc7b615838b..00000000000 --- a/packages/shared/src/svg/ConfettiSvg.tsx +++ /dev/null @@ -1,428 +0,0 @@ -import type { HTMLAttributes, ReactElement } from 'react'; -import React from 'react'; - -export default function ConfettiSvg( - props: HTMLAttributes, -): ReactElement { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/packages/shared/tailwind.config.ts b/packages/shared/tailwind.config.ts index 7ea3ce23f76..6633005e464 100644 --- a/packages/shared/tailwind.config.ts +++ b/packages/shared/tailwind.config.ts @@ -320,6 +320,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)' }, @@ -390,6 +396,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', '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',