diff --git a/NIP.md b/NIP.md index 8047c4a..11937cc 100644 --- a/NIP.md +++ b/NIP.md @@ -2,6 +2,36 @@ This document defines the Nostr event kinds used by the SkateSpots application for managing skateboarding locations, shops, and related content. +## Profile Metadata Extensions (kind 0) + +In addition to the standard NIP-01 metadata fields (`name`, `about`, `picture`, +`banner`, `website`, `nip05`, `lud16`, `bot`), SkateSpots profiles may carry a +few custom keys inside the kind 0 content JSON. These are optional, ignored by +other clients, and preserved on edit: + +| Field | Type | Description | +|-------|------|-------------| +| `noffer` | string | CLINK Offer code (`noffer1…`) — self-custodial Bitcoin tip code. | +| `bitcoin_silent_payment` | string | Bitcoin Silent Payment address (BIP-352, `sp1…`) — a reusable, private on-chain address. | +| `sponsors` | string | Freeform highlight of the skater's sponsors / supporters (brands, shops, people). | +| `cordn` | string | A Cordn (cordn.net) contact URL for private, MLS-encrypted messaging. | + +`lud16` (Lightning address) is standard NIP-01 but is also editable directly in +the SkateSpots profile form for convenience. + +Example: + +```json +{ + "name": "Sk8rBoi", + "lud16": "sk8rboi@getalby.com", + "bitcoin_silent_payment": "sp1qq...long...", + "sponsors": "Independent Trucks, Local Skate Co.", + "cordn": "https://cordn.net/u/abc123", + "noffer": "noffer1..." +} +``` + ## Skate Shops (kind 30017) Skate shops use the NIP-15 marketplace "stall" kind (30017) extended with skateboarding-specific tags. This enables interoperability with existing Nostr marketplace clients. diff --git a/src/components/EditProfileForm.tsx b/src/components/EditProfileForm.tsx index 8f01c7d..804f64a 100644 --- a/src/components/EditProfileForm.tsx +++ b/src/components/EditProfileForm.tsx @@ -18,11 +18,11 @@ import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; -import { Loader2, Upload, Zap, ShieldCheck, AlertCircle } from 'lucide-react'; +import { Loader2, Upload, Zap, ShieldCheck, AlertCircle, Bitcoin, Award, MessageSquareLock } from 'lucide-react'; import { NSchema as n, type NostrMetadata } from '@nostrify/nostrify'; import { useQueryClient } from '@tanstack/react-query'; import { useUploadFile } from '@/hooks/useUploadFile'; -import { isValidNoffer } from '@/lib/types'; +import { isValidNoffer, isValidSilentPayment, isValidLightningAddress } from '@/lib/types'; export const EditProfileForm: React.FC = () => { const queryClient = useQueryClient(); @@ -40,6 +40,28 @@ export const EditProfileForm: React.FC = () => { const nofferInvalid = nofferTrimmed.length > 0 && !isValidNoffer(nofferTrimmed); const nofferValid = isValidNoffer(nofferTrimmed); + // Lightning address (LUD-16). Part of NostrMetadata but not in the default + // form fields, so we manage it here and merge it on save. + const [lud16, setLud16] = useState(''); + const lud16Trimmed = lud16.trim(); + const lud16Invalid = lud16Trimmed.length > 0 && !isValidLightningAddress(lud16Trimmed); + + // Bitcoin Silent Payment address (BIP-352). Custom kind-0 field. + const [silentPayment, setSilentPayment] = useState(''); + const spTrimmed = silentPayment.trim(); + const spInvalid = spTrimmed.length > 0 && !isValidSilentPayment(spTrimmed); + const spValid = isValidSilentPayment(spTrimmed); + + // Sponsorships — freeform highlight (custom kind-0 field `sponsors`). + const [sponsors, setSponsors] = useState(''); + + // Cordn contact link — private (MLS-encrypted) group messaging you can own. + // A URL others can use to reach out to you on Cordn (cordn.net). + const [cordn, setCordn] = useState(''); + const cordnTrimmed = cordn.trim(); + const cordnInvalid = + cordnTrimmed.length > 0 && !/^https?:\/\/.+/i.test(cordnTrimmed); + // Initialize the form with default values const form = useForm({ resolver: zodResolver(n.metadata()), @@ -66,11 +88,13 @@ export const EditProfileForm: React.FC = () => { nip05: metadata.nip05 || '', bot: metadata.bot || false, }); - // Prefill the custom CLINK code from the raw metadata. - const existingNoffer = (metadata as Record).noffer; - if (typeof existingNoffer === 'string') { - setNoffer(existingNoffer); - } + // Prefill custom / extra fields from the raw metadata. + const raw = metadata as Record; + if (typeof raw.noffer === 'string') setNoffer(raw.noffer); + if (typeof metadata.lud16 === 'string') setLud16(metadata.lud16); + if (typeof raw.bitcoin_silent_payment === 'string') setSilentPayment(raw.bitcoin_silent_payment); + if (typeof raw.sponsors === 'string') setSponsors(raw.sponsors); + if (typeof raw.cordn === 'string') setCordn(raw.cordn); } }, [metadata, form]); @@ -113,6 +137,33 @@ export const EditProfileForm: React.FC = () => { return; } + if (lud16Invalid) { + toast({ + title: 'Invalid Lightning address', + description: 'Use an email-like address such as you@walletofsatoshi.com.', + variant: 'destructive', + }); + return; + } + + if (spInvalid) { + toast({ + title: 'Invalid Silent Payment address', + description: 'A Silent Payment address starts with "sp1" and is quite long. Clear it or paste a valid one.', + variant: 'destructive', + }); + return; + } + + if (cordnInvalid) { + toast({ + title: 'Invalid Cordn link', + description: 'Paste a full https://… Cordn contact link.', + variant: 'destructive', + }); + return; + } + try { // Combine existing metadata with new values const data: Record = { ...metadata, ...values }; @@ -131,6 +182,34 @@ export const EditProfileForm: React.FC = () => { delete data.noffer; } + // Lightning address (LUD-16). + if (lud16Trimmed) { + data.lud16 = lud16Trimmed; + } else { + delete data.lud16; + } + + // Bitcoin Silent Payment address (custom field). + if (spValid) { + data.bitcoin_silent_payment = spTrimmed; + } else { + delete data.bitcoin_silent_payment; + } + + // Sponsorships highlight (custom field). + if (sponsors.trim()) { + data.sponsors = sponsors.trim(); + } else { + delete data.sponsors; + } + + // Cordn contact link (custom field). + if (cordnTrimmed) { + data.cordn = cordnTrimmed; + } else { + delete data.cordn; + } + // Publish the metadata event (kind 0) with retry logic let attempts = 0; let lastError; @@ -329,6 +408,113 @@ href="https://clinkme.dev/specs.html" )} + {/* Lightning address (LUD-16) */} +
+ + setLud16(e.target.value)} + spellCheck={false} + autoCapitalize="none" + className="font-mono text-sm" + /> + {lud16Invalid ? ( +

+ + Use an email-like address such as you@getalby.com. +

+ ) : ( +

+ Your Lightning address lets anyone zap you instantly. Powers the "Zap" button on your profile. +

+ )} +
+ + {/* Bitcoin Silent Payment address (BIP-352) */} +
+ + setSilentPayment(e.target.value)} + spellCheck={false} + autoCapitalize="none" + className="font-mono text-sm" + /> + {spInvalid ? ( +

+ + A Silent Payment address starts with sp1 and is quite long. +

+ ) : spValid ? ( +

+ + Valid — a reusable, private on-chain address others can pay repeatedly. +

+ ) : ( +

+ A reusable static on-chain address (BIP-352). Share once; every payment lands at a fresh, unlinkable output. +

+ )} +
+ + {/* Sponsorships */} +
+ +