Deck cost calculator: quote logo + business/contact details + notes

A "Quote details" block (in the Result section) lets the user set a business/
shop name, a logo (stored as a data URL — no upload/network, capped 500 KB),
email/phone/website/address and notes/terms. The client quote PDF now renders a
logo + contact header, the line item + total, and the notes/terms. Bumped the
localStorage key to :v2 (and guarded preset/JSON loads) so an older saved sheet
without the quote block can't crash the tool.
This commit is contained in:
SkateSpots 2026-10-05 10:59:28 +07:00
parent 1a8c483682
commit c8cbb848e1
2 changed files with 119 additions and 30 deletions

View file

@ -841,7 +841,9 @@ session with **no other memory of this project**. So:
list is a full deck-build checklist (veneer/edge tape/logs, glue/epoxy/filler, 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/ mold release/vacuum film, sandpaper/saw+drill bits/masking tape, stain/primer/
paint/clearcoat/grip). **Export/Import (2026-10-05):** **Export ▾** — paint/clearcoat/grip). **Export/Import (2026-10-05):** **Export ▾** —
**PDF (full sheet)**, **PDF (client quote, uses an optional `businessName`)**, **PDF (full sheet)**, **PDF (client quote — uses a "Quote details" block:
business name, a data-URL logo, email/phone/website/address, notes/terms; shows
logo + contact header, line item, total and notes, not the internal breakdown)**,
**PNG** summary card (canvas), **XLSX** multi-sheet workbook (lazy `xlsx`), **PNG** summary card (canvas), **XLSX** multi-sheet workbook (lazy `xlsx`),
**CSV** (`section,item,field,value`), **Markdown**, **JSON** (backup), **Copy **CSV** (`section,item,field,value`), **Markdown**, **JSON** (backup), **Copy
summary** (clipboard). **Import** auto-detects `.json`/`.csv`: a CSV whose summary** (clipboard). **Import** auto-detects `.json`/`.csv`: a CSV whose

View file

