Deck calculator quotes: optional Nostr identity (NIP-05, npub, Lightning)

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: <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.
This commit is contained in:
SkateSpots 2026-10-05 11:10:15 +07:00
parent 3f90967f6c
commit a55aa7b53b
2 changed files with 80 additions and 4 deletions

View file

@ -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: <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**
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

View file

@ -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<QuoteProfile>('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) => (
<Input
type="number"
@ -1105,7 +1150,17 @@ export function DeckCostCalculator() {
{/* Quote details — your identity on the client PDF. */}
<div className="mt-4 space-y-3 rounded-xl border border-white/10 bg-black/30 p-3">
<p className="text-[11px] font-black uppercase tracking-wider text-gray-500">Quote details (for the client PDF)</p>
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] font-black uppercase tracking-wider text-gray-500">Quote details (for the client PDF)</p>
<button
type="button"
onClick={fillFromProfile}
className="inline-flex min-h-0 items-center gap-1 rounded-md border border-white/15 px-2 py-1 text-[11px] font-semibold hover:bg-white/5"
title="Fill name, website, NIP-05, Lightning and npub from your Nostr profile"
>
<AtSign className="h-3.5 w-3.5" /> Fill from profile
</button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="space-y-1">
<span className={label}>Business / shop name</span>
@ -1139,6 +1194,20 @@ export function DeckCostCalculator() {
<Input type="number" min={1} value={quote.validDays} onChange={(e) => setQuote({ ...quote, validDays: Math.max(1, Number(e.target.value) || 1) })} className="h-8 bg-black/40 border-white/10" />
</label>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="space-y-1">
<span className={label}>NIP-05</span>
<Input value={quote.nip05} onChange={(e) => setQuote({ ...quote, nip05: e.target.value })} placeholder="you@skate-spots.org" className="h-8 bg-black/40 border-white/10" />
</label>
<label className="space-y-1">
<span className={label}>Lightning address</span>
<Input value={quote.lightning} onChange={(e) => setQuote({ ...quote, lightning: e.target.value })} placeholder="you@getalby.com" className="h-8 bg-black/40 border-white/10" />
</label>
<label className="space-y-1 sm:col-span-2">
<span className={label}>Nostr (npub)</span>
<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>
<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">