From 71f53bcc519720e28323b44a4bfc008f98f00fb2 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Mon, 5 Oct 2026 10:36:25 +0700 Subject: [PATCH] Deck cost calculator: export a PDF by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toolbar's primary Export now generates a real PDF (lazy-loaded jspdf) — a formatted one-page summary: labour, equipment, operations, materials, processes, packaging, margins and the selling prices, plus the date/currency. Print-to-PDF was rejected because window.print() doesn't work in the Capacitor WebView. A secondary JSON export stays (for Import/backup); Import still reads JSON. jspdf is dynamically imported so it never enters the initial bundle. --- AGENTS.md | 6 +- package-lock.json | 177 +++++++++++++++++++++++++- package.json | 1 + src/components/DeckCostCalculator.tsx | 89 ++++++++++++- 4 files changed, 266 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ef53596..758e5eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -832,7 +832,11 @@ session with **no other memory of this project**. So: download/file), and Reset now confirms. The results include a **"List a deck at this price →"** link to `/create-listing?price¤cy` (CreateListingPage reads both params) and **"Get it made →"** to - `/lab?craft=building`. Next: write our own + `/lab?craft=building`. **Export (2026-10-05):** the primary export is a **PDF** + (lazy-loaded `jspdf`) — a formatted summary of every section + the selling + price; the reason it's a real PDF and not print-to-PDF is that + `window.print()` doesn't work in the Capacitor WebView. A secondary **JSON** + export remains (for Import/backup); Import reads JSON. 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 ca5e9c8..34ef974 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,6 +84,7 @@ "idb": "^8.0.3", "idb-keyval": "^6.3.0", "input-otp": "^1.2.4", + "jspdf": "^4.2.1", "leaflet": "^1.9.4", "leaflet.markercluster": "^1.5.3", "lucide-react": "^0.462.0", @@ -244,9 +245,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz", - "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -4711,6 +4712,12 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", @@ -4728,6 +4735,13 @@ "@types/node": "*" } }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "18.3.20", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.20.tgz", @@ -4778,6 +4792,13 @@ "meshoptimizer": "~1.1.1" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/web-push": { "version": "3.6.4", "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", @@ -6333,6 +6354,26 @@ "canonicalize": "bin/canonicalize.js" } }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/capacitor-secure-storage-plugin": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/capacitor-secure-storage-plugin/-/capacitor-secure-storage-plugin-0.13.0.tgz", @@ -6650,6 +6691,21 @@ "node": ">=6.6.0" } }, + "node_modules/core-js": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -7054,6 +7110,16 @@ "csstype": "^3.0.2" } }, + "node_modules/dompurify": { + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dot-prop": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", @@ -7719,6 +7785,17 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fast-uri": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", @@ -7759,7 +7836,6 @@ "version": "0.8.3", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", - "dev": true, "license": "MIT" }, "node_modules/file-entry-cache": { @@ -8422,6 +8498,12 @@ "node": ">=12" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/ip-address": { "version": "10.7.2", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", @@ -8753,6 +8835,23 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -9533,6 +9632,22 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -9652,6 +9767,13 @@ "dev": true, "license": "MIT" }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -10149,6 +10271,16 @@ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/range-parser": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", @@ -10504,6 +10636,13 @@ "node": ">=8" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -10583,6 +10722,16 @@ "node": ">=0.10.0" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rimraf": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", @@ -11368,6 +11517,16 @@ "dev": true, "license": "MIT" }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -11598,6 +11757,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/package.json b/package.json index c374f03..b6e90f1 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "idb": "^8.0.3", "idb-keyval": "^6.3.0", "input-otp": "^1.2.4", + "jspdf": "^4.2.1", "leaflet": "^1.9.4", "leaflet.markercluster": "^1.5.3", "lucide-react": "^0.462.0", diff --git a/src/components/DeckCostCalculator.tsx b/src/components/DeckCostCalculator.tsx index 54638d2..fe682e6 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 { dateLocaleCode } from '@/lib/dateFnsI18n'; import { cn } from '@/lib/utils'; /** @@ -229,6 +230,87 @@ export function DeckCostCalculator() { 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 left = 40; + let y = 48; + + const ensure = (needed: number) => { + if (y + needed > pageH - 40) { doc.addPage(); y = 48; } + }; + const text = (str: string, size = 10, bold = false, gray = 20) => { + ensure(size + 5); + doc.setFontSize(size); + doc.setFont('helvetica', bold ? 'bold' : 'normal'); + doc.setTextColor(gray, gray, gray); + doc.text(str, left, y); + y += size + 5; + }; + const heading = (str: string) => { y += 6; text(str.toUpperCase(), 10, true, 90); y += 2; }; + const row = (k: string, v: string) => { + ensure(13); + doc.setFontSize(9); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(70, 70, 70); + doc.text(k, left, y); + doc.setTextColor(20, 20, 20); + doc.text(v, pageW - 40, y, { align: 'right' }); + y += 13; + }; + + text('SkateSpots — Deck cost calculator', 15, true); + text(new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' }), 9, false, 120); + row('Currency', cur === 'sats' ? 'sats (Bitcoin)' : cur); + + heading('Labor'); + row('Hourly rate', fmt(s.hourlyRate)); + row('Overhead factor', String(s.overheadFactor)); + row('Labor cost', `${fmt(s.hourlyRate * s.overheadFactor)}/hr`); + + heading('Equipment'); + s.equipment.forEach((e) => row(e.name, `${fmt(e.cost)} · ${e.boards.toLocaleString()} boards → ${fmt(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)}`)); + row('Equipment per board', fmt(r.equipment)); + + heading('Operation expenses'); + row('Boards / month', String(s.boardsPerMonth)); + row('Rent / month', fmt(s.rent)); + row('Utilities / month', fmt(s.utilities)); + row('Subscriptions / month', fmt(s.subscriptions)); + row('Per board', fmt(r.operations)); + + heading('Materials'); + s.materials.forEach((m) => row(m.name, `${fmt(m.orderCost)}${m.shipping ? ` + ${fmt(m.shipping)} ship` : ''} · ${m.boards.toLocaleString()} boards`)); + row('Materials per board', fmt(r.materials)); + + heading('Process costs'); + s.processes.forEach((p) => row(p.name, `${p.minutes} min / ${p.boards} boards`)); + row('Processes per board', fmt(r.processes)); + + heading('Packaging'); + s.packaging.forEach((p) => row(p.name, `${p.minutesPerBoard} min · ${fmt(p.materialsPerBoard)}`)); + row('Packaging per board', fmt(r.packaging)); + + heading('Result'); + row('Profit margin', `${s.profitMargin}×`); + row('Scrap', `${s.scrapPct}%`); + row('Cost / board (no packaging)', fmt(r.costNoPack)); + row('Cost / board (with packaging)', fmt(r.costWithPack)); + y += 4; + text(`Selling price (no packaging): ${fmt(r.priceNoPack)}`, 12, true, 20); + text(`Selling price (with packaging): ${fmt(r.priceWithPack)}`, 12, true, 20); + text('Does not include shipping to the buyer.', 8, false, 120); + + y += 8; + text('Generated by SkateSpots · skate-spots.org', 8, false, 130); + + doc.save(`deck-cost-${new Date().toISOString().slice(0, 10)}.pdf`); + }; const importJson = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; @@ -523,8 +605,11 @@ export function DeckCostCalculator() { ))} - +