// Shared primitives for the HTML-first invoice templates: config -> CSS // variable mapping, value formatting, and the print-ready page frame. // // Design rule (OPL-2602): templates NEVER hardcode colors inline. Every color // is exposed once here as a `--inv-*` CSS variable — user-chosen brand colors // come straight from `InvoiceTemplateConfig`, and the neutral "paper" palette // has its single source of truth in `configToCssVars`. Templates reference // `var(--inv-*)` only. import type { CSSProperties, ReactNode } from "react"; import type { InvoicePageSize, InvoiceTemplateConfig, InvoiceTemplateData, InvoiceTemplateLineItem, } from "./types"; // ── Value formatting ─────────────────────────────────────── export function formatInvoiceCurrency(cents: number, currency = "USD"): string { return new Intl.NumberFormat("en-US", { style: "currency", currency, minimumFractionDigits: 2, }).format(cents / 100); } export function formatInvoiceDate(value: string | Date): string { const date = value instanceof Date ? value : new Date(value); return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", }).format(date); } export function formatInvoiceDateShort(value: string | Date): string { const date = value instanceof Date ? value : new Date(value); return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", }).format(date); } /** Tracked minutes -> display hours (one decimal), matching app previews. */ export function minutesToHours(minutes: number): string { return (minutes / 60).toFixed(1); } /** Display string for a line quantity: hours stored as minutes -> "1.5"; unit counts -> "4". */ export function formatLineQuantity( item: Pick, ): string { return item.quantityType === undefined || item.quantityType === null || item.quantityType === "hours" ? minutesToHours(item.quantity) : String(item.quantity); } /** Column header for the quantity column: "Hours" when every item is hours-type (or unset), else "Qty". */ export function lineQuantityHeader( items: ReadonlyArray>, ): string { return items.every( (item) => item.quantityType === undefined || item.quantityType === null || item.quantityType === "hours", ) ? "Hours" : "Qty"; } /** Strip the trailing " - 1.2 hours" suffix some descriptions carry. */ export function cleanLineDescription(description: string): string { return description.replace(/ - [\d.]+ hours$/, ""); } // ── Config -> CSS variables ──────────────────────────────── /** * Map an `InvoiceTemplateConfig` to the `--inv-*` CSS custom properties every * template renders against. Brand colors come from the config; the neutral * paper palette is centralized here so there is exactly one place that holds a * literal color value. */ export function configToCssVars(config: InvoiceTemplateConfig): CSSProperties { return { "--inv-primary": config.primaryColor, "--inv-accent": config.accentColor, "--inv-text": config.textColor, "--inv-paper": "#ffffff", "--inv-muted": "#6b7280", "--inv-muted-strong": "#374151", "--inv-border": "#e5e7eb", "--inv-subtle": "#f9fafb", "--inv-subtle-strong": "#f3f4f6", } as CSSProperties; } // ── Page geometry ────────────────────────────────────────── const PAGE_DIMENSIONS: Record = { a4: { css: "A4", width: "210mm" }, letter: { css: "letter", width: "8.5in" }, }; /** * Base, print-ready CSS shared by all templates. Scoped under * `.scribe-invoice` so it never leaks into the host app's styles. Includes * `@page` sizing, page-break protection, and orphan/widow control so the same * markup renders cleanly on screen and through a headless-browser PDF. */ export function baseInvoiceCss(pageSize: InvoicePageSize): string { const page = PAGE_DIMENSIONS[pageSize]; return ` .scribe-invoice { --inv-gap: 32px; box-sizing: border-box; width: 100%; max-width: ${page.width}; margin: 0 auto; padding: 48px; background: var(--inv-paper); color: var(--inv-text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; border-radius: 12px; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08), 0 8px 24px rgba(15, 23, 42, 0.06); } .scribe-invoice * { box-sizing: border-box; } .scribe-invoice p { margin: 0; } .scribe-invoice table { width: 100%; border-collapse: collapse; } .scribe-invoice .si-break-avoid { break-inside: avoid; } .scribe-invoice .si-flow { orphans: 3; widows: 3; } @page { size: ${page.css}; margin: 0.5in; } @media print { .scribe-invoice { max-width: none; margin: 0; padding: 0; border-radius: 0; box-shadow: none; } .scribe-invoice .si-no-print { display: none !important; } } `; } // ── Page frame ───────────────────────────────────────────── export interface InvoiceFrameProps { config: InvoiceTemplateConfig; pageSize: InvoicePageSize; /** Template-specific CSS appended after the shared base CSS. */ css: string; className?: string; children: ReactNode; } /** * Root wrapper for every template. Establishes the CSS-variable scope, injects * the print-ready stylesheet, and renders a paper-like surface that looks the * same on screen as it will in the future PDF output. */ export function InvoiceFrame({ config, pageSize, css, className, children, }: InvoiceFrameProps) { return (
{/* Static, build-time template CSS — no user input is interpolated, so a plain string child is safe (and avoids dangerouslySetInnerHTML). */} {children}
); } // ── Default config ───────────────────────────────────────── /** * A neutral default config for surfaces that render a template without a * stored one (e.g. the web preview). Mirrors the DB "classic" defaults without * importing the DB layer, keeping this module free of drizzle/sqlite imports. */ export const DEFAULT_HTML_TEMPLATE_CONFIG: InvoiceTemplateConfig = { companyName: "Your Company", primaryColor: "#0f172a", accentColor: "#22d3ee", textColor: "#374151", headerStyle: "classic", showLogo: true, showCompanyDetails: true, showHours: true, showRate: true, showProjectBreakdown: true, footerText: "Thank you for your business!", }; /** True when an invoice has anything to render in its footer/notes region. */ export function hasInvoiceFooter( invoice: InvoiceTemplateData, config: InvoiceTemplateConfig, ): boolean { return Boolean( invoice.notes || invoice.paymentNotes || config.footerText || config.paymentInstructions || (config.paymentMethods && config.paymentMethods.length > 0), ); }