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.
139 lines
4.4 KiB
TypeScript
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>
|
|
);
|
|
}
|