SkateSpots/src/components/CraftProfileForm.tsx

735 lines
34 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import ngeohash from 'ngeohash';
import { nip19 } from 'nostr-tools';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast';
import { LoginArea } from '@/components/auth/LoginArea';
import { NofferField } from '@/components/NofferField';
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
import { LabExtrasFields } from '@/components/LabExtrasFields';
import { LabProfilePreview } from '@/components/LabProfilePreview';
import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField';
import { LabProUpgrade } from '@/components/LabProUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
import { useLabDraft } from '@/hooks/useLabDraft';
import { LabDraftBanner } from '@/components/LabDraftBanner';
import { LabProfileProgress } from '@/components/LabProfileProgress';
import { FormWizard, WizardStep } from '@/components/FormWizard';
import { isValidNoffer, type EntityType } from '@/lib/types';
import { normalizeClinkCode } from '@/lib/clink';
import type { LabOption } from '@/lib/labExtras';
import { labProfilePathFromKind } from '@/lib/labRoutes';
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
import { Upload, X, Loader2, ArrowLeft, MapPin, Eye } from 'lucide-react';
/** A group of selectable tag chips (e.g. "Genre", "Instruments"). */
export interface SkillSection {
title: string;
/** Optional i18n key for the section title (takes precedence over `title`). */
titleKey?: string;
/** [tagValue, label] pairs. */
entries: [string, string][];
/** Optional value → `labOptions.*` translation key for the entry labels. */
keyMap?: Record<string, string>;
/** If true, at least one must be selected before publishing. */
required?: boolean;
}
/** A freeform extra field specific to a craft (e.g. tattoo studio, music links). */
export interface ExtraField {
/** Nostr tag name to write, e.g. "studio", "spotify". */
tag: string;
label: string;
/** Optional i18n key for the label (takes precedence over `label`). */
labelKey?: string;
placeholder?: string;
/** Optional i18n key for the placeholder (takes precedence over `placeholder`). */
placeholderKey?: string;
/** "text" (default) | "url" | "toggle" */
type?: 'text' | 'url' | 'toggle';
helper?: string;
/** Optional i18n key for the helper (takes precedence over `helper`). */
helperKey?: string;
}
export interface CraftFormConfig {
kind: number;
selfTag: string;
/** Fixed `d` identifier for this craft's single profile per user. */
identifier: string;
/** Page heading, e.g. "Become a Tattoo Artist". */
heading: string;
/** Optional i18n key for the heading (takes precedence over `heading`). */
headingKey?: string;
/** Sub-line under the heading. */
tagline: string;
/** Optional i18n key for the tagline (takes precedence over `tagline`). */
taglineKey?: string;
/** Human noun for toasts / alt text, e.g. "tattoo artist". */
noun: string;
/** Optional i18n key for the noun (takes precedence over `noun`). */
nounKey?: string;
/** Tailwind text color for the accent, e.g. "text-rose-400". */
accentText: string;
/** Tailwind bg color for the accent, e.g. "bg-rose-600". */
accentBg: string;
/** Active-chip classes, e.g. "bg-rose-600 text-white border-rose-600". */
accentChip: string;
/** Hover classes for inactive chips. */
accentChipHover: string;
/** Icon component for the login-gate + header. */
icon: React.ComponentType<{ className?: string }>;
/** Tag chip sections. */
skillSections: SkillSection[];
/** Craft-specific freeform fields. */
extraFields?: ExtraField[];
/** Whether this craft can be a collective (default true). */
allowCollective?: boolean;
/** Whether to show the rate field (default true). */
showRate?: boolean;
/** Label for the rate field. */
rateLabel?: string;
/** Optional i18n key for the rate label (takes precedence over `rateLabel`). */
rateLabelKey?: string;
/** Label for the portfolio field. */
portfolioLabel?: string;
/** Optional i18n key for the portfolio label (takes precedence over `portfolioLabel`). */
portfolioLabelKey?: string;
/** Billing units relevant to this craft (structured-rate dropdown). */
rateUnits?: LabOption[];
}
export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
const {
kind, selfTag, identifier, heading, headingKey, tagline, taglineKey, noun, nounKey,
accentText, accentBg, accentChip, accentChipHover, icon: Icon,
skillSections, extraFields = [], allowCollective = true,
showRate = true, rateLabel = 'Rate', rateLabelKey, portfolioLabel = 'Portfolio Link', portfolioLabelKey,
rateUnits,
} = config;
const navigate = useNavigate();
const { t } = useTranslation();
// Prefer the i18n key when the data table supplies one, else the English fallback.
const headingText = headingKey ? t(headingKey) : heading;
const taglineText = taglineKey ? t(taglineKey) : tagline;
const nounText = nounKey ? t(nounKey) : noun;
const rateLabelText = rateLabelKey ? t(rateLabelKey) : rateLabel;
const portfolioLabelText = portfolioLabelKey ? t(portfolioLabelKey) : portfolioLabel;
const sectionLabel = (section: SkillSection) => (section.titleKey ? t(section.titleKey) : section.title);
const { user, metadata } = useCurrentUser();
const author = useAuthor(user?.pubkey || '');
const { mutateAsync: createEvent, isPending } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
const { isActive: isPro, featuredUntilSeconds } = usePlatformFee('labProfile');
const [goPro, setGoPro] = useState(false);
const [bio, setBio] = useState('');
const [location, setLocation] = useState('');
const [latitude, setLatitude] = useState('');
const [longitude, setLongitude] = useState('');
const [selected, setSelected] = useState<string[]>([]);
const [image, setImage] = useState<string | null>(null);
const [experience, setExperience] = useState('');
const [rate, setRate] = useState('');
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
const [website, setWebsite] = useState('');
const [portfolio, setPortfolio] = useState('');
const [social, setSocial] = useState('');
const [noffer, setNoffer] = useState('');
const [entityType, setEntityType] = useState<EntityType>('individual');
const [members, setMembers] = useState<string[]>([]);
const [crewRef, setCrewRef] = useState<string | undefined>(undefined);
const [extras, setExtras] = useState<Record<string, string>>({});
const [isGettingLocation, setIsGettingLocation] = useState(false);
const [titleInput, setTitleInput] = useState('');
const [prefilled, setPrefilled] = useState(false);
const [existingGeohash, setExistingGeohash] = useState<string | undefined>(undefined);
const [extraTags, setExtraTags] = useState<string[][]>([]);
const [previewOpen, setPreviewOpen] = useState(false);
const [step, setStep] = useState(0);
const draft = useLabDraft(
`craft:${kind}:${identifier}`,
{ titleInput, bio, location, latitude, longitude, selected, image, experience, rate, phone, email, website, portfolio, social, noffer, entityType, members, crewRef, extras, extraTags },
(d) => {
setTitleInput(d.titleInput); setBio(d.bio); setLocation(d.location);
setLatitude(d.latitude); setLongitude(d.longitude); setSelected(d.selected);
setImage(d.image); setExperience(d.experience); setRate(d.rate);
setPhone(d.phone); setEmail(d.email); setWebsite(d.website);
setPortfolio(d.portfolio); setSocial(d.social); setNoffer(d.noffer);
setEntityType(d.entityType); setMembers(d.members); setCrewRef(d.crewRef);
setExtras(d.extras); setExtraTags(d.extraTags);
},
);
// Load + prefill an existing profile so this is an EDIT, not a blind overwrite.
const { data: existingProfile } = useMyLabProfileEvent(kind, identifier);
useEffect(() => {
if (prefilled || !existingProfile) return;
setPrefilled(true);
const tag = (n: string) => existingProfile.tags.find(([t]) => t === n)?.[1];
setTitleInput(tag('title') ?? '');
setExistingGeohash(tag('g'));
setGoPro(existingProfile.tags.some(([t]) => t === 'featured'));
setBio(existingProfile.content ?? '');
setLocation(tag('location') ?? '');
setSelected(existingProfile.tags.filter(([t, v]) => t === 't' && v !== selfTag).map(([, v]) => v));
setImage(tag('image') ?? null);
setExperience(tag('experience') ?? '');
setRate(tag('rate') ?? '');
setPhone(tag('phone') ?? '');
setEmail(tag('email') ?? '');
setWebsite(tag('website') ?? '');
setPortfolio(tag('portfolio') ?? '');
setSocial(tag('social') ?? '');
setNoffer(tag('noffer') ?? '');
setEntityType((tag('entity') as EntityType) ?? 'individual');
setMembers(existingProfile.tags.filter(([t]) => t === 'p').map(([, v]) => v));
// Only a 35006 crew coordinate is the crew link; other `a` tags (spot refs)
// are foreign and must be preserved on re-publish, not read as a crew.
setCrewRef(existingProfile.tags.find(([t, v]) => t === 'a' && v?.startsWith('35006:'))?.[1]);
const ex: Record<string, string> = {};
for (const f of extraFields) {
const v = tag(f.tag);
if (v !== undefined) ex[f.tag] = v;
}
setExtras(ex);
}, [existingProfile, prefilled, selfTag, extraFields]);
const getDisplayName = () => {
if (author.data?.metadata?.name) return author.data.metadata.name;
if (user?.pubkey) {
const npub = nip19.npubEncode(user.pubkey);
return `${npub.slice(0, 12)}...${npub.slice(-4)}`;
}
return `Anonymous ${nounText}`;
};
const toggle = (key: string) =>
setSelected((prev) => (prev.includes(key) ? prev.filter((s) => s !== key) : [...prev, key]));
const getCurrentLocation = () => {
if (!navigator.geolocation) {
toast({ title: t('labUi.become.geolocationUnsupported'), variant: 'destructive' });
return;
}
setIsGettingLocation(true);
navigator.geolocation.getCurrentPosition(
(position) => {
setLatitude(position.coords.latitude.toFixed(6));
setLongitude(position.coords.longitude.toFixed(6));
setIsGettingLocation(false);
toast({ title: t('labUi.become.locationFound') });
},
(error) => {
setIsGettingLocation(false);
toast({ title: t('labUi.become.locationError'), description: error.message, variant: 'destructive' });
}
);
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
try {
const tags = await uploadFile(files[0]);
const url = tags[0]?.[1];
if (url) {
setImage(url);
toast({ title: t('labUi.become.imageUploaded') });
}
} catch (error) {
toast({ title: t('labUi.become.uploadFailed'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
}
e.target.value = '';
};
const requiredSections = skillSections.filter((s) => s.required);
// Contact details are optional, but if provided they must be usable — an
// invalid email/URL would otherwise be stored and then silently hidden.
const emailError = email.trim() && !isValidEmail(email) ? t('labUi.become.invalidEmail') : '';
const websiteError = website.trim() && !sanitizeUrl(normalizeUrlInput(website)) ? t('labUi.become.invalidWebsite') : '';
const portfolioError = portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio)) ? t('labUi.become.invalidPortfolio') : '';
const contactError = emailError || websiteError || portfolioError;
const [attempted, setAttempted] = useState(false);
const scrollTo = (id: string) => {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setAttempted(true);
if (contactError) {
setStep(3);
window.setTimeout(() => scrollTo(emailError ? 'craft-email' : websiteError ? 'craft-website' : 'craft-portfolio'), 60);
toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' });
return;
}
// Enforce required skill sections
for (const section of requiredSections) {
const keys = section.entries.map(([k]) => k);
if (!selected.some((s) => keys.includes(s))) {
setStep(1);
window.setTimeout(() => scrollTo('craft-skills'), 60);
toast({ title: t('labUi.craftForm.sectionRequired', { section: sectionLabel(section) }), description: t('labUi.craftForm.sectionRequiredDesc', { section: sectionLabel(section) }), variant: 'destructive' });
return;
}
}
try {
const displayName = getDisplayName();
const tags: string[][] = [
['d', identifier],
['title', titleInput.trim() || displayName],
['t', selfTag],
['alt', `Skate ${nounText}: ${displayName}`],
];
if (location.trim()) tags.push(['location', location.trim()]);
if (latitude && longitude) {
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
if (!isNaN(lat) && !isNaN(lng)) tags.push(['g', ngeohash.encode(lat, lng, 7)]);
} else if (existingGeohash) {
// Preserve the existing map pin when the user edits without re-locating.
tags.push(['g', existingGeohash]);
}
for (const s of selected) tags.push(['t', s]);
if (image) tags.push(['image', image]);
if (experience.trim()) tags.push(['experience', experience.trim()]);
if (showRate && rate.trim()) tags.push(['rate', rate.trim()]);
if (phone.trim()) tags.push(['phone', phone.trim()]);
if (email.trim()) tags.push(['email', email.trim()]);
if (website.trim()) tags.push(['website', normalizeUrlInput(website)]);
if (portfolio.trim()) tags.push(['portfolio', normalizeUrlInput(portfolio)]);
if (social.trim()) tags.push(['social', normalizeUrlInput(social)]);
if (isValidNoffer(noffer.trim())) tags.push(['noffer', normalizeClinkCode(noffer)]);
// Craft-specific extras
for (const f of extraFields) {
const v = (extras[f.tag] ?? '').trim();
if (f.type === 'toggle') {
if (extras[f.tag] === 'true') tags.push([f.tag, 'true']);
} else if (v) {
// Coerce bare domains ("example.com") to https:// instead of silently
// dropping the field, then reject anything that still isn't a web URL.
const val = f.type === 'url' ? normalizeUrlInput(v) : v;
if (f.type === 'url' && !/^https?:\/\//i.test(val)) continue;
tags.push([f.tag, val]);
}
}
tags.push(['entity', entityType]);
// Shared enrichment (languages, availability, rate, payments, portfolio).
for (const extra of extraTags) tags.push(extra);
// Preserve foreign `a` tags (spot references) — this form doesn't manage
// them, and an addressable re-publish replaces the entire tag set.
for (const tag of existingProfile?.tags ?? []) {
const [n, v] = tag;
if (n === 'a' && !v?.startsWith('35006:')) tags.push(tag);
}
if (allowCollective && entityType === 'collective') {
for (const pk of members) tags.push(['p', pk]);
if (crewRef) tags.push(['a', crewRef]);
}
if (isPro && goPro && featuredUntilSeconds) tags.push(['featured', String(featuredUntilSeconds)]);
await createEvent({ kind, content: bio.trim(), tags });
draft.clear();
toast({ title: t(existingProfile ? 'labUi.craftForm.updated' : 'labUi.craftForm.created'), description: t('labUi.become.profilePublished') });
navigate(`${labProfilePathFromKind(kind, user?.pubkey ?? '', identifier)}${existingProfile ? '' : '?published=1'}`);
} catch (error) {
toast({ title: t('labUi.become.failedToPublish'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
}
};
if (!user) {
return (
<>
<div className="container px-4 py-8">
<Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
<CardContent className="py-12 text-center">
<Icon className={cn('h-12 w-12 mx-auto mb-4', accentText)} />
<h2 className="text-xl font-bold mb-2">{t('labUi.become.loginRequired')}</h2>
<p className="text-gray-400 mb-6">{t('labUi.become.loginRequiredDescCraft', { noun: nounText })}</p>
<LoginArea className="justify-center max-w-60 mx-auto" />
</CardContent>
</Card>
</div>
</>
);
}
const hasNip05 = metadata?.nip05;
const entityCoord = `${kind}:${user.pubkey}:${identifier}`;
const previewSkills = skillSections.flatMap((s) =>
s.entries
.filter(([k]) => selected.includes(k))
.map(([k, label]) => ({ label: s.keyMap?.[k] ? t(s.keyMap[k]) : label })),
);
return (
<>
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
{/* Header */}
<div className="mb-6">
<Link to="/lab" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4">
<ArrowLeft className="h-4 w-4 mr-2" />
{t('labUi.become.backToLab')}
</Link>
<div className="flex items-center gap-3">
<div className={cn('w-11 h-11 rounded-xl flex items-center justify-center', accentChip)}>
<Icon className="h-6 w-6" />
</div>
<div>
<h1 className={cn('text-2xl sm:text-3xl font-black uppercase leading-none', accentText)} style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
{headingText}
</h1>
<p className="text-gray-400 mt-1 text-sm">{taglineText}</p>
</div>
</div>
{!hasNip05 && (
<Card className="mt-4 bg-yellow-950/30 border-2 border-yellow-500/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="text-yellow-500">⚠️</div>
<div>
<h3 className="font-bold text-yellow-400 mb-1">{t('labUi.craftForm.nip05Title')}</h3>
<p className="text-sm text-gray-300 mb-2">
{t('labUi.craftForm.nip05Desc')}
</p>
<Link to="/settings">
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold">
{t('labUi.become.addNip05')}
</Button>
</Link>
</div>
</div>
</CardContent>
</Card>
)}
</div>
{existingProfile && (
<p className="mb-3 text-xs text-gray-500">
{t('labUi.craftForm.editingNote', { noun: nounText })}
</p>
)}
{draft.resumable && <LabDraftBanner onResume={draft.resume} onDiscard={draft.discard} />}
<LabProfileProgress
hasPhoto={!!image}
hasBio={bio.trim().length > 20}
hasSkills={selected.length > 0}
hasRate={!!rate.trim()}
hasLocation={!!location.trim()}
hasContact={!!(email || website || phone || social)}
/>
<form onSubmit={handleSubmit} className="space-y-6">
<FormWizard
steps={[
{ id: 'identity', title: t('labUi.wizard.identity') },
{ id: 'offer', title: t('labUi.wizard.offer') },
{ id: 'rates', title: t('labUi.wizard.rates') },
{ id: 'contact', title: t('labUi.wizard.contact') },
{ id: 'media', title: t('labUi.wizard.media') },
]}
step={step}
onStepChange={setStep}
accentChip={accentChip}
submitting={isPending}
submitLabel={existingProfile ? t('labUi.craftForm.updateProfile') : t('labUi.craftForm.publishProfile')}
submittingLabel={t('labUi.become.publishing')}
footerExtra={
<Button type="button" variant="outline" onClick={() => setPreviewOpen(true)} className="h-12 flex-shrink-0 gap-2 rounded-xl border-white/15 font-bold">
<Eye className="h-4 w-4" /> {t('labUi.preview.button')}
</Button>
}
>
<WizardStep index={0}>
{/* Image + Bio */}
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-4">
<div className="flex items-center gap-4">
<div className={cn('relative w-20 h-20 rounded-2xl border-2 flex items-center justify-center overflow-hidden flex-shrink-0', image ? 'border-white/20' : 'border-dashed border-white/20')}>
{image ? (
<>
<img src={image} alt={t('legacyMiscUi.altProfile')} className="w-full h-full object-cover" />
<button type="button" onClick={() => setImage(null)} className="absolute top-0.5 right-0.5 bg-black/70 rounded-full p-0.5">
<X className="h-3 w-3 text-white" />
</button>
</>
) : (
<Icon className="h-7 w-7 text-gray-600" />
)}
</div>
<div className="flex-1">
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400">{t('labUi.craftForm.profileImage')}</Label>
<label className={cn('mt-1.5 flex items-center gap-2 px-3 py-2 rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 cursor-pointer text-sm w-fit transition-colors', isUploading && 'opacity-50 pointer-events-none')}>
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{isUploading ? t('labUi.become.uploading') : t('labUi.become.upload')}
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} disabled={isUploading} />
</label>
</div>
</div>
<div>
<Label htmlFor="title" className="text-xs font-bold uppercase tracking-widest text-gray-400">{t('labUi.craftForm.nameHeadline')}</Label>
<Input
id="title"
value={titleInput}
onChange={(e) => setTitleInput(e.target.value)}
placeholder={getDisplayName()}
maxLength={100}
className="mt-1.5 bg-white/5 border-white/10"
/>
<p className="mt-1 text-xs text-gray-500">
{t('labUi.become.headlineHint', { example: t('labUi.craftForm.headlineExample') })}
</p>
</div>
<div>
<Label htmlFor="bio" className="text-xs font-bold uppercase tracking-widest text-gray-400">{t('labUi.craftForm.about')}</Label>
<Textarea
id="bio"
value={bio}
onChange={(e) => setBio(e.target.value)}
placeholder={t('labUi.craftForm.aboutPlaceholder', { noun: nounText })}
className="mt-1.5 bg-white/5 border-white/10 min-h-[100px] resize-none"
maxLength={3000}
/>
<div className="mt-1 flex justify-end">
<span className="text-[10px] text-gray-500">{bio.length}/3000</span>
</div>
</div>
</CardContent>
</Card>
</WizardStep>
<WizardStep index={1}>
{/* Skill sections */}
{skillSections.map((section, sectionIndex) => (
<Card key={section.title} id={sectionIndex === 0 ? 'craft-skills' : undefined} className="bg-black/60 border-white/10">
<CardContent className="p-5">
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400 flex items-center gap-2">
{sectionLabel(section)}
{section.required && <span className={cn('text-[10px]', accentText)}>• {t('labUi.craftForm.required')}</span>}
</Label>
<div className="mt-3 flex flex-wrap gap-2">
{section.entries.map(([key, label]) => {
const active = selected.includes(key);
const labelKey = section.keyMap?.[key];
return (
<button
key={key}
type="button"
onClick={() => toggle(key)}
className={cn(
'px-3 py-1.5 rounded-full text-xs font-bold border transition-all active:scale-95',
active ? accentChip : cn('bg-white/5 border-white/10 text-gray-400', accentChipHover)
)}
>
{labelKey ? t(labelKey) : label}
</button>
);
})}
</div>
{attempted && section.required && !section.entries.some(([k]) => selected.includes(k)) && (
<p className="mt-2 text-xs font-bold text-red-400">{t('labUi.craftForm.sectionRequiredDesc', { section: sectionLabel(section) })}</p>
)}
</CardContent>
</Card>
))}
{/* Craft-specific extras */}
{extraFields.length > 0 && (
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-4">
{extraFields.map((f) => (
f.type === 'toggle' ? (
<div key={f.tag} className="flex items-center justify-between gap-3">
<div>
<Label className="text-sm font-bold text-gray-200">{f.labelKey ? t(f.labelKey) : f.label}</Label>
{f.helper && <p className="text-xs text-gray-500">{f.helperKey ? t(f.helperKey) : f.helper}</p>}
</div>
<Switch
checked={extras[f.tag] === 'true'}
onCheckedChange={(v) => setExtras((prev) => ({ ...prev, [f.tag]: v ? 'true' : 'false' }))}
/>
</div>
) : (
<div key={f.tag}>
<Label htmlFor={f.tag} className="text-xs font-bold uppercase tracking-widest text-gray-400">{f.labelKey ? t(f.labelKey) : f.label}</Label>
<Input
id={f.tag}
value={extras[f.tag] ?? ''}
onChange={(e) => setExtras((prev) => ({ ...prev, [f.tag]: e.target.value }))}
placeholder={f.placeholderKey ? t(f.placeholderKey) : f.placeholder}
inputMode={f.type === 'url' ? 'url' : undefined}
className="mt-1.5 bg-white/5 border-white/10"
/>
{f.helper && <p className="text-xs text-gray-500 mt-1">{f.helperKey ? t(f.helperKey) : f.helper}</p>}
</div>
)
))}
</CardContent>
</Card>
)}
</WizardStep>
<WizardStep index={2}>
{/* Location + experience + rate */}
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-4">
<div>
<Label htmlFor="location" className="text-xs font-bold uppercase tracking-widest text-gray-400">{t('labUi.craftForm.location')}</Label>
<div className="mt-1.5 flex gap-2">
<Input id="location" value={location} onChange={(e) => setLocation(e.target.value)} placeholder={t('labUi.become.cityCountry')} className="bg-white/5 border-white/10" />
<Button type="button" variant="outline" onClick={getCurrentLocation} disabled={isGettingLocation} className="border-white/10 flex-shrink-0">
{isGettingLocation ? <Loader2 className="h-4 w-4 animate-spin" /> : <MapPin className="h-4 w-4" />}
</Button>
</div>
{latitude && longitude && <p className="text-xs text-green-400 mt-1">{t('labUi.craftForm.pinned', { lat: latitude, lng: longitude })}</p>}
</div>
<div className={cn('grid gap-4', showRate ? 'grid-cols-2' : 'grid-cols-1')}>
<div>
<Label htmlFor="exp" className="text-xs font-bold uppercase tracking-widest text-gray-400">{t('labUi.craftForm.experienceYears')}</Label>
<Input id="exp" value={experience} onChange={(e) => setExperience(e.target.value)} placeholder={t('labUi.craftForm.expPlaceholder')} inputMode="numeric" className="mt-1.5 bg-white/5 border-white/10" />
</div>
{showRate && (
<div>
<Label htmlFor="rate" className="text-xs font-bold uppercase tracking-widest text-gray-400">{rateLabelText}</Label>
<Input id="rate" value={rate} onChange={(e) => setRate(e.target.value)} placeholder={t('labUi.craftForm.ratePlaceholder')} className="mt-1.5 bg-white/5 border-white/10" />
</div>
)}
</div>
</CardContent>
</Card>
</WizardStep>
<WizardStep index={3}>
{/* Contact + links */}
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-4">
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400">{t('labUi.craftForm.contactLinks')}</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder={t('labUi.craftForm.phoneOptional')} className="bg-white/5 border-white/10" />
<div>
<Input id="craft-email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder={t('labUi.craftForm.emailOptional')} type="email" className={cn('bg-white/5 border-white/10', emailError && 'border-red-500')} />
{emailError && <p className="mt-1 text-xs text-red-400">{emailError}</p>}
</div>
<div>
<Input id="craft-website" value={website} onChange={(e) => setWebsite(e.target.value)} placeholder={t('labUi.become.website')} inputMode="url" className={cn('bg-white/5 border-white/10', websiteError && 'border-red-500')} />
{websiteError && <p className="mt-1 text-xs text-red-400">{websiteError}</p>}
</div>
<Input value={social} onChange={(e) => setSocial(e.target.value)} placeholder={t('labUi.craftForm.socialPlaceholder')} className="bg-white/5 border-white/10" />
<div className="sm:col-span-2">
<Input id="craft-portfolio" value={portfolio} onChange={(e) => setPortfolio(e.target.value)} placeholder={portfolioLabelText} inputMode="url" className={cn('bg-white/5 border-white/10', portfolioError && 'border-red-500')} />
{portfolioError && <p className="mt-1 text-xs text-red-400">{portfolioError}</p>}
</div>
</div>
</CardContent>
</Card>
{/* Payments — Lightning address (from profile) + optional CLINK code */}
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-4">
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400">
{t('labUi.craftForm.gettingPaid')}
</Label>
<PaymentReadinessNote />
<NofferField value={noffer} onChange={setNoffer} />
</CardContent>
</Card>
</WizardStep>
<WizardStep index={4}>
{/* Shared enrichment: languages, availability, rate, payments, portfolio */}
<LabExtrasFields
existingTags={existingProfile?.tags}
onChange={setExtraTags}
accentText={accentText}
accentChip={accentChip}
accentChipHover={accentChipHover}
rateUnits={rateUnits}
/>
{/* Entity type + crew link (collective-capable crafts) */}
{allowCollective && (
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-4">
<EntityTypeField
entityType={entityType}
onEntityTypeChange={setEntityType}
members={members}
onMembersChange={setMembers}
accentBg={accentBg}
accentText={accentText}
/>
{entityType === 'collective' && (
<CrewLinkField value={crewRef} onChange={setCrewRef} accentText={accentText} accentBg={accentBg} />
)}
</CardContent>
</Card>
)}
{/* Pro upgrade (optional) */}
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText={accentText} entityCoord={entityCoord} />
</WizardStep>
</FormWizard>
</form>
</div>
<LabProfilePreview
open={previewOpen}
onOpenChange={setPreviewOpen}
heading={headingText}
noun={nounText}
accentText={accentText}
accentChip={accentChip}
icon={Icon}
draft={{
title: titleInput.trim() || getDisplayName(),
bio,
image,
location,
experience,
rate,
skills: previewSkills,
extraTags,
contact: { phone, email, website, portfolio, social, noffer },
}}
/>
</>
);
}