import { useState } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import ngeohash from 'ngeohash'; import { nip19 } from 'nostr-tools'; import { Layout } from '@/components/Layout'; 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 { EntityTypeField } from '@/components/EntityTypeField'; import { CrewLinkField } from '@/components/CrewLinkField'; import { LabProUpgrade } from '@/components/LabProUpgrade'; import { usePlatformFee } from '@/hooks/usePlatformFee'; import { isValidNoffer, type EntityType } from '@/lib/types'; import { cn } from '@/lib/utils'; import { Upload, X, Loader2, ArrowLeft, MapPin } from 'lucide-react'; /** A group of selectable tag chips (e.g. "Genre", "Instruments"). */ export interface SkillSection { title: string; /** [tagValue, label] pairs. */ entries: [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; placeholder?: string; /** "text" (default) | "url" | "toggle" */ type?: 'text' | 'url' | 'toggle'; helper?: 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; /** Sub-line under the heading. */ tagline: string; /** Human noun for toasts / alt text, e.g. "tattoo artist". */ noun: 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; /** Label for the portfolio field. */ portfolioLabel?: string; } export function CraftProfileForm({ config }: { config: CraftFormConfig }) { const { kind, selfTag, identifier, heading, tagline, noun, accentText, accentBg, accentChip, accentChipHover, icon: Icon, skillSections, extraFields = [], allowCollective = true, showRate = true, rateLabel = 'Rate', portfolioLabel = 'Portfolio Link', } = config; const navigate = useNavigate(); const { user } = 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 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 ${noun}`; }; const toggle = (key: string) => setSelected((prev) => (prev.includes(key) ? prev.filter((s) => s !== key) : [...prev, key])); const getCurrentLocation = () => { if (!navigator.geolocation) { toast({ title: 'Geolocation not supported', 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: 'Location found' }); }, (error) => { setIsGettingLocation(false); toast({ title: 'Location error', 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: 'Image uploaded' }); } } catch (error) { toast({ title: 'Upload failed', description: error instanceof Error ? error.message : 'Failed', variant: 'destructive' }); } e.target.value = ''; }; const requiredSections = skillSections.filter((s) => s.required); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); // Enforce required skill sections for (const section of requiredSections) { const keys = section.entries.map(([k]) => k); if (!selected.some((s) => keys.includes(s))) { toast({ title: `${section.title} required`, description: `Select at least one option under "${section.title}".`, variant: 'destructive' }); return; } } try { const displayName = getDisplayName(); const tags: string[][] = [ ['d', identifier], ['title', displayName], ['t', selfTag], ['alt', `Skate ${noun}: ${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)]); } 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', website.trim()]); if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]); if (social.trim()) tags.push(['social', social.trim()]); 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) { if (f.type === 'url' && !/^https?:\/\//i.test(v)) continue; tags.push([f.tag, v]); } } tags.push(['entity', entityType]); 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 }); toast({ title: `${heading.replace(/^Become an?/, '').trim()} profile created!`, description: 'Your profile has been published.' }); navigate(`/coach/${user?.pubkey}/${identifier}`); } catch (error) { toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : 'Error', variant: 'destructive' }); } }; if (!user) { return (

Login Required

You need to log in to create a {noun} profile.

); } const hasNip05 = user?.metadata?.nip05; const entityCoord = `${kind}:${user.pubkey}:${identifier}`; return (
{/* Header */}
Back to The Lab

{heading}

{tagline}

{!hasNip05 && (
⚠️

Add NIP-05 to build trust

A NIP-05 identifier helps skaters verify you're the real deal. Highly recommended before listing.

)}
{/* Image + Bio */}
{image ? ( <> Profile ) : ( )}