import { useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Plus, Trash2, RotateCcw, Search, ChevronDown, ChevronRight, Check, Save, FolderOpen, Download, Upload } from 'lucide-react'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useBtcRates } from '@/hooks/useExchangeRate'; import { useToast } from '@/hooks/useToast'; import { dateLocaleCode } from '@/lib/dateFnsI18n'; import { cn } from '@/lib/utils'; /** * Deck cost calculator — a self-contained tool for working out what a deck * costs to make and a fair selling price (labour, equipment amortisation, * operation expenses, materials, process time, packaging, margin, scrap). * * No server, no accounts: state lives in localStorage. Mirrors the well-known * open-source skateboard pricing model. Column headers stay English on purpose * (technical terms); results are labelled by the host page. */ interface EquipmentRow { id: string; name: string; cost: number; setupHrs: number; boards: number } interface MaterialRow { id: string; name: string; orderCost: number; shipping: number; boards: number; orderMin: number; unloadMin: number } interface ProcessRow { id: string; name: string; minutes: number; boards: number } interface PackageRow { id: string; name: string; minutesPerBoard: number; materialsPerBoard: number } type CurrencyCode = string; const CURRENCY_SYMBOLS: Record = { USD: '$', EUR: '€', GBP: '£', JPY: '¥', CNY: '¥', AUD: 'A$', CAD: 'C$', CHF: 'CHF', HKD: 'HK$', SGD: 'S$', NZD: 'NZ$', SEK: 'kr', NOK: 'kr', DKK: 'kr', PLN: 'zł', CZK: 'Kč', HUF: 'Ft', RON: 'lei', BGN: 'лв', TRY: '₺', RUB: '₽', UAH: '₴', INR: '₹', KRW: '₩', TWD: 'NT$', THB: '฿', MYR: 'RM', IDR: 'Rp', PHP: '₱', VND: '₫', BRL: 'R$', MXN: 'MX$', ARS: 'AR$', CLP: 'CL$', COP: 'CO$', PEN: 'S/', ZAR: 'R', NGN: '₦', KES: 'KSh', GHS: '₵', EGP: 'E£', ILS: '₪', PKR: '₨', BDT: '৳', LKR: 'Rs', NPR: 'Rs', }; /** ISO-4217 fiat codes — the only unit currencies we offer (plus `sats`). Keeps * crypto/stables (BTC, ETH, USDT, …) out of the picker. */ const FIAT_CODES = new Set([ 'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', 'BAM', 'BBD', 'BDT', 'BGN', 'BHD', 'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', 'BTN', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLP', 'CNY', 'COP', 'CRC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', 'ERN', 'ETB', 'EUR', 'FJD', 'FKP', 'GBP', 'GEL', 'GGP', 'GHS', 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HRK', 'HTG', 'HUF', 'IDR', 'ILS', 'IMP', 'INR', 'IQD', 'IRR', 'ISK', 'JEP', 'JMD', 'JOD', 'JPY', 'KES', 'KGS', 'KHR', 'KMF', 'KRW', 'KWD', 'KYD', 'KZT', 'LAK', 'LBP', 'LKR', 'LRD', 'LSL', 'LYD', 'MAD', 'MDL', 'MGA', 'MKD', 'MMK', 'MNT', 'MOP', 'MRU', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD', 'NGN', 'NIO', 'NOK', 'NPR', 'NZD', 'OMR', 'PAB', 'PEN', 'PGK', 'PHP', 'PKR', 'PLN', 'PYG', 'QAR', 'RON', 'RSD', 'RUB', 'RWF', 'SAR', 'SBD', 'SCR', 'SDG', 'SEK', 'SGD', 'SHP', 'SLL', 'SOS', 'SRD', 'SSP', 'STN', 'SYP', 'SZL', 'THB', 'TJS', 'TMT', 'TND', 'TOP', 'TRY', 'TTD', 'TWD', 'TZS', 'UAH', 'UGX', 'USD', 'UYU', 'UZS', 'VES', 'VND', 'VUV', 'WST', 'XAF', 'XCD', 'XOF', 'XPF', 'YER', 'ZAR', 'ZMW', ]); /** How a currency is shown: `$1.00`, `CHF 1.00`, `1,000 sats`, `0.00012345 BTC`. */ function formatMoney(v: number, cur: string): string { if (cur === 'sats') return `${Math.round(v).toLocaleString()} sats`; if (cur === 'BTC') { const str = v.toFixed(8).replace(/0+$/, '').replace(/\.$/, ''); return `${str || '0'} BTC`; } const s = CURRENCY_SYMBOLS[cur]; const n = v.toFixed(2); if (!s) return `${cur} ${n}`; return /[A-Za-z]/.test(s) ? `${s} ${n}` : `${s}${n}`; } /** Bitcoin is one asset shown two ways — `sats` and `BTC` are both offered. */ const BITCOIN_UNITS: Record = { sats: 1e8, BTC: 1 }; /** Units of a currency per 1 BTC (sats/BTC = fixed; fiats come from rates). */ function ratePerBtc(cur: string, rates?: Record): number | undefined { return BITCOIN_UNITS[cur] ?? rates?.[cur]; } /** Chip/trigger label, e.g. `₿ sats`, `₿ BTC`, `$ USD`. */ function currencyLabel(cur: string): string { if (cur === 'sats') return '₿ sats'; if (cur === 'BTC') return '₿ BTC'; const s = CURRENCY_SYMBOLS[cur]; return s ? `${s} ${cur}` : cur; } /** Client-quote identity — kept OUT of the sheet so presets share one identity. */ interface QuoteProfile { businessName: string; /** Data URL — no upload/network. */ logo: string; email: string; phone: string; website: string; address: string; notes: string; /** Quote number counter (displayed as `Q-0007`). */ number: number; /** Days the quote stays valid. */ validDays: number; } const DEFAULT_QUOTE: QuoteProfile = { businessName: '', logo: '', email: '', phone: '', website: '', address: '', notes: '', number: 1, validDays: 30, }; interface CalcState { currency: CurrencyCode; hourlyRate: number; overheadFactor: number; boardsPerMonth: number; rent: number; utilities: number; subscriptions: number; profitMargin: number; scrapPct: number; equipment: EquipmentRow[]; materials: MaterialRow[]; processes: ProcessRow[]; packaging: PackageRow[]; } const uid = () => Math.random().toString(36).slice(2, 9); const DEFAULT_CALC: CalcState = { currency: 'USD', hourlyRate: 20, overheadFactor: 2.5, boardsPerMonth: 160, rent: 1000, utilities: 100, subscriptions: 50, profitMargin: 2.5, scrapPct: 5, equipment: [ { id: uid(), name: 'Molds', cost: 900, setupHrs: 1, boards: 10000 }, { id: uid(), name: 'Press', cost: 5000, setupHrs: 1, boards: 5000 }, { id: uid(), name: '3D Printer', cost: 900, setupHrs: 8, boards: 100000 }, { id: uid(), name: 'CNC Router', cost: 6000, setupHrs: 24, boards: 40000 }, { id: uid(), name: 'Saws', cost: 200, setupHrs: 0, boards: 10000 }, { id: uid(), name: 'Sanders', cost: 200, setupHrs: 0, boards: 5000 }, { id: uid(), name: 'Spraying Equipment', cost: 500, setupHrs: 0, boards: 100000 }, { id: uid(), name: 'Graphics Equipment', cost: 400, setupHrs: 8, boards: 10000 }, { id: uid(), name: 'Other Equipment', cost: 0, setupHrs: 0, boards: 100000 }, ], materials: [ // Wood & core { id: uid(), name: 'Veneer (maple plies)', orderCost: 1500, shipping: 500, boards: 200, orderMin: 15, unloadMin: 120 }, { id: uid(), name: 'Veneer tape / edge tape', orderCost: 0, shipping: 0, boards: 100, orderMin: 5, unloadMin: 0 }, { id: uid(), name: 'Logs (if milling your own veneer)', orderCost: 80, shipping: 0, boards: 500, orderMin: 0, unloadMin: 0 }, // Adhesives & fillers { id: uid(), name: 'Wood glue / epoxy resin', orderCost: 35, shipping: 0, boards: 10, orderMin: 20, unloadMin: 0 }, { id: uid(), name: 'Wood filler / putty', orderCost: 0, shipping: 0, boards: 50, orderMin: 0, unloadMin: 0 }, // Press / mold consumables { id: uid(), name: 'Mold release / wax paper', orderCost: 0, shipping: 0, boards: 100, orderMin: 0, unloadMin: 0 }, { id: uid(), name: 'Vacuum bag / breather film', orderCost: 0, shipping: 0, boards: 200, orderMin: 0, unloadMin: 0 }, // Shaping consumables { id: uid(), name: 'Sandpaper', orderCost: 30, shipping: 0, boards: 100, orderMin: 5, unloadMin: 15 }, { id: uid(), name: 'Saw blades', orderCost: 20, shipping: 0, boards: 500, orderMin: 5, unloadMin: 0 }, { id: uid(), name: 'Drill bits / router bits', orderCost: 50, shipping: 0, boards: 500, orderMin: 5, unloadMin: 0 }, { id: uid(), name: 'Masking tape', orderCost: 0, shipping: 0, boards: 100, orderMin: 0, unloadMin: 0 }, // Finish & graphics { id: uid(), name: 'Stain / dye', orderCost: 0, shipping: 0, boards: 50, orderMin: 0, unloadMin: 0 }, { id: uid(), name: 'Primer / base coat', orderCost: 0, shipping: 0, boards: 50, orderMin: 0, unloadMin: 0 }, { id: uid(), name: 'Paint / screen-print ink', orderCost: 0, shipping: 0, boards: 50, orderMin: 0, unloadMin: 0 }, { id: uid(), name: 'Clearcoat / lacquer', orderCost: 0, shipping: 0, boards: 50, orderMin: 0, unloadMin: 0 }, { id: uid(), name: 'Grip tape', orderCost: 0, shipping: 0, boards: 50, orderMin: 0, unloadMin: 0 }, { id: uid(), name: 'Other (hardware, coatings, etc.)', orderCost: 0, shipping: 0, boards: 50, orderMin: 5, unloadMin: 0 }, ], processes: [ { id: uid(), name: 'Glue & Press', minutes: 20, boards: 3 }, { id: uid(), name: 'Marking & Registration', minutes: 2, boards: 1 }, { id: uid(), name: 'Drilling', minutes: 5, boards: 3 }, { id: uid(), name: 'Shaping', minutes: 4, boards: 1 }, { id: uid(), name: 'Routing Edges', minutes: 1, boards: 1 }, { id: uid(), name: 'Sanding', minutes: 10, boards: 1 }, { id: uid(), name: 'Clearcoating', minutes: 5, boards: 1 }, { id: uid(), name: 'Graphics', minutes: 4, boards: 1 }, { id: uid(), name: 'Other Processes', minutes: 0, boards: 1 }, ], packaging: [ { id: uid(), name: 'Sticker & poly bag', minutesPerBoard: 4, materialsPerBoard: 0.06 }, { id: uid(), name: 'Pack board', minutesPerBoard: 8, materialsPerBoard: 0.67 }, { id: uid(), name: 'Drop off', minutesPerBoard: 10, materialsPerBoard: 0 }, ], }; /** A pristine default with fresh row ids (so React keys don't collide). */ function freshDefault(): CalcState { return { ...DEFAULT_CALC, equipment: DEFAULT_CALC.equipment.map((e) => ({ ...e, id: uid() })), materials: DEFAULT_CALC.materials.map((m) => ({ ...m, id: uid() })), processes: DEFAULT_CALC.processes.map((p) => ({ ...p, id: uid() })), packaging: DEFAULT_CALC.packaging.map((p) => ({ ...p, id: uid() })), }; } // ─── CSV helpers (round-trippable sheet; also handy for supplier price lists) ── function csvEscape(v: string): string { return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v; } function toCsv(rows: string[][]): string { return rows.map((r) => r.map(csvEscape).join(',')).join('\n'); } function parseCsv(text: string): string[][] { const rows: string[][] = []; let row: string[] = []; let field = ''; let inQuotes = false; for (let i = 0; i < text.length; i++) { const c = text[i]; if (inQuotes) { if (c === '"') { if (text[i + 1] === '"') { field += '"'; i++; } else { inQuotes = false; } } else field += c; } else if (c === '"') inQuotes = true; else if (c === ',') { row.push(field); field = ''; } else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; } else if (c !== '\r') field += c; } if (field.length > 0 || row.length > 0) { row.push(field); rows.push(row); } return rows.filter((r) => r.some((c) => c !== '')); } /** Load an image data URL (for embedding a quote logo in the PDF). */ function loadImage(src: string): Promise { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = reject; img.src = src; }); } /** Rows for the CSV export: `section,item,field,value`. */ function calcCsvRows(s: CalcState): string[][] { const rows: string[][] = [['section', 'item', 'field', 'value']]; const num = (n: number) => String(n); rows.push(['settings', 'currency', 'value', s.currency]); rows.push(['labor', 'hourly_rate', 'value', num(s.hourlyRate)]); rows.push(['labor', 'overhead_factor', 'value', num(s.overheadFactor)]); rows.push(['operations', 'boards_per_month', 'value', num(s.boardsPerMonth)]); rows.push(['operations', 'rent', 'value', num(s.rent)]); rows.push(['operations', 'utilities', 'value', num(s.utilities)]); rows.push(['operations', 'subscriptions', 'value', num(s.subscriptions)]); rows.push(['margins', 'profit_margin', 'value', num(s.profitMargin)]); rows.push(['margins', 'scrap_pct', 'value', num(s.scrapPct)]); for (const e of s.equipment) { rows.push(['equipment', e.name, 'cost', num(e.cost)]); rows.push(['equipment', e.name, 'setup_hrs', num(e.setupHrs)]); rows.push(['equipment', e.name, 'boards', num(e.boards)]); } for (const m of s.materials) { rows.push(['materials', m.name, 'order_cost', num(m.orderCost)]); rows.push(['materials', m.name, 'shipping', num(m.shipping)]); rows.push(['materials', m.name, 'boards', num(m.boards)]); rows.push(['materials', m.name, 'order_min', num(m.orderMin)]); rows.push(['materials', m.name, 'unload_min', num(m.unloadMin)]); } for (const p of s.processes) { rows.push(['processes', p.name, 'minutes', num(p.minutes)]); rows.push(['processes', p.name, 'boards', num(p.boards)]); } for (const p of s.packaging) { rows.push(['packaging', p.name, 'minutes_per_board', num(p.minutesPerBoard)]); rows.push(['packaging', p.name, 'materials_per_board', num(p.materialsPerBoard)]); } return rows; } /** Parse an exported CSV back into a CalcState (unknown sections ignored). */ function calcStateFromCsv(text: string): CalcState | null { const rows = parseCsv(text); if (rows.length < 2) return null; const out = freshDefault(); const eq = new Map(); const ma = new Map(); const pr = new Map(); const pk = new Map(); const n = (v: string) => Number(v) || 0; for (const r of rows.slice(1)) { const [section, item, field, value] = r; if (!section || !field) continue; if (section === 'settings' && field === 'value') { out.currency = item || 'USD'; continue; } if (section === 'labor' && field === 'value') { if (item === 'hourly_rate') out.hourlyRate = n(value); else if (item === 'overhead_factor') out.overheadFactor = n(value); continue; } if (section === 'operations' && field === 'value') { if (item === 'boards_per_month') out.boardsPerMonth = n(value); else if (item === 'rent') out.rent = n(value); else if (item === 'utilities') out.utilities = n(value); else if (item === 'subscriptions') out.subscriptions = n(value); continue; } if (section === 'margins' && field === 'value') { if (item === 'profit_margin') out.profitMargin = n(value); else if (item === 'scrap_pct') out.scrapPct = n(value); continue; } if (section === 'equipment') { const row = eq.get(item) ?? { id: uid(), name: item, cost: 0, setupHrs: 0, boards: 1 }; if (field === 'cost') row.cost = n(value); else if (field === 'setup_hrs') row.setupHrs = n(value); else if (field === 'boards') row.boards = n(value); eq.set(item, row); } else if (section === 'materials') { const row = ma.get(item) ?? { id: uid(), name: item, orderCost: 0, shipping: 0, boards: 1, orderMin: 0, unloadMin: 0 }; if (field === 'order_cost') row.orderCost = n(value); else if (field === 'shipping') row.shipping = n(value); else if (field === 'boards') row.boards = n(value); else if (field === 'order_min') row.orderMin = n(value); else if (field === 'unload_min') row.unloadMin = n(value); ma.set(item, row); } else if (section === 'processes') { const row = pr.get(item) ?? { id: uid(), name: item, minutes: 0, boards: 1 }; if (field === 'minutes') row.minutes = n(value); else if (field === 'boards') row.boards = n(value); pr.set(item, row); } else if (section === 'packaging') { const row = pk.get(item) ?? { id: uid(), name: item, minutesPerBoard: 0, materialsPerBoard: 0 }; if (field === 'minutes_per_board') row.minutesPerBoard = n(value); else if (field === 'materials_per_board') row.materialsPerBoard = n(value); pk.set(item, row); } } out.equipment = [...eq.values()]; out.materials = [...ma.values()]; out.processes = [...pr.values()]; out.packaging = [...pk.values()]; return out; } /** Supplier price-list shape: `name,order_cost,shipping,boards,order_min,unload_min`. */function materialsFromSupplierCsv(text: string): MaterialRow[] | null { const rows = parseCsv(text); if (rows.length < 2) return null; const header = rows[0].map((h) => h.trim().toLowerCase()); const idx = (n: string) => header.indexOf(n); if (idx('name') !== 0 || idx('order_cost') < 0) return null; const out: MaterialRow[] = []; for (const r of rows.slice(1)) { const name = (r[idx('name')] ?? '').trim(); if (!name) continue; const n = (col: string) => (idx(col) >= 0 ? Number(r[idx(col)]) || 0 : 0); out.push({ id: uid(), name, orderCost: n('order_cost'), shipping: n('shipping'), boards: n('boards') || 1, orderMin: n('order_min'), unloadMin: n('unload_min'), }); } return out.length > 0 ? out : null; } export function DeckCostCalculator() { const [s, setS] = useLocalStorage('skatespots:deck-cost:v2', DEFAULT_CALC); const { data: rates } = useBtcRates(); const { toast } = useToast(); // Quote identity lives in its own store, so switching presets keeps the same // business name / logo / contact details. const [quote, setQuote] = useLocalStorage('skatespots:deck-cost:quote', DEFAULT_QUOTE); const [pickerOpen, setPickerOpen] = useState(false); const [query, setQuery] = useState(''); const [presets, setPresets] = useLocalStorage<{ name: string; state: CalcState }[]>('skatespots:deck-cost-presets', []); const [loadOpen, setLoadOpen] = useState(false); const cur = s.currency; const unit = cur === 'sats' ? 'sats' : (CURRENCY_SYMBOLS[cur] ?? cur); const fmt = (n: number) => formatMoney(Number.isFinite(n) ? n : 0, cur); const currencies = useMemo(() => { const fiat = rates ? Object.keys(rates).filter((c) => FIAT_CODES.has(c)).sort() : ['USD', 'EUR', 'GBP']; return ['sats', 'BTC', ...fiat]; }, [rates]); const filteredCurrencies = query ? currencies.filter((c) => c.toLowerCase().includes(query.toLowerCase())) : currencies; // Convert every MONEY field when the unit changes (unitless fields like the // overhead factor, board counts, minutes and margins are left untouched). const changeCurrency = (to: CurrencyCode) => { setPickerOpen(false); setQuery(''); setS((prev) => { if (to === prev.currency) return prev; const rf = ratePerBtc(prev.currency, rates); const rt = ratePerBtc(to, rates); if (!rf || !rt) return { ...prev, currency: to }; // no rate → relabel only const k = rt / rf; const r2 = (v: number) => to === 'sats' ? Math.round(v * k) : to === 'BTC' ? Math.round(v * k * 1e8) / 1e8 : Math.round(v * k * 100) / 100; return { ...prev, currency: to, hourlyRate: r2(prev.hourlyRate), rent: r2(prev.rent), utilities: r2(prev.utilities), subscriptions: r2(prev.subscriptions), equipment: prev.equipment.map((e) => ({ ...e, cost: r2(e.cost) })), materials: prev.materials.map((m) => ({ ...m, orderCost: r2(m.orderCost), shipping: r2(m.shipping) })), packaging: prev.packaging.map((p) => ({ ...p, materialsPerBoard: r2(p.materialsPerBoard) })), }; }); }; const r = useMemo(() => { const laborPerHr = s.hourlyRate * s.overheadFactor; const laborPerMin = laborPerHr / 60; const equipment = s.equipment.reduce((a, e) => a + (e.boards > 0 ? (e.cost + e.setupHrs * laborPerHr) / e.boards : 0), 0); const operations = s.boardsPerMonth > 0 ? (s.rent + s.utilities + s.subscriptions) / s.boardsPerMonth : 0; const materials = s.materials.reduce((a, m) => a + (m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * laborPerMin) / m.boards : 0), 0); const processes = s.processes.reduce((a, p) => a + (p.boards > 0 ? (p.minutes * laborPerMin) / p.boards : 0), 0); const packaging = s.packaging.reduce((a, p) => a + p.materialsPerBoard + p.minutesPerBoard * laborPerMin, 0); const costNoPack = equipment + operations + materials + processes; const costWithPack = costNoPack + packaging; const factor = s.scrapPct < 100 ? s.profitMargin / (1 - s.scrapPct / 100) : s.profitMargin; return { laborPerHr, laborPerMin, equipment, operations, materials, processes, packaging, costNoPack, costWithPack, priceNoPack: costNoPack * factor, priceWithPack: costWithPack * factor, }; }, [s]); const patch = (p: Partial) => setS((prev) => ({ ...prev, ...p })); const savePreset = () => { const name = window.prompt('Name this preset', `Preset ${presets.length + 1}`); if (!name) return; setPresets((prev) => [...prev.filter((p) => p.name !== name), { name, state: s }]); }; const loadPreset = (state: CalcState) => { // Presets hold the sheet only — the quote identity is stored separately. setS({ ...freshDefault(), ...state }); setLoadOpen(false); }; const deletePreset = (name: string) => setPresets((prev) => prev.filter((p) => p.name !== name)); const exportJson = () => { const blob = new Blob([JSON.stringify(s, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'deck-cost.json'; a.click(); URL.revokeObjectURL(url); }; // A real PDF (jsPDF) — print-to-PDF doesn't work inside the native WebView. // jsPDF is imported lazily so it never touches the initial bundle. const exportPdf = async () => { const { jsPDF } = await import('jspdf'); const doc = new jsPDF({ unit: 'pt', format: 'a4' }); const pageW = doc.internal.pageSize.getWidth(); const pageH = doc.internal.pageSize.getHeight(); const M = 48; const rightX = pageW - M; const contentW = pageW - M * 2; let y = M + 8; const ensure = (h: number) => { if (y + h > pageH - M) { doc.addPage(); y = M; } }; const setText = (size: number, bold: boolean, gray: number) => { doc.setFontSize(size); doc.setFont('helvetica', bold ? 'bold' : 'normal'); doc.setTextColor(gray, gray, gray); }; const section = (title: string) => { ensure(52); y += 12; setText(10, true, 40); doc.text(title.toUpperCase(), M, y); y += 6; doc.setDrawColor(180, 220, 0); doc.setLineWidth(1.4); doc.line(M, y, M + 26, y); y += 12; doc.setDrawColor(230, 230, 230); doc.setLineWidth(0.6); doc.line(M, y, rightX, y); y += 12; }; const kv = (label: string, value: string, accent = false) => { ensure(15); setText(9.5, false, 95); doc.text(label, M, y); setText(9.5, true, accent ? 40 : 20); doc.text(value, rightX, y, { align: 'right' }); y += 15; }; const item = (name: string, value: string, detail?: string) => { ensure(detail ? 30 : 16); setText(9.5, true, 20); doc.text(name, M, y); setText(9.5, true, 20); doc.text(value, rightX, y, { align: 'right' }); y += 12; if (detail) { setText(8, false, 135); doc.text(detail, M, y); y += 12; } y += 2; }; // ── Header ── doc.setFillColor(255, 0, 64); doc.rect(M, y - 12, 34, 3, 'F'); setText(17, true, 15); doc.text('SkateSpots — Deck cost calculator', M, y + 8); y += 26; setText(9, false, 120); doc.text( `${new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })} · ${cur === 'sats' ? 'sats (Bitcoin)' : cur === 'BTC' ? 'BTC (Bitcoin)' : cur}`, M, y, ); y += 10; section('Labor'); kv('Hourly rate', fmt(s.hourlyRate)); kv('Overhead factor', `${s.overheadFactor}\u00d7`); kv('Labor cost', `${fmt(s.hourlyRate * s.overheadFactor)}/hr`); section('Equipment'); s.equipment.forEach((e) => { const per = e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0; item(e.name, fmt(per), `${fmt(e.cost)}${e.setupHrs ? ` + ${e.setupHrs}h setup` : ''} \u00b7 ${e.boards.toLocaleString()} boards`); }); kv('Equipment per board', fmt(r.equipment), true); section('Operation expenses'); kv('Boards / month', String(s.boardsPerMonth)); kv('Rent / month', fmt(s.rent)); kv('Utilities / month', fmt(s.utilities)); kv('Subscriptions / month', fmt(s.subscriptions)); kv('Per board', fmt(r.operations), true); section('Materials'); s.materials.forEach((m) => { const per = m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0; item(m.name, fmt(per), `${fmt(m.orderCost)}${m.shipping ? ` + ${fmt(m.shipping)} ship` : ''} \u00b7 ${m.boards.toLocaleString()} boards`); }); kv('Materials per board', fmt(r.materials), true); section('Process costs'); s.processes.forEach((p) => item(p.name, fmt(p.boards > 0 ? (p.minutes * r.laborPerMin) / p.boards : 0), `${p.minutes} min${p.boards > 1 ? ` / ${p.boards} boards` : ''}`)); kv('Processes per board', fmt(r.processes), true); section('Packaging'); s.packaging.forEach((p) => item(p.name, fmt(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin), `${p.minutesPerBoard} min \u00b7 ${fmt(p.materialsPerBoard)} materials`)); kv('Packaging per board', fmt(r.packaging), true); // ── Result (kept together) ── ensure(180); section('Result'); kv('Profit margin', `${s.profitMargin}\u00d7`); kv('Scrap', `${s.scrapPct}%`); kv('Cost / board (no packaging)', fmt(r.costNoPack)); kv('Cost / board (with packaging)', fmt(r.costWithPack)); y += 6; const priceBox = (label: string, value: string) => { ensure(36); doc.setFillColor(244, 252, 218); doc.roundedRect(M, y - 2, contentW, 27, 4, 4, 'F'); setText(9, false, 90); doc.text(label, M + 10, y + 16); setText(14, true, 30); doc.text(value, rightX - 10, y + 17, { align: 'right' }); y += 36; }; priceBox('Selling price (no packaging)', fmt(r.priceNoPack)); priceBox('Selling price (with packaging)', fmt(r.priceWithPack)); ensure(30); y += 4; setText(8, false, 135); doc.text('Does not include shipping to the buyer.', M, y); y += 16; setText(8, false, 155); doc.text('Generated by SkateSpots \u00b7 skate-spots.org', M, y); // ── Page numbers ── const pages = doc.getNumberOfPages(); for (let i = 1; i <= pages; i++) { doc.setPage(i); setText(8, false, 160); doc.text(`${i} / ${pages}`, rightX, pageH - 24, { align: 'right' }); } doc.save(`deck-cost-${new Date().toISOString().slice(0, 10)}.pdf`); }; // Customer-facing quote: logo + business block + line item + notes. const exportQuotePdf = async () => { const { jsPDF } = await import('jspdf'); const doc = new jsPDF({ unit: 'pt', format: 'a4' }); const pageW = doc.internal.pageSize.getWidth(); const M = 48; const rightX = pageW - M; const q = quote; let y = M + 24; // Header — logo left, business + contact right. if (q.logo.startsWith('data:image/')) { try { const img = await loadImage(q.logo); const ratio = Math.min(150 / img.width, 64 / img.height, 1); doc.addImage(q.logo, q.logo.startsWith('data:image/png') ? 'PNG' : 'JPEG', M, y, img.width * ratio, img.height * ratio); } catch { /* ignore a bad logo */ } } doc.setFont('helvetica', 'bold'); doc.setFontSize(16); doc.setTextColor(15, 15, 15); doc.text(quote.businessName.trim() || 'SkateSpots', rightX, y + 10, { align: 'right' }); doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120); const contact = [q.email, q.phone, q.website, q.address].map((v) => v.trim()).filter(Boolean); contact.forEach((line, i) => doc.text(line, rightX, y + 26 + i * 12, { align: 'right' })); y += Math.max(64, 26 + contact.length * 12) + 22; doc.setDrawColor(220, 220, 220); doc.setLineWidth(0.7); doc.line(M, y, rightX, y); y += 30; doc.setFont('helvetica', 'bold'); doc.setFontSize(26); doc.setTextColor(15, 15, 15); doc.text('QUOTE', M, y); doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120); const now = new Date(); doc.text(now.toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' }), rightX, y, { align: 'right' }); doc.text(`Ref Q-${String(q.number).padStart(4, '0')}`, rightX, y + 12, { align: 'right' }); y += 34; doc.setDrawColor(230, 230, 230); doc.line(M, y, rightX, y); y += 20; doc.setFont('helvetica', 'bold'); doc.setFontSize(12); doc.setTextColor(20, 20, 20); doc.text('Custom skateboard deck', M, y); doc.text(fmt(r.priceWithPack), rightX, y, { align: 'right' }); y += 16; doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120); doc.text('Hand-built, finished and packed. Quantity 1.', M, y); y += 16; doc.setDrawColor(230, 230, 230); doc.line(M, y, rightX, y); y += 24; doc.setFont('helvetica', 'bold'); doc.setFontSize(14); doc.setTextColor(20, 20, 20); doc.text('Total', M, y); doc.text(fmt(r.priceWithPack), rightX, y, { align: 'right' }); y += 30; if (q.notes.trim()) { doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(90, 90, 90); const wrapped = doc.splitTextToSize(q.notes.trim(), pageW - M * 2) as string[]; doc.text(wrapped, M, y); y += wrapped.length * 12 + 10; } doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120); const until = new Date(now.getTime() + Math.max(1, q.validDays) * 86_400_000); doc.text(`Does not include shipping. Quote valid until ${until.toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })}.`, M, y); y += 14; doc.text('Generated with SkateSpots \u00b7 skate-spots.org', M, y); doc.save(`deck-quote-${new Date().toISOString().slice(0, 10)}.pdf`); }; // Shareable PNG summary card (canvas). Carries the quote identity too. const exportPng = async () => { const W = 1000; const H = 660; const c = document.createElement('canvas'); c.width = W; c.height = H; const ctx = c.getContext('2d'); if (!ctx) return; ctx.fillStyle = '#0a0a0a'; ctx.fillRect(0, 0, W, H); // Logo (top-right) when the user has set one. if (quote.logo.startsWith('data:image/')) { try { const img = await loadImage(quote.logo); const ratio = Math.min(200 / img.width, 62 / img.height, 1); ctx.drawImage(img, W - 48 - img.width * ratio, 42, img.width * ratio, img.height * ratio); } catch { /* ignore a bad logo */ } } ctx.fillStyle = '#ff0040'; ctx.fillRect(48, 46, 64, 6); ctx.fillStyle = '#ffffff'; ctx.font = 'bold 34px system-ui, sans-serif'; ctx.fillText('Deck cost', 48, 110); ctx.fillStyle = '#9ca3af'; ctx.font = '16px system-ui, sans-serif'; ctx.fillText(`${new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })} · ${currencyLabel(cur)}`, 48, 140); if (quote.businessName.trim()) { ctx.fillStyle = '#e5e7eb'; ctx.font = 'bold 18px system-ui, sans-serif'; ctx.textAlign = 'right'; ctx.fillText(quote.businessName.trim(), W - 48, 140); ctx.textAlign = 'left'; } ctx.fillStyle = '#6b7280'; ctx.font = '13px system-ui, sans-serif'; ctx.fillText('SELLING PRICE · WITH PACKAGING', 48, 206); ctx.fillStyle = '#ccff00'; ctx.font = 'bold 46px system-ui, sans-serif'; ctx.fillText(fmt(r.priceWithPack), 48, 256); ctx.fillStyle = '#6b7280'; ctx.font = '13px system-ui, sans-serif'; ctx.fillText('SELLING PRICE · NO PACKAGING', 540, 206); ctx.fillStyle = '#e5e7eb'; ctx.font = 'bold 32px system-ui, sans-serif'; ctx.fillText(fmt(r.priceNoPack), 540, 250); const rows: [string, number][] = [ ['Equipment', r.equipment], ['Operations', r.operations], ['Materials', r.materials], ['Processes', r.processes], ['Packaging', r.packaging], ]; let y = 330; ctx.font = '15px system-ui, sans-serif'; for (const [label, val] of rows) { ctx.fillStyle = '#9ca3af'; ctx.fillText(label, 48, y); ctx.fillStyle = '#e5e7eb'; ctx.textAlign = 'right'; ctx.fillText(fmt(val), W - 48, y); ctx.textAlign = 'left'; y += 30; } y += 12; ctx.fillStyle = '#6b7280'; ctx.font = '13px system-ui, sans-serif'; ctx.fillText('COST / BOARD (WITH PACKAGING)', 48, y); ctx.fillStyle = '#ffffff'; ctx.font = 'bold 24px system-ui, sans-serif'; ctx.fillText(fmt(r.costWithPack), 48, y + 32); ctx.fillStyle = '#6b7280'; ctx.font = '12px system-ui, sans-serif'; ctx.fillText('Generated with SkateSpots · skate-spots.org', 48, H - 40); const a = document.createElement('a'); a.href = c.toDataURL('image/png'); a.download = 'deck-cost.png'; a.click(); }; // Multi-sheet XLSX workbook (lazy — SheetJS is only loaded on demand). const exportXlsx = async () => { const XLSX = await import('xlsx'); const wb = XLSX.utils.book_new(); const num = (v: number) => Number(v.toFixed(4)); const summary: (string | number)[][] = [ ['SkateSpots — Deck cost calculator'], [], ['Currency', cur], ['Date', new Date().toISOString().slice(0, 10)], [], ['Cost / board (no packaging)', num(r.costNoPack)], ['Cost / board (with packaging)', num(r.costWithPack)], ['Selling price (no packaging)', num(r.priceNoPack)], ['Selling price (with packaging)', num(r.priceWithPack)], [], ['Equipment per board', num(r.equipment)], ['Operations per board', num(r.operations)], ['Materials per board', num(r.materials)], ['Processes per board', num(r.processes)], ['Packaging per board', num(r.packaging)], ]; XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(summary), 'Summary'); XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([ ['Equipment', 'Cost', 'Setup (h)', 'Boards', 'Per board'], ...s.equipment.map((e) => [e.name, e.cost, e.setupHrs, e.boards, num(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)]), ]), 'Equipment'); XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([ ['Material', 'Order cost', 'Shipping', 'Boards', 'Order (min)', 'Unload (min)', 'Per board'], ...s.materials.map((m) => [m.name, m.orderCost, m.shipping, m.boards, m.orderMin, m.unloadMin, num(m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0)]), ]), 'Materials'); XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([ ['Process', 'Time (min)', 'Boards', 'Per board'], ...s.processes.map((p) => [p.name, p.minutes, p.boards, num(p.boards > 0 ? (p.minutes * r.laborPerMin) / p.boards : 0)]), ]), 'Processes'); XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([ ['Packaging', 'Time/board (min)', 'Materials/board', 'Per board'], ...s.packaging.map((p) => [p.name, p.minutesPerBoard, p.materialsPerBoard, num(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin)]), ]), 'Packaging'); XLSX.writeFile(wb, `deck-cost-${new Date().toISOString().slice(0, 10)}.xlsx`); }; const copySummary = async () => { const text = [ `Deck cost — ${currencyLabel(cur)}`, `Cost / board (with packaging): ${fmt(r.costWithPack)}`, `Selling price (no packaging): ${fmt(r.priceNoPack)}`, `Selling price (with packaging): ${fmt(r.priceWithPack)}`, '', `Equipment ${fmt(r.equipment)} · Operations ${fmt(r.operations)} · Materials ${fmt(r.materials)} · Processes ${fmt(r.processes)} · Packaging ${fmt(r.packaging)}`, ].join('\n'); try { await navigator.clipboard.writeText(text); toast({ title: 'Summary copied' }); } catch { toast({ title: 'Could not copy', variant: 'destructive' }); } }; const download = (filename: string, content: string, mime: string) => { const blob = new Blob([content], { type: mime }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); }; const exportCsv = () => download('deck-cost.csv', toCsv(calcCsvRows(s)), 'text/csv'); const exportMarkdown = () => { const md = [ '# Deck cost calculator', '', `${new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })} · ${currencyLabel(cur)}`, '', '## Labor', `- Hourly rate: ${fmt(s.hourlyRate)}`, `- Overhead factor: ${s.overheadFactor}×`, `- Labor cost: ${fmt(s.hourlyRate * s.overheadFactor)}/hr`, '', '## Equipment', ...s.equipment.map((e) => `- ${e.name}: ${fmt(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)} (${fmt(e.cost)} · ${e.boards.toLocaleString()} boards)`), `- **Equipment per board: ${fmt(r.equipment)}**`, '', '## Operation expenses', `- Boards/month: ${s.boardsPerMonth}`, `- Rent: ${fmt(s.rent)}`, `- Utilities: ${fmt(s.utilities)}`, `- Subscriptions: ${fmt(s.subscriptions)}`, `- **Per board: ${fmt(r.operations)}**`, '', '## Materials', ...s.materials.map((m) => `- ${m.name}: ${fmt(m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0)} (${fmt(m.orderCost)} · ${m.boards.toLocaleString()} boards)`), `- **Materials per board: ${fmt(r.materials)}**`, '', '## Process costs', ...s.processes.map((p) => `- ${p.name}: ${fmt(p.boards > 0 ? (p.minutes * r.laborPerMin) / p.boards : 0)} (${p.minutes} min)`), `- **Processes per board: ${fmt(r.processes)}**`, '', '## Packaging', ...s.packaging.map((p) => `- ${p.name}: ${fmt(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin)}`), `- **Packaging per board: ${fmt(r.packaging)}**`, '', '## Result', `- Profit margin: ${s.profitMargin}× · Scrap: ${s.scrapPct}%`, `- Cost/board (no packaging): ${fmt(r.costNoPack)}`, `- Cost/board (with packaging): ${fmt(r.costWithPack)}`, `- **Selling price (no packaging): ${fmt(r.priceNoPack)}**`, `- **Selling price (with packaging): ${fmt(r.priceWithPack)}**`, '', '_Does not include shipping to the buyer. Generated by SkateSpots._', ].join('\n'); download('deck-cost.md', md, 'text/markdown'); }; const importFile = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const isCsv = file.name.toLowerCase().endsWith('.csv'); const reader = new FileReader(); reader.onload = () => { try { const text = String(reader.result); if (isCsv) { const mats = materialsFromSupplierCsv(text); if (mats) setS((prev) => ({ ...prev, materials: mats })); else { const parsed = calcStateFromCsv(text); if (parsed) setS(parsed); } } else { setS({ ...freshDefault(), ...(JSON.parse(text) as CalcState) }); } } catch { /* ignore malformed */ } }; reader.readAsText(file); e.target.value = ''; }; const reset = () => { if (!window.confirm('Reset everything to the example values?')) return; setS(freshDefault()); }; const onLogo = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; if (file.size > 500 * 1024) { toast({ title: 'Logo too large (max 500 KB)', variant: 'destructive' }); e.target.value = ''; return; } const reader = new FileReader(); reader.onload = () => setQuote({ ...quote, logo: String(reader.result) }); reader.readAsDataURL(file); e.target.value = ''; }; const numInput = (value: number, onChange: (n: number) => void, className?: string) => ( onChange(Number(e.target.value) || 0)} className={`h-8 min-w-16 ${className ?? ''}`} /> ); const th = 'px-2 py-1 text-left text-[10px] font-bold uppercase tracking-wider text-gray-500 whitespace-nowrap'; const td = 'px-1 py-1 align-middle'; const label = 'text-[11px] font-semibold text-gray-400'; return (
{/* Currency — searchable; switching CONVERTS every money field via live BTC rates (sats included). */}
Currency
setQuery(e.target.value)} placeholder="Search currency…" className="h-8 border-white/10 bg-white/5 pl-8 text-xs" />
{filteredCurrencies.map((c) => ( ))} {filteredCurrencies.length === 0 && (

No currency matches.

)}
{!rates && (

Live rates unavailable — switching relabels only.

)}
{rates && live BTC rates · sats included} {fmt(r.costWithPack)} cost · {fmt(r.priceWithPack)} sell
{/* Labor */}

