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,
mold release/vacuum film, sandpaper/saw+drill bits/masking tape, stain/primer/
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`),
**CSV** (`section,item,field,value`), **Markdown**, **JSON** (backup), **Copy
summary** (clipboard). **Import** auto-detects `.json`/`.csv`: a CSV whose

View file

@ -1,6 +1,7 @@
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';
@ -86,6 +87,15 @@ interface CalcState {
currency: CurrencyCode;
/** Optional business/shop name, used on client quotes. */
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;
overheadFactor: number;
boardsPerMonth: number;
@ -105,6 +115,7 @@ const uid = () => Math.random().toString(36).slice(2, 9);
const DEFAULT_CALC: CalcState = {
currency: 'USD',
businessName: '',
quote: { logo: '', email: '', phone: '', website: '', address: '', notes: '' },
hourlyRate: 20,
overheadFactor: 2.5,
boardsPerMonth: 160,
@ -205,6 +216,16 @@ function parseCsv(text: string): string[][] {
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`. */
function calcCsvRows(s: CalcState): string[][] {
const rows: string[][] = [['section', 'item', 'field', 'value']];
@ -306,8 +327,7 @@ 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 {
/** 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());
@ -332,7 +352,7 @@ function materialsFromSupplierCsv(text: string): MaterialRow[] | null {
}
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 { toast } = useToast();
const [pickerOpen, setPickerOpen] = useState(false);
@ -416,7 +436,10 @@ export function DeckCostCalculator() {
if (!name) return;
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 exportJson = () => {
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`);
};
// Customer-facing quote (optional business name).
// 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;
let y = 84;
const q = s.quote;
let y = M + 24;
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;
// 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(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.text('QUOTE', M, y); y += 26;
doc.setDrawColor(220, 220, 220); doc.setLineWidth(0.7); doc.line(M, y, rightX, y); y += 26;
doc.text('QUOTE', M, y);
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.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.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.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 += 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.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.text('Generated with SkateSpots \u00b7 skate-spots.org', M, y);
doc.save(`deck-quote-${new Date().toISOString().slice(0, 10)}.pdf`);
};
@ -777,6 +821,20 @@ export function DeckCostCalculator() {
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) => (
<Input
type="number"
@ -1018,16 +1076,45 @@ export function DeckCostCalculator() {
</div>
<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. */}
<div className="mt-3">
<label className="mb-1 block text-[11px] font-semibold text-gray-400" htmlFor="calc-business">Quote name (optional — for the client PDF)</label>
<Input
id="calc-business"
value={s.businessName}
onChange={(e) => patch({ businessName: e.target.value })}
placeholder="Your name / shop"
className="max-w-xs bg-black/40 border-white/10"
/>
{/* Quote details — your identity on the client PDF. */}
<div className="mt-4 space-y-3 rounded-xl border border-white/10 bg-black/30 p-3">
<p className="text-[11px] font-black uppercase tracking-wider text-gray-500">Quote details (for the client PDF)</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="space-y-1">
<span className={label}>Business / shop name</span>
<Input value={s.businessName} onChange={(e) => patch({ businessName: e.target.value })} placeholder="Your name / shop" className="h-8 bg-black/40 border-white/10" />
</label>
<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>
{/* Turn the number into an action: list it at this price, or find someone