Redesign profile edit form UI/UX + per-line sponsors
- Restructure EditProfileForm into clean, titled sections (Identity, Bitcoin & Payments, Skate Profile, Account) with gradient icon headers, consistent spacing, and a reusable FieldRow with inline icon + validation status. - Sponsors is now a dynamic per-line list (add/remove rows, Enter to add) instead of a textarea; stored newline-separated. The profile renders each sponsor as its own badge. - Lightning address placeholder is now generic (you@yourdomain.com) — no specific wallet names anywhere. - Nicer image upload: click-the-thumbnail-to-upload with hover overlay, round avatar preview / wide banner preview. - Sticky Save bar at the bottom of the form. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
11c67fcf22
commit
9599adaf0d
2 changed files with 407 additions and 332 deletions
|
|
@ -18,7 +18,7 @@ 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 } from 'lucide-react';
|
||||
import { Loader2, Upload, Zap, ShieldCheck, AlertCircle, Bitcoin, Award, MessageSquareLock, UserRound, X, Plus, Bot } from 'lucide-react';
|
||||
import { NSchema as n, type NostrMetadata } from '@nostrify/nostrify';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
|
|
@ -52,8 +52,10 @@ export const EditProfileForm: React.FC = () => {
|
|||
const spInvalid = spTrimmed.length > 0 && !isValidSilentPayment(spTrimmed);
|
||||
const spValid = isValidSilentPayment(spTrimmed);
|
||||
|
||||
// Sponsorships — freeform highlight (custom kind-0 field `sponsors`).
|
||||
const [sponsors, setSponsors] = useState('');
|
||||
// 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).
|
||||
|
|
@ -93,7 +95,10 @@ export const EditProfileForm: React.FC = () => {
|
|||
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') setSponsors(raw.sponsors);
|
||||
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]);
|
||||
|
|
@ -196,9 +201,9 @@ export const EditProfileForm: React.FC = () => {
|
|||
delete data.bitcoin_silent_payment;
|
||||
}
|
||||
|
||||
// Sponsorships highlight (custom field).
|
||||
if (sponsors.trim()) {
|
||||
data.sponsors = sponsors.trim();
|
||||
// Sponsorships highlight (custom field) — one entry per line.
|
||||
if (cleanSponsors.length) {
|
||||
data.sponsors = cleanSponsors.join('\n');
|
||||
} else {
|
||||
delete data.sponsors;
|
||||
}
|
||||
|
|
@ -258,299 +263,357 @@ export const EditProfileForm: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const saving = isPending || isUploading;
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Your name" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is your display name that will be displayed to others.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="about"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bio</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Tell others about yourself"
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A short description about yourself.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="picture"
|
||||
render={({ field }) => (
|
||||
<ImageUploadField
|
||||
field={field}
|
||||
label="Profile Picture"
|
||||
placeholder="https://example.com/profile.jpg"
|
||||
description="URL to your profile picture. You can upload an image or provide a URL."
|
||||
previewType="square"
|
||||
onUpload={(file) => uploadPicture(file, 'picture')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="banner"
|
||||
render={({ field }) => (
|
||||
<ImageUploadField
|
||||
field={field}
|
||||
label="Banner Image"
|
||||
placeholder="https://example.com/banner.jpg"
|
||||
description="URL to a wide banner image for your profile. You can upload an image or provide a URL."
|
||||
previewType="wide"
|
||||
onUpload={(file) => uploadPicture(file, 'banner')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="website"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Website</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://yourwebsite.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your personal website or social media link.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nip05"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>NIP-05 Identifier</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="you@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your verified Nostr identifier.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* CLINK Offer — self-custodial Bitcoin tip code (custom metadata field) */}
|
||||
<div className="space-y-2 rounded-lg border border-indigo-500/30 bg-indigo-500/5 p-4">
|
||||
<Label htmlFor="profile-noffer" className="flex items-center gap-2 text-indigo-600 dark:text-indigo-400">
|
||||
<Zap className="h-4 w-4" />
|
||||
Bitcoin Tip Code (CLINK) <span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="profile-noffer"
|
||||
placeholder="noffer1..."
|
||||
value={noffer}
|
||||
onChange={(e) => setNoffer(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
{nofferInvalid ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-red-500">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
That doesn't look like a valid <code className="mx-1">noffer1…</code> code.
|
||||
</p>
|
||||
) : nofferValid ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
|
||||
<ShieldCheck className="h-3.5 w-3.5 shrink-0" />
|
||||
Valid — a "Tip in Bitcoin" button will show on your profile.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paste a self-custodial CLINK payment code (from e.g. ShockWallet / Lightning.Pub)
|
||||
so anyone can tip you directly in Bitcoin over Nostr — no middleman.{' '}
|
||||
<a
|
||||
href="https://clinkme.dev/specs.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-500 underline hover:text-indigo-400"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lightning address (LUD-16) */}
|
||||
<div className="space-y-2 rounded-lg border border-yellow-500/30 bg-yellow-500/5 p-4">
|
||||
<Label htmlFor="profile-lud16" className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
|
||||
<Zap className="h-4 w-4" />
|
||||
Bitcoin Lightning Address <span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="profile-lud16"
|
||||
placeholder="you@walletofsatoshi.com"
|
||||
value={lud16}
|
||||
onChange={(e) => setLud16(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
{lud16Invalid ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-red-500">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
Use an email-like address such as <code className="mx-1">you@getalby.com</code>.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your Lightning address lets anyone zap you instantly. Powers the "Zap" button on your profile.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bitcoin Silent Payment address (BIP-352) */}
|
||||
<div className="space-y-2 rounded-lg border border-orange-500/30 bg-orange-500/5 p-4">
|
||||
<Label htmlFor="profile-sp" className="flex items-center gap-2 text-orange-600 dark:text-orange-400">
|
||||
<Bitcoin className="h-4 w-4" />
|
||||
Bitcoin Silent Payment Address <span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="profile-sp"
|
||||
placeholder="sp1q..."
|
||||
value={silentPayment}
|
||||
onChange={(e) => setSilentPayment(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
{spInvalid ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-red-500">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
A Silent Payment address starts with <code className="mx-1">sp1</code> and is quite long.
|
||||
</p>
|
||||
) : spValid ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
|
||||
<ShieldCheck className="h-3.5 w-3.5 shrink-0" />
|
||||
Valid — a reusable, private on-chain address others can pay repeatedly.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A reusable static on-chain address (BIP-352). Share once; every payment lands at a fresh, unlinkable output.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sponsorships */}
|
||||
<div className="space-y-2 rounded-lg border border-pink-500/30 bg-pink-500/5 p-4">
|
||||
<Label htmlFor="profile-sponsors" className="flex items-center gap-2 text-pink-600 dark:text-pink-400">
|
||||
<Award className="h-4 w-4" />
|
||||
Sponsors & Support <span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="profile-sponsors"
|
||||
placeholder="Flow, Independent Trucks, your local shop… whoever backs your skating."
|
||||
value={sponsors}
|
||||
onChange={(e) => setSponsors(e.target.value)}
|
||||
className="resize-none text-sm"
|
||||
rows={2}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Highlight the brands, shops or people who sponsor or support you. Shown on your profile.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cordn contact link */}
|
||||
<div className="space-y-2 rounded-lg border border-teal-500/30 bg-teal-500/5 p-4">
|
||||
<Label htmlFor="profile-cordn" className="flex items-center gap-2 text-teal-600 dark:text-teal-400">
|
||||
<MessageSquareLock className="h-4 w-4" />
|
||||
Cordn Contact Link <span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="profile-cordn"
|
||||
placeholder="https://cordn.net/..."
|
||||
value={cordn}
|
||||
onChange={(e) => setCordn(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
{cordnInvalid ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-red-500">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
Paste a full link starting with <code className="mx-1">https://</code>.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A link others can use to reach you on{' '}
|
||||
<a href="https://cordn.net" target="_blank" rel="noopener noreferrer" className="text-teal-500 underline hover:text-teal-400">Cordn</a>
|
||||
{' '}— private, encrypted group messaging you own.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bot"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full md:w-auto"
|
||||
disabled={isPending || isUploading}
|
||||
{/* ── Section: Identity ── */}
|
||||
<FormSection
|
||||
icon={<UserRound className="h-4 w-4" />}
|
||||
title="Identity"
|
||||
description="How you show up across SkateSpots."
|
||||
accent="from-[#ff0040] to-[#ff6600]"
|
||||
>
|
||||
{(isPending || isUploading) && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Save Profile
|
||||
</Button>
|
||||
<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>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="website"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Website</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://yourwebsite.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nip05"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>NIP-05 identifier</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="you@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
|
||||
// Reusable component for image upload fields
|
||||
interface ImageUploadFieldProps {
|
||||
field: {
|
||||
|
|
@ -579,17 +642,29 @@ const ImageUploadField: React.FC<ImageUploadFieldProps> = ({
|
|||
return (
|
||||
<FormItem>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
name={field.name}
|
||||
value={field.value ?? ''}
|
||||
onChange={e => field.onChange(e.target.value)}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<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}
|
||||
|
|
@ -597,34 +672,22 @@ const ImageUploadField: React.FC<ImageUploadFieldProps> = ({
|
|||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onUpload(file);
|
||||
}
|
||||
if (file) onUpload(file);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Upload Image
|
||||
</Button>
|
||||
{field.value && (
|
||||
<div className={`h-10 ${previewType === 'square' ? 'w-10' : 'w-24'} rounded overflow-hidden`}>
|
||||
<img
|
||||
src={field.value}
|
||||
alt={`${label} preview`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
<FormDescription>
|
||||
{description}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -66,7 +66,9 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
|||
const nofferCode = typeof noffer === 'string' ? noffer : undefined;
|
||||
// Extra custom profile fields.
|
||||
const silentPayment = typeof raw?.bitcoin_silent_payment === 'string' ? raw.bitcoin_silent_payment : undefined;
|
||||
const sponsors = typeof raw?.sponsors === 'string' ? raw.sponsors.trim() : undefined;
|
||||
const sponsorItems = typeof raw?.sponsors === 'string'
|
||||
? raw.sponsors.split('\n').map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const cordnUrl = typeof raw?.cordn === 'string' ? raw.cordn : undefined;
|
||||
const [spCopied, setSpCopied] = useState(false);
|
||||
|
||||
|
|
@ -484,8 +486,8 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sponsors Card */}
|
||||
{sponsors && (
|
||||
{/* Sponsors Card — one badge per line */}
|
||||
{sponsorItems.length > 0 && (
|
||||
<Card className="bg-gradient-to-br from-pink-500/10 to-transparent border-2 border-pink-500/30">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
|
|
@ -494,9 +496,19 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
|||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-300 whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
|
||||
{sponsors}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{sponsorItems.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2.5 px-3 py-2 rounded-lg bg-pink-500/10 border border-pink-500/20"
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-pink-400 flex-shrink-0" />
|
||||
<span className="text-sm font-semibold text-gray-200 break-words [overflow-wrap:anywhere]">
|
||||
{s}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue