"use client"; import { CalendarIcon, PackageIcon, PlusIcon, XIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { useId, useMemo, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "@/components/ui/menu"; import { NumberField, NumberFieldDecrement, NumberFieldGroup, NumberFieldIncrement, NumberFieldInput, } from "@/components/ui/number-field"; import { Popover, PopoverPopup, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; export type BillingRepeat = "monthly" | "quarterly" | "annually" | "upfront"; export type BillingTiming = "advance" | "arrears"; export interface BillingProduct { id: string; name: string; price: number; unit: string; oneTime?: boolean; } export interface BillingLine { productId: string; quantity: number; repeat: BillingRepeat; timing: BillingTiming; } export interface BillingPhase { name: string; start: Date; months: number; lines: BillingLine[]; discount: { enabled: boolean; percent: number }; minimum: { enabled: boolean; amount: number }; } export interface BillingTotals { subtotal: number; discount: number; trueUp: number; total: number; invoices: number[]; } export interface PhaseBuilderProps { catalog: BillingProduct[]; defaultPhase: BillingPhase; currency?: string; locale?: string; onChange?: (phase: BillingPhase, totals: BillingTotals) => void; className?: string; } const repeatLabels: Record = { monthly: "Monthly", quarterly: "Quarterly", annually: "Annually", upfront: "Upfront", }; const repeatMonths: Record = { monthly: 1, quarterly: 3, annually: 12, upfront: Infinity }; const timingLabels: Record = { advance: "In advance", arrears: "In arrears" }; function addMonths(d: Date, n: number) { return new Date(d.getFullYear(), d.getMonth() + n, d.getDate()); } function computeTotals(phase: BillingPhase, catalog: BillingProduct[]): BillingTotals & { trueUpMonth: number } { const n = Math.max(1, phase.months); const invoices = Array.from({ length: n }, () => 0); let subtotal = 0; let discount = 0; const pct = phase.discount.enabled ? phase.discount.percent : 0; for (const line of phase.lines) { const p = catalog.find((c) => c.id === line.productId); if (!p) continue; if (p.oneTime) { const amt = p.price * line.quantity; const month = line.timing === "advance" ? 0 : n - 1; invoices[month] += amt; subtotal += amt; continue; } const period = Math.min(repeatMonths[line.repeat], n); for (let start = 0; start < n; start += period) { const len = Math.min(period, n - start); const gross = p.price * line.quantity * len; const off = gross * pct; const month = line.timing === "advance" ? start : start + len - 1; invoices[month] += gross - off; subtotal += gross; discount += off; } } const net = subtotal - discount; const trueUp = phase.minimum.enabled ? Math.max(0, phase.minimum.amount - net) : 0; if (trueUp) invoices[n - 1] += trueUp; return { subtotal, discount, trueUp, total: net + trueUp, invoices, trueUpMonth: n - 1 }; } function Rolling({ value, format, className }: { value: number; format: (n: number) => string; className?: string }) { const reduce = useReducedMotion(); const text = format(value); const chars = text.split(""); return ( {text} {chars.map((ch, i) => { const key = chars.length - i; if (!/\d/.test(ch)) { return ( {ch} ); } const d = Number(ch); return ( 0 {"0123456789".split("").map((n) => ( {n} ))} ); })} ); } const press = "transition-[scale,box-shadow,background-color] duration-150 ease-out active:scale-[0.97]"; const pillTrigger = "h-7 min-h-7 w-auto min-w-0 gap-1 rounded-md border-transparent bg-muted px-2 text-xs shadow-none before:hidden sm:h-6 sm:min-h-6 sm:text-xs dark:bg-input/48 [&_svg]:size-3!"; function Field({ label, children, action }: { label: string; children: React.ReactNode; action?: React.ReactNode }) { return (
{label} {action}
{children}
); } export function PhaseBuilder({ catalog, defaultPhase, currency = "USD", locale = "en-US", onChange, className, }: PhaseBuilderProps) { const reduce = useReducedMotion(); const id = useId(); const [phase, setPhase] = useState(defaultPhase); const [hover, setHover] = useState(null); const [dateOpen, setDateOpen] = useState(false); const totals = useMemo(() => computeTotals(phase, catalog), [phase, catalog]); const update = (next: BillingPhase) => { setPhase(next); onChange?.(next, computeTotals(next, catalog)); }; const setLine = (i: number, patch: Partial) => update({ ...phase, lines: phase.lines.map((l, j) => (j === i ? { ...l, ...patch } : l)) }); const money = useMemo(() => { const whole = new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 0 }); const cents = new Intl.NumberFormat(locale, { style: "currency", currency }); return { whole: (n: number) => whole.format(Math.round(n)), cents: (n: number) => cents.format(n) }; }, [currency, locale]); const dateFmt = useMemo(() => new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", year: "numeric" }), [locale]); const monthFmt = useMemo(() => new Intl.DateTimeFormat(locale, { month: "short", year: "numeric" }), [locale]); const tickFmt = useMemo(() => new Intl.DateTimeFormat(locale, { month: "short" }), [locale]); const end = addMonths(phase.start, phase.months); end.setDate(end.getDate() - 1); const max = Math.max(...totals.invoices, 1); const available = catalog.filter((c) => !phase.lines.some((l) => l.productId === c.id)); const shown = hover ?? 0; const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 420, damping: 34 }; return (

{phase.name}

{dateFmt.format(phase.start)} → {dateFmt.format(end)}

Draft
} > {dateFmt.format(phase.start)} { if (d) update({ ...phase, start: d }); setDateOpen(false); }} selected={phase.start} /> update({ ...phase, months: Math.min(36, Math.max(1, v ?? 1)) })} size="sm" value={phase.months} > update({ ...phase, discount: { ...phase.discount, enabled: v } })} /> } label="Discount" > update({ ...phase, discount: { ...phase.discount, percent: v ?? 0 } })} size="sm" smallStep={0.01} step={0.01} value={phase.discount.percent} > update({ ...phase, minimum: { ...phase.minimum, enabled: v } })} /> } label="Minimum" > update({ ...phase, minimum: { ...phase.minimum, amount: v ?? 0 } })} size="sm" step={1000} value={phase.minimum.amount} >
Products {phase.lines.length} } > Add product {available.map((p) => ( update({ ...phase, lines: [ ...phase.lines, { productId: p.id, quantity: 1, repeat: p.oneTime ? "upfront" : "monthly", timing: "advance", }, ], }) } > {p.name} {money.whole(p.price)} {p.oneTime ? "" : "/mo"} ))}
{phase.lines.map((line, i) => { const p = catalog.find((c) => c.id === line.productId); if (!p) return null; const lineTotal = computeTotals({ ...phase, lines: [line], minimum: { enabled: false, amount: 0 } }, catalog).total; return (
{p.name} {money.whole(p.price)} {p.oneTime ? " flat" : ` / ${p.unit} / mo`}
setLine(i, { quantity: Math.max(1, v ?? 1) })} size="sm" value={line.quantity} > {p.oneTime ? ( One-time ) : ( )}
); })}
{phase.lines.length === 0 && (

No products yet. Add one to see the schedule.

)}

