From a55aa7b53baa9f78bab35b6c2635ac3be4675465 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Mon, 5 Oct 2026 11:10:15 +0700 Subject: [PATCH] Deck calculator quotes: optional Nostr identity (NIP-05, npub, Lightning) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Quote details block gains NIP-05, npub and a Lightning address (lud16), plus a "Fill from profile" button that autofills name/website/NIP-05/Lightning/npub from the logged-in kind-0 profile. The client PDF lists the NIP-05 and 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. --- AGENTS.md | 9 +++- src/components/DeckCostCalculator.tsx | 75 +++++++++++++++++++++++++-- 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 52d9ae6..25b0b9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -851,7 +851,14 @@ session with **no other memory of this project**. So: 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** + numbers only). The sheet's key is `skatespots:deck-cost:v2`. **Nostr identity on + the quote (2026-10-05):** `QuoteProfile` also carries **NIP-05, npub and a + Lightning address (lud16)**; a **"Fill from profile"** button autofills them (plus + name/website) from the logged-in **kind-0** (`useCurrentUser` + `useAuthor` + + `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** 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 diff --git a/src/components/DeckCostCalculator.tsx b/src/components/DeckCostCalculator.tsx index 44a9110..36933c4 100644 --- a/src/components/DeckCostCalculator.tsx +++ b/src/components/DeckCostCalculator.tsx @@ -1,13 +1,16 @@ import { useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; +import { nip19 } from 'nostr-tools'; 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'; +import { Plus, Trash2, RotateCcw, Search, ChevronDown, ChevronRight, Check, Save, FolderOpen, Download, Upload, AtSign } from 'lucide-react'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useBtcRates } from '@/hooks/useExchangeRate'; import { useToast } from '@/hooks/useToast'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useAuthor } from '@/hooks/useAuthor'; import { dateLocaleCode } from '@/lib/dateFnsI18n'; import { cn } from '@/lib/utils'; @@ -97,10 +100,17 @@ interface QuoteProfile { number: number; /** Days the quote stays valid. */ validDays: number; + /** Nostr identity — shown on the quote so the client can verify/contact you. */ + npub: string; + /** Human-readable Nostr address (you@domain). */ + nip05: string; + /** Lightning address (lud16) — where the client can pay. */ + lightning: string; } const DEFAULT_QUOTE: QuoteProfile = { businessName: '', logo: '', email: '', phone: '', website: '', address: '', notes: '', number: 1, validDays: 30, + npub: '', nip05: '', lightning: '', }; interface CalcState { @@ -365,6 +375,9 @@ export function DeckCostCalculator() { // 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); + // Nostr identity can be pulled from the logged-in profile (kind 0). + const { user } = useCurrentUser(); + const author = useAuthor(user?.pubkey); const [pickerOpen, setPickerOpen] = useState(false); const [query, setQuery] = useState(''); const [presets, setPresets] = useLocalStorage<{ name: string; state: CalcState }[]>('skatespots:deck-cost-presets', []); @@ -602,7 +615,11 @@ export function DeckCostCalculator() { doc.setFont('helvetica', 'bold'); doc.setFontSize(16); doc.setTextColor(15, 15, 15); 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); + const contact = [ + q.email, q.phone, q.website, q.address, + q.nip05 ? `NIP-05 ${q.nip05}` : '', + q.npub ? `nostr ${q.npub}` : '', + ].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; @@ -628,6 +645,11 @@ export function DeckCostCalculator() { doc.text('Total', M, y); doc.text(fmt(r.priceWithPack), rightX, y, { align: 'right' }); y += 30; + 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; + } + 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[]; @@ -669,6 +691,11 @@ export function DeckCostCalculator() { 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'; } + const idLine = quote.nip05.trim() || quote.npub.trim(); + if (idLine) { + ctx.fillStyle = '#9ca3af'; ctx.font = '13px system-ui, sans-serif'; + ctx.textAlign = 'right'; ctx.fillText(idLine, W - 48, 162); ctx.textAlign = 'left'; + } ctx.fillStyle = '#6b7280'; ctx.font = '13px system-ui, sans-serif'; ctx.fillText('SELLING PRICE · WITH PACKAGING', 48, 206); @@ -862,6 +889,24 @@ export function DeckCostCalculator() { e.target.value = ''; }; + /** Autofill the quote identity from the logged-in Nostr profile (kind 0). */ + const fillFromProfile = () => { + if (!user) { + toast({ title: 'Log in to fill from your Nostr profile', variant: 'destructive' }); + return; + } + const meta = author.data?.metadata; + setQuote({ + ...quote, + businessName: quote.businessName || meta?.display_name || meta?.name || '', + website: quote.website || meta?.website || '', + nip05: quote.nip05 || meta?.nip05 || '', + lightning: quote.lightning || meta?.lud16 || meta?.lud06 || '', + npub: quote.npub || nip19.npubEncode(user.pubkey), + }); + toast({ title: 'Filled from your Nostr profile' }); + }; + const numInput = (value: number, onChange: (n: number) => void, className?: string) => ( -

Quote details (for the client PDF)

+
+

Quote details (for the client PDF)

+ +
+
+ + + +
{quote.logo && }