Position-based cleanup (no name collisions) of unused catch bindings and unused single-line pure consts across 12 files. Automated destructure/prop removals were proven unsafe (shadowed names + typed shorthand), so those remain. Warning backlog: 76 -> 60 (39 no-unused-vars, 14 exhaustive-deps, 7 react-refresh). Gate green: 101 tests, 0 errors, boot smoke passing.
788 lines
28 KiB
TypeScript
788 lines
28 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 {
|
||
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 } 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 { 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);
|
||
|
||
// Sponsorships — one entry per line. Stored as newline-separated text in the
|
||
// custom kind-0 field `sponsors`; edited here as a dynamic list of rows.
|
||
const [sponsorList, setSponsorList] = useState<string[]>(['']);
|
||
const cleanSponsors = sponsorList.map((s) => s.trim()).filter(Boolean);
|
||
|
||
// Cordn contact link — private (MLS-encrypted) group messaging you can own.
|
||
// A URL others can use to reach out to you on Cordn (cordn.net).
|
||
const [cordn, setCordn] = useState('');
|
||
const cordnTrimmed = cordn.trim();
|
||
const cordnInvalid =
|
||
cordnTrimmed.length > 0 && !/^https?:\/\/.+/i.test(cordnTrimmed);
|
||
|
||
// 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);
|
||
if (typeof raw.sponsors === 'string') {
|
||
const lines = raw.sponsors.split('\n').map((s) => s.trim()).filter(Boolean);
|
||
setSponsorList(lines.length ? lines : ['']);
|
||
}
|
||
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;
|
||
}
|
||
|
||
// Sponsorships highlight (custom field) — one entry per line.
|
||
if (cleanSponsors.length) {
|
||
data.sponsors = cleanSponsors.join('\n');
|
||
} else {
|
||
delete data.sponsors;
|
||
}
|
||
|
||
// Cordn contact link (custom field).
|
||
if (cordnTrimmed) {
|
||
data.cordn = 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"
|
||
>
|
||
{/* Sponsors — one per line */}
|
||
<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" />
|
||
Sponsors & support
|
||
<span className="text-xs font-normal text-muted-foreground">(one per line)</span>
|
||
</Label>
|
||
<div className="space-y-2">
|
||
{sponsorList.map((value, 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={value}
|
||
placeholder={i === 0 ? 'e.g. Independent Trucks' : 'Add another sponsor…'}
|
||
onChange={(e) => {
|
||
const next = [...sponsorList];
|
||
next[i] = e.target.value;
|
||
setSponsorList(next);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
if (i === sponsorList.length - 1 && value.trim()) {
|
||
setSponsorList([...sponsorList, '']);
|
||
}
|
||
}
|
||
}}
|
||
className="flex-1"
|
||
/>
|
||
<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 = sponsorList.filter((_, idx) => idx !== i);
|
||
setSponsorList(next.length ? next : ['']);
|
||
}}
|
||
aria-label="Remove sponsor"
|
||
disabled={sponsorList.length === 1 && !value}
|
||
>
|
||
<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={() => setSponsorList([...sponsorList, ''])}
|
||
>
|
||
<Plus className="h-3.5 w-3.5" />
|
||
Add sponsor
|
||
</Button>
|
||
<p className="text-xs text-muted-foreground">
|
||
Brands, shops or people who back your skating. Each line shows as its own badge on your profile.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Cordn contact link */}
|
||
<FieldRow
|
||
id="profile-cordn"
|
||
icon={<MessageSquareLock className="h-4 w-4" />}
|
||
iconClass="text-teal-500 bg-teal-500/10"
|
||
label="Cordn contact link"
|
||
value={cordn}
|
||
onChange={setCordn}
|
||
placeholder="https://cordn.net/…"
|
||
status={
|
||
cordnInvalid
|
||
? { kind: 'error', text: 'Paste a full link starting with https://.' }
|
||
: { kind: 'hint', text: 'A link others use to reach you on Cordn — private, encrypted messaging you own.' }
|
||
}
|
||
/>
|
||
</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);
|
||
|
||
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>
|
||
)}
|
||
</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>
|
||
);
|
||
};
|