"use client"; import { ArrowRightIcon, CheckIcon } from "lucide-react"; import { AnimatePresence, animate, MotionConfig, motion, useInView, useMotionValue, useReducedMotion, useTransform, } from "motion/react"; import { type KeyboardEvent, useEffect, useId, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { SegmentedControl, SegmentedControlItem } from "@/components/ui/segmented-control"; import { cn } from "@/lib/utils"; export interface TallyLine { label: string; detail?: string; amount: number; } export interface TallyPeriod { id: string; label: string; range: string; caption: string; lines: TallyLine[]; } export interface TallyHeroProps { periods: TallyPeriod[]; defaultPeriod?: string; eyebrow?: string; description?: string; cta: { label: string; href: string }; note?: string; statementLabel?: string; totalLabel?: string; reference?: string; reconciledLabel?: string; currency?: string; locale?: string; className?: string; } type Glyph = { key: string; kind: "digit" | "group" | "currency" | "literal"; char: string; place: number }; const ease = [0.23, 1, 0.32, 1] as const; const hidden = "inset(0% 100% 0% 0%)"; const shown = "inset(0% 0% 0% 0%)"; const wheelStiffness = 340; const strip = Array.from({ length: 11 }, (_, slot) => slot); function useReducedMotionSafe() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && Boolean(prefers); } function toGlyphs(formatter: Intl.NumberFormat, total: number): { glyphs: Glyph[]; places: number } { const parts = formatter.formatToParts(total); const places = parts.filter((p) => p.type === "integer").reduce((n, p) => n + p.value.length, 0); let place = places; const glyphs: Glyph[] = []; parts.forEach((part, i) => { if (part.type === "integer") { for (const char of part.value) { place--; glyphs.push({ key: `d${place}`, kind: "digit", char, place }); } } else if (part.type === "group") { glyphs.push({ key: `g${place}`, kind: "group", char: part.value, place }); } else if (part.type === "currency") { glyphs.push({ key: place === places ? "currency" : "currency-end", kind: "currency", char: part.value, place: -1 }); } else { glyphs.push({ key: `l${i}`, kind: "literal", char: part.value, place: -1 }); } }); return { glyphs, places }; } function Wheel({ digit, from, direction, delay, reduce }: { digit: number; from: number; direction: number; delay: number; reduce: boolean }) { const turn = useMotionValue(from); const goal = useRef(from); const y = useTransform(turn, (t) => `${-(((t % 10) + 10) % 10) * 1.1}em`); useEffect(() => { const at = ((goal.current % 10) + 10) % 10; if (at !== digit) goal.current += direction < 0 ? -((at - digit + 10) % 10) : (digit - at + 10) % 10; if (reduce) { turn.jump(goal.current); return; } if (turn.get() === goal.current) return; const controls = animate(turn, goal.current, { type: "spring", stiffness: wheelStiffness, damping: 2 * Math.sqrt(wheelStiffness), delay, }); return () => controls.stop(); }, [digit, direction, delay, reduce, turn]); return ( <> 0 {strip.map((slot) => ( {slot % 10} ))} > ); } function Counter({ value, total, formatter, reduce }: { value: number; total: number; formatter: Intl.NumberFormat; reduce: boolean }) { const { glyphs, places } = useMemo(() => toGlyphs(formatter, total), [formatter, total]); const padded = String(Math.round(value)).padStart(places, "0"); const lit = value > 0 ? String(Math.round(value)).length : 0; const previous = useRef(value); const direction = Math.sign(value - previous.current); useEffect(() => { previous.current = value; }, [value]); const painted = useRef(false); useEffect(() => { painted.current = true; }, []); return ( {glyphs.map((glyph) => { const on = glyph.place < lit; if (glyph.kind === "currency") { return ( {glyph.char} ); } return ( {glyph.kind === "digit" ? ( ) : ( glyph.char )} ); })} ); } function PeriodSwitch({ periods, value, onChange }: { periods: TallyPeriod[]; value: string; onChange: (id: string) => void }) { const refs = useRef<(HTMLButtonElement | null)[]>([]); const index = Math.max( 0, periods.findIndex((p) => p.id === value), ); const onKeyDown = (event: KeyboardEvent) => { const moves: Record = { ArrowRight: index + 1, ArrowDown: index + 1, ArrowLeft: index - 1, ArrowUp: index - 1, Home: 0, End: periods.length - 1, }; if (!(event.key in moves)) return; event.preventDefault(); const next = (moves[event.key] + periods.length) % periods.length; onChange(periods[next].id); refs.current[next]?.focus(); }; return ( {periods.map((period, i) => { const checked = i === index; return ( onChange(period.id)} ref={(el: HTMLButtonElement | null) => { refs.current[i] = el; }} role="radio" tabIndex={checked ? 0 : -1} > {period.label} ); })} ); } export function TallyHero({ periods, defaultPeriod, eyebrow, description, cta, note, statementLabel = "Statement", totalLabel = "Total", reference, reconciledLabel = "Reconciled", currency = "USD", locale = "en-US", className, }: TallyHeroProps) { const ref = useRef(null); const headingId = useId(); const inView = useInView(ref, { once: true, amount: 0.35 }); const reduce = useReducedMotionSafe(); const formatter = useMemo( () => new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 0, minimumFractionDigits: 0 }), [locale, currency], ); const [periodId, setPeriodId] = useState(defaultPeriod ?? periods[0]?.id); const period = periods.find((p) => p.id === periodId) ?? periods[0]; const lines = period.lines; const total = lines.reduce((sum, line) => sum + line.amount, 0); const [printed, setPrinted] = useState(0); const [swapped, setSwapped] = useState(false); const [announcement, setAnnouncement] = useState(""); useEffect(() => { if (reduce) { setPrinted(Number.POSITIVE_INFINITY); return; } if (!inView) return; const timers: number[] = []; let step = 0; const tick = () => { step++; setPrinted((p) => Math.max(p, step)); if (step <= lines.length) timers.push(window.setTimeout(tick, step === lines.length ? 420 : 400)); }; timers.push(window.setTimeout(tick, 280)); return () => timers.forEach(window.clearTimeout); }, [inView, reduce, lines.length]); const choose = (id: string) => { if (id === periodId) return; const next = periods.find((p) => p.id === id); if (!next) return; setPeriodId(id); setSwapped(true); setPrinted(Number.POSITIVE_INFINITY); setAnnouncement(`${formatter.format(next.lines.reduce((s, l) => s + l.amount, 0))} ${next.caption}`); }; const value = lines.slice(0, Math.min(printed, lines.length)).reduce((sum, line) => sum + line.amount, 0); const totalPrinted = printed > lines.length; const sweep = (delay: number) => (reduce ? { duration: 0 } : { duration: 0.26, ease, delay }); return ( {eyebrow && {eyebrow}} {periods.length > 1 && } {formatter.format(total)} {period.caption} {period.caption} {description && {description}} } size="lg" > {cta.label} {note && {note}} {statementLabel} {period.range} {lines.map((line, i) => { const latest = !swapped && !totalPrinted && i === printed - 1; return ( {line.label} {line.detail && {line.detail}} {formatter.format(line.amount)} ); })} {totalLabel} {formatter.format(total)} {reference} {reconciledLabel} {announcement} ); }
{eyebrow}
{description}
{note}
{announcement}