SkateSpots/src/components/EditProfileForm.tsx
SkateSpots ae2a1c70b8 feat(coordinator): profile discovery — publish yours, add theirs in one tap
Carrier is the existing kind-0 'cordn' field (app convention, NIP.md) — no new
kind. It now holds a coordinator *pointer* rather than only a URL.

- EditProfileForm: accepts nprofile / cordn.net link / npub / hex / http URL,
  validates via parseCoordinatorInput, normalises a bare pointer to Cordn's
  canonical add-link on save, and adds 'Use my coordinator'. Label -> Coordinator.
- NIP19Page: when the profile's cordn value parses as a pointer, render a
  Coordinator row with a one-tap Add (sets the viewer's coordinator, with toast)
  and an 'Open in Cordn' link; otherwise keep the old plain-link row.
- NIP.md: document the broadened cordn field (pointer formats + one-tap add).
- smoke test: now boots a real profile route too.
2026-09-29 16:17:16 +07:00

861 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 } 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 { SkateNip05Input } from '@/components/SkateNip05Input';
import { SKATE_NIP05_DOMAIN, parseSkateNip05 } from '@/lib/skateNip05';
import { isValidNoffer, isValidSilentPayment, isValidLightningAddress } from '@/lib/types';
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();
// 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('');
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]);
// 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: 'Success',
description: `${field === 'picture' ? 'Profile picture' : 'Banner'} uploaded successfully`,
});
} catch (error) {
console.error(`Failed to upload ${field}:`, error);
toast({
title: 'Error',
description: `Failed to upload ${field === 'picture' ? 'profile picture' : 'banner'}. Please try again.`,
variant: 'destructive',
});
}
};
const onSubmit = async (values: NostrMetadata) => {
if (!user) {
toast({
title: 'Error',
description: 'You must be logged in to update your profile',
variant: 'destructive',
});
return;
}
if (nofferInvalid) {
toast({
title: 'Invalid CLINK code',
description: 'That doesn\'t look like a valid noffer1… code. Clear it or paste a valid one.',
variant: 'destructive',
});
return;
}
if (lud16Invalid) {
toast({
title: 'Invalid Lightning address',
description: 'Use an email-like address such as you@walletofsatoshi.com.',
variant: 'destructive',
});
return;
}
if (spInvalid) {
toast({
title: 'Invalid Silent Payment address',
description: 'A Silent Payment address starts with "sp1" and is quite long. Clear it or paste a valid one.',
variant: 'destructive',
});
return;
}
if (cordnInvalid) {
toast({
title: 'Invalid Cordn link',
description: 'Paste a full https://… Cordn contact link.',
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;
}
// Invalidate queries to refresh the data
queryClient.invalidateQueries({ queryKey: ['logins'] });
queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] });
toast({
title: 'Success',
description: 'Your profile has been updated',
});
} catch (error) {
console.error('Failed to update profile:', error);
toast({
title: 'Error',
description: 'Failed to publish profile. Check your relay connections in Settings > Relays',
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="Identity"
description="How you show up across SkateSpots."
accent="from-[#ff0040] to-[#ff6600]"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Display name</FormLabel>
<FormControl>
<Input placeholder="Your name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="about"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea placeholder="A few words about you and your skating…" 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="Profile picture"
placeholder="https://…/avatar.jpg"
description="Upload or paste a square image."
previewType="square"
onUpload={(file) => uploadPicture(file, 'picture')}
/>
)}
/>
<FormField
control={form.control}
name="banner"
render={({ field }) => (
<ImageUploadField
field={field}
label="Banner image"
placeholder="https://…/banner.jpg"
description="A wide image for the top of your profile."
previewType="wide"
onUpload={(file) => uploadPicture(file, 'banner')}
/>
)}
/>
</div>
<FormField
control={form.control}
name="website"
render={({ field }) => (
<FormItem>
<FormLabel>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} />}
/>
</FormSection>
{/* ── Section: Bitcoin & Payments ── */}
<FormSection
icon={<Bitcoin className="h-4 w-4" />}
title="Bitcoin & Payments"
description="Let people zap, tip and pay you directly — no middleman."
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="Lightning address"
value={lud16}
onChange={setLud16}
placeholder="you@yourdomain.com"
status={
lud16Invalid
? { kind: 'error', text: 'Use an email-like address, e.g. you@yourdomain.com.' }
: lud16Trimmed
? { kind: 'ok', text: 'Powers the “Zap” button on your profile.' }
: { kind: 'hint', text: 'Instant Bitcoin tips. Powers the “Zap” button on your profile.' }
}
/>
{/* Silent Payment address */}
<FieldRow
id="profile-sp"
icon={<Bitcoin className="h-4 w-4" />}
iconClass="text-orange-500 bg-orange-500/10"
label="Silent Payment address"
value={silentPayment}
onChange={setSilentPayment}
placeholder="sp1q…"
status={
spInvalid
? { kind: 'error', text: 'Should start with “sp1” and be quite long.' }
: spValid
? { kind: 'ok', text: 'Valid — a reusable, private on-chain address.' }
: { kind: 'hint', text: 'Reusable static on-chain address (BIP-352). Share once; every payment is unlinkable.' }
}
/>
{/* CLINK tip code */}
<FieldRow
id="profile-noffer"
icon={<Zap className="h-4 w-4" />}
iconClass="text-indigo-500 bg-indigo-500/10"
label="CLINK tip code"
value={noffer}
onChange={setNoffer}
placeholder="noffer1…"
status={
nofferInvalid
? { kind: 'error', text: 'That doesn’t look like a valid noffer1… code.' }
: nofferValid
? { kind: 'ok', text: 'Valid — a “Tip in Bitcoin” button will show on your profile.' }
: { kind: 'hint', text: 'Self-custodial CLINK code so anyone can tip you over Nostr — no middleman.' }
}
/>
</FormSection>
{/* ── Section: Skate profile ── */}
<FormSection
icon={<Award className="h-4 w-4" />}
title="Skate Profile"
description="Reppin’ your sponsors and how the crew reaches you."
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" />
Badges
<span className="text-xs font-normal text-muted-foreground">(label + optional link)</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 ? 'e.g. Independent Trucks' : 'Badge label…'}
onChange={(e) => {
const next = [...badgeList];
next[i] = { ...next[i], label: e.target.value };
setBadgeList(next);
}}
className="flex-1"
/>
<Input
value={row.url}
placeholder="https://… (optional)"
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="Remove badge"
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" />
Add badge
</Button>
<p className="text-xs text-muted-foreground">
Sponsors, shops or people who back your skating — and anything else you want to show off. Each
becomes a clickable chip on your profile. Your{' '}
<span className="text-[#ccff00]">@skate-spots.org</span> member badge is added automatically.
</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="Coordinator"
value={cordn}
onChange={setCordn}
placeholder="nprofile1… · cordn.net/chat/coordinators?c=… · https://…"
status={
cordnInvalid
? { kind: 'error', text: "That doesn't look like a coordinator. Paste an nprofile, a cordn.net link, or an https:// URL." }
: cordnTrimmed
? { kind: 'ok', text: 'Shown on your profile — others can add it in one tap.' }
: { kind: 'hint', text: 'The Cordn coordinator others use to message you. Private, encrypted, yours.' }
}
/>
{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" />
Use my coordinator
</Button>
)}
</FormSection>
{/* ── Section: Account ── */}
<FormSection
icon={<Bot className="h-4 w-4" />}
title="Account"
description="Extra account settings."
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">Bot account</FormLabel>
<FormDescription>Mark this account as automated or a bot.</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" />Saving…</>
) : (
'Save Profile'
)}
</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 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: 'A verified handle like you@yourdomain.com — Nostr’s version of a blue check. Shows a ✓ badge on your profile and unlocks the “Verified” filter in the Skaters directory.' };
} else if (!looksValid) {
status = { kind: 'error', text: 'Use an email-like handle, e.g. you@yourdomain.com — no “https://” or spaces.' };
} else if (isLoading) {
status = { kind: 'checking', text: 'Checking your domain…' };
} else if (result?.status === 'verified') {
status = { kind: 'ok', text: 'Verified — your domain confirms this handle belongs to you. ✓' };
} else if (result?.status === 'mismatch') {
status = { kind: 'error', text: 'Your domain maps this handle to a DIFFERENT key. Fix your .well-known/nostr.json so it points to this account.' };
} else if (result?.status === 'unreachable') {
status = { kind: 'warn', text: 'Couldn’t reach your domain’s /.well-known/nostr.json yet. Saved handles still work once your domain is set up correctly.' };
} else {
status = { kind: 'hint', text: 'Enter your NIP-05 handle.' };
}
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>
NIP-05 verified handle
<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" />
How?
</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">
No domain of your own? Claim a free{' '}
<span className="font-semibold text-foreground">@{SKATE_NIP05_DOMAIN}</span> handle from SkateSpots.
</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);
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={`Upload ${label}`}
>
{field.value ? (
<>
<img src={field.value} alt={`${label} preview`} 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>
);
};