@ -1,6 +1,7 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Plus, Trash2, RotateCcw, Search, ChevronDown, ChevronRight, Check, Save, FolderOpen, Download, Upload } from 'lucide-react'; import { Plus, Trash2, RotateCcw, Search, ChevronDown, ChevronRight, Check, Save, FolderOpen, Download, Upload } from 'lucide-react';
@ -86,6 +87,15 @@ interface CalcState {
currency: CurrencyCode; currency: CurrencyCode;
/** Optional business/shop name, used on client quotes. */ /** Optional business/shop name, used on client quotes. */
businessName: string; businessName: string;
/** Client-quote details (the logo is a data URL — no upload/network). */
quote: {
logo: string;
email: string;
phone: string;
website: string;
address: string;
notes: string;
};
hourlyRate: number; hourlyRate: number;
overheadFactor: number; overheadFactor: number;
boardsPerMonth: number; boardsPerMonth: number;
@ -105,6 +115,7 @@ const uid = () => Math.random().toString(36).slice(2, 9);
const DEFAULT_CALC: CalcState = { const DEFAULT_CALC: CalcState = {
currency: 'USD', currency: 'USD',
businessName: '', businessName: '',
quote: { logo: '', email: '', phone: '', website: '', address: '', notes: '' },
hourlyRate: 20, hourlyRate: 20,
overheadFactor: 2.5, overheadFactor: 2.5,
boardsPerMonth: 160, boardsPerMonth: 160,
@ -205,6 +216,16 @@ function parseCsv(text: string): string[][] {
return rows.filter((r) => r.some((c) => c !== '')); 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<HTMLImageElement> {
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`. */ /** Rows for the CSV export: `section,item,field,value`. */
function calcCsvRows(s: CalcState): string[][] { function calcCsvRows(s: CalcState): string[][] {
const rows: string[][] = [['section', 'item', 'field', 'value']]; const rows: string[][] = [['section', 'item', 'field', 'value']];
@ -306,8 +327,7 @@ function calcStateFromCsv(text: string): CalcState | null {
return out; return out;
} }
/** Supplier price-list shape: `name,order_cost,shipping,boards,order_min,unload_min`. */ /** Supplier price-list shape: `name,order_cost,shipping,boards,order_min,unload_min`. */function materialsFromSupplierCsv(text: string): MaterialRow[] | null {
function materialsFromSupplierCsv(text: string): MaterialRow[] | null {
const rows = parseCsv(text); const rows = parseCsv(text);
if (rows.length < 2) return null; if (rows.length < 2) return null;
const header = rows[0].map((h) => h.trim().toLowerCase()); const header = rows[0].map((h) => h.trim().toLowerCase());
@ -332,7 +352,7 @@ function materialsFromSupplierCsv(text: string): MaterialRow[] | null {
} }
export function DeckCostCalculator() { export function DeckCostCalculator() {
const [s, setS] = useLocalStorage<CalcState>('skatespots:deck-cost', DEFAULT_CALC); const [s, setS] = useLocalStorage<CalcState>('skatespots:deck-cost:v2', DEFAULT_CALC);
const { data: rates } = useBtcRates(); const { data: rates } = useBtcRates();
const { toast } = useToast(); const { toast } = useToast();
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
@ -416,7 +436,10 @@ export function DeckCostCalculator() {
if (!name) return; if (!name) return;
setPresets((prev) => [...prev.filter((p) => p.name !== name), { name, state: s }]); setPresets((prev) => [...prev.filter((p) => p.name !== name), { name, state: s }]);
}; };
const loadPreset = (state: CalcState) => { setS(state); setLoadOpen(false); }; const loadPreset = (state: CalcState) => {
setS({ ...freshDefault(), ...state, quote: { ...DEFAULT_CALC.quote, ...(state.quote ?? {}) } });
setLoadOpen(false);
};
const deletePreset = (name: string) => setPresets((prev) => prev.filter((p) => p.name !== name)); const deletePreset = (name: string) => setPresets((prev) => prev.filter((p) => p.name !== name));
const exportJson = () => { const exportJson = () => {
const blob = new Blob([JSON.stringify(s, null, 2)], { type: 'application/json' }); const blob = new Blob([JSON.stringify(s, null, 2)], { type: 'application/json' });
@ -547,40 +570,61 @@ export function DeckCostCalculator() {
doc.save(`deck-cost-${new Date().toISOString().slice(0, 10)}.pdf`); doc.save(`deck-cost-${new Date().toISOString().slice(0, 10)}.pdf`);
}; };
// Customer-facing quote (optional business name). // Customer-facing quote: logo + business block + line item + notes.
const exportQuotePdf = async () => { const exportQuotePdf = async () => {
const { jsPDF } = await import('jspdf'); const { jsPDF } = await import('jspdf');
const doc = new jsPDF({ unit: 'pt', format: 'a4' }); const doc = new jsPDF({ unit: 'pt', format: 'a4' });
const pageW = doc.internal.pageSize.getWidth(); const pageW = doc.internal.pageSize.getWidth();
const M = 48; const M = 48;
const rightX = pageW - M; const rightX = pageW - M;
let y = 84; const q = s.quote;
let y = M + 24;
doc.setFillColor(255, 0, 64); doc.rect(M, y - 24, 40, 4, 'F'); // Header — logo left, business + contact right.
doc.setFont('helvetica', 'bold'); doc.setFontSize(20); doc.setTextColor(15, 15, 15); if (q.logo.startsWith('data:image/')) {
doc.text(s.businessName.trim() || 'SkateSpots', M, y); y += 20; try {
doc.setFont('helvetica', 'normal'); doc.setFontSize(10); doc.setTextColor(120, 120, 120); const img = await loadImage(q.logo);
doc.text(new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' }), M, y); y += 44; 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(s.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.setFont('helvetica', 'bold'); doc.setFontSize(26); doc.setTextColor(15, 15, 15);
doc.text('QUOTE', M, y); y += 26; doc.text('QUOTE', M, y);
doc.setDrawColor(220, 220, 220); doc.setLineWidth(0.7); doc.line(M, y, rightX, y); y += 26; doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120);
doc.text(new Date().toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' }), rightX, y, { align: 'right' });
doc.text(`Ref ${new Date().toISOString().slice(0, 10)}`, 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.setFont('helvetica', 'bold'); doc.setFontSize(12); doc.setTextColor(20, 20, 20);
doc.text('Custom skateboard deck', M, y); doc.text('Custom skateboard deck', M, y);
doc.text(fmt(r.priceWithPack), rightX, y, { align: 'right' }); y += 18; doc.text(fmt(r.priceWithPack), rightX, y, { align: 'right' }); y += 16;
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120); 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.text('Hand-built, finished and packed. Quantity 1.', M, y); y += 16;
doc.setDrawColor(220, 220, 220); doc.line(M, y, rightX, y); y += 24; 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.setFont('helvetica', 'bold'); doc.setFontSize(14); doc.setTextColor(20, 20, 20);
doc.text('Total', M, y); doc.text('Total', M, y);
doc.text(fmt(r.priceWithPack), rightX, y, { align: 'right' }); y += 32; 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); 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; 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 \u00b7 skate-spots.org', M, y);
doc.text('Generated with SkateSpots · skate-spots.org', M, y);
doc.save(`deck-quote-${new Date().toISOString().slice(0, 10)}.pdf`); doc.save(`deck-quote-${new Date().toISOString().slice(0, 10)}.pdf`);
}; };
@ -777,6 +821,20 @@ export function DeckCostCalculator() {
setS(freshDefault()); setS(freshDefault());
}; };
const onLogo = (e: React.ChangeEvent<HTMLInputElement>) => {
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 = () => patch({ quote: { ...s.quote, logo: String(reader.result) } });
reader.readAsDataURL(file);
e.target.value = '';
};
const numInput = (value: number, onChange: (n: number) => void, className?: string) => ( const numInput = (value: number, onChange: (n: number) => void, className?: string) => (
<Input <Input
type="number" type="number"
@ -1018,16 +1076,45 @@ export function DeckCostCalculator() {
</div> </div>
<p className="mt-2 text-[11px] text-gray-500">Does not include shipping to the buyer.</p> <p className="mt-2 text-[11px] text-gray-500">Does not include shipping to the buyer.</p>
{/* Optional business name — appears on the client quote PDF. */} {/* Quote details — your identity on the client PDF. */}
<div className="mt-3"> <div className="mt-4 space-y-3 rounded-xl border border-white/10 bg-black/30 p-3">
<label className="mb-1 block text-[11px] font-semibold text-gray-400" htmlFor="calc-business">Quote name (optional — for the client PDF)</label> <p className="text-[11px] font-black uppercase tracking-wider text-gray-500">Quote details (for the client PDF)</p>
<Input <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
id="calc-business" <label className="space-y-1">
value={s.businessName} <span className={label}>Business / shop name</span>
onChange={(e) => patch({ businessName: e.target.value })} <Input value={s.businessName} onChange={(e) => patch({ businessName: e.target.value })} placeholder="Your name / shop" className="h-8 bg-black/40 border-white/10" />
placeholder="Your name / shop" </label>
className="max-w-xs bg-black/40 border-white/10" <label className="space-y-1">
/> <span className={label}>Email</span>
<Input value={s.quote.email} onChange={(e) => patch({ quote: { ...s.quote, email: e.target.value } })} placeholder="you@example.com" className="h-8 bg-black/40 border-white/10" />
</label>
<label className="space-y-1">
<span className={label}>Phone</span>
<Input value={s.quote.phone} onChange={(e) => patch({ quote: { ...s.quote, phone: e.target.value } })} placeholder="+1 …" className="h-8 bg-black/40 border-white/10" />
</label>
<label className="space-y-1">
<span className={label}>Website</span>
<Input value={s.quote.website} onChange={(e) => patch({ quote: { ...s.quote, website: e.target.value } })} placeholder="yourshop.com" className="h-8 bg-black/40 border-white/10" />
</label>
<label className="space-y-1 sm:col-span-2">
<span className={label}>Address</span>
<Input value={s.quote.address} onChange={(e) => patch({ quote: { ...s.quote, address: e.target.value } })} placeholder="Street, city" className="h-8 bg-black/40 border-white/10" />
</label>
</div>
<div className="flex flex-wrap items-center gap-3">
{s.quote.logo && <img src={s.quote.logo} alt="" className="h-10 rounded border border-white/10" />}
<label className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/15 px-3 text-xs font-semibold hover:bg-white/5">
<Upload className="h-3.5 w-3.5" /> {s.quote.logo ? 'Change logo' : 'Add logo'}
<input type="file" accept="image/png,image/jpeg" className="hidden" onChange={onLogo} />
</label>
{s.quote.logo && (
<button type="button" onClick={() => patch({ quote: { ...s.quote, logo: '' } })} className="min-h-0 text-xs text-gray-500 hover:text-red-400">Remove</button>
)}
</div>
<label className="block space-y-1">
<span className={label}>Notes / terms</span>
<Textarea value={s.quote.notes} onChange={(e) => patch({ quote: { ...s.quote, notes: e.target.value } })} rows={2} placeholder="Lead time, deposit, warranty…" className="bg-black/40 border-white/10" />
</label>
</div> </div>
{/* Turn the number into an action: list it at this price, or find someone {/* Turn the number into an action: list it at this price, or find someone