SkateSpots/src/components/Nip05HandlesEditor.tsx
SkateSpots 127819e3da 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.
2026-10-03 17:26:59 +07:00

139 lines
4.4 KiB
TypeScript

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