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 { 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_COACH_KIND, SKATE_SPOT_KIND, COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types'; import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl'; import { cn } from '@/lib/utils'; import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react'; import { LabProUpgrade } from '@/components/LabProUpgrade'; import { usePlatformFee } from '@/hooks/usePlatformFee'; import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile'; const SKILL_ENTRIES = Object.entries(COACH_SKILL_LABELS); export default function BecomeCoachPage() { useSeoMeta({ title: 'Become a Coach - SkateSpots', description: 'Create your skateboarding coach profile.', }); 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(); // Check if user has any wallet connected const hasWallet = !!webln || !!activeNWC; // Optional "Pro" upgrade — free to list; Pro adds a badge + featured placement 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 [selectedSkills, setSelectedSkills] = 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 [instagram, setInstagram] = useState(''); const [noffer, setNoffer] = useState(''); const [entityType, setEntityType] = useState('individual'); 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 [existingGeohash, setExistingGeohash] = useState(undefined); // Prefill from an existing profile so this is an edit, not a blind overwrite. const { data: existingProfile } = useMyLabProfileEvent(SKATE_COACH_KIND, 'coach-profile'); 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); 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') ?? ''); setPhone(tag('phone') ?? ''); setEmail(tag('email') ?? ''); setWebsite(tag('website') ?? ''); setInstagram(tag('instagram') ?? ''); setNoffer(tag('noffer') ?? ''); setEntityType((tag('entity') as EntityType) ?? 'individual'); setMembers(tags('p')); setCrewRef(existingProfile.tags.find(([t, v]) => t === 'a' && v.startsWith('35006:'))?.[1]); setSelectedSkills(tags('t').filter((v) => v !== 'skatecoach')); 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 'Anonymous Coach'; }; const toggleSkill = (skill: string) => { setSelectedSkills((prev) => prev.includes(skill) ? prev.filter((s) => s !== skill) : [...prev, skill]); }; 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 file = files[0]; const tags = await uploadFile(file); 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 = ''; }; // 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) ? 'Enter a valid email address.' : website.trim() && !sanitizeUrl(normalizeUrlInput(website)) ? 'Enter a valid website URL.' : ''; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (contactError) { toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' }); return; } if (selectedSkills.length === 0) { toast({ title: 'Skills required', description: 'Select at least one skill.', variant: 'destructive' }); return; } try { const identifier = 'coach-profile'; const displayName = getDisplayName(); const tags: string[][] = [ ['d', identifier], ['title', titleInput.trim() || displayName], ['t', 'skatecoach'], ['alt', `Skate coach: ${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 skill of selectedSkills) tags.push(['t', skill]); 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 (instagram.trim()) tags.push(['instagram', instagram.trim()]); if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]); tags.push(['entity', entityType]); if (entityType === 'collective') { for (const pk of members) tags.push(['p', pk]); if (crewRef) tags.push(['a', crewRef]); } // Add spot references 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_COACH_KIND, content: bio.trim(), tags, }); toast({ title: `Coach profile ${existingProfile ? 'updated' : '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 coach profile.

); } const hasNip05 = metadata?.nip05; return ( <>
Back to The Lab

Become a Coach

Share your skateboarding knowledge

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

⚡ Payment Setup Required

To receive lesson payments from students, you need to connect a Lightning wallet. Students will zap you directly for coaching sessions.

{hasWallet ? (

Wallet Connected - Ready to Receive Payments!

{webln ? 'Using WebLN browser extension' : 'Using Nostr Wallet Connect'}

) : (

No Wallet Connected

💡 Tip: Most Lightning wallets support NWC (Alby, Minibits, Zeus, Coinos). Takes 30 seconds to set up.

)}
Coach Profile Tell students about your experience

{getDisplayName()}

From your Nostr profile

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

Shown as the title on your Lab card — e.g. “Skate Coach Berlin”. Defaults to your name.