SkateSpots/src/components/EditProfileForm.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

903 lines
34 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { Button } from '@/components/ui/button';
import { useCoordinator } from '@/hooks/useCoordinator';
import { coordinatorAddUrl, parseCoordinatorInput } from '@/lib/coordinator';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
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 { useQueryClient } from '@tanstack/react-query';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useCrewNip05 } from '@/hooks/useCrewNip05';
import { useProfileHandles, useSetProfileHandles } from '@/hooks/useProfileHandles';
import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor';
import { SkateNip05Input } from '@/components/SkateNip05Input';
import { SKATE_NIP05_DOMAIN, parseSkateNip05 } from '@/lib/skateNip05';
import { isValidNoffer, isValidSilentPayment, isValidLightningAddress } from '@/lib/types';
import { useTranslation } from '@/contexts/language';
export const EditProfileForm: React.FC = () => {
const queryClient = useQueryClient();
const { user, metadata } = useCurrentUser();
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
const { t } = useTranslation();
// CLINK Offer (noffer1...) — a self-custodial Bitcoin tip code. Not part of
// the standard NostrMetadata schema, so we keep it in its own state and merge
// it into the kind 0 content JSON on save (preserved as a custom field).
const [noffer, setNoffer] = useState('');
const nofferTrimmed = noffer.trim();
const nofferInvalid = nofferTrimmed.length > 0 && !isValidNoffer(nofferTrimmed);
const nofferValid = isValidNoffer(nofferTrimmed);
// Lightning address (LUD-16). Part of NostrMetadata but not in the default
// form fields, so we manage it here and merge it on save.
const [lud16, setLud16] = useState('');
const lud16Trimmed = lud16.trim();
const lud16Invalid = lud16Trimmed.length > 0 && !isValidLightningAddress(lud16Trimmed);
// Bitcoin Silent Payment address (BIP-352). Custom kind-0 field.
const [silentPayment, setSilentPayment] = useState('');
const spTrimmed = silentPayment.trim();
const spInvalid = spTrimmed.length > 0 && !isValidSilentPayment(spTrimmed);
const spValid = isValidSilentPayment(spTrimmed);
// Badges — a label + optional link each, stored as a `badges` array in kind-0.
// Used for sponsors/backers, and rendered as clickable chips on the profile.
const [badgeList, setBadgeList] = useState<Array<{ label: string; url: string }>>([
{ label: '', url: '' },
]);
// Cordn contact link — private (MLS-encrypted) group messaging you can own.
// The coordinator others can use to message you on Cordn. Accepts the same
// pointer formats as Settings → Coordinator (nprofile / cordn.net link /
// npub / hex) as well as an http(s) URL — this is the discovery field.
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 cordnParsed = cordnTrimmed ? parseCoordinatorInput(cordnTrimmed) : null;
const cordnInvalid =
cordnTrimmed.length > 0 && !cordnParsed && !/^https?:\/\/.+/i.test(cordnTrimmed);
const { coordinator: myCoordinator } = useCoordinator();
// Initialize the form with default values
const form = useForm<NostrMetadata>({
resolver: zodResolver(n.metadata()),
defaultValues: {
name: '',
about: '',
picture: '',
banner: '',
website: '',
nip05: '',
bot: false,
},
});
// Update form values when user data is loaded
useEffect(() => {
if (metadata) {
form.reset({
name: metadata.name || '',
about: metadata.about || '',
picture: metadata.picture || '',
banner: metadata.banner || '',
website: metadata.website || '',
nip05: metadata.nip05 || '',
bot: metadata.bot || false,
});
// Prefill custom / extra fields from the raw metadata.
const raw = metadata as Record<string, unknown>;
if (typeof raw.noffer === 'string') setNoffer(raw.noffer);
if (typeof metadata.lud16 === 'string') setLud16(metadata.lud16);
if (typeof raw.bitcoin_silent_payment === 'string') setSilentPayment(raw.bitcoin_silent_payment);
// Badges: prefer the structured array; fall back to legacy `sponsors` lines.
const rawBadges = Array.isArray(raw.badges) ? raw.badges : null;
if (rawBadges) {
const rows = rawBadges
.map((b) => (b && typeof b === 'object' ? (b as Record<string, unknown>) : {}))
.map((b) => ({ label: String(b.label ?? ''), url: String(b.url ?? '') }))
.filter((b) => b.label.trim());
setBadgeList(rows.length ? rows : [{ label: '', url: '' }]);
} else if (typeof raw.sponsors === 'string') {
const lines = raw.sponsors.split('\n').map((s) => s.trim()).filter(Boolean);
setBadgeList(
lines.length ? lines.map((label) => ({ label, url: '' })) : [{ label: '', url: '' }],
);
}
if (typeof raw.cordn === 'string') setCordn(raw.cordn);
}
}, [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
const uploadPicture = async (file: File, field: 'picture' | 'banner') => {
try {
// The first tuple in the array contains the URL
const [[_, url]] = await uploadFile(file);
form.setValue(field, url);
toast({
title: t('settingsUi.editProfile.uploadSuccessTitle'),
description: t('settingsUi.editProfile.uploadSuccessDesc', { field: field === 'picture' ? t('settingsUi.editProfile.profilePicture') : t('settingsUi.editProfile.bannerImage') }),
});
} catch (error) {
console.error(`Failed to upload ${field}:`, error);
toast({
title: t('common.error'),
description: t('settingsUi.editProfile.uploadErrorDesc', { field: field === 'picture' ? t('settingsUi.editProfile.profilePicture') : t('settingsUi.editProfile.bannerImage') }),
variant: 'destructive',
});
}
};
const onSubmit = async (values: NostrMetadata) => {
if (!user) {
toast({
title: t('common.error'),
description: t('settingsUi.editProfile.mustBeLoggedIn'),
variant: 'destructive',
});
return;
}
if (nofferInvalid) {
toast({
title: t('settingsUi.editProfile.invalidClinkTitle'),
description: t('settingsUi.editProfile.invalidClinkDesc'),
variant: 'destructive',
});
return;
}
if (lud16Invalid) {
toast({
title: t('settingsUi.editProfile.invalidLightningTitle'),
description: t('settingsUi.editProfile.invalidLightningDesc'),
variant: 'destructive',
});
return;
}
if (spInvalid) {
toast({
title: t('settingsUi.editProfile.invalidSpTitle'),
description: t('settingsUi.editProfile.invalidSpDesc'),
variant: 'destructive',
});
return;
}
if (cordnInvalid) {
toast({
title: t('settingsUi.editProfile.invalidCordnTitle'),
description: t('settingsUi.editProfile.invalidCordnDesc'),
variant: 'destructive',
});
return;
}
try {
// Combine existing metadata with new values
const data: Record<string, unknown> = { ...metadata, ...values };
// Clean up empty values
for (const key in data) {
if (data[key] === '') {
delete data[key];
}
}
// Merge the custom CLINK offer code (or remove it when cleared).
if (nofferValid) {
data.noffer = nofferTrimmed.toLowerCase();
} else {
delete data.noffer;
}
// Lightning address (LUD-16).
if (lud16Trimmed) {
data.lud16 = lud16Trimmed;
} else {
delete data.lud16;
}
// Bitcoin Silent Payment address (custom field).
if (spValid) {
data.bitcoin_silent_payment = spTrimmed;
} else {
delete data.bitcoin_silent_payment;
}
// Badges (custom field) — label + optional link, shown as chips on profile.
const cleanBadges = badgeList
.map((b) => ({ label: b.label.trim(), url: b.url.trim() }))
.filter((b) => b.label)
.map((b) => (b.url ? { label: b.label, url: b.url } : { label: b.label }));
if (cleanBadges.length) {
data.badges = cleanBadges;
} else {
delete data.badges;
}
// Retire the legacy field now that badges supersede it.
delete data.sponsors;
// Cordn contact link (custom field).
if (cordnTrimmed) {
// Normalise a bare pointer (nprofile/npub/hex) to Cordn's canonical
// add-link, so the profile carries a human-clickable URL other clients
// can parse back. A plain http(s) URL is stored as-is.
data.cordn = cordnParsed ? coordinatorAddUrl(cordnParsed) : cordnTrimmed;
} else {
delete data.cordn;
}
// Publish the metadata event (kind 0) with retry logic
let attempts = 0;
let lastError;
while (attempts < 3) {
try {
await publishEvent({
kind: 0,
content: JSON.stringify(data),
});
// Success! Break out of retry loop
break;
} catch (err) {
lastError = err;
attempts++;
// Wait a bit before retrying
if (attempts < 3) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
}
// If all attempts failed, throw the last error
if (attempts === 3 && 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
queryClient.invalidateQueries({ queryKey: ['logins'] });
queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] });
toast({
title: t('settingsUi.editProfile.updateSuccessTitle'),
description: t('settingsUi.editProfile.updateSuccessDesc'),
});
} catch (error) {
console.error('Failed to update profile:', error);
toast({
title: t('common.error'),
description: t('settingsUi.editProfile.updateErrorDesc'),
variant: 'destructive',
});
}
};
const saving = isPending || isUploading;
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* ── Section: Identity ── */}
<FormSection
icon={<UserRound className="h-4 w-4" />}
title={t('settingsUi.editProfile.identityTitle')}
description={t('settingsUi.editProfile.identityDesc')}
accent="from-[#ff0040] to-[#ff6600]"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('settingsUi.editProfile.displayName')}</FormLabel>
<FormControl>
<Input placeholder={t('settingsUi.editProfile.displayNamePlaceholder')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="about"
render={({ field }) => (
<FormItem>
<FormLabel>{t('settingsUi.editProfile.bio')}</FormLabel>
<FormControl>
<Textarea placeholder={t('settingsUi.editProfile.bioPlaceholder')} className="resize-none min-h-[88px]" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<FormField
control={form.control}
name="picture"
render={({ field }) => (
<ImageUploadField
field={field}
label={t('settingsUi.editProfile.profilePicture')}
placeholder="https://…/avatar.jpg"
description={t('settingsUi.editProfile.pictureDesc')}
previewType="square"
onUpload={(file) => uploadPicture(file, 'picture')}
/>
)}
/>
<FormField
control={form.control}
name="banner"
render={({ field }) => (
<ImageUploadField
field={field}
label={t('settingsUi.editProfile.bannerImage')}
placeholder="https://…/banner.jpg"
description={t('settingsUi.editProfile.bannerDesc')}
previewType="wide"
onUpload={(file) => uploadPicture(file, 'banner')}
/>
)}
/>
</div>
<FormField
control={form.control}
name="website"
render={({ field }) => (
<FormItem>
<FormLabel>{t('settingsUi.editProfile.website')}</FormLabel>
<FormControl>
<Input placeholder="https://yourwebsite.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* NIP-05 — the verified-handle field. Given real prominence + live
verification feedback so people know if it's actually working. */}
<FormField
control={form.control}
name="nip05"
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>
{/* ── Section: Bitcoin & Payments ── */}
<FormSection
icon={<Bitcoin className="h-4 w-4" />}
title={t('settingsUi.editProfile.bitcoinTitle')}
description={t('settingsUi.editProfile.bitcoinDesc')}
accent="from-amber-500 to-orange-500"
>
{/* Lightning address */}
<FieldRow
id="profile-lud16"
icon={<Zap className="h-4 w-4" />}
iconClass="text-yellow-500 bg-yellow-500/10"
label={t('settingsUi.editProfile.lightningAddress')}
value={lud16}
onChange={setLud16}
placeholder="you@yourdomain.com"
status={
lud16Invalid
? { kind: 'error', text: t('settingsUi.editProfile.lud16Invalid') }
: lud16Trimmed
? { kind: 'ok', text: t('settingsUi.editProfile.lud16Ok') }
: { kind: 'hint', text: t('settingsUi.editProfile.lud16Hint') }
}
/>
{/* Silent Payment address */}
<FieldRow
id="profile-sp"
icon={<Bitcoin className="h-4 w-4" />}
iconClass="text-orange-500 bg-orange-500/10"
label={t('settingsUi.editProfile.silentPaymentAddress')}
value={silentPayment}
onChange={setSilentPayment}
placeholder="sp1q…"
status={
spInvalid
? { kind: 'error', text: t('settingsUi.editProfile.spInvalid') }
: spValid
? { kind: 'ok', text: t('settingsUi.editProfile.spOk') }
: { kind: 'hint', text: t('settingsUi.editProfile.spHint') }
}
/>
{/* CLINK tip code */}
<FieldRow
id="profile-noffer"
icon={<Zap className="h-4 w-4" />}
iconClass="text-indigo-500 bg-indigo-500/10"
label={t('settingsUi.editProfile.clinkTipCode')}
value={noffer}
onChange={setNoffer}
placeholder="noffer1…"
status={
nofferInvalid
? { kind: 'error', text: t('settingsUi.editProfile.nofferInvalid') }
: nofferValid
? { kind: 'ok', text: t('settingsUi.editProfile.nofferOk') }
: { kind: 'hint', text: t('settingsUi.editProfile.nofferHint') }
}
/>
</FormSection>
{/* ── Section: Skate profile ── */}
<FormSection
icon={<Award className="h-4 w-4" />}
title={t('settingsUi.editProfile.skateProfileTitle')}
description={t('settingsUi.editProfile.skateProfileDesc')}
accent="from-pink-500 to-fuchsia-500"
>
{/* Badges — sponsor/backer chips, each with an optional link */}
<div className="space-y-2.5">
<Label className="flex items-center gap-2 text-sm font-semibold">
<Award className="h-4 w-4 text-pink-500" />
{t('settingsUi.editProfile.badges')}
<span className="text-xs font-normal text-muted-foreground">{t('settingsUi.editProfile.badgesHint')}</span>
</Label>
<div className="space-y-2">
{badgeList.map((row, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-pink-500/60 text-sm font-black w-4 text-center flex-shrink-0">•</span>
<Input
value={row.label}
placeholder={i === 0 ? t('settingsUi.editProfile.badgeExample') : t('settingsUi.editProfile.badgeLabelPlaceholder')}
onChange={(e) => {
const next = [...badgeList];
next[i] = { ...next[i], label: e.target.value };
setBadgeList(next);
}}
className="flex-1"
/>
<Input
value={row.url}
placeholder={t('settingsUi.editProfile.badgeUrlPlaceholder')}
onChange={(e) => {
const next = [...badgeList];
next[i] = { ...next[i], url: e.target.value };
setBadgeList(next);
}}
className="flex-1"
spellCheck={false}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 flex-shrink-0 text-muted-foreground hover:text-red-500"
onClick={() => {
const next = badgeList.filter((_, idx) => idx !== i);
setBadgeList(next.length ? next : [{ label: '', url: '' }]);
}}
aria-label={t('settingsUi.editProfile.removeBadge')}
disabled={badgeList.length === 1 && !row.label && !row.url}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5 border-pink-500/40 text-pink-600 dark:text-pink-400 hover:bg-pink-500/10"
onClick={() => setBadgeList([...badgeList, { label: '', url: '' }])}
>
<Plus className="h-3.5 w-3.5" />
{t('settingsUi.editProfile.addBadge')}
</Button>
<p className="text-xs text-muted-foreground">
{t('settingsUi.editProfile.badgesDescBefore')}{' '}
<span className="text-[#ccff00]">@skate-spots.org</span> {t('settingsUi.editProfile.badgesDescAfter')}
</p>
</div>
{/* Cordn coordinator — the pointer others discover you by. */}
<FieldRow
id="profile-cordn"
icon={<MessageSquareLock className="h-4 w-4" />}
iconClass="text-teal-500 bg-teal-500/10"
label={t('settingsUi.editProfile.coordinator')}
value={cordn}
onChange={setCordn}
placeholder="nprofile1… · cordn.net/chat/coordinators?c=… · https://…"
status={
cordnInvalid
? { kind: 'error', text: t('settingsUi.editProfile.cordnInvalid') }
: cordnTrimmed
? { kind: 'ok', text: t('settingsUi.editProfile.cordnOk') }
: { kind: 'hint', text: t('settingsUi.editProfile.cordnHint') }
}
/>
{myCoordinator && (
<Button
type="button"
variant="outline"
size="sm"
className="border-teal-500/40 text-teal-600 hover:bg-teal-500/10 dark:text-teal-400"
onClick={() => setCordn(coordinatorAddUrl(myCoordinator))}
>
<Server className="mr-1.5 h-3.5 w-3.5" />
{t('settingsUi.editProfile.useMyCoordinator')}
</Button>
)}
</FormSection>
{/* ── Section: Account ── */}
<FormSection
icon={<Bot className="h-4 w-4" />}
title={t('settingsUi.editProfile.accountTitle')}
description={t('settingsUi.editProfile.accountDesc')}
accent="from-slate-500 to-gray-500"
>
<FormField
control={form.control}
name="bot"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-xl border p-4 bg-muted/20">
<div className="space-y-0.5">
<FormLabel className="text-base">{t('settingsUi.editProfile.botAccount')}</FormLabel>
<FormDescription>{t('settingsUi.editProfile.botAccountDesc')}</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
</FormSection>
{/* ── Sticky save bar ── */}
<div className="sticky bottom-0 -mx-1 px-1 py-3 bg-gradient-to-t from-background via-background to-transparent">
<Button type="submit" className="w-full h-11 font-bold" disabled={saving}>
{saving ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />{t('settingsUi.editProfile.saving')}</>
) : (
t('settingsUi.editProfile.saveProfile')
)}
</Button>
</div>
</form>
</Form>
);
};
/** A titled, visually-grouped section of the profile form. */
const FormSection: React.FC<{
icon: React.ReactNode;
title: string;
description?: string;
accent: string; // tailwind gradient stops, e.g. "from-x to-y"
children: React.ReactNode;
}> = ({ icon, title, description, accent, children }) => (
<section className="rounded-2xl border bg-card/40 overflow-hidden">
<div className="flex items-center gap-3 px-4 sm:px-5 py-4 border-b bg-muted/30">
<div className={`flex-shrink-0 h-9 w-9 rounded-xl bg-gradient-to-br ${accent} text-white flex items-center justify-center shadow-sm`}>
{icon}
</div>
<div className="min-w-0">
<h3 className="text-base font-bold leading-tight">{title}</h3>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div>
</div>
<div className="p-4 sm:p-5 space-y-5">{children}</div>
</section>
);
/** A single labeled input row with an icon and inline validation status. */
const FieldRow: React.FC<{
id: string;
icon: React.ReactNode;
iconClass: string;
label: string;
value: string;
onChange: (v: string) => void;
placeholder: string;
status: { kind: 'hint' | 'ok' | 'error'; text: string };
}> = ({ id, icon, iconClass, label, value, onChange, placeholder, status }) => (
<div className="space-y-2">
<Label htmlFor={id} className="flex items-center gap-2 text-sm font-semibold">
<span className={`h-6 w-6 rounded-lg flex items-center justify-center ${iconClass}`}>{icon}</span>
{label}
</Label>
<Input
id={id}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
autoCapitalize="none"
className="font-mono text-sm"
/>
{status.kind === '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" />
{status.text}
</p>
) : status.kind === 'ok' ? (
<p className="flex items-start gap-1.5 text-xs text-green-600 dark:text-green-400">
<ShieldCheck className="h-3.5 w-3.5 shrink-0 mt-px" />
{status.text}
</p>
) : (
<p className="text-xs text-muted-foreground">{status.text}</p>
)}
</div>
);
/**
* The NIP-05 identifier field — a verified, human-readable handle
* (`you@yourdomain.com`) that proves you control both the Nostr key and the
* domain. This is the closest thing Nostr has to a "verified" checkmark, so it
* gets a prominent, self-explaining row with LIVE verification feedback that
* checks the handle actually resolves back to the logged-in key.
*/
const Nip05Field: React.FC<{
value: string;
onChange: (v: string) => void;
pubkey: string | undefined;
}> = ({ value, onChange, pubkey }) => {
const { t } = useTranslation();
const trimmed = value.trim().toLowerCase();
const looksValid = /^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/.test(trimmed);
// Only hit the network once it's a plausible address.
const { data: result, isLoading } = useCrewNip05(looksValid ? trimmed : undefined, pubkey);
// Free SkateSpots handles (`name@skate-spots.org`). A local draft the skater
// can claim; on success it fills the field above and verifies normally.
const skateHandle = parseSkateNip05(value);
const [draft, setDraft] = useState('');
useEffect(() => {
if (skateHandle) setDraft(skateHandle);
}, [skateHandle]);
let status: { kind: 'hint' | 'ok' | 'error' | 'warn' | 'checking'; text: string };
if (!trimmed) {
status = { kind: 'hint', text: t('settingsUi.editProfile.nip05HintEmpty') };
} else if (!looksValid) {
status = { kind: 'error', text: t('settingsUi.editProfile.nip05Invalid') };
} else if (isLoading) {
status = { kind: 'checking', text: t('settingsUi.editProfile.nip05Checking') };
} else if (result?.status === 'verified') {
status = { kind: 'ok', text: t('settingsUi.editProfile.nip05Verified') };
} else if (result?.status === 'mismatch') {
status = { kind: 'error', text: t('settingsUi.editProfile.nip05Mismatch') };
} else if (result?.status === 'unreachable') {
status = { kind: 'warn', text: t('settingsUi.editProfile.nip05Unreachable') };
} else {
status = { kind: 'hint', text: t('settingsUi.editProfile.nip05Enter') };
}
return (
<div className="space-y-2 rounded-xl border bg-muted/20 p-4">
<Label htmlFor="profile-nip05" className="flex items-center gap-2 text-sm font-semibold">
<span className="h-6 w-6 rounded-lg flex items-center justify-center text-[#00a3ff] bg-[#00a3ff]/10">
<BadgeCheck className="h-4 w-4" />
</span>
{t('settingsUi.editProfile.nip05VerifiedHandle')}
<a
href="https://nostr.how/en/guides/get-verified"
target="_blank"
rel="noopener noreferrer"
className="ml-auto inline-flex items-center gap-1 text-xs font-normal text-muted-foreground hover:text-foreground"
>
<HelpCircle className="h-3.5 w-3.5" />
{t('settingsUi.editProfile.nip05How')}
</a>
</Label>
<div className="relative">
<Input
id="profile-nip05"
placeholder="you@yourdomain.com"
value={value}
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
autoCapitalize="none"
className="font-mono text-sm pr-9"
/>
{looksValid && (
<span className="absolute right-3 top-1/2 -translate-y-1/2">
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : result?.status === 'verified' ? (
<BadgeCheck className="h-4 w-4 text-green-500" />
) : result?.status === 'mismatch' ? (
<AlertCircle className="h-4 w-4 text-red-500" />
) : null}
</span>
)}
</div>
{status.kind === '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" />
{status.text}
</p>
) : status.kind === 'ok' ? (
<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" />
{status.text}
</p>
) : status.kind === 'warn' ? (
<p className="flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400">
<AlertCircle className="h-3.5 w-3.5 shrink-0 mt-px" />
{status.text}
</p>
) : status.kind === 'checking' ? (
<p className="flex items-start gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 shrink-0 mt-px animate-spin" />
{status.text}
</p>
) : (
<p className="text-xs text-muted-foreground">{status.text}</p>
)}
{/* Free SkateSpots handles — claim one here and it fills the field above. */}
<div className="space-y-2 rounded-lg border border-dashed border-muted-foreground/25 p-3">
<p className="text-xs text-muted-foreground">
{t('settingsUi.editProfile.freeHandleBefore')}{' '}
<span className="font-semibold text-foreground">@{SKATE_NIP05_DOMAIN}</span> {t('settingsUi.editProfile.freeHandleAfter')}
</p>
<SkateNip05Input
id="skate-nip05-claim"
handle={draft}
onHandleChange={(h) => {
setDraft(h);
if (skateHandle) onChange('');
}}
claimed={value}
onClaimed={(nip05) => onChange(nip05)}
/>
</div>
</div>
);
};
// Reusable component for image upload fields
interface ImageUploadFieldProps {
field: {
value: string | undefined;
onChange: (value: string) => void;
name: string;
onBlur: () => void;
};
label: string;
placeholder: string;
description: string;
previewType: 'square' | 'wide';
onUpload: (file: File) => void;
}
const ImageUploadField: React.FC<ImageUploadFieldProps> = ({
field,
label,
placeholder,
description,
previewType,
onUpload,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const { t } = useTranslation();
return (
<FormItem>
<FormLabel>{label}</FormLabel>
<div className="flex items-start gap-3">
{/* Preview thumbnail — click to upload */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className={`group relative flex-shrink-0 overflow-hidden border-2 border-dashed border-muted-foreground/25 hover:border-primary/50 transition-colors bg-muted/30 flex items-center justify-center ${
previewType === 'square' ? 'h-16 w-16 rounded-full' : 'h-16 w-28 rounded-lg'
}`}
aria-label={t('settingsUi.editProfile.uploadLabel', { label })}
>
{field.value ? (
<>
<img src={field.value} alt={t('settingsUi.editProfile.previewLabel', { label })} className="h-full w-full object-cover" />
<span className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Upload className="h-4 w-4 text-white" />
</span>
</>
) : (
<Upload className="h-5 w-5 text-muted-foreground/50 group-hover:text-primary/60 transition-colors" />
)}
</button>
<div className="flex-1 min-w-0 space-y-1.5">
<input
type="file"
ref={fileInputRef}
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onUpload(file);
}}
/>
<FormControl>
<Input
placeholder={placeholder}
name={field.name}
value={field.value ?? ''}
onChange={e => field.onChange(e.target.value)}
onBlur={field.onBlur}
className="text-sm"
/>
</FormControl>
<FormDescription className="text-xs">{description}</FormDescription>
</div>
</div>
<FormMessage />
</FormItem>
);
};