"use client"; import { Download, Send } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type ReactNode, useMemo, useState } from "react"; import { Badge } from "@/components/motion/badge"; import { Button, StatefulButton } from "@/components/motion/button"; import { NumberTicker } from "@/components/motion/number-ticker"; import { EASE_OUT, SPRING_SWAP } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type InvoiceStatus = "paid" | "due" | "overdue"; export type InvoiceParty = { name: string; /** Address lines, one per row. */ lines?: string[]; email?: string; }; export type InvoiceLineItem = { id: string; description: string; detail?: string; quantity: number; /** Unit price in major units. */ unitPrice: number; }; export interface InvoiceProps { number: string; /** ISO date (YYYY-MM-DD), formatted in UTC so server and client agree. */ issueDate: string; dueDate: string; from: InvoiceParty; to: InvoiceParty; items: InvoiceLineItem[]; /** 0.1 = 10%. Default 0. */ taxRate?: number; currency?: string; locale?: string; notes?: ReactNode; /** Brand mark shown beside the title. */ logo?: ReactNode; status?: InvoiceStatus; defaultStatus?: InvoiceStatus; onStatusChange?: (status: InvoiceStatus) => void; /** Defaults to `window.print()` — the layout carries `print:` styles. */ onDownload?: () => void; /** Return a promise to drive the Send button's pending/sent/error states. */ onSend?: () => void | Promise; /** Resolve to flip the status to Paid (stamp + amount due rolls to zero). */ onMarkPaid?: () => void | Promise; className?: string; } type ButtonState = "idle" | "loading" | "success" | "error"; const STATUS: Record< InvoiceStatus, { label: string; variant: "success" | "warning" | "destructive"; dot: true | "pulse" } > = { paid: { label: "Paid", variant: "success", dot: true }, due: { label: "Due", variant: "warning", dot: true }, overdue: { label: "Overdue", variant: "destructive", dot: "pulse" }, }; const cents = (major: number) => Math.round(major * 100); function Party({ title, party }: { title: string; party: InvoiceParty }) { return (

{title}

{party.name}

{party.lines?.map((line) => (

{line}

))} {party.email ? (

{party.email}

) : null}
); } /** * Printable invoice: header with number, dates and an animated status badge * (Paid / Due / Overdue), bill-from / bill-to, a line-item table, subtotal / * tax / total, and actions — Download (prints by default), Send and Mark as * paid, which stamps the sheet and rolls the amount due to zero. `print:` * variants drop the actions and card chrome and force ink-friendly colors. */ export function Invoice({ number, issueDate, dueDate, from, to, items, taxRate = 0, currency = "USD", locale = "en-US", notes, logo, status: statusProp, defaultStatus = "due", onStatusChange, onDownload, onSend, onMarkPaid, className, }: InvoiceProps) { const reduce = useReducedMotion(); const [internalStatus, setInternalStatus] = useState(defaultStatus); const status = statusProp ?? internalStatus; const [sendState, setSendState] = useState("idle"); const [payState, setPayState] = useState("idle"); const money = useMemo( () => new Intl.NumberFormat(locale, { style: "currency", currency }), [locale, currency], ); const format = (c: number) => money.format(c / 100); const date = useMemo(() => { const f = new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", year: "numeric", timeZone: "UTC", }); return (iso: string) => { const [y, m, d] = iso.split("-").map(Number); return f.format(new Date(Date.UTC(y ?? 1970, (m ?? 1) - 1, d ?? 1))); }; }, [locale]); const subtotal = items.reduce((sum, item) => sum + cents(item.unitPrice) * item.quantity, 0); const tax = Math.round(subtotal * taxRate); const total = subtotal + tax; const due = status === "paid" ? 0 : total; const send = async () => { setSendState("loading"); try { await onSend?.(); setSendState("success"); } catch { setSendState("error"); } }; const markPaid = async () => { setPayState("loading"); try { await onMarkPaid?.(); setPayState("success"); if (statusProp === undefined) setInternalStatus("paid"); onStatusChange?.("paid"); } catch { setPayState("error"); } }; const meta = STATUS[status]; return (
{/* Paid stamp. Rotation is a static CSS transform on the wrapper; the motion child only scales and fades, so nothing fights over it. */}
{status === "paid" ? ( PAID ) : null}
{logo ? {logo} : null}

Invoice {number}

Issued {date(issueDate)} · Due {date(dueDate)}

{meta.label}
{items.map((item, index) => ( ))}
Description Qty Price Amount
{item.description} {item.detail ? ( {item.detail} ) : null} {item.quantity} {format(cents(item.unitPrice))} {format(cents(item.unitPrice) * item.quantity)}
{notes}
Subtotal
{format(subtotal)}
{taxRate > 0 ? ( <>
Tax ({Math.round(taxRate * 1000) / 10}%)
{format(tax)}
) : null}
Total
{format(total)}
Amount due
); }