import { useState, useEffect } from 'react'; import { useSeoMeta } from '@unhead/react'; import { useNavigate, Link } from 'react-router-dom'; import ngeohash from 'ngeohash'; import { nip19 } from 'nostr-tools'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } 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 { useWallet } from '@/hooks/useWallet'; import { WalletModal } from '@/components/WalletModal'; import { LoginArea } from '@/components/auth/LoginArea'; import { Nip05Required } from '@/components/Nip05Required'; import { SpotPicker } from '@/components/SpotPicker'; import { NofferField } from '@/components/NofferField'; import { PaymentReadinessNote } from '@/components/PaymentReadinessNote'; import { EntityTypeField } from '@/components/EntityTypeField'; import { CrewLinkField } from '@/components/CrewLinkField'; import { SKATE_BUILDER_KIND, SKATE_SPOT_KIND, BUILDER_SERVICE_LABELS, BUILDER_SERVICE_DESCRIPTIONS, BUILDER_SKILL_LABELS, BUILDER_SCALE_LABELS, isValidNoffer, type EntityType, } from '@/lib/types'; import { BUILDER_SERVICE_KEYS, BUILDER_SERVICE_DESC_KEYS, BUILDER_SKILL_KEYS, BUILDER_SCALE_KEYS, } from '@/lib/labelKeys'; import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl'; import { useTranslation } from '@/contexts/language'; import { cn } from '@/lib/utils'; import { HardHat, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Hammer, Eye } from 'lucide-react'; import { LabProUpgrade } from '@/components/LabProUpgrade'; import { usePlatformFee } from '@/hooks/usePlatformFee'; import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile'; import { LabExtrasFields } from '@/components/LabExtrasFields'; import { LabProfilePreview } from '@/components/LabProfilePreview'; import { LabDraftBanner } from '@/components/LabDraftBanner'; import { LabProfileProgress } from '@/components/LabProfileProgress'; import { useLabDraft } from '@/hooks/useLabDraft'; import { labProfilePath } from '@/lib/labRoutes'; import { pickRateUnits } from '@/lib/labExtras'; const SERVICE_ENTRIES = Object.entries(BUILDER_SERVICE_LABELS); const SKILL_ENTRIES = Object.entries(BUILDER_SKILL_LABELS); const SCALE_ENTRIES = Object.entries(BUILDER_SCALE_LABELS); function ChipGrid({ entries, selected, onToggle, keys, }: { entries: [string, string][]; selected: string[]; onToggle: (key: string) => void; keys?: Record; }) { const { t } = useTranslation(); return (
{entries.map(([key, label]) => ( ))}
); } export default function BecomeBuilderPage() { const { t } = useTranslation(); useSeoMeta({ title: t('seoUi.becomeBuilderTitle'), description: t('seoUi.becomeBuilderDesc'), }); const navigate = useNavigate(); const { user, metadata } = useCurrentUser(); const author = useAuthor(user?.pubkey || ''); const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { toast } = useToast(); const { webln, activeNWC } = useWallet(); const hasWallet = !!webln || !!activeNWC; 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 [selectedServices, setSelectedServices] = useState([]); const [selectedSkills, setSelectedSkills] = useState([]); const [selectedScale, setSelectedScale] = useState([]); const [gear, setGear] = useState(''); const [serviceArea, setServiceArea] = useState(''); const [insured, setInsured] = useState(false); 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('collective'); const [members, setMembers] = useState([]); const [crewRef, setCrewRef] = useState(undefined); const [selectedSpots, setSelectedSpots] = useState([]); const [isGettingLocation, setIsGettingLocation] = useState(false); const [titleInput, setTitleInput] = useState(''); const [prefilled, setPrefilled] = useState(false); const [extraTags, setExtraTags] = useState([]); const [previewOpen, setPreviewOpen] = useState(false); const [lead_time, setLeadTime] = useState(''); const [warranty, setWarranty] = useState(''); const [existingGeohash, setExistingGeohash] = useState(undefined); // Prefill from an existing profile so this is an edit, not a blind overwrite. const { data: existingProfile } = useMyLabProfileEvent(SKATE_BUILDER_KIND, 'builder-profile'); const draft = useLabDraft( `craft:builder`, { titleInput, bio, location, latitude, longitude, selectedServices, selectedSkills, selectedScale, gear, serviceArea, insured, image, experience, rate, phone, email, website, portfolio, social, noffer, entityType, members, crewRef, selectedSpots, lead_time, warranty, extraTags }, (d) => { setTitleInput(d.titleInput); setBio(d.bio); setLocation(d.location); setLatitude(d.latitude); setLongitude(d.longitude); setSelectedServices(d.selectedServices); setSelectedSkills(d.selectedSkills); setSelectedScale(d.selectedScale); setGear(d.gear); setServiceArea(d.serviceArea); setInsured(d.insured); 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); setSelectedSpots(d.selectedSpots); setLeadTime(d.lead_time); setWarranty(d.warranty); setExtraTags(d.extraTags); }, ); useEffect(() => { if (prefilled || !existingProfile) return; setPrefilled(true); const tag = (n: string) => existingProfile.tags.find(([t]) => t === n)?.[1]; const tags = (n: string) => existingProfile.tags.filter(([t]) => t === n).map(([, v]) => v); const keys = (m: Record) => new Set(Object.keys(m)); const svcK = keys(BUILDER_SERVICE_LABELS); const skillK = keys(BUILDER_SKILL_LABELS); const scaleK = keys(BUILDER_SCALE_LABELS); const sel = tags('t').filter((v) => v !== 'skatebuilder'); setTitleInput(tag('title') ?? ''); setExistingGeohash(tag('g')); setGoPro(existingProfile.tags.some(([t]) => t === 'featured')); setBio(existingProfile.content ?? ''); setLocation(tag('location') ?? ''); setImage(tag('image') ?? null); setExperience(tag('experience') ?? ''); setRate(tag('rate') ?? ''); setLeadTime(tag('lead_time') ?? ''); setWarranty(tag('warranty') ?? ''); setPhone(tag('phone') ?? ''); setEmail(tag('email') ?? ''); setWebsite(tag('website') ?? ''); setPortfolio(tag('portfolio') ?? ''); setSocial(tag('social') ?? ''); setNoffer(tag('noffer') ?? ''); setGear(tag('gear') ?? ''); setServiceArea(tag('service_area') ?? ''); setInsured(tag('insured') === 'true'); setEntityType((tag('entity') as EntityType) ?? 'collective'); setMembers(tags('p')); setCrewRef(existingProfile.tags.find(([t, v]) => t === 'a' && v.startsWith('35006:'))?.[1]); setSelectedServices(sel.filter((v) => svcK.has(v))); setSelectedSkills(sel.filter((v) => skillK.has(v))); setSelectedScale(sel.filter((v) => scaleK.has(v))); setSelectedSpots( existingProfile.tags .filter(([t, v]) => t === 'a' && v.startsWith(`${SKATE_SPOT_KIND}:`)) .map(([, v]) => { const [, pk, ...rest] = v.split(':'); return `${pk}:${rest.join(':')}`; }), ); }, [existingProfile, prefilled]); 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 t('labUi.builderForm.anonymous'); }; const toggle = (setter: React.Dispatch>) => (key: string) => { setter((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 file = files[0]; const tags = await uploadFile(file); 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 = ''; }; // Optional, but if given it must be usable β€” a bad email/URL would otherwise // be stored and then silently hidden. const contactError = email.trim() && !isValidEmail(email) ? t('labUi.become.invalidEmail') : website.trim() && !sanitizeUrl(normalizeUrlInput(website)) ? t('labUi.become.invalidWebsite') : portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio)) ? t('labUi.become.invalidPortfolio') : ''; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (contactError) { document.getElementById('contact-error')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' }); return; } if (selectedServices.length === 0) { document.querySelector('form')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); toast({ title: t('labUi.builderForm.buildTypesRequired'), description: t('labUi.builderForm.selectBuild'), variant: 'destructive' }); return; } try { const identifier = 'builder-profile'; const displayName = getDisplayName(); const tags: string[][] = [ ['d', identifier], ['title', titleInput.trim() || displayName], ['t', 'skatebuilder'], ['alt', `Skate ramp/park builder: ${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)) { const geohash = ngeohash.encode(lat, lng, 7); tags.push(['g', geohash]); } } else if (existingGeohash) { // Preserve the existing map pin when editing without re-locating. tags.push(['g', existingGeohash]); } for (const s of [...selectedServices, ...selectedSkills, ...selectedScale]) { tags.push(['t', s]); } if (gear.trim()) tags.push(['gear', gear.trim()]); if (serviceArea.trim()) tags.push(['service_area', serviceArea.trim()]); if (insured) tags.push(['insured', 'true']); if (image) tags.push(['image', image]); if (experience.trim()) tags.push(['experience', experience.trim()]); if (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()]); tags.push(['entity', entityType]); for (const extra of extraTags) tags.push(extra); if (lead_time.trim()) tags.push(['lead_time', lead_time.trim()]); if (warranty.trim()) tags.push(['warranty', warranty.trim()]); if (entityType === 'collective') { for (const pk of members) tags.push(['p', pk]); if (crewRef) tags.push(['a', crewRef]); } for (const spotId of selectedSpots) { const [spotPubkey, spotIdentifier] = spotId.split(':'); tags.push(['a', `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`]); } // Pro / featured listing (optional, paid) if (isPro && featuredUntilSeconds) { tags.push(['featured', String(featuredUntilSeconds)]); } await createEvent({ kind: SKATE_BUILDER_KIND, content: bio.trim(), tags, }); draft.clear(); toast({ title: existingProfile ? t('labUi.builderForm.updated') : t('labUi.builderForm.created'), description: t('labUi.become.profilePublished') }); navigate(`${labProfilePath('builder', 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.loginRequiredDesc', { role: t('labUi.role.builder') })}

); } const hasNip05 = metadata?.nip05; return ( <>
{t('labUi.become.backToLab')}

{t('labUi.builderForm.heading')}

{t('labUi.builderForm.tagline')}

{!hasNip05 && ( )} {/* Wallet Requirement */}

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

{t('labUi.builderForm.paymentDesc')}

{hasWallet ? (

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

{webln ? t('labUi.become.usingWebln') : t('labUi.become.usingNwc')}

) : (

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

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

)}
{draft.resumable && } 20} hasSkills={selectedServices.length + selectedSkills.length + selectedScale.length > 0} hasRate={!!rate.trim()} hasContact={!!(email || website || phone || social)} hasLocation={!!location.trim()} />
{t('labUi.builderForm.profileTitle')} {t('labUi.builderForm.profileDesc')}

{getDisplayName()}

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

setTitleInput(e.target.value)} placeholder={getDisplayName()} maxLength={100} className="bg-gray-800 border-gray-700" />

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