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; /** 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([]); const [image, setImage] = useState(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('individual'); const [members, setMembers] = useState([]); const [crewRef, setCrewRef] = useState(undefined); const [extras, setExtras] = useState>({}); const [isGettingLocation, setIsGettingLocation] = useState(false); const [titleInput, setTitleInput] = useState(''); const [prefilled, setPrefilled] = useState(false); const [existingGeohash, setExistingGeohash] = useState(undefined); const [extraTags, setExtraTags] = useState([]); 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 = {}; 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) => { 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 ( <>

{t('labUi.become.loginRequired')}

{t('labUi.become.loginRequiredDescCraft', { noun: nounText })}

); } 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 ( <>
{/* Header */}
{t('labUi.become.backToLab')}

{headingText}

{taglineText}

{!hasNip05 && (
⚠️

{t('labUi.craftForm.nip05Title')}

{t('labUi.craftForm.nip05Desc')}

)}
{existingProfile && (

{t('labUi.craftForm.editingNote', { noun: nounText })}

)} {draft.resumable && } 20} hasSkills={selected.length > 0} hasRate={!!rate.trim()} hasLocation={!!location.trim()} hasContact={!!(email || website || phone || social)} />
setPreviewOpen(true)} className="h-12 flex-shrink-0 gap-2 rounded-xl border-white/15 font-bold"> {t('labUi.preview.button')} } > {/* Image + Bio */}
{image ? ( <> {t('legacyMiscUi.altProfile')} ) : ( )}
setTitleInput(e.target.value)} placeholder={getDisplayName()} maxLength={100} className="mt-1.5 bg-white/5 border-white/10" />

{t('labUi.become.headlineHint', { example: t('labUi.craftForm.headlineExample') })}