diff --git a/AGENTS.md b/AGENTS.md index 841ee62..52d9ae6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -842,11 +842,17 @@ session with **no other memory of this project**. So: 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 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`), + business name, a data-URL logo, email/phone/website/address, notes/terms, a + **quote # counter** (`Q-0007`, with a +1 button) and **validity days**; shows + logo + contact header, line item, total, `Quote valid until ` and notes, + not the internal breakdown)**, **PNG** summary card (canvas — now also draws + the **logo + business name**), **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 + summary** (clipboard). ⚠️ The **quote identity lives in its own store** + (`skatespots:deck-cost:quote`, `QuoteProfile`), **not** in `CalcState`, so + loading/saving presets keeps the same business name/logo/contact (the sheet is + numbers only). The sheet's key is `skatespots:deck-cost:v2`. **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 diff --git a/src/components/DeckCostCalculator.tsx b/src/components/DeckCostCalculator.tsx index 9c1d898..44a9110 100644 --- a/src/components/DeckCostCalculator.tsx +++ b/src/components/DeckCostCalculator.tsx @@ -83,19 +83,28 @@ function currencyLabel(cur: string): string { return s ? `${s} ${cur}` : cur; } +/** Client-quote identity — kept OUT of the sheet so presets share one identity. */ +interface QuoteProfile { + businessName: string; + /** Data URL — no upload/network. */ + logo: string; + email: string; + phone: string; + website: string; + address: string; + notes: string; + /** Quote number counter (displayed as `Q-0007`). */ + number: number; + /** Days the quote stays valid. */ + validDays: number; +} + +const DEFAULT_QUOTE: QuoteProfile = { + businessName: '', logo: '', email: '', phone: '', website: '', address: '', notes: '', number: 1, validDays: 30, +}; + 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; @@ -114,8 +123,6 @@ 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, @@ -355,6 +362,9 @@ export function DeckCostCalculator() { const [s, setS] = useLocalStorage('skatespots:deck-cost:v2', DEFAULT_CALC); const { data: rates } = useBtcRates(); const { toast } = useToast(); + // Quote identity lives in its own store, so switching presets keeps the same + // business name / logo / contact details. + const [quote, setQuote] = useLocalStorage('skatespots:deck-cost:quote', DEFAULT_QUOTE); const [pickerOpen, setPickerOpen] = useState(false); const [query, setQuery] = useState(''); const [presets, setPresets] = useLocalStorage<{ name: string; state: CalcState }[]>('skatespots:deck-cost-presets', []); @@ -437,7 +447,8 @@ export function DeckCostCalculator() { setPresets((prev) => [...prev.filter((p) => p.name !== name), { name, state: s }]); }; const loadPreset = (state: CalcState) => { - setS({ ...freshDefault(), ...state, quote: { ...DEFAULT_CALC.quote, ...(state.quote ?? {}) } }); + // Presets hold the sheet only — the quote identity is stored separately. + setS({ ...freshDefault(), ...state }); setLoadOpen(false); }; const deletePreset = (name: string) => setPresets((prev) => prev.filter((p) => p.name !== name)); @@ -577,7 +588,7 @@ export function DeckCostCalculator() { const pageW = doc.internal.pageSize.getWidth(); const M = 48; const rightX = pageW - M; - const q = s.quote; + const q = quote; let y = M + 24; // Header — logo left, business + contact right. @@ -589,7 +600,7 @@ export function DeckCostCalculator() { } 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.text(quote.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' })); @@ -600,8 +611,9 @@ export function DeckCostCalculator() { doc.setFont('helvetica', 'bold'); doc.setFontSize(26); doc.setTextColor(15, 15, 15); 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' }); + const now = new Date(); + doc.text(now.toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' }), rightX, y, { align: 'right' }); + doc.text(`Ref Q-${String(q.number).padStart(4, '0')}`, rightX, y + 12, { align: 'right' }); y += 34; doc.setDrawColor(230, 230, 230); doc.line(M, y, rightX, y); y += 20; @@ -622,14 +634,15 @@ export function DeckCostCalculator() { 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; + const until = new Date(now.getTime() + Math.max(1, q.validDays) * 86_400_000); + doc.text(`Does not include shipping. Quote valid until ${until.toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric' })}.`, M, y); y += 14; doc.text('Generated with SkateSpots \u00b7 skate-spots.org', M, y); doc.save(`deck-quote-${new Date().toISOString().slice(0, 10)}.pdf`); }; - // Shareable PNG summary card (canvas). - const exportPng = () => { + // Shareable PNG summary card (canvas). Carries the quote identity too. + const exportPng = async () => { const W = 1000; const H = 660; const c = document.createElement('canvas'); @@ -637,11 +650,25 @@ export function DeckCostCalculator() { const ctx = c.getContext('2d'); if (!ctx) return; ctx.fillStyle = '#0a0a0a'; ctx.fillRect(0, 0, W, H); + + // Logo (top-right) when the user has set one. + if (quote.logo.startsWith('data:image/')) { + try { + const img = await loadImage(quote.logo); + const ratio = Math.min(200 / img.width, 62 / img.height, 1); + ctx.drawImage(img, W - 48 - img.width * ratio, 42, img.width * ratio, img.height * ratio); + } catch { /* ignore a bad logo */ } + } + 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); + if (quote.businessName.trim()) { + ctx.fillStyle = '#e5e7eb'; ctx.font = 'bold 18px system-ui, sans-serif'; + ctx.textAlign = 'right'; ctx.fillText(quote.businessName.trim(), W - 48, 140); ctx.textAlign = 'left'; + } ctx.fillStyle = '#6b7280'; ctx.font = '13px system-ui, sans-serif'; ctx.fillText('SELLING PRICE · WITH PACKAGING', 48, 206); @@ -830,7 +857,7 @@ export function DeckCostCalculator() { return; } const reader = new FileReader(); - reader.onload = () => patch({ quote: { ...s.quote, logo: String(reader.result) } }); + reader.onload = () => setQuote({ ...quote, logo: String(reader.result) }); reader.readAsDataURL(file); e.target.value = ''; }; @@ -1082,38 +1109,49 @@ export function DeckCostCalculator() {
+ +
- {s.quote.logo && } + {quote.logo && } - {s.quote.logo && ( - + {quote.logo && ( + )}