From 127819e3daa8190a66ae809f788b089c77a2c458 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sat, 3 Oct 2026 17:26:59 +0700 Subject: [PATCH] feat(nip05): multiple handles for profiles, crews and shops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A NIP-05 name maps to one key, so "who owns it" decides where it lives: - Crew: repeatable nip05 tags (up to 5), verified against the founder key. - Shop: same on kind 30017, verified against the owner key (new field). - Profile: kind-0 has ONE standard nip05; extra personal handles live in a separate NIP-78 record (kind 30078, d=skatespots:profile-handles) so another client re-saving the profile can't wipe them. Shared pieces: - components/Nip05Badges.tsx — generic verify-and-render chip row (any owner). - components/Nip05HandlesEditor.tsx — shared add/remove editor with live verified/mismatch status; used by profile, crew and shop forms. - CrewNip05Badge now delegates to Nip05Badges; CrewForm uses the shared editor. - Profile page shows primary + extra handles, and each crew/shop chips its own verified handle. - lib/profileHandles.ts + hooks/useProfileHandles.ts (read/publish the record). - types: SkateShop gains nip05/nip05s; parseSkateShop parses repeatable nip05s. - NIP.md + AGENTS.md + i18n (EN/ES) updated. --- AGENTS.md | 16 +++ NIP.md | 1 + src/components/CrewForm.tsx | 98 +----------------- src/components/CrewNip05Badge.tsx | 81 ++------------- src/components/EditProfileForm.tsx | 41 +++++++- src/components/Nip05Badges.tsx | 87 ++++++++++++++++ src/components/Nip05HandlesEditor.tsx | 139 ++++++++++++++++++++++++++ src/hooks/useProfileHandles.ts | 43 ++++++++ src/hooks/useShops.ts | 2 + src/i18n/en.ts | 4 + src/i18n/es.ts | 4 + src/lib/profileHandles.ts | 51 ++++++++++ src/lib/types.ts | 12 +++ src/pages/CreateShopPage.tsx | 14 +++ src/pages/EditShopPage.tsx | 15 ++- src/pages/NIP19Page.tsx | 14 +++ src/pages/ShopDetailPage.tsx | 4 + 17 files changed, 454 insertions(+), 172 deletions(-) create mode 100644 src/components/Nip05Badges.tsx create mode 100644 src/components/Nip05HandlesEditor.tsx create mode 100644 src/hooks/useProfileHandles.ts create mode 100644 src/lib/profileHandles.ts diff --git a/AGENTS.md b/AGENTS.md index ab75901..656011f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,22 @@ session with **no other memory of this project**. So: profile (a single `nip05` string), a crew is our own tag convention, so it isn't limited to one. Normalise → dedupe → regex-validate → cap 5 in `parseSkateCrew`. + - **Shop handles (2026-10-03):** same idea on shops (kind `30017`) — + repeatable `nip05` tags (up to 5) verified against the **owner** key + (`SkateShop.nip05s`), added/removed in `CreateShopPage`/`EditShopPage` + (`'nip05'` is in `EditShopPage`'s `MANAGED_TAGS` so republish doesn't + duplicate them); shown via `Nip05Badges` on the shop page. + - **Profile extra handles (2026-10-03):** a profile can advertise **up to 5** + extra personal NIP-05 handles, since kind-0 has only one standard `nip05` + field and other clients may drop non-standard keys. The extras live in a + **separate NIP-78 record** (`kind 30078`, `d=skatespots:profile-handles`, + authored by the user; see `lib/profileHandles.ts` + `hooks/useProfileHandles`). + Primary stays in kind-0 `nip05`; `EditProfileForm` edits extras; + `NIP19Page` shows all via `Nip05Badges`. Shared editor: + `components/Nip05HandlesEditor.tsx` (used by profile/crew/shop). + - **Shared NIP-05 UI (2026-10-03):** `components/Nip05Badges.tsx` is the + generic verify-and-render chip row (any owner pubkey); `useCrewNip05` is the + generic verifier (name kept for back-compat). - **Rider Cards (2026-10-02) — now REAL + verifiable.** `32271` **Rider Card** (addressable, `d=rider-card`) — the skater signs their own card customization (theme/style/skills/sponsors/rides-for/show-flags; `v:1` marks show flags diff --git a/NIP.md b/NIP.md index 2eae416..a0ad6c8 100644 --- a/NIP.md +++ b/NIP.md @@ -79,6 +79,7 @@ Skate shops use the NIP-15 marketplace "stall" kind (30017) extended with skateb | `website` | Shop website URL | | `instagram` | Instagram handle | | `hours` | Hours of operation (freeform text) | +| `nip05` | Optional shop NIP-05 handle(s) (`local@domain`). **Repeatable** (up to 5), each verified against the **owner** pubkey — the shop's own handle, independent of the owner's profile handle. See Shop Ownership Verification. | | `noffer` | Optional CLINK Offer code (`noffer1…`) for self-custodial payments — see the CLINK Payments section | | `imeta` | Image metadata per NIP-92 | | `lat` | Latitude (decimal) | diff --git a/src/components/CrewForm.tsx b/src/components/CrewForm.tsx index b0b88aa..d722e72 100644 --- a/src/components/CrewForm.tsx +++ b/src/components/CrewForm.tsx @@ -25,11 +25,11 @@ import { SKATE_CREW_KIND, SKATE_SPOT_KIND, SKATE_SHOP_KIND, parseAddrCoord, ZAP_ import { CREW_VIBE_KEYS, CREW_FOCUS_KEYS, CREW_FOCUS_DESC_KEYS, CREW_LOOKING_FOR_KEYS, CREW_ROLE_KEYS } from '@/lib/labelKeys'; import { useCrewGoal } from '@/hooks/useCrewGoal'; import { useCoordinator } from '@/hooks/useCoordinator'; -import { useCrewNip05 } from '@/hooks/useCrewNip05'; +import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor'; import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, shortNpub } from '@/lib/coordinator'; import { useRelays } from '@/lib/relays'; import { cn } from '@/lib/utils'; -import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon, Server, BadgeCheck, AlertTriangle } from 'lucide-react'; +import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon, Server } from 'lucide-react'; const FOCUS_ORDER: CrewFocus[] = ['mixed', 'builder', 'photo', 'coaching', 'art', 'music', 'wellness']; const FOCUS_ICONS: Record = { @@ -238,7 +238,6 @@ export function CrewForm({ existing }: CrewFormProps) { const [socials, setSocials] = useState(existing?.socials ?? []); const [socialInput, setSocialInput] = useState(''); const [nip05s, setNip05s] = useState(existing?.nip05s ?? (existing?.nip05 ? [existing.nip05] : [])); - const [nip05Input, setNip05Input] = useState(''); const [noffer, setNoffer] = useState(existing?.noffer ?? ''); const [members, setMembers] = useState(existing?.members ?? []); const [admins, setAdmins] = useState(existing?.admins ?? []); @@ -349,21 +348,7 @@ export function CrewForm({ existing }: CrewFormProps) { }; // Crew NIP-05 handles — up to 5, each verified against the founder's key. - const NIP05_RE = /^[a-z0-9\-_.]+@[a-z0-9.-]+\.[a-z]{2,}$/; - const MAX_NIP05 = 5; const founderPubkey = existing?.pubkey ?? user?.pubkey; - const [nip05Error, setNip05Error] = useState(null); - const addNip05 = () => { - const val = nip05Input.trim().toLowerCase(); - setNip05Input(''); - if (!val) return; - if (!NIP05_RE.test(val)) return setNip05Error(t('socialUi.form.nip05Invalid')); - if (nip05s.includes(val)) return setNip05Error(null); - if (nip05s.length >= MAX_NIP05) return setNip05Error(t('socialUi.form.nip05TooMany', { count: MAX_NIP05 })); - setNip05Error(null); - setNip05s((prev) => [...prev, val]); - }; - const removeNip05 = (handle: string) => setNip05s((prev) => prev.filter((h) => h !== handle)); // Live-decode whatever's typed in the member field for the preview chip. const memberPreviewPk = decodePubkeyInput(memberInput); @@ -1160,29 +1145,7 @@ export function CrewForm({ existing }: CrewFormProps) {

{t('socialUi.form.nip05DescA')} crew@yourdomain.com{t('socialUi.form.nip05DescB')}

-
- { setNip05Input(e.target.value); if (nip05Error) setNip05Error(null); }} - onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addNip05(); } }} - spellCheck={false} - autoCapitalize="none" - className="bg-gray-800 border-gray-700 font-mono text-sm" - /> - -
- {nip05Error &&

{nip05Error}

} - {nip05s.length > 0 && ( -
- {nip05s.map((handle) => ( - removeNip05(handle)} /> - ))} -
- )} + ); @@ -1583,58 +1546,3 @@ export function CrewForm({ existing }: CrewFormProps) { ); } - -/** One crew NIP-05 handle row with a live verification status (verified / mismatch / unchecked). */ -function CrewNip05Row({ - handle, - founderPubkey, - onRemove, -}: { - handle: string; - founderPubkey?: string; - onRemove: () => void; -}) { - const { t } = useTranslation(); - const { data, isLoading } = useCrewNip05(handle, founderPubkey); - const status = data?.status ?? (isLoading ? 'loading' : 'unreachable'); - - const icon = - status === 'verified' ? ( - - ) : status === 'mismatch' ? ( - - ) : status === 'loading' ? ( - - ) : ( - - ); - const label = - status === 'verified' - ? t('socialUi.nip05.verified') - : status === 'mismatch' - ? t('socialUi.nip05.mismatch') - : t('socialUi.form.nip05Unverified'); - - return ( -
- {icon} - {handle} - - {label} - - -
- ); -} diff --git a/src/components/CrewNip05Badge.tsx b/src/components/CrewNip05Badge.tsx index d4e4908..b48997f 100644 --- a/src/components/CrewNip05Badge.tsx +++ b/src/components/CrewNip05Badge.tsx @@ -1,13 +1,4 @@ -import { BadgeCheck, Loader2, AlertTriangle } from 'lucide-react'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip'; -import { useCrewNip05 } from '@/hooks/useCrewNip05'; -import { useTranslation } from '@/contexts/language'; -import { cn } from '@/lib/utils'; +import { Nip05Badges } from '@/components/Nip05Badges'; interface CrewNip05BadgeProps { /** A single handle (back-compat). */ @@ -19,71 +10,11 @@ interface CrewNip05BadgeProps { } /** - * Verified-handle badge(s) for a crew's optional NIP-05. A crew may carry - * several handles (our own tag convention — unlike the single kind-0 `nip05`), - * each verified against the founder key. - * - * - `verified`: green check + the handle. The domain confirms the founder key. - * - `mismatch`: red warning — the domain points the handle at a different key. - * - `unreachable`: muted handle (shown but unverified) while the domain is down. + * Verified handle badge(s) for a crew's optional NIP-05. A crew may carry several + * handles (our own tag convention — unlike the single kind-0 `nip05`), each + * verified against the founder key. Thin wrapper over the shared `Nip05Badges`. */ export function CrewNip05Badge({ nip05, nip05s, founderPubkey, className }: CrewNip05BadgeProps) { - const handles = (nip05s && nip05s.length > 0 ? nip05s : nip05 ? [nip05] : []) - .map((h) => h.trim().toLowerCase()) - .filter(Boolean) - .filter((h, i, arr) => arr.indexOf(h) === i); - - if (handles.length === 0) return null; - - return ( - - {handles.map((handle) => ( - - ))} - - ); -} - -function Nip05BadgeRow({ handle, founderPubkey }: { handle: string; founderPubkey?: string }) { - const { t } = useTranslation(); - const { data, isLoading } = useCrewNip05(handle, founderPubkey); - const status = data?.status ?? (isLoading ? 'loading' : 'unreachable'); - - if (status === 'mismatch') { - return ( - - - - - {handle} - - - {t('socialUi.nip05.mismatch')} - - - ); - } - - if (status === 'verified') { - return ( - - - - - {handle} - - - {t('socialUi.nip05.verified')} - - - ); - } - - // loading / unreachable — show the handle, muted. - return ( - - {status === 'loading' ? : null} - {handle} - - ); + const handles = nip05s && nip05s.length > 0 ? nip05s : nip05 ? [nip05] : []; + return ; } diff --git a/src/components/EditProfileForm.tsx b/src/components/EditProfileForm.tsx index 3483a83..fe532b9 100644 --- a/src/components/EditProfileForm.tsx +++ b/src/components/EditProfileForm.tsx @@ -20,11 +20,13 @@ 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, Bitcoin, Award, MessageSquareLock, UserRound, X, Plus, Bot, BadgeCheck, HelpCircle, Server } from 'lucide-react'; +import { Loader2, Upload, Zap, ShieldCheck, AlertCircle, Bitcoin, Award, MessageSquareLock, UserRound, X, Plus, Bot, BadgeCheck, HelpCircle, Server, AtSign } from 'lucide-react'; import { NSchema as n, type NostrMetadata } from '@nostrify/nostrify'; import { useQueryClient } from '@tanstack/react-query'; import { useUploadFile } from '@/hooks/useUploadFile'; import { useCrewNip05 } from '@/hooks/useCrewNip05'; +import { useProfileHandles, useSetProfileHandles } from '@/hooks/useProfileHandles'; +import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor'; import { SkateNip05Input } from '@/components/SkateNip05Input'; import { SKATE_NIP05_DOMAIN, parseSkateNip05 } from '@/lib/skateNip05'; import { isValidNoffer, isValidSilentPayment, isValidLightningAddress } from '@/lib/types'; @@ -70,6 +72,13 @@ export const EditProfileForm: React.FC = () => { // pointer formats as Settings → Coordinator (nprofile / cordn.net link / // npub / hex) as well as an http(s) URL — this is the discovery field. const [cordn, setCordn] = useState(''); + + // Extra personal NIP-05 handles — stored in a separate kind-30078 record so + // another client re-saving the profile can't drop them. + const { data: savedHandles } = useProfileHandles(user?.pubkey); + const { setHandles } = useSetProfileHandles(); + const [extraHandles, setExtraHandles] = useState([]); + const handlesSeededRef = useRef(false); const cordnTrimmed = cordn.trim(); const cordnParsed = cordnTrimmed ? parseCoordinatorInput(cordnTrimmed) : null; const cordnInvalid = @@ -125,6 +134,13 @@ export const EditProfileForm: React.FC = () => { } }, [metadata, form]); + // Seed the extra handles once from the separate record. + useEffect(() => { + if (handlesSeededRef.current || !savedHandles) return; + handlesSeededRef.current = true; + setExtraHandles(savedHandles); + }, [savedHandles]); + // Handle file uploads for profile picture and banner const uploadPicture = async (file: File, field: 'picture' | 'banner') => { try { @@ -275,6 +291,13 @@ export const EditProfileForm: React.FC = () => { throw lastError; } + // Extra handles live in a separate record — only publish when changed. + const before = [...(savedHandles ?? [])].sort().join(','); + const after = [...extraHandles].sort().join(','); + if (before !== after) { + await setHandles(extraHandles); + } + // Invalidate queries to refresh the data queryClient.invalidateQueries({ queryKey: ['logins'] }); queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] }); @@ -386,6 +409,22 @@ export const EditProfileForm: React.FC = () => { name="nip05" render={({ field }) => } /> + + {/* Extra handles — a skater can be known on several domains. Each is + verified against this same key; stored in a separate record. */} +
+ + +
{/* ── Section: Bitcoin & Payments ── */} diff --git a/src/components/Nip05Badges.tsx b/src/components/Nip05Badges.tsx new file mode 100644 index 0000000..90dfa11 --- /dev/null +++ b/src/components/Nip05Badges.tsx @@ -0,0 +1,87 @@ +import { BadgeCheck, Loader2, AlertTriangle } from 'lucide-react'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { useCrewNip05 } from '@/hooks/useCrewNip05'; +import { useTranslation } from '@/contexts/language'; +import { cn } from '@/lib/utils'; + +/** + * A row of verified-handle chips. Works for any owner key — a crew's founder, a + * shop's owner, or a profile's own npub. Each handle is verified independently: + * + * - `verified`: green check — the domain maps the handle to `pubkey`. + * - `mismatch`: red warning — the domain points it at a different key. + * - `unreachable`: muted handle (shown but unverified) while the domain is down. + */ +export function Nip05Badges({ + handles, + pubkey, + className, +}: { + handles: (string | undefined | null)[]; + pubkey?: string; + className?: string; +}) { + const unique = handles + .map((h) => h?.trim().toLowerCase() ?? '') + .filter(Boolean) + .filter((h, i, arr) => arr.indexOf(h) === i); + + if (unique.length === 0) return null; + + return ( + + {unique.map((handle) => ( + + ))} + + ); +} + +function Nip05Chip({ handle, pubkey }: { handle: string; pubkey?: string }) { + const { t } = useTranslation(); + const { data, isLoading } = useCrewNip05(handle, pubkey); + const status = data?.status ?? (isLoading ? 'loading' : 'unreachable'); + + if (status === 'mismatch') { + return ( + + + + + {handle} + + + {t('socialUi.nip05.mismatch')} + + + ); + } + + if (status === 'verified') { + return ( + + + + + {handle} + + + {t('socialUi.nip05.verified')} + + + ); + } + + // loading / unreachable — show the handle, muted. + return ( + + {status === 'loading' ? : null} + {handle} + + ); +} diff --git a/src/components/Nip05HandlesEditor.tsx b/src/components/Nip05HandlesEditor.tsx new file mode 100644 index 0000000..5520b95 --- /dev/null +++ b/src/components/Nip05HandlesEditor.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react'; +import { AtSign, Plus, X, Loader2, BadgeCheck, AlertTriangle } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useCrewNip05 } from '@/hooks/useCrewNip05'; +import { useTranslation } from '@/contexts/language'; +import { cn } from '@/lib/utils'; +import { MAX_PROFILE_HANDLES, normalizeHandle } from '@/lib/profileHandles'; + +/** + * Editable list of NIP-05 handles (add / remove / live-verify) for a profile, + * crew or shop. Each handle is verified against the owner `pubkey`. + */ +export function Nip05HandlesEditor({ + handles, + onChange, + pubkey, + inputId, + accent = 'teal', + className, +}: { + handles: string[]; + onChange: (next: string[]) => void; + pubkey?: string; + inputId?: string; + accent?: 'teal' | 'lime'; + className?: string; +}) { + const { t } = useTranslation(); + const [input, setInput] = useState(''); + const [error, setError] = useState(null); + + const accentText = accent === 'lime' ? 'text-[#ccff00]' : 'text-teal-400'; + const addBtn = accent === 'lime' ? 'bg-[#ccff00] text-black hover:bg-[#ccff00]/90' : 'bg-teal-500 text-white hover:bg-teal-600'; + + const add = () => { + const value = normalizeHandle(input); + setInput(''); + if (!value) return setError(t('socialUi.form.nip05Invalid')); + if (handles.includes(value)) return setError(null); + if (handles.length >= MAX_PROFILE_HANDLES) { + return setError(t('socialUi.form.nip05TooMany', { count: MAX_PROFILE_HANDLES })); + } + setError(null); + onChange([...handles, value]); + }; + + const remove = (handle: string) => onChange(handles.filter((h) => h !== handle)); + + return ( +
+
+ { setInput(e.target.value); if (error) setError(null); }} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); add(); } }} + spellCheck={false} + autoCapitalize="none" + className="bg-gray-800 border-gray-700 font-mono text-sm" + /> + +
+ {error &&

{error}

} + {handles.length > 0 && ( +
+ {handles.map((handle) => ( + remove(handle)} accentText={accentText} /> + ))} +
+ )} +
+ ); +} + +function HandleRow({ + handle, + pubkey, + onRemove, + accentText, +}: { + handle: string; + pubkey?: string; + onRemove: () => void; + accentText: string; +}) { + const { t } = useTranslation(); + const { data, isLoading } = useCrewNip05(handle, pubkey); + const status = data?.status ?? (isLoading ? 'loading' : 'unreachable'); + + const icon = + status === 'verified' ? ( + + ) : status === 'mismatch' ? ( + + ) : status === 'loading' ? ( + + ) : ( + + ); + const label = + status === 'verified' + ? t('socialUi.nip05.verified') + : status === 'mismatch' + ? t('socialUi.nip05.mismatch') + : t('socialUi.form.nip05Unverified'); + + return ( +
+ {icon} + {handle} + + {label} + + +
+ ); +} diff --git a/src/hooks/useProfileHandles.ts b/src/hooks/useProfileHandles.ts new file mode 100644 index 0000000..2984e70 --- /dev/null +++ b/src/hooks/useProfileHandles.ts @@ -0,0 +1,43 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; + +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useSkateRelays } from '@/hooks/useSkateRelays'; +import { APP_DATA_KIND, PROFILE_HANDLES_D, parseProfileHandles, profileHandlesTags } from '@/lib/profileHandles'; + +/** The extra personal NIP-05 handles a profile advertises (kind 30078 record). */ +export function useProfileHandles(pubkey?: string) { + const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + + return useQuery({ + queryKey: ['profile-handles', pubkey, getQueryKeySuffix()], + enabled: !!pubkey, + staleTime: 5 * 60 * 1000, + queryFn: async () => { + if (!pubkey) return []; + const signal = AbortSignal.timeout(8000); + const events = await querySkateContent( + [{ kinds: [APP_DATA_KIND], authors: [pubkey], '#d': [PROFILE_HANDLES_D], limit: 1 }], + { signal }, + ); + const newest = [...events].sort((a, b) => b.created_at - a.created_at)[0]; + return newest ? parseProfileHandles(newest) : []; + }, + }); +} + +/** Publish the extra handles (replaces the record; an empty list clears it). */ +export function useSetProfileHandles() { + const { mutateAsync: publishEvent, isPending } = useNostrPublish(); + const { user } = useCurrentUser(); + const queryClient = useQueryClient(); + + return { + isPending, + setHandles: async (handles: string[]) => { + if (!user) throw new Error('Log in first.'); + await publishEvent({ kind: APP_DATA_KIND, content: '', tags: profileHandlesTags(handles) }); + queryClient.invalidateQueries({ queryKey: ['profile-handles', user.pubkey] }); + }, + }; +} diff --git a/src/hooks/useShops.ts b/src/hooks/useShops.ts index 2a986b2..8ed09b6 100644 --- a/src/hooks/useShops.ts +++ b/src/hooks/useShops.ts @@ -60,6 +60,7 @@ export function useShops(limit = 100) { website: undefined, instagram: undefined, hours: undefined, + nip05s: [], createdAt: spot.createdAt, event: spot.event, }; @@ -148,6 +149,7 @@ export function useShop(identifier: string, pubkey?: string) { website: undefined, instagram: undefined, hours: undefined, + nip05s: [], createdAt: spot.createdAt, event: spot.event, }; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 6a4b4d4..6b06509 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -308,6 +308,8 @@ nav: { services: 'Services', brands: 'Brands', supportLocal: 'Support Local', + nip05: 'Shop NIP-05', + nip05Hint: 'An optional handle you control for this shop (e.g. shop@yourdomain.com), verified against your key. Add up to 5.', }, // Wallet @@ -4479,6 +4481,8 @@ settingsUi: { saveProfile: 'Save Profile', nip05VerifiedHandle: 'NIP-05 verified handle', nip05How: 'How?', + extraHandles: 'Extra handles', + extraHandlesHint: 'optional — add up to 5', nip05HintEmpty: 'A verified handle like you@yourdomain.com — Nostr’s version of a blue check. Shows a ✓ badge on your profile and unlocks the “Verified” filter in the Skaters directory.', nip05Invalid: 'Use an email-like handle, e.g. you@yourdomain.com — no “https://” or spaces.', nip05Checking: 'Checking your domain…', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index b2748f3..3c674f1 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -308,6 +308,8 @@ nav: { services: 'Servicios', brands: 'Marcas', supportLocal: 'Apoya lo Local', + nip05: 'NIP-05 de la tienda', + nip05Hint: 'Un identificador opcional que controlas para esta tienda (p. ej. tienda@tudominio.com), verificado con tu clave. Añade hasta 5.', }, // Wallet @@ -4479,6 +4481,8 @@ settingsUi: { saveProfile: 'Guardar perfil', nip05VerifiedHandle: 'Identificador verificado NIP-05', nip05How: '¿Cómo?', + extraHandles: 'Identificadores adicionales', + extraHandlesHint: 'opcional — añade hasta 5', nip05HintEmpty: 'Un identificador verificado tipo you@yourdomain.com, la versión de Nostr del check azul. Muestra una insignia ✓ en tu perfil y desbloquea el filtro “Verificado” en el directorio de skaters.', nip05Invalid: 'Usa un identificador tipo correo, p. ej. you@yourdomain.com, sin “https://” ni espacios.', nip05Checking: 'Comprobando tu dominio…', diff --git a/src/lib/profileHandles.ts b/src/lib/profileHandles.ts new file mode 100644 index 0000000..0e1152e --- /dev/null +++ b/src/lib/profileHandles.ts @@ -0,0 +1,51 @@ +/** + * Extra personal NIP-05 handles for a profile. + * + * A kind-0 profile has ONE standard `nip05` field, and other clients editing it + * may drop anything non-standard. So the extra handles live in a **separate + * NIP-78 record** (kind 30078, `d=skatespots:profile-handles`) authored by the + * user — the primary stays in `nip05` (works everywhere), the extras ride here + * (SkateSpots-only, but durable against another client re-saving the profile). + * + * Each handle must resolve (via `/.well-known/nostr.json`) to the **owner's own** + * pubkey — verified the same way as a crew handle, just against the profile key. + */ +import { APP_DATA_KIND } from '@/lib/types'; + +export { APP_DATA_KIND }; + +/** `d` identifier for the per-user extra-handles record. */ +export const PROFILE_HANDLES_D = 'skatespots:profile-handles'; + +export const MAX_PROFILE_HANDLES = 5; + +/** `local@domain.tld` per NIP-05 (local part is `[a-z0-9-_.]`). */ +export const NIP05_HANDLE_RE = /^[a-z0-9\-_.]+@[a-z0-9.-]+\.[a-z]{2,}$/; + +/** Normalise a raw handle; returns null when it isn't a valid `local@domain`. */ +export function normalizeHandle(raw: string): string | null { + const value = raw.trim().toLowerCase(); + return NIP05_HANDLE_RE.test(value) ? value : null; +} + +/** All extra handles stored on a profile-handles event (deduped, ordered, capped). */ +export function parseProfileHandles(event: { tags: string[][] }): string[] { + const handles = event.tags + .filter(([name, value]) => name === 'nip05' && !!value?.trim()) + .map(([, value]) => value.trim().toLowerCase()) + .filter((value) => NIP05_HANDLE_RE.test(value)); + return [...new Set(handles)].slice(0, MAX_PROFILE_HANDLES); +} + +/** Tags for the addressable record. An empty list clears the extras (replace). */ +export function profileHandlesTags(handles: string[]): string[][] { + const clean = [ + ...new Set(handles.map((h) => h.trim().toLowerCase()).filter((h) => NIP05_HANDLE_RE.test(h))), + ].slice(0, MAX_PROFILE_HANDLES); + const tags: string[][] = [ + ['d', PROFILE_HANDLES_D], + ['alt', 'SkateSpots profile handles'], + ]; + for (const handle of clean) tags.push(['nip05', handle]); + return tags; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 40b3046..91309f0 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -727,6 +727,8 @@ export interface SkateShop { instagram?: string; hours?: string; noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments + nip05?: string; // Shop NIP-05 identifier (first handle; verified against the owner pubkey) + nip05s: string[]; // ALL shop NIP-05 handles (each verified against the owner pubkey) createdAt: number; event: NostrEvent; } @@ -973,6 +975,14 @@ export function parseSkateShop(event: NostrEvent): SkateShop | null { const hours = event.tags.find(([name]) => name === 'hours')?.[1]; const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1]; const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined; + // Shop NIP-05 handles (mirrors crews): repeatable, verified against the owner. + const nip05s = [...new Set( + event.tags + .filter(([tagName, value]) => tagName === 'nip05' && !!value?.trim()) + .map(([, value]) => value.trim().toLowerCase()) + .filter((v) => /^[a-z0-9\-_.]+@[a-z0-9.-]+\.[a-z]{2,}$/.test(v)), + )].slice(0, 5); + const nip05 = nip05s[0]; return { id: event.id, @@ -989,6 +999,8 @@ export function parseSkateShop(event: NostrEvent): SkateShop | null { instagram, hours, noffer, + nip05, + nip05s, createdAt: event.created_at, event, }; diff --git a/src/pages/CreateShopPage.tsx b/src/pages/CreateShopPage.tsx index 08b4eff..c28b46d 100644 --- a/src/pages/CreateShopPage.tsx +++ b/src/pages/CreateShopPage.tsx @@ -9,6 +9,7 @@ import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor'; import { useUploadFile } from '@/hooks/useUploadFile'; import { useToast } from '@/hooks/useToast'; import { ShopBountyCard } from '@/components/ShopBountyCard'; @@ -52,6 +53,7 @@ export default function CreateShopPage() { const [website, setWebsite] = useState(''); const [instagram, setInstagram] = useState(''); const [noffer, setNoffer] = useState(''); + const [nip05s, setNip05s] = useState([]); const [hours, setHours] = useState(''); const [images, setImages] = useState([]); const [isGettingLocation, setIsGettingLocation] = useState(false); @@ -222,6 +224,8 @@ export default function CreateShopPage() { tags.push(['noffer', noffer.trim().toLowerCase()]); } + for (const handle of nip05s) tags.push(['nip05', handle]); + for (const imageUrl of images) { // Use imeta tag for NIP-92 compatibility tags.push(['imeta', `url ${imageUrl}`, 'm image/jpeg']); @@ -444,6 +448,16 @@ export default function CreateShopPage() { {/* CLINK self-custodial payments (optional) */} + {/* Shop NIP-05 handle(s) — optional, verified against the owner key */} +
+ +

