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:
parent
3f90967f6c
commit
a55aa7b53b
2 changed files with 80 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue