"use client"; import { useParams } from "next/navigation"; import { useState } from "react"; import { Download, Loader2 } from "lucide-react"; import { Button } from "~/components/ui/button"; import { Separator } from "~/components/ui/separator"; import { api } from "~/trpc/react"; import { generateInvoicePDF } from "~/lib/pdf-export"; import { formatLineItemDetail } from "~/lib/invoice-line-item"; import { toast } from "sonner"; function formatDate(date: Date) { return new Intl.DateTimeFormat("en-US", { year: "numeric", month: "long", day: "numeric", }).format(new Date(date)); } function formatCurrency(amount: number, currency = "USD") { return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount); } function StatusPill({ status, dueDate }: { status: string; dueDate: Date }) { const overdue = status === "sent" && new Date(dueDate) < new Date(); const label = overdue ? "Overdue" : status.charAt(0).toUpperCase() + status.slice(1); const cls = overdue ? "bg-red-50 text-red-700 border-red-200" : status === "paid" ? "bg-green-50 text-green-700 border-green-200" : "bg-yellow-50 text-yellow-700 border-yellow-200"; return ( {label} ); } function PublicInvoiceView({ token }: { token: string }) { const [downloading, setDownloading] = useState(false); const { data: invoice, isLoading, error } = api.invoices.getByPublicToken.useQuery({ token }); const handleDownload = async () => { if (!invoice || downloading) return; setDownloading(true); try { await generateInvoicePDF({ invoiceNumber: invoice.invoiceNumber, invoicePrefix: invoice.invoicePrefix, issueDate: new Date(invoice.issueDate), dueDate: new Date(invoice.dueDate), status: invoice.status, totalAmount: invoice.totalAmount, taxRate: invoice.taxRate, currency: invoice.currency ?? "USD", notes: invoice.notes, business: invoice.business, client: invoice.client, items: invoice.items, }); } catch { toast.error("Failed to generate PDF"); } finally { setDownloading(false); } }; if (isLoading) { return (
); } if (error ?? !invoice) { return (

Invoice not found

This link may have expired or been revoked.

); } const subtotal = invoice.items.reduce((s, i) => s + i.amount, 0); const taxAmount = (subtotal * invoice.taxRate) / 100; const total = subtotal + taxAmount; const senderName = invoice.business ? invoice.business.nickname ? `${invoice.business.name} (${invoice.business.nickname})` : invoice.business.name : null; const hasLogo = Boolean(invoice.business?.logoStorageKey); const hideName = hasLogo && Boolean(invoice.business?.hideNameWithLogo); return (
{/* Card */}
{/* Header */}
{hasLogo && ( // Uploaded SVGs are sanitized and served by our route. next/image's // optimizer intentionally rejects SVG, so a native img is required. // eslint-disable-next-line @next/next/no-img-element )}
{!hideName && (

{senderName ?? "Invoice"}

)} {invoice.business?.email && (

{invoice.business.email}

)}
{/* Body */}
{/* Invoice meta */}

{invoice.invoiceNumber}

Issued {formatDate(invoice.issueDate)} · Due {formatDate(invoice.dueDate)}

{/* Bill to */}

Bill to

{invoice.client.name}

{invoice.client.email && (

{invoice.client.email}

)}
{/* Line items */}
{invoice.items.map((item) => (

{item.description}

{formatLineItemDetail( item.hours, item.rate, (amount) => formatCurrency(amount, invoice.currency ?? "USD"), )}

{formatCurrency(item.amount, invoice.currency ?? "USD")}

))}
{/* Totals */}
Subtotal {formatCurrency(subtotal, invoice.currency ?? "USD")}
{invoice.taxRate > 0 && (
Tax ({invoice.taxRate}%) {formatCurrency(taxAmount, invoice.currency ?? "USD")}
)}
Total {formatCurrency(total, invoice.currency ?? "USD")}
{/* Notes */} {invoice.notes && ( <>

Notes

{invoice.notes}

)} {/* PDF download */}
{/* Footer */}

Powered by beenvoice

); } export default function PublicInvoicePage() { const params = useParams(); const token = params.token as string; return ; }