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.
861 lines
32 KiB
TypeScript
861 lines
32 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 } 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>
|
||
);
|
||
};
|