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.
This commit is contained in:
parent
b247be13b0
commit
5b9616a86d
2 changed files with 133 additions and 7 deletions
26
AGENTS.md
26
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: <lud16>` 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
|
||||
|
|
|
|||
|
|
@ -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<QuoteProfile>('skatespots:deck-cost:quote', DEFAULT_QUOTE);
|
||||
const [rawQuote, setQuote] = useLocalStorage<QuoteProfile>('skatespots:deck-cost:quote', DEFAULT_QUOTE);
|
||||
// Merge over defaults so a quote saved before a new field existed can't crash.
|
||||
const quote = useMemo<QuoteProfile>(
|
||||
() => ({ ...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() {
|
|||
<Input value={quote.npub} onChange={(e) => setQuote({ ...quote, npub: e.target.value })} placeholder="npub1…" className="h-8 bg-black/40 border-white/10 font-mono text-[11px]" />
|
||||
</label>
|
||||
</div>
|
||||
{/* Payment details & terms */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className={label}>Deposit due up front (%)</span>
|
||||
<Input type="number" min={0} max={100} value={quote.depositPct} onChange={(e) => setQuote({ ...quote, depositPct: Math.min(100, Math.max(0, Number(e.target.value) || 0)) })} className="h-8 bg-black/40 border-white/10" />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className={label}>Delivery terms (Incoterm, optional)</span>
|
||||
<select
|
||||
value={quote.incoterm}
|
||||
onChange={(e) => setQuote({ ...quote, incoterm: e.target.value })}
|
||||
className="h-8 w-full rounded-md border border-white/10 bg-black/40 px-2 text-sm text-gray-200"
|
||||
>
|
||||
{INCOTERM_OPTIONS.map((it) => (
|
||||
<option key={it.code} value={it.code} className="bg-neutral-900">{it.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className={label}>Payment methods</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PAY_METHODS.map((m) => {
|
||||
const on = quote.payMethods.includes(m);
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setQuote({ ...quote, payMethods: on ? quote.payMethods.filter((x) => x !== m) : [...quote.payMethods, m] })}
|
||||
className={`min-h-0 rounded-full border px-2.5 py-1 text-[11px] font-semibold ${on ? 'border-[#ccff00]/60 bg-[#ccff00]/10 text-white' : 'border-white/15 text-gray-400 hover:bg-white/5'}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<label className="block space-y-1">
|
||||
<span className={label}>Payment terms</span>
|
||||
<Input value={quote.paymentTerms} onChange={(e) => setQuote({ ...quote, paymentTerms: e.target.value })} placeholder="50% deposit, balance before dispatch" className="h-8 bg-black/40 border-white/10" />
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className={label}>Payment details</span>
|
||||
<Input value={quote.paymentDetails} onChange={(e) => setQuote({ ...quote, paymentDetails: e.target.value })} placeholder="Bank/IBAN, PayPal, Revolut… (optional)" className="h-8 bg-black/40 border-white/10" />
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{quote.logo && <img src={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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue