= {
+ [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) => (
+
+ ))}
+
+ )}
+ : undefined}
+ className={classNames(
+ 'relative',
+ celebrate && 'motion-safe:animate-reward-pop',
+ )}
+ >
+ Claim reward
+
+
+ );
+ }
+ 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
+
+
+
+ Take action
+
+
+ )}
+
+
+
+ );
+};
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 => (
+
+
+
+ {icon}
+
+ {connectorBelow && }
+
+
+ {/* Match the icon's height so the label centers against the node, not the
+ full icon + connector run. */}
+
+
+ {label}
+
+
+
+
+);
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 ? (
- 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',