feat(nip05): multiple handles for profiles, crews and shops

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.
This commit is contained in:
SkateSpots 2026-10-03 17:26:59 +07:00
parent 2daa0a9832
commit 127819e3da
17 changed files with 454 additions and 172 deletions

View file

@ -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 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 isn't limited to one. Normalise → dedupe → regex-validate → cap 5 in
`parseSkateCrew`. `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** - **Rider Cards (2026-10-02) — now REAL + verifiable.** `32271` **Rider Card**
(addressable, `d=rider-card`) — the skater signs their own card customization (addressable, `d=rider-card`) — the skater signs their own card customization
(theme/style/skills/sponsors/rides-for/show-flags; `v:1` marks show flags (theme/style/skills/sponsors/rides-for/show-flags; `v:1` marks show flags

1
NIP.md
View file

@ -79,6 +79,7 @@ Skate shops use the NIP-15 marketplace "stall" kind (30017) extended with skateb
| `website` | Shop website URL | | `website` | Shop website URL |
| `instagram` | Instagram handle | | `instagram` | Instagram handle |
| `hours` | Hours of operation (freeform text) | | `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 | | `noffer` | Optional CLINK Offer code (`noffer1…`) for self-custodial payments — see the CLINK Payments section |
| `imeta` | Image metadata per NIP-92 | | `imeta` | Image metadata per NIP-92 |
| `lat` | Latitude (decimal) | | `lat` | Latitude (decimal) |

View file

