diff --git a/AGENTS.md b/AGENTS.md index c81b68f..841ee62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/src/components/DeckCostCalculator.tsx b/src/components/DeckCostCalculator.tsx index 266905c..9c1d898 100644 --- a/src/components/DeckCostCalculator.tsx +++ b/src/components/DeckCostCalculator.tsx @@ -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 { + 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('skatespots:deck-cost', DEFAULT_CALC); + const [s, setS] = useLocalStorage('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) => { + 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) => (

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" - /> + {/* Quote details — your identity on the client PDF. */} +
+

Quote details (for the client PDF)

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