{t('shops.nip05Hint')}

+ +
+ {/* Images */}
diff --git a/src/pages/EditShopPage.tsx b/src/pages/EditShopPage.tsx index 0e92292..a503bd4 100644 --- a/src/pages/EditShopPage.tsx +++ b/src/pages/EditShopPage.tsx @@ -10,6 +10,7 @@ import { Label } from '@/components/ui/label'; import { Skeleton } from '@/components/ui/skeleton'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor'; import { useUploadFile } from '@/hooks/useUploadFile'; import { useToast } from '@/hooks/useToast'; import { useShop } from '@/hooks/useShops'; @@ -80,6 +81,7 @@ export default function EditShopPage() { const [instagram, setInstagram] = useState(''); const [hours, setHours] = useState(''); const [noffer, setNoffer] = useState(''); + const [nip05s, setNip05s] = useState([]); const [images, setImages] = useState([]); const [isGettingLocation, setIsGettingLocation] = useState(false); const [initialized, setInitialized] = useState(false); @@ -101,6 +103,7 @@ export default function EditShopPage() { setInstagram(shop.instagram || ''); setHours(shop.hours || ''); setNoffer(shop.noffer || ''); + setNip05s(shop.nip05s ?? []); setImages(shop.images); // Decode geohash to get coordinates @@ -266,6 +269,8 @@ export default function EditShopPage() { tags.push(['noffer', noffer.trim().toLowerCase()]); } + for (const handle of nip05s) tags.push(['nip05', handle]); + for (const imageUrl of images) { // Use imeta tag for NIP-92 compatibility tags.push(['imeta', `url ${imageUrl}`, 'm image/jpeg']); @@ -279,7 +284,7 @@ export default function EditShopPage() { // owns the 'skateshop' marker, so keep every OTHER topic tag. const MANAGED_TAGS = new Set([ 'd', 'name', 'g', 'lat', 'lon', 'location', 'alt', - 'phone', 'email', 'website', 'instagram', 'hours', 'noffer', 'imeta', + 'phone', 'email', 'website', 'instagram', 'hours', 'noffer', 'imeta', 'nip05', ]); for (const tag of shop.event.tags) { const [tagName, tagValue] = tag; @@ -512,6 +517,14 @@ export default function EditShopPage() { className="bg-black border-[#ccff00]/30" />
+
+ +

