SkateSpots/src/components/CrewForm.tsx
SkateSpots 21a0390ed5 refactor(relays): route every relay consumer through useRelays()
So Local relays are used and Blocked relays are excluded EVERYWHERE, not just in
the NPool routers:
- useListingReports, useSellerInfo, useSkateRelays (read+write), useZaps
  (NIP-57 relays tag), usePurchasePro, CollectionDetailPage, CrewForm,
  useDirectMessages write fallback
- AppProvider's empty-relays defaults check is the only intentional direct reader
- AGENTS.md updated

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 22:36:14 +07:00

1521 lines
65 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { 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 { 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 { useCrewGoal } from '@/hooks/useCrewGoal';
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 } from 'lucide-react';
const FOCUS_ORDER: CrewFocus[] = ['mixed', 'builder', 'photo', 'coaching', 'art', 'music', 'wellness'];
const FOCUS_ICONS: Record<CrewFocus, typeof Users> = {
mixed: Sparkles,
builder: HardHat,
photo: Camera,
coaching: GraduationCap,
art: Palette,
music: Music,
wellness: Dumbbell,
};
const VIBE_ENTRIES = Object.entries(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 author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const name = metadata?.name ?? genUserName(pubkey);
const npub = nip19.npubEncode(pubkey);
return (
<div className="space-y-2 rounded-lg border border-gray-700 bg-gray-800/60 p-2">
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={metadata?.picture} alt={name} />
<AvatarFallback className="text-xs">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-1.5 truncate text-sm font-medium">
{name}
{isAdmin && (
<span className="inline-flex items-center gap-0.5 rounded-full bg-teal-500/20 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">
<ShieldCheck className="h-2.5 w-2.5" /> Admin
</span>
)}
</p>
<p className="truncate text-xs text-gray-500">{npub.slice(0, 16)}…</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
'h-7 shrink-0 gap-1 px-2 text-xs',
isAdmin ? 'text-teal-300 hover:text-teal-200' : 'text-gray-400 hover:text-teal-300',
)}
onClick={onToggleAdmin}
aria-label={isAdmin ? 'Remove admin role' : 'Make admin'}
>
<ShieldCheck className="h-3.5 w-3.5" />
{isAdmin ? 'Admin' : 'Make admin'}
</Button>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-gray-400 hover:text-red-400" onClick={onRemove} aria-label="Remove member">
<X className="h-4 w-4" />
</Button>
</div>
{/* Skate roles — the *functions* on the team. Additive, no powers. */}
<div className="flex flex-wrap items-center gap-1 pl-11">
<span className="text-[10px] font-bold uppercase tracking-wider text-gray-600">Roles:</span>
{CREW_ROLE_ORDER.map((role) => {
const active = roles.includes(role);
const meta = CREW_ROLE_META[role];
return (
<button
key={role}
type="button"
onClick={() => onToggleRole(role)}
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-bold transition-all active:scale-95',
active
? 'border-teal-500 bg-teal-500/20 text-teal-200'
: 'border-gray-700 bg-gray-900/60 text-gray-500 hover:border-teal-500/40 hover:text-teal-300',
)}
aria-pressed={active}
>
<span className="leading-none">{meta.emoji}</span> {meta.label}
</button>
);
})}
</div>
</div>
);
}
/** 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 author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const name = metadata?.name ?? genUserName(pubkey);
const npub = nip19.npubEncode(pubkey);
return (
<div className="flex items-center gap-2.5 rounded-lg border border-teal-500/30 bg-teal-500/[0.06] p-2 animate-in fade-in slide-in-from-top-1 duration-200">
<Avatar className="h-8 w-8">
<AvatarImage src={metadata?.picture} alt={name} />
<AvatarFallback className="text-xs">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-white">{name}</p>
<p className="truncate font-mono text-[11px] text-gray-500">{npub.slice(0, 20)}…</p>
</div>
{alreadyAdded ? (
<span className="shrink-0 rounded-full bg-gray-700 px-2 py-0.5 text-[10px] font-bold uppercase text-gray-300">
Added
</span>
) : (
<span className="inline-flex shrink-0 items-center gap-1 text-[11px] font-bold text-teal-400">
<Check className="h-3 w-3" /> Ready
</span>
)}
</div>
);
}
interface CrewFormProps {
/** Existing crew when editing. */
existing?: SkateCrew | null;
}
export function CrewForm({ existing }: CrewFormProps) {
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<string[]>(
(existing?.vibes ?? []).filter((v) => presetKeys.has(v))
);
const [customVibes, setCustomVibes] = useState<string[]>(
(existing?.vibes ?? []).filter((v) => !presetKeys.has(v))
);
const [customVibeInput, setCustomVibeInput] = useState('');
const [focus, setFocus] = useState<CrewFocus>(existing?.focus ?? 'mixed');
const [agentEnabled, setAgentEnabled] = useState(existing?.agentEnabled ?? false);
const [founded, setFounded] = useState(existing?.founded ?? '');
const [image, setImage] = useState<string | null>(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<string[]>(existing?.socials ?? []);
const [socialInput, setSocialInput] = useState('');
const [nip05, setNip05] = useState(existing?.nip05 ?? '');
const [noffer, setNoffer] = useState(existing?.noffer ?? '');
const [members, setMembers] = useState<string[]>(existing?.members ?? []);
const [admins, setAdmins] = useState<string[]>(existing?.admins ?? []);
const [memberInput, setMemberInput] = useState('');
const [memberError, setMemberError] = useState<string | null>(null);
// Skate roles per member (pubkey → set of CrewRole). Seed from the existing crew.
const [memberRoles, setMemberRoles] = useState<Record<string, CrewRole[]>>(() => {
const seed: Record<string, CrewRole[]> = {};
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<string[]>(
(existing?.spotRefs ?? []).map((ref) => {
const parts = ref.split(':');
return `${parts[1]}:${parts[2]}`;
})
);
const [labRefs, setLabRefs] = useState<string[]>(existing?.labRefs ?? []);
const [isGettingLocation, setIsGettingLocation] = useState(false);
// Open call ("looking for") — Lab craft slugs the crew is hiring for.
const [lookingFor, setLookingFor] = useState<string[]>(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 URL.
const [sponsorRef, setSponsorRef] = useState(existing?.sponsorRef ?? '');
const [coordinatorUrl, setCoordinatorUrl] = useState(existing?.coordinator?.url ?? '');
// 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 val = socialInput.trim();
setSocialInput('');
if (!val || socials.includes(val)) return;
setSocials((prev) => [...prev, val]);
};
// 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('Enter a valid npub, nprofile, or hex pubkey.');
return;
}
if (members.includes(hex)) {
setMemberError('That member is already added.');
return;
}
setMembers([...members, hex]);
setMemberInput('');
setMemberError(null);
};
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<HTMLInputElement>) => {
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: 'Logo uploaded' });
}
} catch (error) {
toast({ title: 'Upload failed', description: error instanceof Error ? error.message : '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: 'Name your crew', description: 'A crew needs a name before you continue.', 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: 'Name required', description: 'Give your crew a name.', 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()) tags.push(['founded', founded.trim()]);
if (image) tags.push(['image', image]);
if (website.trim()) tags.push(['website', website.trim()]);
if (instagram.trim()) tags.push(['instagram', instagram.trim()]);
if (bandName.trim()) tags.push(['band', bandName.trim()]);
if (bandUrl.trim() && /^https?:\/\//i.test(bandUrl.trim())) {
tags.push(['band-url', bandUrl.trim()]);
}
for (const s of socials) {
if (s.trim()) tags.push(['social', s.trim()]);
}
if (nip05.trim()) tags.push(['nip05', nip05.trim().toLowerCase()]);
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 (https/wss URL only).
const coordUrl = coordinatorUrl.trim();
if (coordUrl && /^(https?|wss?):\/\//i.test(coordUrl)) {
tags.push(['coordinator', coordUrl]);
}
// 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 ? 'Crew updated!' : 'Crew created!', description: 'Published to the scene.' });
navigate(`/crew/${user?.pubkey}/${identifier}`);
} catch (error) {
toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : '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 = (
<div className="space-y-2">
<Label>Crew Logo / Photo</Label>
{image ? (
<div className="relative w-32 h-32 rounded-2xl overflow-hidden bg-gray-800">
<img src={image} alt="Crew" className="w-full h-full object-cover" />
<button type="button" onClick={() => setImage(null)} className="absolute top-2 right-2 p-1 rounded-full bg-black/80 hover:bg-red-500">
<X className="h-4 w-4" />
</button>
</div>
) : (
<label className="w-32 h-32 rounded-2xl border-2 border-dashed border-gray-700 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-800/50 hover:border-teal-500/50 transition-colors">
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} disabled={isUploading} />
{isUploading ? <Loader2 className="h-6 w-6 animate-spin" /> : <><Upload className="h-6 w-6 mb-1 text-gray-500" /><span className="text-xs text-gray-500">Upload</span></>}
</label>
)}
</div>
);
const nameValid = name.trim().length >= 2;
const nameBlock = (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="crew-name">Crew Name <span className="text-teal-400">*</span></Label>
{nameValid && (
<span className="inline-flex items-center gap-1 text-[11px] font-bold text-teal-400">
<Check className="h-3 w-3" /> Looks good
</span>
)}
</div>
<div className="relative">
<Input
id="crew-name"
placeholder="e.g. Backyard Bandits"
value={name}
onChange={(e) => 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 && (
<Check className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-teal-400" />
)}
</div>
<p className="text-[11px] text-gray-500">The only thing you truly need. You can change it later.</p>
</div>
);
const bioBlock = (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="crew-bio">About / Manifesto <span className="font-normal text-gray-500">· optional</span></Label>
{bio.length > 0 && (
<span className={cn('text-[11px] tabular-nums', bio.length > 2800 ? 'text-amber-400' : 'text-gray-500')}>
{bio.length}/3000
</span>
)}
</div>
<Textarea id="crew-bio" placeholder="Who you are, what you skate, your story..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} maxLength={3000} className="bg-gray-800 border-gray-700" />
</div>
);
const locationBlock = (
<>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="crew-location">Home Turf (City / Area)</Label>
<Input id="crew-location" placeholder="Bristol, UK" value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="crew-founded">Founded (Year)</Label>
<Input id="crew-founded" type="number" placeholder="2015" value={founded} onChange={(e) => setFounded(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
<div className="space-y-2">
<Label>Map Pin <span className="font-normal text-gray-500">· optional</span></Label>
<p className="text-xs text-gray-500">Drop your crew on the map so locals can find you. Tap the button — no typing needed.</p>
<div className="grid grid-cols-2 gap-4">
<Input placeholder="Latitude" value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input placeholder="Longitude" value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="mt-2 border-gray-700">
{isGettingLocation ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Getting...</> : <><MapPin className="h-4 w-4 mr-2" />Use current location</>}
</Button>
{existing?.geohash && !latitude && (
<p className="text-xs text-gray-500">Leave blank to keep the current home turf location.</p>
)}
</div>
</>
);
const vibeBlock = (
<>
{/* Vibe tags */}
<div className="space-y-3">
<Label>Vibe / Focus</Label>
<div className="flex flex-wrap gap-2">
{VIBE_ENTRIES.map(([key, label]) => (
<button
key={key}
type="button"
onClick={() => toggleVibe(key)}
className={cn(
'px-3 py-2 rounded-xl text-xs font-bold border transition-all active:scale-95',
vibes.includes(key)
? 'bg-teal-500 text-black border-teal-500'
: 'bg-gray-800 hover:bg-gray-700 border-gray-700 text-gray-300'
)}
>
{label}
</button>
))}
</div>
{/* Custom vibe / focus (user-defined hashtags) */}
<div className="space-y-2">
<p className="text-xs text-gray-500">Add your own vibe or focus with a #</p>
<div className="flex gap-2">
<div className="relative flex-1">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">#</span>
<Input
placeholder="e.g. downhill, night-sessions, no-comply"
value={customVibeInput}
onChange={(e) => setCustomVibeInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCustomVibe(); } }}
spellCheck={false}
autoCapitalize="none"
className="bg-gray-800 border-gray-700 pl-7"
/>
</div>
<Button type="button" onClick={addCustomVibe} className={cn('shrink-0 gap-1', TEAL)}>
<Plus className="h-4 w-4" /> Add
</Button>
</div>
{customVibes.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{customVibes.map((v) => (
<span
key={v}
className="inline-flex items-center gap-1 rounded-xl border border-teal-500/60 bg-teal-500/15 px-3 py-1.5 text-xs font-bold text-teal-300"
>
#{v}
<button
type="button"
onClick={() => setCustomVibes(customVibes.filter((x) => x !== v))}
className="text-teal-400/70 hover:text-red-400"
aria-label={`Remove ${v}`}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
</div>
</div>
</>
);
const crewTypeBlock = (
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
<div>
<Label className="flex items-center gap-2 text-teal-400">
<Sparkles className="h-4 w-4" /> Crew Type
</Label>
<p className="mt-1 text-xs text-gray-500">
Is your crew focused on a craft? Pick one to also show up in that scene in{' '}
<strong className="text-gray-300">The Lab</strong>. Regular skate crews stay{' '}
<strong className="text-gray-300">Mixed</strong>.
</p>
</div>
<div className="grid grid-cols-2 gap-2">
{FOCUS_ORDER.map((key) => {
const Icon = FOCUS_ICONS[key];
const selected = focus === key;
return (
<button
key={key}
type="button"
onClick={() => setFocus(key)}
aria-pressed={selected}
className={cn(
'flex items-start gap-2.5 rounded-xl border p-3 text-left transition-all active:scale-[0.98]',
selected
? 'border-teal-500 bg-teal-500/15 ring-1 ring-teal-500/40'
: 'border-gray-700 bg-gray-800 hover:bg-gray-700/70'
)}
>
<Icon className={cn('mt-0.5 h-4 w-4 flex-shrink-0', selected ? 'text-teal-300' : 'text-gray-400')} />
<span className="min-w-0">
<span className={cn('block text-sm font-bold leading-tight', selected ? 'text-teal-200' : 'text-gray-200')}>
{CREW_FOCUS_LABELS[key]}
</span>
<span className="mt-0.5 block text-[11px] leading-snug text-gray-500">
{CREW_FOCUS_DESCRIPTIONS[key]}
</span>
</span>
</button>
);
})}
</div>
</div>
);
const agentBlock = (
<div className="space-y-3 rounded-lg border border-teal-500/30 bg-teal-500/[0.04] p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Label className="flex items-center gap-2 text-teal-400">
<img
src={CREW_AGENT_AVATAR}
alt="Sasquatch"
className="h-5 w-5 rounded-md object-cover"
loading="lazy"
/>
Crew Agent
<span className="rounded-full bg-teal-500/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">Beta</span>
</Label>
<p className="mt-1 text-xs text-gray-500">
Add <strong className="text-gray-300">Sasquatch</strong>, an AI guide who actually knows your crew.
Members can ask about <strong className="text-gray-300">your next jam, who runs the crew, what you're
looking for and your vibe</strong> — plus nearby spots, events, coaches and gear from live SkateSpots
data. Runs right in the browser; no keys or setup. It's early (free-tier AI, so occasionally slow).
</p>
</div>
<button
type="button"
role="switch"
aria-checked={agentEnabled}
aria-label="Enable Crew Agent"
onClick={() => setAgentEnabled((v) => !v)}
className={cn(
'relative mt-1 h-6 w-11 flex-shrink-0 rounded-full transition-colors',
agentEnabled ? 'bg-teal-500' : 'bg-gray-700'
)}
>
<span
className={cn(
'absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform',
agentEnabled ? 'translate-x-[22px]' : 'translate-x-0.5'
)}
/>
</button>
</div>
{agentEnabled && (
<p className="flex items-center gap-1.5 text-[11px] font-bold text-teal-400 animate-in fade-in duration-200">
<Check className="h-3.5 w-3.5" /> Sasquatch will appear on your crew page once you save.
</p>
)}
</div>
);
const openCallBlock = (
<div className="space-y-3 rounded-lg border border-fuchsia-500/30 bg-fuchsia-500/[0.04] p-4">
<div>
<Label className="flex items-center gap-2 text-fuchsia-400">
<Megaphone className="h-4 w-4" /> Open Call <span className="font-normal text-gray-500">· optional</span>
</Label>
<p className="mt-1 text-xs text-gray-500">
Looking for a filmer, editor or new blood? Put out a call — it shows on your crew page so
matching folks from <strong className="text-gray-300">The Lab</strong> can reach out.
</p>
</div>
<div className="flex flex-wrap gap-2">
{CREW_LOOKING_FOR_OPTIONS.map((opt) => {
const on = lookingFor.includes(opt.slug);
return (
<button
key={opt.slug}
type="button"
onClick={() => toggleLookingFor(opt.slug)}
className={cn(
'inline-flex items-center gap-1.5 rounded-xl border px-3 py-2 text-xs font-bold transition-all active:scale-95',
on
? 'border-fuchsia-500 bg-fuchsia-500/20 text-fuchsia-200'
: 'border-gray-700 bg-gray-800 text-gray-300 hover:border-fuchsia-500/40',
)}
>
<span>{opt.emoji}</span>
{opt.label}
</button>
);
})}
</div>
</div>
);
const treasuryBlock = SHOW_CREW_TREASURY ? (
<div className="space-y-3 rounded-lg border border-orange-500/30 bg-orange-500/[0.04] p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Label className="flex items-center gap-2 text-orange-400">
<Target className="h-4 w-4" /> Crew Treasury <span className="font-normal text-gray-500">· optional</span>
</Label>
<p className="mt-1 text-xs text-gray-500">
Raise sats for a shared goal — fixing the local DIY spot, a crew trip, a comp prize pot.
It's a transparent Lightning goal; zaps go <strong className="text-gray-300">straight to your wallet</strong>{' '}
(via your profile's Lightning address). SkateSpots never holds the funds.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={goalEnabled}
aria-label="Enable Crew Treasury"
onClick={() => setGoalEnabled((v) => !v)}
className={cn(
'relative mt-1 h-6 w-11 flex-shrink-0 rounded-full transition-colors',
goalEnabled ? 'bg-orange-500' : 'bg-gray-700',
)}
>
<span
className={cn(
'absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform',
goalEnabled ? 'translate-x-[22px]' : 'translate-x-0.5',
)}
/>
</button>
</div>
{goalEnabled && (
<div className="space-y-3 pt-1">
<div className="space-y-2">
<Label htmlFor="goal-title" className="text-xs text-gray-400">What's the goal?</Label>
<Input
id="goal-title"
placeholder="e.g. Concrete the DIY spot"
value={goalTitle}
onChange={(e) => setGoalTitle(e.target.value)}
className="bg-gray-800 border-gray-700"
/>
</div>
<div className="space-y-2">
<Label htmlFor="goal-target" className="text-xs text-gray-400">Target (sats)</Label>
<Input
id="goal-target"
type="number"
min={1}
placeholder="500000"
value={goalTarget}
onChange={(e) => setGoalTarget(e.target.value)}
className="bg-gray-800 border-gray-700"
/>
</div>
<p className="flex items-start gap-1.5 text-[11px] text-gray-500">
<Target className="mt-0.5 h-3 w-3 shrink-0 text-orange-400" />
Make sure your Nostr profile has a Lightning address (lud16) so contributions can land.
</p>
</div>
)}
</div>
) : null;
const membersBlock = (
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
<div>
<Label className="flex items-center gap-2 text-teal-400"><Users className="h-4 w-4" /> Members</Label>
<p className="mt-1 text-xs text-gray-500">
Add your crew's Nostr accounts. Each member confirms from their own key — until then they show as “pending”.
</p>
<p className="mt-1 flex items-start gap-1 text-xs text-gray-500">
<ShieldCheck className="mt-0.5 h-3 w-3 shrink-0 text-teal-400" />
<span>
Make trusted members <strong className="text-teal-300">admins</strong> — they can invite members
and moderate the crew feed &amp; chat with their own keys. You (the founder) always stay the
owner of the crew's identity.
</span>
</p>
<p className="mt-1 flex items-start gap-1 text-xs text-gray-500">
<Eye className="mt-0.5 h-3 w-3 shrink-0 text-teal-400" />
<span>
Each member controls their <strong className="text-teal-300">own visibility</strong> — when they
confirm from their key they can choose <em>Public</em> or <em>Crew-only</em> (hidden from
outsiders). You can't set this for them; it's their call.
</span>
</p>
</div>
{/* Founder's own skate roles (you're the leader — are you also the filmer?) */}
{user && (
<div className="rounded-lg border border-teal-500/20 bg-teal-500/[0.04] p-2.5">
<p className="mb-1.5 flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-wider text-teal-400">
<ShieldCheck className="h-3 w-3" /> Your roles (Team Leader)
</p>
<div className="flex flex-wrap items-center gap-1">
{CREW_ROLE_ORDER.map((role) => {
const active = (memberRoles[user.pubkey.toLowerCase()] ?? []).includes(role);
const meta = CREW_ROLE_META[role];
return (
<button
key={role}
type="button"
onClick={() => toggleMemberRole(user.pubkey.toLowerCase(), role)}
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-bold transition-all active:scale-95',
active
? 'border-teal-500 bg-teal-500/20 text-teal-200'
: 'border-gray-700 bg-gray-900/60 text-gray-500 hover:border-teal-500/40 hover:text-teal-300',
)}
aria-pressed={active}
>
<span className="leading-none">{meta.emoji}</span> {meta.label}
</button>
);
})}
</div>
</div>
)}
<div className="flex gap-2">
<Input
placeholder="npub1… / nprofile1… / hex pubkey"
value={memberInput}
onChange={(e) => { setMemberInput(e.target.value); setMemberError(null); }}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addMember(); } }}
spellCheck={false}
autoCapitalize="none"
className={cn(
'bg-gray-800 border-gray-700 font-mono text-sm',
memberPreviewPk && 'border-teal-500/50',
memberInputDirty && !memberPreviewPk && 'border-amber-500/40',
)}
/>
<Button
type="button"
onClick={addMember}
disabled={!memberPreviewPk || members.includes(memberPreviewPk)}
className={cn('shrink-0 gap-1', TEAL)}
>
<Plus className="h-4 w-4" /> Add
</Button>
</div>
{/* Live preview of the resolved skater (or a gentle hint if it's not a key yet). */}
{memberPreviewPk ? (
<MemberPreview pubkey={memberPreviewPk} alreadyAdded={members.includes(memberPreviewPk)} />
) : memberInputDirty ? (
<p className="text-xs text-amber-400/90">
That doesn't look like a Nostr key yet — paste an <span className="font-mono">npub1…</span>, <span className="font-mono">nprofile1…</span>, or hex pubkey.
</p>
) : null}
{memberError && <p className="text-xs text-red-400">{memberError}</p>}
{members.length > 0 && (
<div className="space-y-2">
{members.map((pk) => (
<MemberRow
key={pk}
pubkey={pk}
isAdmin={admins.includes(pk)}
roles={memberRoles[pk] ?? []}
onToggleAdmin={() => toggleAdmin(pk)}
onToggleRole={(role) => toggleMemberRole(pk, role)}
onRemove={() => removeMember(pk)}
/>
))}
</div>
)}
</div>
);
const spotsBlock = (
<SpotPicker
selectedSpots={selectedSpots}
onSpotsChange={setSelectedSpots}
maxSpots={10}
label="Home Spots"
description="Link the spots your crew haunts — they'll connect to your crew on the map"
/>
);
const labLinkBlock = <LabLinkField value={labRefs} onChange={setLabRefs} />;
const videoBlock = (
<div className="space-y-2 rounded-lg border border-teal-500/30 bg-teal-500/[0.04] p-4">
<Label htmlFor="crew-video" className="flex items-center gap-2 text-teal-400">
<Film className="h-4 w-4" /> The Part <span className="font-normal text-gray-500">· optional</span>
</Label>
<p className="text-xs text-gray-500">
Your crew's full part, montage or latest clip. Paste a{' '}
<span className="font-semibold text-teal-300">YouTube</span> or{' '}
<span className="font-semibold text-teal-300">Instagram</span> link — it'll play right on
your crew page.
</p>
<Input
id="crew-video"
placeholder="https://youtube.com/watch?v=… or https://instagram.com/reel/…"
value={videoUrl}
onChange={(e) => setVideoUrl(e.target.value)}
className={cn(
'bg-gray-800 border-gray-700 text-xs',
videoUrl.trim() && !isCrewVideoUrl(videoUrl) && 'border-red-500/50',
)}
/>
{videoUrl.trim() && (
isCrewVideoUrl(videoUrl) ? (
<p className="flex items-center gap-1.5 text-xs font-bold text-teal-400">
<Film className="h-3.5 w-3.5" />
{parseCrewVideo(videoUrl)?.source === 'youtube' ? 'YouTube video detected' : 'Instagram clip detected'} ✓
</p>
) : (
<p className="text-xs text-red-400">Only YouTube and Instagram links are supported.</p>
)
)}
</div>
);
const sponsorBlock = (
<div className="space-y-2 rounded-lg border border-amber-500/30 bg-amber-500/[0.04] p-4">
<Label className="flex items-center gap-2 text-amber-400">
<Store className="h-4 w-4" /> Sponsored by <span className="font-normal text-gray-500">· optional</span>
</Label>
<p className="text-xs text-gray-500">
If a local shop backs your crew, pick them here to show a
“Sponsored by” banner linking to their shop.
</p>
<CrewSponsorPicker value={sponsorRef} onChange={setSponsorRef} />
</div>
);
const coordinatorBlock = (
<div className="space-y-2 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
<Label htmlFor="crew-coordinator" className="flex items-center gap-2 text-gray-300">
<Server className="h-4 w-4" /> Custom Chat Coordinator <span className="font-normal text-gray-500">· experimental · self-host</span>
</Label>
<p className="text-xs text-gray-500">
<span className="font-semibold text-gray-300">You don't need this.</span> Your crew already has private,
end-to-end encrypted group chat built in — no setup. This field is only for crews who{' '}
<span className="font-semibold text-gray-300">run their own</span>{' '}
<a
href="https://github.com/Cordn-msg/cordn"
target="_blank"
rel="noopener noreferrer"
className="text-teal-400 underline underline-offset-2 hover:text-teal-300"
>
Cordn coordinator
</a>{' '}
(a small self-hosted MLS delivery service). Paste its URL to route chat through it instead. Leave blank
to use the built-in chat.
</p>
<Input
id="crew-coordinator"
placeholder="https://your-coordinator.example or wss://…"
value={coordinatorUrl}
onChange={(e) => setCoordinatorUrl(e.target.value)}
className="bg-gray-800 border-gray-700 font-mono text-xs"
/>
</div>
);
const nip05Block = (
<div className="space-y-2 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
<Label htmlFor="crew-nip05" className="flex items-center gap-2 text-teal-400">
<AtSign className="h-4 w-4" /> Crew NIP-05 <span className="font-normal text-gray-500">· optional</span>
</Label>
<p className="text-xs text-gray-500">
A verifiable handle you control on any domain, e.g. <code className="text-teal-300">crew@yourdomain.com</code>.
It's verified against this crew's founder key — a green check shows when the domain confirms it.
</p>
<Input
id="crew-nip05"
placeholder="crew@yourdomain.com"
value={nip05}
onChange={(e) => setNip05(e.target.value)}
spellCheck={false}
autoCapitalize="none"
className="bg-gray-800 border-gray-700 font-mono text-sm"
/>
</div>
);
const linksBlock = (
<div className="space-y-3">
<Label className="text-base font-bold">Links</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="crew-instagram">Instagram</Label>
<Input id="crew-instagram" placeholder="@crew" value={instagram} onChange={(e) => setInstagram(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="crew-website">Website</Label>
<Input id="crew-website" type="url" placeholder="https://crew.com" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="crew-social">Other Socials</Label>
<p className="text-xs text-gray-500">Add as many as you like — YouTube, TikTok, Discord, Telegram, etc.</p>
<div className="flex gap-2">
<Input
id="crew-social"
placeholder="@username or link"
value={socialInput}
onChange={(e) => setSocialInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSocial(); } }}
className="bg-gray-800 border-gray-700"
/>
<Button type="button" onClick={addSocial} className={cn('shrink-0 gap-1', TEAL)}>
<Plus className="h-4 w-4" /> Add
</Button>
</div>
{socials.length > 0 && (
<div className="space-y-2 pt-1">
{socials.map((s, i) => (
<div key={`${s}-${i}`} className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800/60 p-2">
<span className="min-w-0 flex-1 truncate text-sm text-gray-200">{s}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-gray-400 hover:text-red-400"
onClick={() => setSocials(socials.filter((_, idx) => idx !== i))}
aria-label="Remove social link"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
<NofferField value={noffer} onChange={setNoffer} />
</div>
);
const bandBlock = (
<div className="space-y-3 rounded-xl border border-purple-500/20 bg-purple-500/[0.04] p-4">
<div className="flex items-center gap-2">
<Music className="h-4 w-4 text-purple-400" />
<Label className="text-base font-bold">Crew Band <span className="text-xs font-normal text-gray-500">(optional)</span></Label>
</div>
<p className="text-xs text-gray-500">
Got a band? Skate scenes and local music go hand in hand — drop your band name and a link to the tunes.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="crew-band-name">Band name</Label>
<Input
id="crew-band-name"
placeholder="e.g. The Bail Outs"
value={bandName}
onChange={(e) => setBandName(e.target.value)}
maxLength={100}
className="bg-gray-800 border-gray-700"
/>
</div>
<div className="space-y-2">
<Label htmlFor="crew-band-url">Music link</Label>
<Input
id="crew-band-url"
type="url"
placeholder="Spotify / Bandcamp / YouTube"
value={bandUrl}
onChange={(e) => setBandUrl(e.target.value)}
spellCheck={false}
autoCapitalize="none"
className="bg-gray-800 border-gray-700"
/>
</div>
</div>
{/* Interconnect nudge: a band should live in the Lab's Music scene. */}
{bandName.trim() && focus !== 'music' && (
<button
type="button"
onClick={() => setFocus('music')}
className="flex w-full items-start gap-2 rounded-lg border border-purple-500/40 bg-purple-500/10 px-3 py-2.5 text-left transition-colors hover:bg-purple-500/20"
>
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-purple-300" />
<span className="text-xs text-purple-200">
You've got a band — tap to set your <strong>Crew Type</strong> to{' '}
<strong>Band &amp; Music</strong> so you also show up in The Lab's Music scene.
</span>
</button>
)}
<p className="flex items-start gap-1.5 text-[11px] leading-snug text-gray-500">
<Music className="mt-0.5 h-3 w-3 shrink-0 text-purple-400" />
For a full band profile (genres, instruments, gigs), create a{' '}
<strong className="text-gray-300">Musician</strong> in The Lab, then link it in the{' '}
<strong className="text-gray-300">Linked Lab profiles</strong> section above.
</p>
</div>
);
const busy = isPublishing || isUploading;
// ─── Edit mode: flat single page (all sections at once) ───────────────────
if (isEditing) {
return (
<form onSubmit={handleSubmit}>
<Card className="bg-gray-900 border-gray-700">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5 text-teal-400" /> Edit Crew
</CardTitle>
<CardDescription className="text-gray-400">Your crew's identity, home turf and members</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{logoBlock}
{nameBlock}
{bioBlock}
{vibeBlock}
{crewTypeBlock}
{agentBlock}
{openCallBlock}
{treasuryBlock}
{membersBlock}
{spotsBlock}
{labLinkBlock}
{videoBlock}
{sponsorBlock}
{coordinatorBlock}
{locationBlock}
{nip05Block}
{linksBlock}
{bandBlock}
<Button type="submit" className={cn('w-full uppercase font-black', TEAL)} disabled={busy}>
{isPublishing ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Publishing...</>
) : (
<><Users className="h-4 w-4 mr-2" />Save Crew</>
)}
</Button>
</CardContent>
</Card>
</form>
);
}
// ─── Create mode: guided 4-step wizard ────────────────────────────────────
const hasExtras =
!!videoUrl.trim() || !!sponsorRef.trim() || labRefs.length > 0 ||
socials.length > 0 || !!website.trim() || !!instagram.trim() ||
!!bandName.trim() || !!nip05.trim() || agentEnabled || !!coordinatorUrl.trim() ||
(goalEnabled && !!goalTitle.trim());
const steps: { title: string; short: string; icon: typeof Users; hint: string; filled: boolean; content: ReactNode }[] = [
{
title: 'The Basics',
short: 'Basics',
icon: Sparkles,
hint: 'Just the essentials — you can spin up a crew with only this step.',
filled: nameValid,
content: (
<div className="space-y-6">
<div className="flex flex-col items-center gap-3 text-center sm:flex-row sm:items-start sm:text-left">
{logoBlock}
<p className="max-w-xs text-xs text-gray-500 sm:pt-8">
A logo makes your crew instantly recognisable on the map and in feeds. Square works best.
</p>
</div>
{nameBlock}
{bioBlock}
{locationBlock}
</div>
),
},
{
title: 'Identity & Vibe',
short: 'Vibe',
icon: Palette,
hint: 'How your crew shows up around the app. All optional.',
filled: vibes.length > 0 || customVibes.length > 0 || focus !== 'mixed',
content: (
<div className="space-y-6">
{vibeBlock}
{crewTypeBlock}
</div>
),
},
{
title: 'Squad & Spots',
short: 'Squad',
icon: Users,
hint: 'Add your people and your home turf. You can always do this later.',
filled: members.length > 0 || selectedSpots.length > 0 || lookingFor.length > 0,
content: (
<div className="space-y-6">
{membersBlock}
{spotsBlock}
{openCallBlock}
</div>
),
},
{
title: 'Extras',
short: 'Extras',
icon: Plus,
hint: 'Power-ups for later — skip anything you’re unsure about.',
filled: hasExtras,
content: (
<div className="space-y-6">
{treasuryBlock}
{videoBlock}
{sponsorBlock}
{labLinkBlock}
{linksBlock}
{bandBlock}
{nip05Block}
{agentBlock}
{coordinatorBlock}
</div>
),
},
];
const lastStep = steps.length - 1;
const current = steps[step];
const goNext = () => {
// Only gate on the one truly-required field, at the step it lives on.
if (step === 0 && !name.trim()) {
toast({ title: 'Name your crew', description: 'A crew needs a name before you continue.', variant: 'destructive' });
return;
}
goToStep(Math.min(step + 1, lastStep));
};
const goBack = () => goToStep(Math.max(step - 1, 0));
return (
<form onSubmit={handleSubmit}>
{/* Progress stepper */}
<nav className="mb-6" aria-label="Progress">
<ol className="flex items-center">
{steps.map((s, i) => {
const isCurrent = i === step;
const isPast = i < step;
const StepIcon = s.icon;
const jumpTo = () => {
if (i > step && !name.trim()) {
toast({ title: 'Name your crew first', variant: 'destructive' });
return;
}
goToStep(i);
};
return (
<li key={s.title} className={cn('flex items-center', i < lastStep && 'flex-1')}>
<button
type="button"
onClick={jumpTo}
className="group flex flex-col items-center gap-1.5"
aria-current={isCurrent ? 'step' : undefined}
aria-label={`Step ${i + 1}: ${s.title}`}
>
<span
className={cn(
'flex h-9 w-9 items-center justify-center rounded-full border-2 transition-all',
isCurrent
? 'border-teal-400 bg-teal-500/20 text-teal-300 ring-4 ring-teal-500/15 scale-110'
: isPast
? 'border-teal-500 bg-teal-500 text-black'
: s.filled
? 'border-teal-500/50 bg-teal-500/10 text-teal-400/80'
: 'border-gray-700 bg-gray-800 text-gray-500 group-hover:border-gray-600',
)}
>
{isPast || (s.filled && !isCurrent) ? (
<Check className="h-4 w-4" />
) : (
<StepIcon className="h-4 w-4" />
)}
</span>
<span
className={cn(
'text-[10px] font-bold uppercase tracking-wide transition-colors',
isCurrent ? 'text-teal-300' : 'text-gray-500 group-hover:text-gray-400',
)}
>
{s.short}
</span>
</button>
{i < lastStep && (
<span
className={cn(
'mx-1 mb-5 h-0.5 flex-1 rounded-full transition-colors sm:mx-2',
i < step ? 'bg-teal-500' : 'bg-gray-700',
)}
/>
)}
</li>
);
})}
</ol>
</nav>
<Card className="bg-gray-900 border-gray-700">
<CardHeader>
<div className="flex items-center gap-2 text-[11px] font-black uppercase tracking-widest text-teal-400/70">
Step {step + 1} of {steps.length}
</div>
<CardTitle className="flex items-center gap-2">
<current.icon className="h-5 w-5 text-teal-400" /> {current.title}
</CardTitle>
<CardDescription className="text-gray-400">{current.hint}</CardDescription>
</CardHeader>
<CardContent
key={step}
className={cn(
'animate-in fade-in duration-300',
navDir === 'forward' ? 'slide-in-from-right-2' : 'slide-in-from-left-2',
)}
>
{current.content}
</CardContent>
</Card>
{/* Sticky footer nav */}
<div className="sticky bottom-3 z-10 mt-4 rounded-2xl border border-gray-700 bg-gray-900/95 p-3 shadow-2xl shadow-black/40 backdrop-blur supports-[backdrop-filter]:bg-gray-900/80">
{/* On the last step, remind them they're ready. */}
{step === lastStep && (
<p className="mb-2.5 flex items-center justify-center gap-1.5 text-center text-[11px] text-gray-400">
{nameValid ? (
<>
<Check className="h-3.5 w-3.5 text-teal-400" />
Ready to roll — hit <strong className="text-teal-300">Create Crew</strong> whenever you like.
</>
) : (
<>Add a crew name back in <strong className="text-teal-300">Basics</strong> to finish.</>
)}
</p>
)}
<div className="flex items-center gap-3">
{step > 0 ? (
<Button type="button" variant="outline" onClick={goBack} disabled={busy} className="gap-1.5 border-gray-700">
<ArrowLeftIcon className="h-4 w-4" /> Back
</Button>
) : (
<span className="flex-1" />
)}
{step < lastStep ? (
<>
{step > 0 && (
<Button
type="button"
variant="ghost"
onClick={() => {
if (!name.trim()) {
toast({ title: 'Name your crew first', variant: 'destructive' });
return;
}
goToStep(lastStep);
}}
disabled={busy}
className="hidden text-gray-400 hover:text-white sm:inline-flex"
>
Skip to end
</Button>
)}
<Button type="button" onClick={goNext} disabled={busy} className={cn('flex-1 gap-1.5 font-black uppercase', TEAL)}>
Continue <ArrowRight className="h-4 w-4" />
</Button>
</>
) : (
<Button type="submit" disabled={busy || !nameValid} className={cn('flex-1 font-black uppercase', TEAL)}>
{isPublishing ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Publishing...</>
) : (
<><Users className="h-4 w-4 mr-2" />Create Crew</>
)}
</Button>
)}
</div>
</div>
</form>
);
}