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:
parent
2daa0a9832
commit
127819e3da
17 changed files with 454 additions and 172 deletions
16
AGENTS.md
16
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
|
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
1
NIP.md
|
|
@ -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) |
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ── */}
|
||||||
|
|
|
||||||
87
src/components/Nip05Badges.tsx
Normal file
87
src/components/Nip05Badges.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
src/components/Nip05HandlesEditor.tsx
Normal file
139
src/components/Nip05HandlesEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/hooks/useProfileHandles.ts
Normal file
43
src/hooks/useProfileHandles.ts
Normal 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] });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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…',
|
||||||
|
|
|
||||||
|
|
@ -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
51
src/lib/profileHandles.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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')}</>
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue