SkateSpots/src/components/EditProfileForm.tsx
SkateSpots ab87b8ab83 chore(lint): remove safely-detectable unused locals
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.
2026-09-26 21:58:03 +07:00

788 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 &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>
);
/**
* 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>
);
};