Crew-level Cordn chat already existed (lib/cordn, useCordnChat, CrewChat, CrewCoordinatorCard, the crew 'coordinator' tag). The real gap: the crew form accepted a URL only, so a coordinator run from Cordn's phone app — which yields an nprofile, not a URL — could not be entered at all. - lib/coordinator.ts: parseCrewCoordinatorInput / crewCoordinatorInputValue — the same pointer formats as Settings -> Coordinator (nprofile / cordn.net link / npub / hex) plus a plain endpoint URL. - CrewForm: parses the pointer, shows live valid/invalid feedback, sets ['coordinator', url, pubkey?] (schema unchanged), and gains a one-tap 'Use my coordinator' prefill from Settings. - NIP.md: note the accepted pointer formats on the coordinator tag. - AGENTS.md: record that crew-level Cordn chat already exists (the lesson: grep src before building).
1557 lines
67 KiB
TypeScript
1557 lines
67 KiB
TypeScript
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 { useCoordinator } from '@/hooks/useCoordinator';
|
||
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 } 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 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);
|
||
// 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. 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 ? '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 & 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>{' '}
|
||
— even on an old phone. Paste its <span className="font-mono">nprofile</span>, a cordn.net link, or an
|
||
endpoint URL. Leave blank to use the built-in chat.
|
||
</p>
|
||
<Input
|
||
id="crew-coordinator"
|
||
placeholder="nprofile1… · cordn.net/chat/coordinators?c=… · https://…"
|
||
value={coordinatorInput}
|
||
onChange={(e) => setCoordinatorInput(e.target.value)}
|
||
className="bg-gray-800 border-gray-700 font-mono text-xs"
|
||
spellCheck={false}
|
||
/>
|
||
{coordinatorInput.trim() !== '' &&
|
||
(parsedCoordinator ? (
|
||
<p className="break-all text-[11px] text-teal-400">
|
||
✓ {parsedCoordinator.pubkey ? `${shortNpub(parsedCoordinator.pubkey)} · ` : ''}
|
||
{parsedCoordinator.url}
|
||
</p>
|
||
) : (
|
||
<p className="text-[11px] text-amber-400">
|
||
That doesn't look like a coordinator. Paste an nprofile, a cordn.net link, or an https/wss URL.
|
||
</p>
|
||
))}
|
||
{myCoordinator && (
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
className="border-teal-500/40 text-teal-300 hover:bg-teal-500/10"
|
||
onClick={() => setCoordinatorInput(coordinatorAddUrl(myCoordinator))}
|
||
>
|
||
<Server className="mr-1.5 h-3.5 w-3.5" />
|
||
Use my coordinator
|
||
</Button>
|
||
)}
|
||
</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 & 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 || !!coordinatorInput.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>
|
||
);
|
||
}
|