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**
|
summary** (clipboard). ⚠️ The **quote identity lives in its own store**
|
||||||
(`skatespots:deck-cost:quote`, `QuoteProfile`), **not** in `CalcState`, so
|
(`skatespots:deck-cost:quote`, `QuoteProfile`), **not** in `CalcState`, so
|
||||||
loading/saving presets keeps the same business name/logo/contact (the sheet is
|
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
|
auto-detects `.json`/`.csv`: a CSV whose
|
||||||
header is `name,order_cost,…` is treated as a **supplier price list** (replaces
|
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
|
the Materials table), otherwise it round-trips the whole sheet. Hand-rolled CSV
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
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 { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
import { useBtcRates } from '@/hooks/useExchangeRate';
|
import { useBtcRates } from '@/hooks/useExchangeRate';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { dateLocaleCode } from '@/lib/dateFnsI18n';
|
import { dateLocaleCode } from '@/lib/dateFnsI18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
|
@ -97,10 +100,17 @@ interface QuoteProfile {
|
||||||
number: number;
|
number: number;
|
||||||
/** Days the quote stays valid. */
|
/** Days the quote stays valid. */
|
||||||
validDays: number;
|
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 = {
|
const DEFAULT_QUOTE: QuoteProfile = {
|
||||||
businessName: '', logo: '', email: '', phone: '', website: '', address: '', notes: '', number: 1, validDays: 30,
|
businessName: '', logo: '', email: '', phone: '', website: '', address: '', notes: '', number: 1, validDays: 30,
|
||||||
|
npub: '', nip05: '', lightning: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CalcState {
|
interface CalcState {
|
||||||
|
|
@ -365,6 +375,9 @@ export function DeckCostCalculator() {
|
||||||
// Quote identity lives in its own store, so switching presets keeps the same
|
// Quote identity lives in its own store, so switching presets keeps the same
|
||||||
// business name / logo / contact details.
|
// business name / logo / contact details.
|
||||||
const [quote, setQuote] = useLocalStorage<QuoteProfile>('skatespots:deck-cost:quote', DEFAULT_QUOTE);
|
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 [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [presets, setPresets] = useLocalStorage<{ name: string; state: CalcState }[]>('skatespots:deck-cost-presets', []);
|
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.setFont('helvetica', 'bold'); doc.setFontSize(16); doc.setTextColor(15, 15, 15);
|
||||||
doc.text(quote.businessName.trim() || 'SkateSpots', rightX, y + 10, { align: 'right' });
|
doc.text(quote.businessName.trim() || 'SkateSpots', rightX, y + 10, { align: 'right' });
|
||||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(120, 120, 120);
|
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' }));
|
contact.forEach((line, i) => doc.text(line, rightX, y + 26 + i * 12, { align: 'right' }));
|
||||||
y += Math.max(64, 26 + contact.length * 12) + 22;
|
y += Math.max(64, 26 + contact.length * 12) + 22;
|
||||||
|
|
||||||
|
|
@ -628,6 +645,11 @@ export function DeckCostCalculator() {
|
||||||
doc.text('Total', M, y);
|
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 += 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()) {
|
if (q.notes.trim()) {
|
||||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(90, 90, 90);
|
doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(90, 90, 90);
|
||||||
const wrapped = doc.splitTextToSize(q.notes.trim(), pageW - M * 2) as string[];
|
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.fillStyle = '#e5e7eb'; ctx.font = 'bold 18px system-ui, sans-serif';
|
||||||
ctx.textAlign = 'right'; ctx.fillText(quote.businessName.trim(), W - 48, 140); ctx.textAlign = 'left';
|
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.fillStyle = '#6b7280'; ctx.font = '13px system-ui, sans-serif';
|
||||||
ctx.fillText('SELLING PRICE · WITH PACKAGING', 48, 206);
|
ctx.fillText('SELLING PRICE · WITH PACKAGING', 48, 206);
|
||||||
|
|
@ -862,6 +889,24 @@ export function DeckCostCalculator() {
|
||||||
e.target.value = '';
|
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) => (
|
const numInput = (value: number, onChange: (n: number) => void, className?: string) => (
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
|
|
@ -1105,7 +1150,17 @@ export function DeckCostCalculator() {
|
||||||
|
|
||||||
{/* Quote details — your identity on the client PDF. */}
|
{/* 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">
|
<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">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<label className="space-y-1">
|
<label className="space-y-1">
|
||||||
<span className={label}>Business / shop name</span>
|
<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" />
|
<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>
|
</label>
|
||||||
</div>
|
</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">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{quote.logo && <img src={quote.logo} alt="" className="h-10 rounded border border-white/10" />}
|
{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">
|
<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