957 lines
37 KiB
TypeScript
957 lines
37 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, Handshake, 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 { normalizeClinkCode } from '@/lib/clink';
|
|
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().replace(/^lightning:\/\//i, '').replace(/^lightning:/i, '');
|
|
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 [openToSponsorships, setOpenToSponsorships] = useState(false);
|
|
const [sponsorNote, setSponsorNote] = 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);
|
|
setOpenToSponsorships(raw.sponsorships_open === true);
|
|
if (typeof raw.sponsorships_note === 'string') setSponsorNote(raw.sponsorships_note);
|
|
}
|
|
}, [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 = normalizeClinkCode(nofferTrimmed);
|
|
} 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;
|
|
|
|
// Sponsorship opt-in (custom kind-0 fields). The profile only claims
|
|
// this when the skater explicitly turned it on.
|
|
if (openToSponsorships) {
|
|
data.sponsorships_open = true;
|
|
if (sponsorNote.trim()) data.sponsorships_note = sponsorNote.trim();
|
|
else delete data.sponsorships_note;
|
|
} else {
|
|
delete data.sponsorships_open;
|
|
delete data.sponsorships_note;
|
|
}
|
|
|
|
// 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>
|
|
)}
|
|
/>
|
|
|
|
{/* Sponsorship opt-in — explicit consent, never inferred from activity. */}
|
|
<div className="space-y-3 rounded-xl border border-pink-500/20 bg-pink-500/[0.04] p-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<Label htmlFor="profile-open-sponsorships" className="flex items-center gap-2 text-sm font-semibold">
|
|
<Handshake className="h-4 w-4 text-pink-400" />
|
|
{t('settingsUi.editProfile.openToSponsorships')}
|
|
</Label>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{t('settingsUi.editProfile.openToSponsorshipsHint')}
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id="profile-open-sponsorships"
|
|
checked={openToSponsorships}
|
|
onCheckedChange={setOpenToSponsorships}
|
|
/>
|
|
</div>
|
|
{openToSponsorships && (
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="profile-sponsor-note" className="text-xs font-bold uppercase tracking-wide text-pink-300">
|
|
{t('settingsUi.editProfile.sponsorNote')}
|
|
</Label>
|
|
<Textarea
|
|
id="profile-sponsor-note"
|
|
value={sponsorNote}
|
|
onChange={(event) => setSponsorNote(event.target.value)}
|
|
placeholder={t('settingsUi.editProfile.sponsorNotePlaceholder')}
|
|
maxLength={180}
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 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"
|
|
inputMode="email"
|
|
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 };
|
|
inputMode?: React.HTMLAttributes<HTMLInputElement>['inputMode'];
|
|
}> = ({ id, icon, iconClass, label, value, onChange, placeholder, status, inputMode }) => (
|
|
<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"
|
|
inputMode={inputMode}
|
|
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>
|
|
);
|
|
};
|