import { GiftIcon, SparkleIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { whenPageVisible } from "./celebration-burst"; import { formatCreditAmount } from "../../lib/onboarding-use-case"; const COUNT_UP_MS = 1_100; function prefersReducedMotion(): boolean { return Boolean(window.matchMedia?.("(prefers-reduced-motion: reduce)").matches); } /** Counts up to `target` once, easing out; the final value at once under reduced motion. */ function useCountUp(target: number, from: number): number { const [value, setValue] = useState(() => (prefersReducedMotion() ? target : from)); useEffect(() => { if (prefersReducedMotion() || typeof requestAnimationFrame !== "function") { setValue(target); return; } let frame = 0; let start: number | null = null; const tick = (now: number) => { start ??= now; const progress = Math.min(1, (now - start) / COUNT_UP_MS); const eased = 1 - (1 - progress) ** 3; setValue(from + (target - from) * eased); if (progress < 1) frame = requestAnimationFrame(tick); }; // Start counting when the page is seen (credits can land in a hidden tab). const stopWaiting = whenPageVisible(() => { frame = requestAnimationFrame(tick); }); return () => { stopWaiting(); cancelAnimationFrame(frame); }; }, [from, target]); return value; } /** * The "you won something" visual for credits: a gift-card shaped voucher on * the palette's teal and peach glow, with the amount counting up, a shine that * sweeps across once and a few twinkling sparkles. The heading next to it * carries the amount in words, so the voucher is hidden from assistive * technology. Under reduced motion it renders the final, still card. */ export function CreditsPrize({ amountMicros, currency, label, caption, }: { amountMicros: number; currency: string; /** Small caps line above the amount, for example "Free credits". */ label: string; /** One quiet line under the amount, for example the new balance. */ caption?: string | undefined; }) { const wholeAmount = amountMicros / 1_000_000; const shown = useCountUp(wholeAmount, Number.isInteger(wholeAmount) ? 0 : wholeAmount); const shineRef = useRef(null); const sparkleRefs = useRef>([]); useEffect(() => { if (prefersReducedMotion()) return; const animations: Animation[] = []; const stopWaiting = whenPageVisible(() => playAccents(animations)); return () => { stopWaiting(); for (const animation of animations) animation.cancel(); }; }, []); function playAccents(animations: Animation[]): void { const shine = shineRef.current; if (shine && typeof shine.animate === "function") { animations.push( shine.animate( [ { transform: "translateX(-120%) skewX(-18deg)", opacity: 0 }, { transform: "translateX(-40%) skewX(-18deg)", opacity: 1, offset: 0.3 }, { transform: "translateX(260%) skewX(-18deg)", opacity: 0 }, ], { duration: 1_400, delay: 500, easing: "ease-out", fill: "both" }, ), ); } sparkleRefs.current.forEach((sparkle, index) => { if (!sparkle || typeof sparkle.animate !== "function") return; animations.push( sparkle.animate( [ { transform: "scale(0.4) rotate(0deg)", opacity: 0 }, { transform: "scale(1.15) rotate(45deg)", opacity: 1, offset: 0.4 }, { transform: "scale(0.85) rotate(90deg)", opacity: 0.85 }, ], { duration: 900, delay: 350 + index * 220, easing: "ease-out", fill: "both" }, ), ); }); } const sparkles = [ "top-3 right-5 size-4", "top-10 right-12 size-2.5", "bottom-4 left-6 size-3", "top-5 left-[38%] size-2", ]; return ( ); }