"use client"; import { ArrowUpRightIcon, SparklesIcon } from "lucide-react"; import { AnimatePresence, animate, type MotionValue, motion, useMotionValue, useReducedMotion, useTransform, } from "motion/react"; import { type KeyboardEvent, useEffect, useRef, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; export interface CreditCategory { id: string; label: string; used: number; } export interface CreditGaugeProps { title?: string; plan?: string; categories: CreditCategory[]; limit: number; warnAt?: number; daily: number[]; daysInPeriod?: number; periodStart?: Date; unit?: string; onUpgrade?: () => void; className?: string; } const COLORS = ["var(--color-chart-1)", "var(--color-chart-2)", "var(--color-chart-3)", "var(--color-chart-4)", "var(--color-chart-5)"]; const int = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }); const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; function Face({ n, turn }: { n: number; turn: MotionValue }) { const y = useTransform(turn, (t) => { let offset = (((n - t) % 10) + 10) % 10; if (offset > 5) offset -= 10; return `${offset * 100}%`; }); return ( {n} ); } function Wheel({ digit, instant, delay }: { digit: number; instant: boolean; delay: number }) { const reduce = useReducedMotion(); const turn = useMotionValue(digit); const target = useRef(digit); useEffect(() => { let step = digit - (((target.current % 10) + 10) % 10); if (step > 5) step -= 10; else if (step < -5) step += 10; target.current += step; if (reduce || instant) turn.jump(target.current); else if (step !== 0 || (!turn.isAnimating() && turn.get() !== target.current)) animate(turn, target.current, { type: "spring", stiffness: 280, damping: 34, delay }); }, [digit, instant, delay, reduce, turn]); useEffect(() => () => turn.stop(), [turn]); return ( 0 {DIGITS.map((n) => ( ))} ); } function Roll({ text, className, instant = false, delay = 0, }: { text: string; className?: string; instant?: boolean; delay?: number; }) { let placeFromRight = 0; let other = 0; const glyphs = [...text] .reverse() .map((char) => { const isDigit = char >= "0" && char <= "9"; return { char, isDigit, key: isDigit ? `d${placeFromRight++}` : `s${other++}${char}` }; }) .reverse(); return ( {text} {glyphs.map((g) => g.isDigit ? ( ) : ( {g.char === " " ? " " : g.char} ), )} ); } type Level = "calm" | "busy" | "near" | "over"; function levelFor(ratio: number, warnAt: number): Level { if (ratio >= 1) return "over"; if (ratio >= warnAt) return "near"; if (ratio >= 0.6) return "busy"; return "calm"; } const LABELS: Record = { calm: "Manage plan", busy: "Add credits", near: "Upgrade plan", over: "Upgrade now", }; function PlanButton({ level, onClick }: { level: Level; onClick?: () => void }) { const reduce = useReducedMotion(); const label = LABELS[level]; const variant = level === "over" ? "destructive" : level === "near" ? "default" : "outline"; return ( ); } export function CreditGauge({ title = "AI credits", plan = "Pro", categories, limit, warnAt = 0.8, daily, daysInPeriod = 30, periodStart, unit = "credits", onUpgrade, className, }: CreditGaugeProps) { const reduce = useReducedMotion(); const [hoverDay, setHoverDay] = useState(null); const [hoverCat, setHoverCat] = useState(null); const mounted = useRef(false); useEffect(() => { mounted.current = true; }, []); const total = categories.reduce((sum, c) => sum + c.used, 0); const ratio = total / limit; const level = levelFor(ratio, warnAt); const scale = Math.max(limit, total) * 1.04; const limitPos = (limit / scale) * 100; const warnPos = ((limit * warnAt) / scale) * 100; const daysLeft = Math.max(0, daysInPeriod - daily.length); const recent = daily.slice(-7); const pace = recent.length ? recent.reduce((a, b) => a + b, 0) / recent.length : 0; const daysToLimit = pace > 0 ? Math.ceil((limit - total) / pace) : Number.POSITIVE_INFINITY; const status = level === "over" ? `${int.format(total - limit)} ${unit} over your limit` : daysToLimit <= daysLeft ? `Hits the limit in ~${daysToLimit} day${daysToLimit === 1 ? "" : "s"}` : `On pace · resets in ${daysLeft} day${daysLeft === 1 ? "" : "s"}`; const spring = reduce ? { duration: 0 } : ({ type: "spring", duration: 0.5, bounce: 0 } as const); let acc = 0; const segments = categories.map((c, i) => { const start = (acc / scale) * 100; acc += c.used; const end = (acc / scale) * 100; return { ...c, start, end, color: COLORS[i % COLORS.length] }; }); const W = 340; const H = 52; const gap = 2; const barW = (W - gap * (daysInPeriod - 1)) / daysInPeriod; const budget = limit / daysInPeriod; const peak = Math.max(budget * 1.6, ...daily); const yFor = (v: number) => H - (v / peak) * (H - 4); const dayLabel = (i: number) => { if (!periodStart) return `Day ${i + 1}`; const d = new Date(periodStart); d.setDate(d.getDate() + i); return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); }; const lastDay = daily.length - 1; const focusDay = hoverDay !== null && hoverDay <= lastDay ? hoverDay : lastDay; function onDaysKey(e: KeyboardEvent) { if (e.key === "ArrowLeft") setHoverDay(Math.max(0, focusDay - 1)); else if (e.key === "ArrowRight") setHoverDay(Math.min(lastDay, focusDay + 1)); else if (e.key === "Home") setHoverDay(0); else if (e.key === "End" || e.key === "Escape") setHoverDay(null); else return; e.preventDefault(); } return (
{title}
/ {int.format(limit)}
{plan}
{segments.map((s, i) => ( ))}
{Math.round(warnAt * 100)}% Limit
    {segments.map((s) => (
  • ))}
setHoverDay(null)} onKeyDown={onDaysKey} role="group" tabIndex={0} >
{dayLabel(focusDay)} {int.format(daily[focusDay] ?? 0)} {unit}
setHoverDay(null)} preserveAspectRatio="none" viewBox={`0 0 ${W} ${H}`} > {Array.from({ length: daysInPeriod }, (_, i) => { const v = daily[i]; const x = i * (barW + gap); if (v === undefined) { return ; } const over = v > budget * 1.25; const active = i === focusDay; return ( setHoverDay(i)}> ); })}
{dayLabel(0)} daily budget {int.format(budget)}
); }