@ -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 { 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 { useCrewGoal } from '@/hooks/useCrewGoal';
import { useCoordinator } from '@/hooks/useCoordinator'; import { useCoordinator } from '@/hooks/useCoordinator';
import { useCrewNip05 } from '@/hooks/useCrewNip05'; import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor';
import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, shortNpub } from '@/lib/coordinator'; import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, shortNpub } from '@/lib/coordinator';
import { useRelays } from '@/lib/relays'; import { useRelays } from '@/lib/relays';
import { cn } from '@/lib/utils'; 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_ORDER: CrewFocus[] = ['mixed', 'builder', 'photo', 'coaching', 'art', 'music', 'wellness'];
const FOCUS_ICONS: Record<CrewFocus, typeof Users> = { const FOCUS_ICONS: Record<CrewFocus, typeof Users> = {
@ -238,7 +238,6 @@ export function CrewForm({ existing }: CrewFormProps) {
const [socials, setSocials] = useState<string[]>(existing?.socials ?? []); const [socials, setSocials] = useState<string[]>(existing?.socials ?? []);
const [socialInput, setSocialInput] = useState(''); const [socialInput, setSocialInput] = useState('');
const [nip05s, setNip05s] = useState<string[]>(existing?.nip05s ?? (existing?.nip05 ? [existing.nip05] : [])); const [nip05s, setNip05s] = useState<string[]>(existing?.nip05s ?? (existing?.nip05 ? [existing.nip05] : []));
const [nip05Input, setNip05Input] = useState('');
const [noffer, setNoffer] = useState(existing?.noffer ?? ''); const [noffer, setNoffer] = useState(existing?.noffer ?? '');
const [members, setMembers] = useState<string[]>(existing?.members ?? []); const [members, setMembers] = useState<string[]>(existing?.members ?? []);
const [admins, setAdmins] = useState<string[]>(existing?.admins ?? []); const [admins, setAdmins] = useState<string[]>(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. // 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 founderPubkey = existing?.pubkey ?? user?.pubkey;
const [nip05Error, setNip05Error] = useState<string | null>(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. // Live-decode whatever's typed in the member field for the preview chip.
const memberPreviewPk = decodePubkeyInput(memberInput); const memberPreviewPk = decodePubkeyInput(memberInput);
@ -1160,29 +1145,7 @@ export function CrewForm({ existing }: CrewFormProps) {
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
{t('socialUi.form.nip05DescA')} <code className="text-teal-300">crew@yourdomain.com</code>{t('socialUi.form.nip05DescB')} {t('socialUi.form.nip05DescA')} <code className="text-teal-300">crew@yourdomain.com</code>{t('socialUi.form.nip05DescB')}
</p> </p>
<div className="flex gap-2"> <Nip05HandlesEditor handles={nip05s} onChange={setNip05s} pubkey={founderPubkey} inputId="crew-nip05" />
<Input
id="crew-nip05"
placeholder={t('socialUi.form.nip05Placeholder')}
value={nip05Input}
onChange={(e) => { 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"
/>
<Button type="button" onClick={addNip05} className={cn('shrink-0 gap-1', TEAL)} disabled={nip05s.length >= MAX_NIP05}>
<Plus className="h-4 w-4" /> {t('socialUi.form.nip05Add')}
</Button>
</div>
{nip05Error && <p className="text-[11px] text-amber-400">{nip05Error}</p>}
{nip05s.length > 0 && (
<div className="space-y-1.5 pt-1">
{nip05s.map((handle) => (
<CrewNip05Row key={handle} handle={handle} founderPubkey={founderPubkey} onRemove={() => removeNip05(handle)} />
))}
</div>
)}
</div> </div>
); );
@ -1583,58 +1546,3 @@ export function CrewForm({ existing }: CrewFormProps) {
</form> </form>
); );
} }
/** 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' ? (
<BadgeCheck className="h-3.5 w-3.5 text-teal-300" />
) : status === 'mismatch' ? (
<AlertTriangle className="h-3.5 w-3.5 text-red-400" />
) : status === 'loading' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400" />
) : (
<AtSign className="h-3.5 w-3.5 text-gray-500" />
);
const label =
status === 'verified'
? t('socialUi.nip05.verified')
: status === 'mismatch'
? t('socialUi.nip05.mismatch')
: t('socialUi.form.nip05Unverified');
return (
<div className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800/60 p-2">
{icon}
<span className="min-w-0 flex-1 truncate font-mono text-xs text-gray-200">{handle}</span>
<span
className={cn(
'text-[10px] uppercase tracking-wide',
status === 'verified' ? 'text-teal-300' : status === 'mismatch' ? 'text-red-400' : 'text-gray-500',
)}
>
{label}
</span>
<button
type="button"
onClick={onRemove}
aria-label={t('socialUi.form.nip05Remove', { handle })}
className="rounded p-1 text-gray-500 transition-colors hover:bg-white/5 hover:text-red-400"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
}

View file

@ -1,13 +1,4 @@
import { BadgeCheck, Loader2, AlertTriangle } from 'lucide-react'; import { Nip05Badges } from '@/components/Nip05Badges';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useCrewNip05 } from '@/hooks/useCrewNip05';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
interface CrewNip05BadgeProps { interface CrewNip05BadgeProps {
/** A single handle (back-compat). */ /** 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 * Verified handle badge(s) for a crew's optional NIP-05. A crew may carry several
* several handles (our own tag convention — unlike the single kind-0 `nip05`), * handles (our own tag convention — unlike the single kind-0 `nip05`), each
* each verified against the founder key. * verified against the founder key. Thin wrapper over the shared `Nip05Badges`.
*
* - `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.
*/ */
export function CrewNip05Badge({ nip05, nip05s, founderPubkey, className }: CrewNip05BadgeProps) { export function CrewNip05Badge({ nip05, nip05s, founderPubkey, className }: CrewNip05BadgeProps) {
const handles = (nip05s && nip05s.length > 0 ? nip05s : nip05 ? [nip05] : []) const handles = nip05s && nip05s.length > 0 ? nip05s : nip05 ? [nip05] : [];
.map((h) => h.trim().toLowerCase()) return <Nip05Badges handles={handles} pubkey={founderPubkey} className={className} />;
.filter(Boolean)
.filter((h, i, arr) => arr.indexOf(h) === i);
if (handles.length === 0) return null;
return (
<span className={cn('inline-flex flex-wrap items-center gap-1.5', className)}>
{handles.map((handle) => (
<Nip05BadgeRow key={handle} handle={handle} founderPubkey={founderPubkey} />
))}
</span>
);
}
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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1 rounded-full bg-red-500/15 px-2 py-0.5 text-xs font-bold text-red-400">
<AlertTriangle className="h-3 w-3" /> {handle}
</span>
</TooltipTrigger>
<TooltipContent>{t('socialUi.nip05.mismatch')}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
if (status === 'verified') {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1 rounded-full bg-teal-500/15 px-2 py-0.5 text-xs font-bold text-teal-300">
<BadgeCheck className="h-3 w-3" /> {handle}
</span>
</TooltipTrigger>
<TooltipContent>{t('socialUi.nip05.verified')}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
// loading / unreachable — show the handle, muted.
return (
<span className="inline-flex items-center gap-1 rounded-full bg-white/5 px-2 py-0.5 text-xs font-medium text-gray-400">
{status === 'loading' ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
{handle}
</span>
);
} }

View file

@ -20,11 +20,13 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label'; 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 { NSchema as n, type NostrMetadata } from '@nostrify/nostrify';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useUploadFile } from '@/hooks/useUploadFile'; import { useUploadFile } from '@/hooks/useUploadFile';
import { useCrewNip05 } from '@/hooks/useCrewNip05'; import { useCrewNip05 } from '@/hooks/useCrewNip05';
import { useProfileHandles, useSetProfileHandles } from '@/hooks/useProfileHandles';
import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor';
import { SkateNip05Input } from '@/components/SkateNip05Input'; import { SkateNip05Input } from '@/components/SkateNip05Input';
import { SKATE_NIP05_DOMAIN, parseSkateNip05 } from '@/lib/skateNip05'; import { SKATE_NIP05_DOMAIN, parseSkateNip05 } from '@/lib/skateNip05';
import { isValidNoffer, isValidSilentPayment, isValidLightningAddress } from '@/lib/types'; 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 / // pointer formats as Settings → Coordinator (nprofile / cordn.net link /
// npub / hex) as well as an http(s) URL — this is the discovery field. // npub / hex) as well as an http(s) URL — this is the discovery field.
const [cordn, setCordn] = useState(''); 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<string[]>([]);
const handlesSeededRef = useRef(false);
const cordnTrimmed = cordn.trim(); const cordnTrimmed = cordn.trim();
const cordnParsed = cordnTrimmed ? parseCoordinatorInput(cordnTrimmed) : null; const cordnParsed = cordnTrimmed ? parseCoordinatorInput(cordnTrimmed) : null;
const cordnInvalid = const cordnInvalid =
@ -125,6 +134,13 @@ export const EditProfileForm: React.FC = () => {
} }
}, [metadata, form]); }, [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 // Handle file uploads for profile picture and banner
const uploadPicture = async (file: File, field: 'picture' | 'banner') => { const uploadPicture = async (file: File, field: 'picture' | 'banner') => {
try { try {
@ -275,6 +291,13 @@ export const EditProfileForm: React.FC = () => {
throw lastError; 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 // Invalidate queries to refresh the data
queryClient.invalidateQueries({ queryKey: ['logins'] }); queryClient.invalidateQueries({ queryKey: ['logins'] });
queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] }); queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] });
@ -386,6 +409,22 @@ export const EditProfileForm: React.FC = () => {
name="nip05" name="nip05"
render={({ field }) => <Nip05Field value={field.value ?? ''} onChange={field.onChange} pubkey={user?.pubkey} />} render={({ field }) => <Nip05Field value={field.value ?? ''} onChange={field.onChange} pubkey={user?.pubkey} />}
/> />
{/* Extra handles — a skater can be known on several domains. Each is
verified against this same key; stored in a separate record. */}
<div className="space-y-2 pt-1">
<Label className="flex items-center gap-2 text-sm font-semibold">
<AtSign className="h-4 w-4 text-teal-400" />
{t('settingsUi.editProfile.extraHandles')}
<span className="text-xs font-normal text-muted-foreground">{t('settingsUi.editProfile.extraHandlesHint')}</span>
</Label>
<Nip05HandlesEditor
handles={extraHandles}
onChange={setExtraHandles}
pubkey={user?.pubkey}
inputId="profile-extra-nip05"
/>
</div>
</FormSection> </FormSection>
{/* ── Section: Bitcoin & Payments ── */} {/* ── Section: Bitcoin & Payments ── */}

View file

@ -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 (
<span className={cn('inline-flex flex-wrap items-center gap-1.5', className)}>
{unique.map((handle) => (
<Nip05Chip key={handle} handle={handle} pubkey={pubkey} />
))}
</span>
);
}
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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1 rounded-full bg-red-500/15 px-2 py-0.5 text-xs font-bold text-red-400">
<AlertTriangle className="h-3 w-3" /> {handle}
</span>
</TooltipTrigger>
<TooltipContent>{t('socialUi.nip05.mismatch')}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
if (status === 'verified') {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1 rounded-full bg-teal-500/15 px-2 py-0.5 text-xs font-bold text-teal-300">
<BadgeCheck className="h-3 w-3" /> {handle}
</span>
</TooltipTrigger>
<TooltipContent>{t('socialUi.nip05.verified')}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
// loading / unreachable — show the handle, muted.
return (
<span className="inline-flex items-center gap-1 rounded-full bg-white/5 px-2 py-0.5 text-xs font-medium text-gray-400">
{status === 'loading' ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
{handle}
</span>
);
}

View file

@ -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<string | null>(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 (
<div className={cn('space-y-2', className)}>
<div className="flex gap-2">
<Input
id={inputId}
placeholder={t('socialUi.form.nip05Placeholder')}
value={input}
onChange={(e) => { 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"
/>
<Button
type="button"
onClick={add}
className={cn('shrink-0 gap-1', addBtn)}
disabled={handles.length >= MAX_PROFILE_HANDLES}
>
<Plus className="h-4 w-4" /> {t('socialUi.form.nip05Add')}
</Button>
</div>
{error && <p className="text-[11px] text-amber-400">{error}</p>}
{handles.length > 0 && (
<div className="space-y-1.5 pt-1">
{handles.map((handle) => (
<HandleRow key={handle} handle={handle} pubkey={pubkey} onRemove={() => remove(handle)} accentText={accentText} />
))}
</div>
)}
</div>
);
}
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' ? (
<BadgeCheck className="h-3.5 w-3.5 text-teal-300" />
) : status === 'mismatch' ? (
<AlertTriangle className="h-3.5 w-3.5 text-red-400" />
) : status === 'loading' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400" />
) : (
<AtSign className="h-3.5 w-3.5 text-gray-500" />
);
const label =
status === 'verified'
? t('socialUi.nip05.verified')
: status === 'mismatch'
? t('socialUi.nip05.mismatch')
: t('socialUi.form.nip05Unverified');
return (
<div className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800/60 p-2">
{icon}
<span className="min-w-0 flex-1 truncate font-mono text-xs text-gray-200">{handle}</span>
<span
className={cn(
'text-[10px] uppercase tracking-wide',
status === 'verified' ? accentText : status === 'mismatch' ? 'text-red-400' : 'text-gray-500',
)}
>
{label}
</span>
<button
type="button"
onClick={onRemove}
aria-label={t('socialUi.form.nip05Remove', { handle })}
className="rounded p-1 text-gray-500 transition-colors hover:bg-white/5 hover:text-red-400"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
}

View file

@ -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<string[]>({
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] });
},
};
}

