734 lines
34 KiB
TypeScript
734 lines
34 KiB
TypeScript
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 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', noffer.trim().toLowerCase()]);
|
||
|
||
// 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 },
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
}
|