SkateSpots/src/components/SkateNip05Input.tsx
SkateSpots 0b796440f1 feat(i18n): Spanish batch 2 — nav/chrome, events/shops/tours, crews/DMs, settings/auth, wallet/stash/newsletter/stats
~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).
2026-10-02 10:11:06 +07:00

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