import { useState, useEffect, type ReactNode } from 'react'; import { nip19 } from 'nostr-tools'; import ngeohash from 'ngeohash'; import { useNavigate } from 'react-router-dom'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible'; 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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useTranslation } from '@/contexts/language'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useUploadFile } from '@/hooks/useUploadFile'; import { useToast } from '@/hooks/useToast'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; import { CREW_AGENT_AVATAR } from '@/lib/crewAgent'; import { SpotPicker } from '@/components/SpotPicker'; import { LabLinkField } from '@/components/LabLinkField'; import { isCrewVideoUrl, parseCrewVideo } from '@/lib/videoEmbed'; import { CrewSponsorPicker } from '@/components/crew/CrewSponsorPicker'; import { NofferField } from '@/components/NofferField'; import { SKATE_CREW_KIND, SKATE_SPOT_KIND, SKATE_SHOP_KIND, parseAddrCoord, ZAP_GOAL_KIND, CREW_VIBE_LABELS, CREW_FOCUS_LABELS, CREW_FOCUS_DESCRIPTIONS, CREW_FOCUS_TAG, CREW_LOOKING_FOR_OPTIONS, CREW_ROLE_META, CREW_ROLE_ORDER, SHOW_CREW_TREASURY, isValidNoffer, type SkateCrew, type CrewFocus, type CrewRole } from '@/lib/types'; import { CREW_VIBE_KEYS, CREW_FOCUS_KEYS, CREW_FOCUS_DESC_KEYS, CREW_LOOKING_FOR_KEYS, CREW_ROLE_KEYS } from '@/lib/labelKeys'; import { useCrewGoal } from '@/hooks/useCrewGoal'; import { useCoordinator, useCoordinatorHealth } from '@/hooks/useCoordinator'; import { normalizeUrlInput, sanitizeUrl } from '@/lib/sanitizeUrl'; import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor'; import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, shortNpub } from '@/lib/coordinator'; import { useRelays } from '@/lib/relays'; import { cn } from '@/lib/utils'; import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon, Server, ChevronDown, Zap } from 'lucide-react'; const FOCUS_ORDER: CrewFocus[] = ['mixed', 'builder', 'photo', 'coaching', 'art', 'music', 'wellness']; const FOCUS_ICONS: Record = { mixed: Sparkles, builder: HardHat, photo: Camera, coaching: GraduationCap, art: Palette, music: Music, wellness: Dumbbell, }; const VIBE_ENTRIES = Object.keys(CREW_VIBE_LABELS); const TEAL = 'bg-teal-500 hover:bg-teal-600 text-black'; /** Number of steps in the create wizard (0-indexed last step). Keep in sync with `steps`. */ const WIZARD_LAST_STEP = 3; function MemberRow({ pubkey, isAdmin, roles, onToggleAdmin, onToggleRole, onRemove, }: { pubkey: string; isAdmin: boolean; roles: CrewRole[]; onToggleAdmin: () => void; onToggleRole: (role: CrewRole) => void; onRemove: () => void; }) { const { t } = useTranslation(); const author = useAuthor(pubkey); const metadata = author.data?.metadata; const name = metadata?.name ?? genUserName(pubkey); const npub = nip19.npubEncode(pubkey); return (
{name.slice(0, 2).toUpperCase()}

{name} {isAdmin && ( {t('socialUi.inviteCard.admin')} )}

{npub.slice(0, 16)}…

{/* Skate roles — the *functions* on the team. Additive, no powers. */}
{t('socialUi.form.roles')} {CREW_ROLE_ORDER.map((role) => { const active = roles.includes(role); const meta = CREW_ROLE_META[role]; return ( ); })}
); } /** Decode an npub / nprofile / hex string into a hex pubkey, or null. */ function decodePubkeyInput(raw: string): string | null { const val = raw.trim(); if (!val) return null; try { if (val.startsWith('npub1') || val.startsWith('nostr:npub1')) { const decoded = nip19.decode(val.replace(/^nostr:/, '')); if (decoded.type === 'npub') return decoded.data; } else if (val.startsWith('nprofile1') || val.startsWith('nostr:nprofile1')) { const decoded = nip19.decode(val.replace(/^nostr:/, '')); if (decoded.type === 'nprofile') return decoded.data.pubkey; } else if (/^[0-9a-f]{64}$/i.test(val)) { return val.toLowerCase(); } } catch { return null; } return null; } /** * Live preview of the skater being typed into the member field — resolves the * pubkey to an avatar + name so the founder can confirm it's the right person * *before* adding. Shows a neutral "resolving…" state while metadata loads. */ function MemberPreview({ pubkey, alreadyAdded }: { pubkey: string; alreadyAdded: boolean }) { const { t } = useTranslation(); const author = useAuthor(pubkey); const metadata = author.data?.metadata; const name = metadata?.name ?? genUserName(pubkey); const npub = nip19.npubEncode(pubkey); return (
{name.slice(0, 2).toUpperCase()}

{name}

{npub.slice(0, 20)}…

{alreadyAdded ? ( {t('socialUi.form.added')} ) : ( {t('socialUi.form.ready')} )}
); } interface CrewFormProps { /** Existing crew when editing. */ existing?: SkateCrew | null; } /** Static map for coordinator health status → i18n key (no dynamic keys). */ const HEALTH_LABEL: Record = { idle: 'socialUi.coordinator.statusIdle', checking: 'socialUi.coordinator.statusChecking', online: 'socialUi.coordinator.statusOnline', stale: 'socialUi.coordinator.statusStale', offline: 'socialUi.coordinator.statusOffline', error: 'socialUi.coordinator.statusError', }; /** * A collapsible group for the "Extras" step, so optional power-ups don't wall off * a new founder. Header is the group label; the blocks inside keep their own. */ function ExtrasGroup({ icon: Icon, title, hint, defaultOpen = false, children, }: { icon: typeof Users; title: string; hint?: string; defaultOpen?: boolean; children: ReactNode; }) { const [open, setOpen] = useState(defaultOpen); return ( {title} {hint && {hint}} {children} ); } export function CrewForm({ existing }: CrewFormProps) { const { t } = useTranslation(); const navigate = useNavigate(); const { user } = useCurrentUser(); const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { toast } = useToast(); // Create flow = guided wizard; edit flow = flat single page (power users // want everything at once). The wizard staggers the ~15 sections into 4 // digestible steps so a first-timer can spin up a crew in seconds. const isEditing = !!existing; const [step, setStep] = useState(0); // Track nav direction so step transitions slide in from the correct side. const [navDir, setNavDir] = useState<'forward' | 'back'>('forward'); const goToStep = (target: number) => { setNavDir(target >= step ? 'forward' : 'back'); setStep(target); window.scrollTo({ top: 0, behavior: 'smooth' }); }; const [name, setName] = useState(existing?.name ?? ''); const [bio, setBio] = useState(existing?.bio ?? ''); const [location, setLocation] = useState(existing?.location ?? ''); const [latitude, setLatitude] = useState(''); const [longitude, setLongitude] = useState(''); // Preset vibes (known keys) vs custom vibes (user-defined hashtags). // Split the existing crew's vibes into the two buckets on load. const presetKeys = new Set(Object.keys(CREW_VIBE_LABELS)); const [vibes, setVibes] = useState( (existing?.vibes ?? []).filter((v) => presetKeys.has(v)) ); const [customVibes, setCustomVibes] = useState( (existing?.vibes ?? []).filter((v) => !presetKeys.has(v)) ); const [customVibeInput, setCustomVibeInput] = useState(''); const [focus, setFocus] = useState(existing?.focus ?? 'mixed'); const [agentEnabled, setAgentEnabled] = useState(existing?.agentEnabled ?? false); const [founded, setFounded] = useState(existing?.founded ?? ''); const [image, setImage] = useState(existing?.image ?? null); const [website, setWebsite] = useState(existing?.website ?? ''); const [instagram, setInstagram] = useState(existing?.instagram ?? ''); const [bandName, setBandName] = useState(existing?.bandName ?? ''); const [bandUrl, setBandUrl] = useState(existing?.bandUrl ?? ''); const [socials, setSocials] = useState(existing?.socials ?? []); const [socialInput, setSocialInput] = useState(''); const [nip05s, setNip05s] = useState(existing?.nip05s ?? (existing?.nip05 ? [existing.nip05] : [])); const [noffer, setNoffer] = useState(existing?.noffer ?? ''); const [members, setMembers] = useState(existing?.members ?? []); const [admins, setAdmins] = useState(existing?.admins ?? []); const [memberInput, setMemberInput] = useState(''); const [memberError, setMemberError] = useState(null); // Skate roles per member (pubkey → set of CrewRole). Seed from the existing crew. const [memberRoles, setMemberRoles] = useState>(() => { const seed: Record = {}; for (const { pubkey, role } of existing?.crewRoles ?? []) { (seed[pubkey] ??= []).push(role); } return seed; }); const toggleAdmin = (pk: string) => setAdmins((prev) => (prev.includes(pk) ? prev.filter((x) => x !== pk) : [...prev, pk])); const toggleMemberRole = (pk: string, role: CrewRole) => setMemberRoles((prev) => { const cur = prev[pk] ?? []; const next = cur.includes(role) ? cur.filter((r) => r !== role) : [...cur, role]; return { ...prev, [pk]: next }; }); const removeMember = (pk: string) => { setMembers((prev) => prev.filter((m) => m !== pk)); setAdmins((prev) => prev.filter((a) => a !== pk)); setMemberRoles((prev) => { const next = { ...prev }; delete next[pk]; return next; }); }; const [selectedSpots, setSelectedSpots] = useState( (existing?.spotRefs ?? []).map((ref) => { const parts = ref.split(':'); return `${parts[1]}:${parts[2]}`; }) ); const [labRefs, setLabRefs] = useState(existing?.labRefs ?? []); const [isGettingLocation, setIsGettingLocation] = useState(false); // Open call ("looking for") — Lab craft slugs the crew is hiring for. const [lookingFor, setLookingFor] = useState(existing?.lookingFor ?? []); const toggleLookingFor = (slug: string) => setLookingFor((prev) => (prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug])); // Sponsoring shop coordinate (30017:pubkey:d) and Cordn/MLS coordinator pointer. const [sponsorRef, setSponsorRef] = useState(existing?.sponsorRef ?? ''); const [coordinatorInput, setCoordinatorInput] = useState( crewCoordinatorInputValue(existing?.coordinator), ); // The coordinator the user set in Settings → Coordinator, for a one-tap prefill. const { coordinator: myCoordinator } = useCoordinator(); // Live parse so the founder gets feedback (and the publish path reuses it). const parsedCoordinator = parseCrewCoordinatorInput(coordinatorInput); // On-demand health probe (the "Check" button) for a coordinator addressed by // pubkey — mirrors the Settings → Coordinator card. const coordinatorHealth = useCoordinatorHealth( parsedCoordinator?.pubkey ? { pubkey: parsedCoordinator.pubkey, relays: /^wss?:\/\//i.test(parsedCoordinator.url) ? [parsedCoordinator.url] : [], addedAt: 0, } : null, ); // Crew video / montage — YouTube or Instagram URL ("The Part"). const [videoUrl, setVideoUrl] = useState(existing?.videoUrl ?? ''); // Crew treasury (NIP-75 zap goal). Load existing goal to prefill when editing. const { data: existingGoal } = useCrewGoal(existing?.goalRef); const { write } = useRelays(); const [goalEnabled, setGoalEnabled] = useState(!!existing?.goalRef); const [goalTitle, setGoalTitle] = useState(''); const [goalTarget, setGoalTarget] = useState(''); const [goalPrefilled, setGoalPrefilled] = useState(false); // When an existing goal loads, prefill the fields once. useEffect(() => { if (existingGoal && !goalPrefilled) { setGoalPrefilled(true); setGoalEnabled(true); setGoalTitle(existingGoal.description || existingGoal.summary || ''); setGoalTarget(existingGoal.targetSats ? String(existingGoal.targetSats) : ''); } }, [existingGoal, goalPrefilled]); const toggleVibe = (v: string) => setVibes((prev) => (prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v])); // Normalize a free-text vibe into a hashtag-friendly slug: // strip leading #, lowercase, collapse whitespace to hyphens, drop invalid chars. const normalizeVibe = (raw: string) => raw .trim() .replace(/^#+/, '') .toLowerCase() .replace(/\s+/g, '-') .replace(/[^a-z0-9-]/g, '') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); const addCustomVibe = () => { const slug = normalizeVibe(customVibeInput); setCustomVibeInput(''); if (!slug) return; // Don't duplicate a preset or an existing custom vibe if (presetKeys.has(slug) || customVibes.includes(slug)) return; setCustomVibes((prev) => [...prev, slug]); }; const addSocial = () => { const raw = socialInput.trim(); setSocialInput(''); if (!raw) return; // Coerce a bare domain to https:// so it renders as a link; keep a non-URL // (e.g. a @handle) as typed so nothing is silently dropped. const safe = sanitizeUrl(normalizeUrlInput(raw)) ?? raw; if (socials.includes(safe)) return; setSocials((prev) => [...prev, safe]); }; // Crew NIP-05 handles — up to 5, each verified against the founder's key. const founderPubkey = existing?.pubkey ?? user?.pubkey; // Live-decode whatever's typed in the member field for the preview chip. const memberPreviewPk = decodePubkeyInput(memberInput); const memberInputDirty = memberInput.trim().length > 0; const addMember = () => { const raw = memberInput.trim(); if (!raw) return; const hex = decodePubkeyInput(raw); if (!hex) { setMemberError(t('socialUi.form.invalidMember')); return; } if (members.includes(hex)) { setMemberError(t('socialUi.form.memberAlreadyAdded')); return; } setMembers([...members, hex]); setMemberInput(''); setMemberError(null); }; const getCurrentLocation = () => { if (!navigator.geolocation) { toast({ title: t('socialUi.form.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('socialUi.form.locationFound') }); }, (error) => { setIsGettingLocation(false); toast({ title: t('socialUi.form.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('socialUi.form.logoUploaded') }); } } catch (error) { toast({ title: t('socialUi.form.uploadFailed'), description: error instanceof Error ? error.message : t('socialUi.form.failed'), variant: 'destructive' }); } e.target.value = ''; }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); // In the create wizard, Enter/submit before the final step should just // advance rather than publish a half-filled crew. (Edit mode has no steps.) if (!existing && step < WIZARD_LAST_STEP) { if (step === 0 && !name.trim()) { toast({ title: t('socialUi.form.nameCrew'), description: t('socialUi.form.nameCrewDesc'), variant: 'destructive' }); return; } setNavDir('forward'); setStep((s) => Math.min(s + 1, WIZARD_LAST_STEP)); window.scrollTo({ top: 0, behavior: 'smooth' }); return; } if (!name.trim()) { toast({ title: t('socialUi.form.nameRequired'), description: t('socialUi.form.nameRequiredDesc'), variant: 'destructive' }); return; } // An invalid coordinator would be silently dropped on save — tell the user. if (coordinatorInput.trim() && !parsedCoordinator) { toast({ title: t('socialUi.coordinator.invalid'), variant: 'destructive' }); return; } try { const identifier = existing?.identifier ?? `crew-${Date.now().toString(36)}`; // ── Crew treasury: publish/refresh a NIP-75 zap goal, then link it. ── // The goal is non-custodial — zaps settle to the founder's own Lightning // wallet. We only publish the public goal + tally later. let goalRef: string | undefined = existing?.goalRef; const targetSats = parseInt(goalTarget, 10); if (goalEnabled && goalTitle.trim() && targetSats > 0) { const relayUrls = write; const needsNewGoal = !existingGoal || existingGoal.description !== goalTitle.trim() || existingGoal.targetSats !== targetSats; if (needsNewGoal) { const goalEvent = await createEvent({ kind: ZAP_GOAL_KIND, content: goalTitle.trim(), tags: [ ['amount', String(targetSats * 1000)], // millisats ['relays', ...(relayUrls.length ? relayUrls : ['wss://relay.nostr.band'])], ['summary', `${name.trim()} crew treasury`], ['alt', `Crew funding goal: ${goalTitle.trim()}`], ], }); goalRef = goalEvent.id; } } else if (!goalEnabled) { goalRef = undefined; // turning the treasury off unlinks the goal } const tags: string[][] = [ ['d', identifier], ['title', name.trim()], ['t', 'skatecrew'], ['alt', `Skate crew: ${name.trim()}`], ]; if (location.trim()) tags.push(['location', location.trim()]); // Home turf geohash — prefer freshly captured coords, else keep existing 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 (existing?.geohash) { tags.push(['g', existing.geohash]); } for (const v of vibes) tags.push(['t', v]); for (const v of customVibes) tags.push(['t', v]); // Lab scene focus — only tagged when not the default "mixed" crew. if (focus !== 'mixed') tags.push(['t', CREW_FOCUS_TAG[focus]]); // Crew AI agent — queryable marker tag when enabled. if (agentEnabled) tags.push(['t', 'crew-agent']); if (founded.trim()) { const year = parseInt(founded, 10); const maxYear = new Date().getFullYear(); if (Number.isFinite(year)) { tags.push(['founded', String(Math.min(maxYear, Math.max(1954, year)))]); } } if (image) tags.push(['image', image]); { const safe = website.trim() ? sanitizeUrl(normalizeUrlInput(website)) : null; if (website.trim()) tags.push(['website', safe ?? website.trim()]); } if (instagram.trim()) tags.push(['instagram', instagram.trim()]); if (bandName.trim()) tags.push(['band', bandName.trim()]); { const safe = bandUrl.trim() ? sanitizeUrl(normalizeUrlInput(bandUrl)) : null; if (bandUrl.trim()) tags.push(['band-url', safe ?? bandUrl.trim()]); } for (const s of socials) { if (s.trim()) tags.push(['social', s.trim()]); } for (const handle of nip05s) tags.push(['nip05', handle]); if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]); // Members: admins get an `admin` role marker on their `p` tag. for (const pk of members) { if (admins.includes(pk)) { tags.push(['p', pk, '', 'admin']); } else { tags.push(['p', pk]); } } for (const spotId of selectedSpots) { const [spotPubkey, spotIdentifier] = spotId.split(':'); tags.push(['a', `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`]); } // Linked Lab profiles (cross-link) — a-tags pointing at Lab kinds for (const coord of labRefs) { tags.push(['a', coord]); } // Crew treasury goal (NIP-75) linkage. if (goalRef) tags.push(['goal', goalRef]); // Open call — Lab crafts the crew is looking for. for (const slug of lookingFor) { tags.push(['looking-for', slug]); } // Sponsoring shop — must be a valid NIP-15 stall coordinate (30017:pubkey:d). const sponsorCoord = parseAddrCoord(sponsorRef.trim()); if (sponsorCoord && sponsorCoord.kind === SKATE_SHOP_KIND) { tags.push(['sponsor', sponsorCoord.coord]); } // Cordn / MLS coordinator for the crew's encrypted chat. Accepts the same // pointer formats as Settings → Coordinator (nprofile / cordn.net link / // npub / hex) as well as a plain endpoint URL, so a coordinator run from // Cordn's phone app works too. Tag: ['coordinator', url, pubkey?]. if (parsedCoordinator) { const coordinatorTag = ['coordinator', parsedCoordinator.url]; if (parsedCoordinator.pubkey) coordinatorTag.push(parsedCoordinator.pubkey); tags.push(coordinatorTag); } // Crew video / montage — only stored if it's a valid YouTube/Instagram URL. const vid = videoUrl.trim(); if (vid && isCrewVideoUrl(vid)) { tags.push(['video', vid]); } // Skate roles (filmer / photographer / …) — only for the founder or a // listed member, so roles can't dangle on non-roster pubkeys. const roleablePubkeys = new Set([user!.pubkey.toLowerCase(), ...members.map((m) => m.toLowerCase())]); for (const [pk, roles] of Object.entries(memberRoles)) { if (!roleablePubkeys.has(pk.toLowerCase())) continue; for (const role of roles) { tags.push(['crew-role', pk.toLowerCase(), role]); } } await createEvent({ kind: SKATE_CREW_KIND, content: bio.trim(), tags }); toast({ title: existing ? t('socialUi.form.crewUpdated') : t('socialUi.form.crewCreated'), description: t('socialUi.form.publishedToScene') }); navigate(`/crew/${user?.pubkey}/${identifier}`); } catch (error) { toast({ title: t('socialUi.form.publishFailed'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' }); } }; // ─── Section blocks ───────────────────────────────────────────────────── // Each visible field group is a self-contained element that closes over the // component's state. They're composed into wizard steps (create) or one flat // scroll (edit) below — no field or publish logic changes between modes. const logoBlock = (
{image ? (
{t('socialUi.form.logoAlt')}
) : ( )}
); const nameValid = name.trim().length >= 2; const nameBlock = (
{nameValid && ( {t('socialUi.form.looksGood')} )}
setName(e.target.value)} maxLength={100} className={cn( 'bg-gray-800 border-gray-700 text-base pr-10 transition-colors', nameValid && 'border-teal-500/50 focus-visible:ring-teal-500/40', )} autoFocus={!isEditing} /> {nameValid && ( )}

{t('socialUi.form.nameHint')}

); const bioBlock = (
{bio.length > 0 && ( 2800 ? 'text-amber-400' : 'text-gray-500')}> {bio.length}/3000 )}