Labor

Labor cost
{fmt(s.hourlyRate * s.overheadFactor)}/hr
{/* Equipment */}

Equipment

{s.equipment.map((e) => ( ))}
EquipmentCost ({unit})Setup (h)# boardsPer board
patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, name: ev.target.value } : x) })} className="h-8 min-w-36" /> {numInput(e.cost, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, cost: n } : x) }))} {numInput(e.setupHrs, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, setupHrs: n } : x) }))} {numInput(e.boards, (n) => patch({ equipment: s.equipment.map((x) => x.id === e.id ? { ...x, boards: n } : x) }))} {fmt(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)}

Equipment per board: {fmt(r.equipment)}

{/* Operations */}

Operation expenses

Per board: {fmt(r.operations)}

{/* Materials */}

Materials

{s.materials.map((m) => { const per = m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0; const up = (p: Partial) => patch({ materials: s.materials.map((x) => x.id === m.id ? { ...x, ...p } : x) }); return ( ); })}
MaterialOrder ({unit})Ship ({unit})# boardsOrder (m)Unload (m)Per board
up({ name: e.target.value })} className="h-8 min-w-36" /> {numInput(m.orderCost, (n) => up({ orderCost: n }))} {numInput(m.shipping, (n) => up({ shipping: n }))} {numInput(m.boards, (n) => up({ boards: n }))} {numInput(m.orderMin, (n) => up({ orderMin: n }))} {numInput(m.unloadMin, (n) => up({ unloadMin: n }))} {fmt(per)}