Contract value

First invoice
Per month
Discount
(n ? `−${money.whole(n)}` : money.whole(0))} value={totals.discount} />
{totals.invoices.filter((v) => v > 0).length} invoices {monthFmt.format(addMonths(phase.start, shown))} · {money.cents(totals.invoices[shown] ?? 0)}
setHover(null)} role="img" > {totals.invoices.map((v, i) => { const isTrueUp = totals.trueUp > 0 && i === totals.trueUpMonth; const trueUpShare = isTrueUp ? totals.trueUp / Math.max(v, 1) : 0; return (
setHover(i)} > 0 ? `${Math.max(6, (v / max) * 100)}%` : "2px" }} className={cn( "relative w-full overflow-hidden rounded-[calc(var(--radius)*0.3)] transition-colors", v > 0 ? "bg-foreground/40" : "bg-foreground/12", (hover === null ? i === 0 : hover === i) && v > 0 && "bg-foreground", )} initial={false} transition={spring} > {isTrueUp && ( )}
); })}
{tickFmt.format(phase.start)} {phase.months > 2 && {tickFmt.format(addMonths(phase.start, Math.floor(phase.months / 2)))}} {tickFmt.format(addMonths(phase.start, phase.months - 1))}
{totals.trueUp > 0 && ( {money.whole(totals.trueUp)} short of the {money.whole(phase.minimum.amount)} minimum, billed as a true-up in {monthFmt.format(addMonths(phase.start, totals.trueUpMonth))}. )}
); }