Profile: add Lightning, Silent Payment, Sponsors & Cordn fields
Editable in EditProfileForm and displayed on the profile: - Bitcoin Lightning Address (lud16) — now editable in-form, shown with a labeled Lightning row in the Info card. - Bitcoin Silent Payment Address (BIP-352, custom `bitcoin_silent_payment`) — validated (sp1…), shown as a copyable, truncated row. - Sponsors & Support (custom `sponsors`) — freeform highlight, rendered in a dedicated pink "Sponsors & Support" sidebar card. - Cordn contact link (custom `cordn`) — private MLS messaging link (cordn.net), shown as a "Message privately" row in the Info card. Crews and the CLINK noffer tip button were already present, so they are left as-is. Added isValidSilentPayment / isValidLightningAddress validators and documented the custom kind-0 fields in NIP.md. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
b3a49499bb
commit
11c67fcf22
4 changed files with 320 additions and 10 deletions
30
NIP.md
30
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.
|
||||
|
|
|
|||
|
|
@ -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<NostrMetadata>({
|
||||
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<string, unknown>).noffer;
|
||||
if (typeof existingNoffer === 'string') {
|
||||
setNoffer(existingNoffer);
|
||||
}
|
||||
// Prefill custom / extra fields from the raw metadata.
|
||||
const raw = metadata as Record<string, unknown>;
|
||||
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<string, unknown> = { ...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"
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Lightning address (LUD-16) */}
|
||||
<div className="space-y-2 rounded-lg border border-yellow-500/30 bg-yellow-500/5 p-4">
|
||||
<Label htmlFor="profile-lud16" className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
|
||||
<Zap className="h-4 w-4" />
|
||||
Bitcoin Lightning Address <span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="profile-lud16"
|
||||
placeholder="you@walletofsatoshi.com"
|
||||
value={lud16}
|
||||
onChange={(e) => setLud16(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
{lud16Invalid ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-red-500">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
Use an email-like address such as <code className="mx-1">you@getalby.com</code>.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your Lightning address lets anyone zap you instantly. Powers the "Zap" button on your profile.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bitcoin Silent Payment address (BIP-352) */}
|
||||
<div className="space-y-2 rounded-lg border border-orange-500/30 bg-orange-500/5 p-4">
|
||||
<Label htmlFor="profile-sp" className="flex items-center gap-2 text-orange-600 dark:text-orange-400">
|
||||
<Bitcoin className="h-4 w-4" />
|
||||
Bitcoin Silent Payment Address <span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="profile-sp"
|
||||
placeholder="sp1q..."
|
||||
value={silentPayment}
|
||||
onChange={(e) => setSilentPayment(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
{spInvalid ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-red-500">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
A Silent Payment address starts with <code className="mx-1">sp1</code> and is quite long.
|
||||
</p>
|
||||
) : spValid ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
|
||||
<ShieldCheck className="h-3.5 w-3.5 shrink-0" />
|
||||
Valid — a reusable, private on-chain address others can pay repeatedly.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A reusable static on-chain address (BIP-352). Share once; every payment lands at a fresh, unlinkable output.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sponsorships */}
|
||||
<div className="space-y-2 rounded-lg border border-pink-500/30 bg-pink-500/5 p-4">
|
||||
<Label htmlFor="profile-sponsors" className="flex items-center gap-2 text-pink-600 dark:text-pink-400">
|
||||
<Award className="h-4 w-4" />
|
||||
Sponsors & Support <span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="profile-sponsors"
|
||||
placeholder="Flow, Independent Trucks, your local shop… whoever backs your skating."
|
||||
value={sponsors}
|
||||
onChange={(e) => setSponsors(e.target.value)}
|
||||
className="resize-none text-sm"
|
||||
rows={2}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Highlight the brands, shops or people who sponsor or support you. Shown on your profile.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cordn contact link */}
|
||||
<div className="space-y-2 rounded-lg border border-teal-500/30 bg-teal-500/5 p-4">
|
||||
<Label htmlFor="profile-cordn" className="flex items-center gap-2 text-teal-600 dark:text-teal-400">
|
||||
<MessageSquareLock className="h-4 w-4" />
|
||||
Cordn Contact Link <span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="profile-cordn"
|
||||
placeholder="https://cordn.net/..."
|
||||
value={cordn}
|
||||
onChange={(e) => setCordn(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
{cordnInvalid ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-red-500">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
Paste a full link starting with <code className="mx-1">https://</code>.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A link others can use to reach you on{' '}
|
||||
<a href="https://cordn.net" target="_blank" rel="noopener noreferrer" className="text-teal-500 underline hover:text-teal-400">Cordn</a>
|
||||
{' '}— private, encrypted group messaging you own.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bot"
|
||||
|
|
|
|||
|
|
@ -267,6 +267,27 @@ export function isValidNoffer(value: string | undefined | null): value is string
|
|||
return /^noffer1[023456789acdefghjklmnpqrstuvwxyz]{20,}$/.test(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a Bitcoin Silent Payment address (BIP-352). Mainnet addresses use
|
||||
* the `sp1` human-readable prefix and are bech32m-encoded (~116 chars for the
|
||||
* standard 66-byte payload). We validate the prefix + charset + rough length;
|
||||
* the app does not derive outputs — it displays the address for others to pay.
|
||||
*/
|
||||
export function isValidSilentPayment(value: string | undefined | null): value is string {
|
||||
if (!value) return false;
|
||||
const v = value.trim().toLowerCase();
|
||||
// sp1 (mainnet) or tsp1 (testnet), followed by bech32 charset, long payload.
|
||||
return /^(t?sp)1[023456789acdefghjklmnpqrstuvwxyz]{100,}$/.test(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a Lightning address (LUD-16 / lud16) — an email-like `user@domain`.
|
||||
*/
|
||||
export function isValidLightningAddress(value: string | undefined | null): value is string {
|
||||
if (!value) return false;
|
||||
return /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(value.trim());
|
||||
}
|
||||
|
||||
export interface SkateSpot {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ import {
|
|||
Package,
|
||||
Tag,
|
||||
ArrowRightLeft,
|
||||
Bitcoin,
|
||||
Award,
|
||||
MessageSquareLock,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
|
||||
|
|
@ -58,8 +61,14 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
|||
const lud16 = metadata?.lud16;
|
||||
const isBot = metadata?.bot === true;
|
||||
// Custom CLINK offer code stored in the kind 0 content JSON.
|
||||
const noffer = (metadata as Record<string, unknown> | undefined)?.noffer;
|
||||
const raw = metadata as Record<string, unknown> | undefined;
|
||||
const noffer = raw?.noffer;
|
||||
const nofferCode = typeof noffer === 'string' ? noffer : undefined;
|
||||
// Extra custom profile fields.
|
||||
const silentPayment = typeof raw?.bitcoin_silent_payment === 'string' ? raw.bitcoin_silent_payment : undefined;
|
||||
const sponsors = typeof raw?.sponsors === 'string' ? raw.sponsors.trim() : undefined;
|
||||
const cordnUrl = typeof raw?.cordn === 'string' ? raw.cordn : undefined;
|
||||
const [spCopied, setSpCopied] = useState(false);
|
||||
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
const isOwnProfile = user?.pubkey === pubkey;
|
||||
|
|
@ -406,11 +415,58 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
|||
|
||||
{lud16 && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-gray-900">
|
||||
<Zap className="h-4 w-4 text-yellow-400" />
|
||||
<span className="text-gray-300 text-sm truncate">{lud16}</span>
|
||||
<Zap className="h-4 w-4 text-yellow-400 flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] uppercase tracking-wide text-gray-500 font-bold">Lightning</p>
|
||||
<span className="text-gray-300 text-sm truncate block">{lud16}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bitcoin Silent Payment address — copyable */}
|
||||
{silentPayment && (
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(silentPayment);
|
||||
setSpCopied(true);
|
||||
setTimeout(() => setSpCopied(false), 2000);
|
||||
}}
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-gray-900 hover:bg-gray-800 transition-colors w-full text-left"
|
||||
>
|
||||
<Bitcoin className="h-4 w-4 text-orange-400 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[10px] uppercase tracking-wide text-gray-500 font-bold">
|
||||
Silent Payment {spCopied && <span className="text-green-400">· Copied!</span>}
|
||||
</p>
|
||||
<span className="text-gray-300 text-sm truncate block font-mono">
|
||||
{silentPayment.slice(0, 14)}…{silentPayment.slice(-6)}
|
||||
</span>
|
||||
</div>
|
||||
{spCopied ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-400 flex-shrink-0" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5 text-gray-500 flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Cordn contact link */}
|
||||
{cordnUrl && (
|
||||
<a
|
||||
href={cordnUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-gray-900 hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<MessageSquareLock className="h-4 w-4 text-teal-400 flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] uppercase tracking-wide text-gray-500 font-bold">Cordn</p>
|
||||
<span className="text-gray-300 text-sm truncate block">Message privately</span>
|
||||
</div>
|
||||
<ExternalLink className="h-3 w-3 text-gray-500 ml-auto flex-shrink-0" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Npub Copy */}
|
||||
<button
|
||||
onClick={handleCopyNpub}
|
||||
|
|
@ -428,6 +484,23 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sponsors Card */}
|
||||
{sponsors && (
|
||||
<Card className="bg-gradient-to-br from-pink-500/10 to-transparent border-2 border-pink-500/30">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Award className="h-5 w-5 text-pink-400" />
|
||||
Sponsors & Support
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-300 whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
|
||||
{sponsors}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Crews Card */}
|
||||
{(crewsLoading || crews.length > 0) && (
|
||||
<Card className="bg-black/90 border-2 border-teal-500/30">
|
||||
|
|
|
|||
Loading…
Reference in a new issue