{t('shops.nip05Hint')}

+ +
)} + {/* Extra verified handles (separate record) — a skater on several domains. */} + {extraHandles.length > 0 && ( + + )} {/* Crew membership summary — links straight to the crews */} {crews.length > 0 && (

@@ -597,6 +605,9 @@ function ProfilePage({ pubkey }: { pubkey: string }) {

{shop.name}

+ {shop.nip05s.length > 0 && ( + + )} {shop.location && (

{shop.location}

)} @@ -878,6 +889,9 @@ function ProfilePage({ pubkey }: { pubkey: string }) {

{m.crew.name}

+ {m.crew.nip05s && m.crew.nip05s.length > 0 && ( + + )}

{m.role === 'founder' ? ( <> {t('walletUi.profile.founder')} diff --git a/src/pages/ShopDetailPage.tsx b/src/pages/ShopDetailPage.tsx index c12119a..6345f7f 100644 --- a/src/pages/ShopDetailPage.tsx +++ b/src/pages/ShopDetailPage.tsx @@ -20,6 +20,7 @@ import { CommentsSection } from '@/components/comments/CommentsSection'; import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl'; import { OfficialBadge } from '@/components/OfficialBadge'; import { ControllerBadge } from '@/components/ControllerBadge'; +import { Nip05Badges } from '@/components/Nip05Badges'; import { VenueAccessCard } from '@/components/VenueAccessCard'; import { useShop } from '@/hooks/useShops'; import { useAuthor } from '@/hooks/useAuthor'; @@ -282,6 +283,9 @@ export default function ShopDetailPage() { {isVerifiedOwner && }

+ {shop.nip05s.length > 0 && ( + + )} {shop.location && (