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:
shakespeare.diy 2026-09-02 20:06:32 +07:00
parent 11c67fcf22
commit 9599adaf0d
2 changed files with 407 additions and 332 deletions

View file

@ -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 &amp; 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>
);

View file

@ -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>
)}