~3,470 new keys (5,770 total, EN=ES), ~115 more files wired. - nav + chromeUi: header, More menu, burger menu, footer, map toolbar widgets (sort/trust/weather), spot-type label helper. - placesUi: events, shops, tours, food. socialUi: crews, DMs, notifications, collectives, crew chat/coordinator. settingsUi: settings, auth/signup, onboarding, profile edit, legal. walletUi: wallet/Cashu, stash, mints, app shop, newsletter, community stats, leaderboards, profile. - Fixed 738 usages where an agent dropped the socialUi. prefix (caught by check:i18n).
173 lines
5.9 KiB
TypeScript
173 lines
5.9 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { AlertCircle, AtSign, BadgeCheck, CheckCircle, Loader2 } from 'lucide-react';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { useSkateNip05 } from '@/hooks/useSkateNip05';
|
|
import {
|
|
SKATE_NIP05_DOMAIN,
|
|
parseSkateNip05,
|
|
validateSkateNip05Name,
|
|
} from '@/lib/skateNip05';
|
|
import { cn } from '@/lib/utils';
|
|
import { useTranslation } from '@/contexts/language';
|
|
|
|
type FieldStatus = 'idle' | 'checking' | 'available' | 'taken' | 'invalid' | 'error' | 'claimed';
|
|
|
|
interface SkateNip05InputProps {
|
|
/** The local part being edited (no `@domain`). */
|
|
handle: string;
|
|
onHandleChange: (handle: string) => void;
|
|
/** The full `name@skate-spots.org` already claimed, if any. */
|
|
claimed?: string | null;
|
|
/** Fired once a claim succeeds. */
|
|
onClaimed?: (nip05: string) => void;
|
|
disabled?: boolean;
|
|
autoFocus?: boolean;
|
|
id?: string;
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* Input for claiming a free `name@skate-spots.org` handle.
|
|
*
|
|
* Self-contained: it validates locally, checks availability against the registry
|
|
* (debounced), and claims on demand. A failure is never fatal — the parent simply
|
|
* ends up without a handle, which is a perfectly usable account.
|
|
*/
|
|
export const SkateNip05Input: React.FC<SkateNip05InputProps> = ({
|
|
handle,
|
|
onHandleChange,
|
|
claimed,
|
|
onClaimed,
|
|
disabled,
|
|
autoFocus,
|
|
id = 'skate-nip05',
|
|
className,
|
|
}) => {
|
|
const { checkAvailability, claim } = useSkateNip05();
|
|
const { t } = useTranslation();
|
|
const [status, setStatus] = useState<FieldStatus>('idle');
|
|
const [message, setMessage] = useState<string | null>(null);
|
|
const [claiming, setClaiming] = useState(false);
|
|
|
|
const validation = validateSkateNip05Name(handle);
|
|
const claimedHandle = parseSkateNip05(claimed);
|
|
const isClaimed = !!claimedHandle && claimedHandle === validation.name;
|
|
|
|
// Debounced availability check — mirrors the server's rules so we rarely make
|
|
// a pointless request, and aborts an in-flight one when the input changes.
|
|
useEffect(() => {
|
|
if (isClaimed) {
|
|
setStatus('claimed');
|
|
setMessage(null);
|
|
return;
|
|
}
|
|
if (!handle.trim()) {
|
|
setStatus('idle');
|
|
setMessage(null);
|
|
return;
|
|
}
|
|
if (!validation.ok) {
|
|
setStatus('invalid');
|
|
setMessage(validation.reason ?? null);
|
|
return;
|
|
}
|
|
|
|
setStatus('checking');
|
|
setMessage(null);
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(async () => {
|
|
const res = await checkAvailability(validation.name, controller.signal);
|
|
if (controller.signal.aborted) return;
|
|
setStatus(res.available ? 'available' : 'taken');
|
|
setMessage(res.reason);
|
|
}, 400);
|
|
|
|
return () => {
|
|
clearTimeout(timer);
|
|
controller.abort();
|
|
};
|
|
}, [handle, isClaimed, validation.ok, validation.name, validation.reason, checkAvailability]);
|
|
|
|
const handleClaim = async () => {
|
|
if (!validation.ok) return;
|
|
setClaiming(true);
|
|
const result = await claim(validation.name);
|
|
setClaiming(false);
|
|
if (result.ok) {
|
|
setStatus('claimed');
|
|
setMessage(null);
|
|
onClaimed?.(result.nip05);
|
|
} else {
|
|
setStatus('error');
|
|
setMessage(result.error);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={cn('space-y-2', className)}>
|
|
<div
|
|
className={cn(
|
|
'flex items-stretch overflow-hidden rounded-lg border border-input bg-background focus-within:ring-1 focus-within:ring-ring',
|
|
(disabled || claiming) && 'opacity-60',
|
|
)}
|
|
>
|
|
<input
|
|
id={id}
|
|
value={handle}
|
|
onChange={(e) => onHandleChange(e.target.value)}
|
|
placeholder={t('settingsUi.skateNip05.placeholder')}
|
|
spellCheck={false}
|
|
autoCapitalize="none"
|
|
autoComplete="off"
|
|
disabled={disabled || claiming}
|
|
autoFocus={autoFocus}
|
|
className="min-w-0 flex-1 bg-transparent px-3 py-2 text-sm outline-none placeholder:text-muted-foreground"
|
|
/>
|
|
<span className="flex select-none items-center gap-1 border-l border-input bg-muted/40 px-2.5 text-xs font-medium text-muted-foreground">
|
|
<AtSign className="h-3 w-3" />
|
|
{SKATE_NIP05_DOMAIN}
|
|
</span>
|
|
</div>
|
|
|
|
{status === 'claimed' ? (
|
|
<p className="flex items-start gap-1.5 text-xs text-green-600 dark:text-green-400">
|
|
<BadgeCheck className="h-3.5 w-3.5 shrink-0 mt-px" />
|
|
<span>
|
|
<span className="font-semibold">{claimedHandle}@{SKATE_NIP05_DOMAIN}</span> {t('settingsUi.skateNip05.claimedSuffix')}
|
|
</span>
|
|
</p>
|
|
) : status === 'checking' ? (
|
|
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
{t('settingsUi.skateNip05.checking')}
|
|
</p>
|
|
) : status === 'available' ? (
|
|
<div className="flex items-center justify-between gap-2">
|
|
<p className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
|
|
<CheckCircle className="h-3.5 w-3.5" />
|
|
{t('settingsUi.skateNip05.available', { name: validation.name, domain: SKATE_NIP05_DOMAIN })}
|
|
</p>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
onClick={handleClaim}
|
|
disabled={claiming || disabled}
|
|
className="h-7 rounded-full bg-[#ccff00] px-3 text-xs font-bold text-black hover:bg-[#ccff00]/90"
|
|
>
|
|
{claiming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : t('settingsUi.skateNip05.claim')}
|
|
</Button>
|
|
</div>
|
|
) : status === 'invalid' || status === 'taken' || status === 'error' ? (
|
|
<p className="flex items-start gap-1.5 text-xs text-red-500">
|
|
<AlertCircle className="h-3.5 w-3.5 shrink-0 mt-px" />
|
|
{message}
|
|
</p>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('settingsUi.skateNip05.hint')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|