diff --git a/AGENTS.md b/AGENTS.md index 05fff36..c81b68f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -840,11 +840,14 @@ session with **no other memory of this project**. So: export remains (for Import/backup); Import reads JSON. The default **Materials** list is a full deck-build checklist (veneer/edge tape/logs, glue/epoxy/filler, mold release/vacuum film, sandpaper/saw+drill bits/masking tape, stain/primer/ - paint/clearcoat/grip). **Export formats (2026-10-05):** an **Export ▾** menu — - **PDF** (quote/print), **CSV** (spreadsheet, `section,item,field,value`), - **Markdown**, **JSON** (backup) — and **Import** auto-detects `.json`/`.csv` - (CSV round-trips the whole sheet; hand-rolled CSV parse/escape so commas in - names are safe). Next: write our own + paint/clearcoat/grip). **Export/Import (2026-10-05):** **Export ▾** — + **PDF (full sheet)**, **PDF (client quote, uses an optional `businessName`)**, + **PNG** summary card (canvas), **XLSX** multi-sheet workbook (lazy `xlsx`), + **CSV** (`section,item,field,value`), **Markdown**, **JSON** (backup), **Copy + summary** (clipboard). **Import** auto-detects `.json`/`.csv`: a CSV whose + header is `name,order_cost,…` is treated as a **supplier price list** (replaces + the Materials table), otherwise it round-trips the whole sheet. Hand-rolled CSV + parse/escape so commas in names are safe. Next: write our own step-by-step guides (NIP-23 `30023` with `t=diy`) to flip the coming-soon cards live. - **App Shop** (`/app-shop`, `src/pages/AppShopPage.tsx`): SkateSpots merch, to be diff --git a/package-lock.json b/package-lock.json index 34ef974..0b30dbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -107,6 +107,7 @@ "three": "^0.185.1", "vaul": "^0.9.3", "websocket-ts": "^2.2.1", + "xlsx": "^0.18.5", "zod": "^3.25.71" }, "devDependencies": { @@ -5306,6 +5307,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -6383,6 +6393,19 @@ "@capacitor/core": ">=8.0.0" } }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chai": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", @@ -6610,6 +6633,15 @@ "typescript": "^5" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -6723,6 +6755,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -7949,6 +7993,15 @@ "node": ">= 0.6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -11510,6 +11563,18 @@ "node": ">= 10.x" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -13356,6 +13421,24 @@ "bs58check": "^4.0.0" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -13548,6 +13631,27 @@ "node": ">=10.0.0" } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index b6e90f1..7f58a64 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "three": "^0.185.1", "vaul": "^0.9.3", "websocket-ts": "^2.2.1", + "xlsx": "^0.18.5", "zod": "^3.25.71" }, "devDependencies": { diff --git a/src/components/DeckCostCalculator.tsx b/src/components/DeckCostCalculator.tsx index b5d7faf..266905c 100644 --- a/src/components/DeckCostCalculator.tsx +++ b/src/components/DeckCostCalculator.tsx @@ -6,6 +6,7 @@ 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'; @@ -83,6 +84,8 @@ function currencyLabel(cur: string): string { interface CalcState { currency: CurrencyCode; + /** Optional business/shop name, used on client quotes. */ + businessName: string; hourlyRate: number; overheadFactor: number; boardsPerMonth: number; @@ -101,6 +104,7 @@ const uid = () => Math.random().toString(36).slice(2, 9); const DEFAULT_CALC: CalcState = { currency: 'USD', + businessName: '', hourlyRate: 20, overheadFactor: 2.5, boardsPerMonth: 160, @@ -302,9 +306,35 @@ function calcStateFromCsv(text: string): CalcState | null { 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', DEFAULT_CALC); const { data: rates } = useBtcRates(); + const { toast } = useToast(); const [pickerOpen, setPickerOpen] = useState(false); const [query, setQuery] = useState(''); const [presets, setPresets] = useLocalStorage<{ name: string; state: CalcState }[]>('skatespots:deck-cost-presets', []); @@ -516,6 +546,154 @@ export function DeckCostCalculator() { doc.save(`deck-cost-${new Date().toISOString().slice(0, 10)}.pdf`); }; + + // Customer-facing quote (optional business name). + 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; + let y = 84; + + doc.setFillColor(255, 0, 64); doc.rect(M, y - 24, 40, 4, 'F'); + doc.setFont('helvetica', 'bold'); doc.setFontSize(20); doc.setTextColor(15, 15, 15); + doc.text(s.businessName.trim() || 'SkateSpots', M, y); y += 20; + doc.setFont('helvetica', 'normal'); doc.setFontSize(10); doc.setTextColor(120, 120, 120); + doc.text(new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' }), M, y); y += 44; + + doc.setFont('helvetica', 'bold'); doc.setFontSize(26); doc.setTextColor(15, 15, 15); + doc.text('QUOTE', M, y); y += 26; + doc.setDrawColor(220, 220, 220); doc.setLineWidth(0.7); doc.line(M, y, rightX, y); y += 26; + + 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 += 18; + doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120); + doc.text('Hand-built, finished and packed. Quantity 1.', M, y); y += 30; + doc.setDrawColor(220, 220, 220); 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 += 32; + + doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120); + doc.text('Does not include shipping. Quote valid 30 days.', M, y); y += 14; + if (s.businessName.trim()) { doc.text(`Prepared by ${s.businessName.trim()}.`, M, y); y += 14; } + doc.text('Generated with SkateSpots · skate-spots.org', M, y); + doc.save(`deck-quote-${new Date().toISOString().slice(0, 10)}.pdf`); + }; + + // Shareable PNG summary card (canvas). + const exportPng = () => { + 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); + 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); + + 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); @@ -580,8 +758,12 @@ export function DeckCostCalculator() { try { const text = String(reader.result); if (isCsv) { - const parsed = calcStateFromCsv(text); - if (parsed) setS(parsed); + 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) }); } @@ -836,6 +1018,18 @@ export function DeckCostCalculator() {

Does not include shipping to the buyer.

+ {/* Optional business name — appears on the client quote PDF. */} +
+ + patch({ businessName: e.target.value })} + placeholder="Your name / shop" + className="max-w-xs bg-black/40 border-white/10" + /> +
+ {/* Turn the number into an action: list it at this price, or find someone to build it. */}
@@ -882,10 +1076,14 @@ export function DeckCostCalculator() { {[ - { label: 'PDF (print / quote)', run: () => void exportPdf() }, + { label: 'PDF — full sheet', run: () => void exportPdf() }, + { label: 'PDF — client quote', run: () => void exportQuotePdf() }, + { label: 'PNG image', run: exportPng }, + { label: 'XLSX workbook', run: () => void exportXlsx() }, { label: 'CSV (spreadsheet)', run: exportCsv }, { label: 'Markdown', run: exportMarkdown }, { label: 'JSON (backup)', run: exportJson }, + { label: 'Copy summary', run: () => void copySummary() }, ].map((o) => (