Materials per board: {fmt(r.materials)}

{/* Processes */}

Process costs

{s.processes.map((p) => { const per = p.boards > 0 ? (p.minutes * r.laborPerMin) / p.boards : 0; const up = (pp: Partial) => patch({ processes: s.processes.map((x) => x.id === p.id ? { ...x, ...pp } : x) }); return ( ); })}
ProcessTime (m)# boardsPer board
up({ name: e.target.value })} className="h-8 min-w-36" /> {numInput(p.minutes, (n) => up({ minutes: n }))} {numInput(p.boards, (n) => up({ boards: n }))} {fmt(per)}

Processes per board: {fmt(r.processes)}

{/* Packaging */}

Packaging

{s.packaging.map((p) => { const per = p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin; const up = (pp: Partial) => patch({ packaging: s.packaging.map((x) => x.id === p.id ? { ...x, ...pp } : x) }); return ( ); })}
PackagingTime/board (m)Materials/board ({unit})Per board
up({ name: e.target.value })} className="h-8 min-w-36" /> {numInput(p.minutesPerBoard, (n) => up({ minutesPerBoard: n }))} {numInput(p.materialsPerBoard, (n) => up({ materialsPerBoard: n }))} {fmt(per)}

Packaging per board: {fmt(r.packaging)}

{/* Margins + results */}

Margins

Cost / board (no packaging)
{fmt(r.costNoPack)}
Cost / board (with packaging)
{fmt(r.costWithPack)}

Selling price · no packaging

{fmt(r.priceNoPack)}

Selling price · with packaging

{fmt(r.priceWithPack)}

Does not include shipping to the buyer.

{/* Quote details — your identity on the client PDF. */}

Quote details (for the client PDF)

{quote.logo && } {quote.logo && ( )}