From 5b9616a86d7731d176dc5444dc15a15eaa7b8600 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Mon, 5 Oct 2026 11:16:37 +0700 Subject: [PATCH] Deck calculator quotes: payment terms, methods, deposit and optional Incoterms QuoteProfile gains payment methods (multi-select chips), a deposit percent (PDF splits "Deposit due now" / "Balance on completion"), free-text payment terms, separate payment details (bank/PayPal), and an optional Incoterm (EXW/FCA/DAP/DDP). The client PDF gains a payment block; the PNG shows the deposit. Quote state is now merged over DEFAULT_QUOTE on read so a quote saved before a new field existed cannot crash the tool. --- AGENTS.md | 26 +++++- src/components/DeckCostCalculator.tsx | 114 ++++++++++++++++++++++++-- 2 files changed, 133 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 25b0b9d..3b7a703 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -858,13 +858,37 @@ session with **no other memory of this project**. So: `nip19.npubEncode`). The client PDF lists `NIP-05 …` / `nostr npub…` in the contact block and `Pay via Lightning: ` under the total; the PNG shows the NIP-05 (or npub) under the business name. A PDF is a **snapshot** — the identity - is verifiable on Nostr, not on the paper. **Import** + is verifiable on Nostr, not on the paper. + **Payment terms on the quote (2026-10-05):** `QuoteProfile` also carries + **payment methods** (multi-select chips: Cash/Lightning/Cashu/Bank/Card/Other), + a **deposit %** (default 50 — the PDF splits *Deposit due now* / *Balance on + completion*), free-text **payment terms** and **payment details** (bank/PayPal, + separate from the schedule), and an **optional Incoterm** (EXW/FCA/DAP/DDP — + light, not a contract; the app still never handles shipping). Read with + `{ ...DEFAULT_QUOTE, ...rawQuote }` so a quote saved before a new field existed + can't crash. **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 step-by-step guides (NIP-23 `30023` with `t=diy`) to flip the coming-soon cards live. +- **Deck designer — parametric 2D outline + 1:1 template (`/diy/deck-designer`, + 2026-10-05):** `src/lib/deckDesign.ts` (pure maths) + `components/DeckDesigner.tsx` + + page. Inputs: width, wheelbase, nose, tail (length is **derived** as + nose+wheelbase+tail), waist, end-roundness, concave, thickness, tail/nose kick, + and the **truck-hole pattern** — **New school 53.975 × 41.275 mm (2.125″×1.625″)** + or **Old school 63.5 × 41.275 mm**; **3/16″ (r 2.38 mm) holes**. Exports: + **SVG at true mm** (width/height in mm → prints 1:1), **tiled 1:1 PDF** (A4/Letter, + split across sheets with corner registration crosses — `doc.lines` deltas, no + path-API dependency, `ensureRoom` guards an overflow), and **DXF** (R12, + `$INSUNITS=4`, outline polyline + hole circles + centreline). ⚠️ **Deliberately + 2D** — this is the template a builder traces and cuts, **not** a SK8CAD clone. + **SK8CAD is proprietary** (its Terms forbid copying/redistribution); only its + *engine*, **JSCAD, is MIT**. We did **not** copy or self-host it, and we don't + link out (DIY policy = no external links). If full 3D/mold CAD is ever wanted, + the honest route is a JSCAD-based generator we own (CSG → STL, **not** STEP) — + see the decision in the session notes. - **App Shop** (`/app-shop`, `src/pages/AppShopPage.tsx`): SkateSpots merch, to be paid in **sats only (Lightning or Cashu)** — no cards/accounts — sold **locally, on request**: there is **no cart, no checkout, no shipping**. Buying diff --git a/src/components/DeckCostCalculator.tsx b/src/components/DeckCostCalculator.tsx index 36933c4..db70d1e 100644 --- a/src/components/DeckCostCalculator.tsx +++ b/src/components/DeckCostCalculator.tsx @@ -106,13 +106,35 @@ interface QuoteProfile { nip05: string; /** Lightning address (lud16) — where the client can pay. */ lightning: string; + /** Accepted payment methods (labels from `PAY_METHODS`). */ + payMethods: string[]; + /** Deposit percent due up front (0–100). */ + depositPct: number; + /** Payment terms / schedule (free text). */ + paymentTerms: string; + /** How to pay (bank/PayPal/other details), separate from the schedule. */ + paymentDetails: string; + /** Optional Incoterm code ('' = none). */ + incoterm: string; } const DEFAULT_QUOTE: QuoteProfile = { businessName: '', logo: '', email: '', phone: '', website: '', address: '', notes: '', number: 1, validDays: 30, - npub: '', nip05: '', lightning: '', + npub: '', nip05: '', lightning: '', payMethods: ['Cash', 'Lightning'], depositPct: 50, paymentTerms: '', paymentDetails: '', incoterm: '', }; +/** Payment methods offered on a quote. */ +const PAY_METHODS = ['Cash', 'Lightning', 'Cashu', 'Bank transfer', 'Card', 'Other']; + +/** Optional Incoterms (2020) for cross-border sales — light, not a contract. */ +const INCOTERM_OPTIONS: { code: string; label: string }[] = [ + { code: '', label: 'None' }, + { code: 'EXW', label: 'EXW — collect from the seller' }, + { code: 'FCA', label: 'FCA — handed to the buyer’s carrier' }, + { code: 'DAP', label: 'DAP — delivered to the buyer’s address' }, + { code: 'DDP', label: 'DDP — delivered, duties paid' }, +]; + interface CalcState { currency: CurrencyCode; hourlyRate: number; @@ -374,7 +396,12 @@ export function DeckCostCalculator() { 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 [rawQuote, setQuote] = useLocalStorage('skatespots:deck-cost:quote', DEFAULT_QUOTE); + // Merge over defaults so a quote saved before a new field existed can't crash. + const quote = useMemo( + () => ({ ...DEFAULT_QUOTE, ...rawQuote, payMethods: rawQuote.payMethods ?? DEFAULT_QUOTE.payMethods }), + [rawQuote], + ); // Nostr identity can be pulled from the logged-in profile (kind 0). const { user } = useCurrentUser(); const author = useAuthor(user?.pubkey); @@ -602,7 +629,9 @@ export function DeckCostCalculator() { const M = 48; const rightX = pageW - M; const q = quote; + const pageH = doc.internal.pageSize.getHeight(); let y = M + 24; + const ensureRoom = (n: number) => { if (y + n > pageH - M) { doc.addPage(); y = M + 20; } }; // Header — logo left, business + contact right. if (q.logo.startsWith('data:image/')) { @@ -643,18 +672,40 @@ export function DeckCostCalculator() { 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 += 30; + doc.text(fmt(r.priceWithPack), rightX, y, { align: 'right' }); y += 26; - if (q.lightning.trim()) { - doc.setFont('helvetica', 'normal'); doc.setFontSize(10); doc.setTextColor(20, 20, 20); - doc.text(`Pay via Lightning: ${q.lightning.trim()}`, M, y); y += 16; + // Payment: deposit + balance + methods + terms + delivery. + ensureRoom(70); + const deposit = q.depositPct > 0 ? (r.priceWithPack * q.depositPct) / 100 : 0; + if (deposit > 0) { + doc.setFont('helvetica', 'bold'); doc.setFontSize(10); doc.setTextColor(20, 20, 20); + doc.text(`Deposit due now (${q.depositPct}%)`, M, y); + doc.text(fmt(deposit), rightX, y, { align: 'right' }); y += 13; + doc.setFont('helvetica', 'normal'); doc.setTextColor(120, 120, 120); + doc.text('Balance on completion', M, y); + doc.text(fmt(r.priceWithPack - deposit), rightX, y, { align: 'right' }); y += 18; + } + + const payLines = [ + q.payMethods.length ? `Payment: ${q.payMethods.join(' \u00b7 ')}` : '', + q.lightning.trim() ? `Lightning: ${q.lightning.trim()}` : '', + q.paymentDetails.trim() ? `Details: ${q.paymentDetails.trim()}` : '', + q.paymentTerms.trim() ? `Terms: ${q.paymentTerms.trim()}` : '', + q.incoterm ? `Delivery: ${q.incoterm}` : '', + ].filter(Boolean); + if (payLines.length) { + doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(90, 90, 90); + for (const line of payLines) { doc.text(line, M, y); y += 12; } + y += 6; } if (q.notes.trim()) { + ensureRoom(50); 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; } + ensureRoom(30); doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120); 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; @@ -725,6 +776,12 @@ export function DeckCostCalculator() { ctx.fillText('COST / BOARD (WITH PACKAGING)', 48, y); ctx.fillStyle = '#ffffff'; ctx.font = 'bold 24px system-ui, sans-serif'; ctx.fillText(fmt(r.costWithPack), 48, y + 32); + if (quote.depositPct > 0) { + ctx.textAlign = 'right'; + ctx.fillStyle = '#9ca3af'; ctx.font = '15px system-ui, sans-serif'; + ctx.fillText(`Deposit ${quote.depositPct}%: ${fmt((r.priceWithPack * quote.depositPct) / 100)}`, W - 48, y + 32); + ctx.textAlign = 'left'; + } ctx.fillStyle = '#6b7280'; ctx.font = '12px system-ui, sans-serif'; ctx.fillText('Generated with SkateSpots · skate-spots.org', 48, H - 40); @@ -1208,6 +1265,51 @@ export function DeckCostCalculator() { setQuote({ ...quote, npub: e.target.value })} placeholder="npub1…" className="h-8 bg-black/40 border-white/10 font-mono text-[11px]" /> + {/* Payment details & terms */} +
+ + +
+
+ Payment methods +
+ {PAY_METHODS.map((m) => { + const on = quote.payMethods.includes(m); + return ( + + ); + })} +
+
+ +
{quote.logo && }