View file

@ -60,6 +60,7 @@ export function useShops(limit = 100) {
website: undefined, website: undefined,
instagram: undefined, instagram: undefined,
hours: undefined, hours: undefined,
nip05s: [],
createdAt: spot.createdAt, createdAt: spot.createdAt,
event: spot.event, event: spot.event,
}; };
@ -148,6 +149,7 @@ export function useShop(identifier: string, pubkey?: string) {
website: undefined, website: undefined,
instagram: undefined, instagram: undefined,
hours: undefined, hours: undefined,
nip05s: [],
createdAt: spot.createdAt, createdAt: spot.createdAt,
event: spot.event, event: spot.event,
}; };

View file

@ -308,6 +308,8 @@ nav: {
services: 'Services', services: 'Services',
brands: 'Brands', brands: 'Brands',
supportLocal: 'Support Local', 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 // Wallet
@ -4479,6 +4481,8 @@ settingsUi: {
saveProfile: 'Save Profile', saveProfile: 'Save Profile',
nip05VerifiedHandle: 'NIP-05 verified handle', nip05VerifiedHandle: 'NIP-05 verified handle',
nip05How: 'How?', 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.', 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.', nip05Invalid: 'Use an email-like handle, e.g. you@yourdomain.com — no “https://” or spaces.',
nip05Checking: 'Checking your domain…', nip05Checking: 'Checking your domain…',

View file

@ -308,6 +308,8 @@ nav: {
services: 'Servicios', services: 'Servicios',
brands: 'Marcas', brands: 'Marcas',
supportLocal: 'Apoya lo Local', 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 // Wallet
@ -4479,6 +4481,8 @@ settingsUi: {
saveProfile: 'Guardar perfil', saveProfile: 'Guardar perfil',
nip05VerifiedHandle: 'Identificador verificado NIP-05', nip05VerifiedHandle: 'Identificador verificado NIP-05',
nip05How: '¿Cómo?', 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.', 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.', nip05Invalid: 'Usa un identificador tipo correo, p. ej. you@yourdomain.com, sin “https://” ni espacios.',
nip05Checking: 'Comprobando tu dominio…', nip05Checking: 'Comprobando tu dominio…',

51
src/lib/profileHandles.ts Normal file
View file

@ -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;
}

View file

@ -727,6 +727,8 @@ export interface SkateShop {
instagram?: string; instagram?: string;
hours?: string; hours?: string;
noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments 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; createdAt: number;
event: NostrEvent; event: NostrEvent;
} }
@ -973,6 +975,14 @@ export function parseSkateShop(event: NostrEvent): SkateShop | null {
const hours = event.tags.find(([name]) => name === 'hours')?.[1]; const hours = event.tags.find(([name]) => name === 'hours')?.[1];
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1]; const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined; 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 { return {
id: event.id, id: event.id,
@ -989,6 +999,8 @@ export function parseSkateShop(event: NostrEvent): SkateShop | null {
instagram, instagram,
hours, hours,
noffer, noffer,
nip05,
nip05s,
createdAt: event.created_at, createdAt: event.created_at,
event, event,
}; };

View file

@ -9,6 +9,7 @@ import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor';
import { useUploadFile } from '@/hooks/useUploadFile'; import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { ShopBountyCard } from '@/components/ShopBountyCard'; import { ShopBountyCard } from '@/components/ShopBountyCard';
@ -52,6 +53,7 @@ export default function CreateShopPage() {
const [website, setWebsite] = useState(''); const [website, setWebsite] = useState('');
const [instagram, setInstagram] = useState(''); const [instagram, setInstagram] = useState('');
const [noffer, setNoffer] = useState(''); const [noffer, setNoffer] = useState('');
const [nip05s, setNip05s] = useState<string[]>([]);
const [hours, setHours] = useState(''); const [hours, setHours] = useState('');
const [images, setImages] = useState<string[]>([]); const [images, setImages] = useState<string[]>([]);
const [isGettingLocation, setIsGettingLocation] = useState(false); const [isGettingLocation, setIsGettingLocation] = useState(false);
@ -222,6 +224,8 @@ export default function CreateShopPage() {
tags.push(['noffer', noffer.trim().toLowerCase()]); tags.push(['noffer', noffer.trim().toLowerCase()]);
} }
for (const handle of nip05s) tags.push(['nip05', handle]);
for (const imageUrl of images) { for (const imageUrl of images) {
// Use imeta tag for NIP-92 compatibility // Use imeta tag for NIP-92 compatibility
tags.push(['imeta', `url ${imageUrl}`, 'm image/jpeg']); tags.push(['imeta', `url ${imageUrl}`, 'm image/jpeg']);
@ -444,6 +448,16 @@ export default function CreateShopPage() {
{/* CLINK self-custodial payments (optional) */} {/* CLINK self-custodial payments (optional) */}
<NofferField value={noffer} onChange={setNoffer} /> <NofferField value={noffer} onChange={setNoffer} />
{/* Shop NIP-05 handle(s) — optional, verified against the owner key */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
{t('shops.nip05')}
<span className="text-xs font-normal text-muted-foreground">{t('socialUi.form.optional')}</span>
</Label>
<p className="text-xs text-muted-foreground">{t('shops.nip05Hint')}</p>
<Nip05HandlesEditor handles={nip05s} onChange={setNip05s} pubkey={user?.pubkey} inputId="shop-nip05" />
</div>
{/* Images */} {/* Images */}
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('placesUi.createShop.shopPhotos')}</Label> <Label>{t('placesUi.createShop.shopPhotos')}</Label>

View file

@ -10,6 +10,7 @@ import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor';
import { useUploadFile } from '@/hooks/useUploadFile'; import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useShop } from '@/hooks/useShops'; import { useShop } from '@/hooks/useShops';
@ -80,6 +81,7 @@ export default function EditShopPage() {
const [instagram, setInstagram] = useState(''); const [instagram, setInstagram] = useState('');
const [hours, setHours] = useState(''); const [hours, setHours] = useState('');
const [noffer, setNoffer] = useState(''); const [noffer, setNoffer] = useState('');
const [nip05s, setNip05s] = useState<string[]>([]);
const [images, setImages] = useState<string[]>([]); const [images, setImages] = useState<string[]>([]);
const [isGettingLocation, setIsGettingLocation] = useState(false); const [isGettingLocation, setIsGettingLocation] = useState(false);
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false);
@ -101,6 +103,7 @@ export default function EditShopPage() {
setInstagram(shop.instagram || ''); setInstagram(shop.instagram || '');
setHours(shop.hours || ''); setHours(shop.hours || '');
setNoffer(shop.noffer || ''); setNoffer(shop.noffer || '');
setNip05s(shop.nip05s ?? []);
setImages(shop.images); setImages(shop.images);
// Decode geohash to get coordinates // Decode geohash to get coordinates
@ -266,6 +269,8 @@ export default function EditShopPage() {
tags.push(['noffer', noffer.trim().toLowerCase()]); tags.push(['noffer', noffer.trim().toLowerCase()]);
} }
for (const handle of nip05s) tags.push(['nip05', handle]);
for (const imageUrl of images) { for (const imageUrl of images) {
// Use imeta tag for NIP-92 compatibility // Use imeta tag for NIP-92 compatibility
tags.push(['imeta', `url ${imageUrl}`, 'm image/jpeg']); 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. // owns the 'skateshop' marker, so keep every OTHER topic tag.
const MANAGED_TAGS = new Set([ const MANAGED_TAGS = new Set([
'd', 'name', 'g', 'lat', 'lon', 'location', 'alt', '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) { for (const tag of shop.event.tags) {
const [tagName, tagValue] = tag; const [tagName, tagValue] = tag;
@ -512,6 +517,14 @@ export default function EditShopPage() {
className="bg-black border-[#ccff00]/30" className="bg-black border-[#ccff00]/30"
/> />
</div> </div>
<div className="space-y-2 sm:col-span-2">
<Label className="flex items-center gap-2">
{t('shops.nip05')}
<span className="text-xs font-normal text-muted-foreground">{t('socialUi.form.optional')}</span>
</Label>
<p className="text-xs text-muted-foreground">{t('shops.nip05Hint')}</p>
<Nip05HandlesEditor handles={nip05s} onChange={setNip05s} pubkey={user?.pubkey} inputId="shop-nip05" accent="lime" />
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="instagram">{t('placesUi.createShop.instagram')}</Label> <Label htmlFor="instagram">{t('placesUi.createShop.instagram')}</Label>
<Input <Input

View file

@ -21,6 +21,9 @@ import { useUserCrews } from '@/hooks/useCrews';
import { useUserPublicStash } from '@/hooks/useStashShowcase'; import { useUserPublicStash } from '@/hooks/useStashShowcase';
import { useUserSkaterProfile } from '@/hooks/useSkaters'; import { useUserSkaterProfile } from '@/hooks/useSkaters';
import { useCrewNip05 } from '@/hooks/useCrewNip05'; import { useCrewNip05 } from '@/hooks/useCrewNip05';
import { useProfileHandles } from '@/hooks/useProfileHandles';
import { Nip05Badges } from '@/components/Nip05Badges';
import { CrewNip05Badge } from '@/components/CrewNip05Badge';
import { useCoordinator } from '@/hooks/useCoordinator'; import { useCoordinator } from '@/hooks/useCoordinator';
import { coordinatorAddUrl, parseCoordinatorInput, shortNpub, CORDN_RELAY_URL } from '@/lib/coordinator'; import { coordinatorAddUrl, parseCoordinatorInput, shortNpub, CORDN_RELAY_URL } from '@/lib/coordinator';
import { toast } from '@/hooks/useToast'; import { toast } from '@/hooks/useToast';
@ -128,6 +131,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
const about = metadata?.about; const about = metadata?.about;
const website = metadata?.website; const website = metadata?.website;
const nip05 = metadata?.nip05; const nip05 = metadata?.nip05;
const { data: extraHandles = [] } = useProfileHandles(pubkey);
const lud16 = metadata?.lud16; const lud16 = metadata?.lud16;
const isBot = metadata?.bot === true; const isBot = metadata?.bot === true;
// Soft NIP-05 verification — confirms the handle resolves to THIS pubkey. // Soft NIP-05 verification — confirms the handle resolves to THIS pubkey.
@ -263,6 +267,10 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
)} )}
</p> </p>
)} )}
{/* Extra verified handles (separate record) — a skater on several domains. */}
{extraHandles.length > 0 && (
<Nip05Badges handles={extraHandles} pubkey={pubkey} className="mt-1.5 justify-center sm:justify-start" />
)}
{/* Crew membership summary — links straight to the crews */} {/* Crew membership summary — links straight to the crews */}
{crews.length > 0 && ( {crews.length > 0 && (
<p className="mt-1.5 text-sm text-gray-400 flex items-center justify-center sm:justify-start gap-1.5 flex-wrap"> <p className="mt-1.5 text-sm text-gray-400 flex items-center justify-center sm:justify-start gap-1.5 flex-wrap">
@ -597,6 +605,9 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-bold text-white truncate">{shop.name}</h3> <h3 className="font-bold text-white truncate">{shop.name}</h3>
{shop.nip05s.length > 0 && (
<Nip05Badges handles={shop.nip05s} pubkey={shop.pubkey} className="mt-0.5" />
)}
{shop.location && ( {shop.location && (
<p className="text-sm text-gray-400 truncate">{shop.location}</p> <p className="text-sm text-gray-400 truncate">{shop.location}</p>
)} )}
@ -878,6 +889,9 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
</Avatar> </Avatar>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-bold text-white text-sm truncate">{m.crew.name}</p> <p className="font-bold text-white text-sm truncate">{m.crew.name}</p>
{m.crew.nip05s && m.crew.nip05s.length > 0 && (
<CrewNip05Badge nip05s={m.crew.nip05s} founderPubkey={m.crew.pubkey} className="mt-0.5" />
)}
<p className="text-xs text-gray-500 flex items-center gap-1"> <p className="text-xs text-gray-500 flex items-center gap-1">
{m.role === 'founder' ? ( {m.role === 'founder' ? (
<><Crown className="h-3 w-3 text-yellow-500" /> {t('walletUi.profile.founder')}</> <><Crown className="h-3 w-3 text-yellow-500" /> {t('walletUi.profile.founder')}</>

View file

@ -20,6 +20,7 @@ import { CommentsSection } from '@/components/comments/CommentsSection';
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl'; import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
import { OfficialBadge } from '@/components/OfficialBadge'; import { OfficialBadge } from '@/components/OfficialBadge';
import { ControllerBadge } from '@/components/ControllerBadge'; import { ControllerBadge } from '@/components/ControllerBadge';
import { Nip05Badges } from '@/components/Nip05Badges';
import { VenueAccessCard } from '@/components/VenueAccessCard'; import { VenueAccessCard } from '@/components/VenueAccessCard';
import { useShop } from '@/hooks/useShops'; import { useShop } from '@/hooks/useShops';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
@ -282,6 +283,9 @@ export default function ShopDetailPage() {
{isVerifiedOwner && <ControllerBadge role="verifiedOwner" />} {isVerifiedOwner && <ControllerBadge role="verifiedOwner" />}
<OfficialBadge pubkey={shop.pubkey} size="md" /> <OfficialBadge pubkey={shop.pubkey} size="md" />
</div> </div>
{shop.nip05s.length > 0 && (
<Nip05Badges handles={shop.nip05s} pubkey={shop.pubkey} className="mb-1.5" />
)}
{shop.location && ( {shop.location && (
<p className="text-muted-foreground flex items-center gap-2"> <p className="text-muted-foreground flex items-center gap-2">
<Store className="h-4 w-4" /> <Store className="h-4 w-4" />