Redesign create-crew into a guided, idiotproof wizard
Audit finding: the crew form was one 1,100-line endless scroll with ~15
sections in a punishing order (logo dead-last, MLS chat coordinator sitting
inline with the crew name), only one validation gate, and no sense of
progress or what's optional.
Fix — split the flow by intent:
- Create mode: a guided 4-step wizard with a sticky progress bar, per-step
hints, Back/Continue + "Skip to end", and a sticky footer nav.
1. The Basics — logo, name, about, home turf (all you actually need)
2. Identity & Vibe — vibe tags, crew type
3. Squad & Spots — members/roles, home spots, open call
4. Extras — treasury, video, sponsor, links, band, NIP-05,
agent, and the advanced chat coordinator (reframed
in plain language, clearly skippable)
Only the crew name is gated, and only at step 1. Clickable step dots allow
jumping back freely.
- Edit mode: unchanged UX intent — renders every section flat on one page
(power users want it all at once).
All field state and the entire publish/handleSubmit logic are preserved
verbatim; sections were extracted into composable blocks with zero behavior
change. Softened the coordinator copy and the map-pin section so a first-timer
isn't scared off.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
a2c34800ce
commit
5716870863
2 changed files with 303 additions and 87 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, type ReactNode } from 'react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import ngeohash from 'ngeohash';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
|
@ -153,6 +153,12 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
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);
|
||||
|
||||
const [name, setName] = useState(existing?.name ?? '');
|
||||
const [bio, setBio] = useState(existing?.bio ?? '');
|
||||
const [location, setLocation] = useState(existing?.location ?? '');
|
||||
|
|
@ -488,26 +494,76 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
}
|
||||
};
|
||||
|
||||
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" /> {existing ? 'Edit Crew' : 'Crew Details'}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">Your crew's identity, home turf and members</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="crew-name">Crew Name *</Label>
|
||||
<Input id="crew-name" placeholder="e.g. Backyard Bandits" value={name} onChange={(e) => setName(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
// ─── 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.
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="crew-bio">About / Manifesto</Label>
|
||||
<Textarea id="crew-bio" placeholder="Who you are, what you skate, your story..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
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 nameBlock = (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="crew-name">Crew Name *</Label>
|
||||
<Input id="crew-name" placeholder="e.g. Backyard Bandits" value={name} onChange={(e) => setName(e.target.value)} className="bg-gray-800 border-gray-700 text-base" autoFocus={!isEditing} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const bioBlock = (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="crew-bio">About / Manifesto</Label>
|
||||
<Textarea id="crew-bio" placeholder="Who you are, what you skate, your story..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} 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>
|
||||
|
|
@ -571,8 +627,10 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
{/* Lab scene focus — cross-links the crew into The Lab */}
|
||||
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">
|
||||
|
|
@ -615,8 +673,9 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Crew AI agent (Sasquatch) */}
|
||||
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">
|
||||
|
|
@ -650,8 +709,9 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Open call — looking for (crew ↔ Lab hiring pipeline) */}
|
||||
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">
|
||||
|
|
@ -684,9 +744,9 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Crew treasury (NIP-75 zap goal) — hidden for now via SHOW_CREW_TREASURY */}
|
||||
{SHOW_CREW_TREASURY && (
|
||||
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">
|
||||
|
|
@ -749,9 +809,9 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : null;
|
||||
|
||||
{/* Members */}
|
||||
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>
|
||||
|
|
@ -838,8 +898,9 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Home spots */}
|
||||
const spotsBlock = (
|
||||
<SpotPicker
|
||||
selectedSpots={selectedSpots}
|
||||
onSpotsChange={setSelectedSpots}
|
||||
|
|
@ -847,11 +908,11 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
label="Home Spots"
|
||||
description="Link the spots your crew haunts — they'll connect to your crew on the map"
|
||||
/>
|
||||
);
|
||||
|
||||
{/* Linked Lab profiles (cross-link) */}
|
||||
<LabLinkField value={labRefs} onChange={setLabRefs} />
|
||||
const labLinkBlock = <LabLinkField value={labRefs} onChange={setLabRefs} />;
|
||||
|
||||
{/* The Part — crew video / montage */}
|
||||
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>
|
||||
|
|
@ -883,8 +944,9 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Sponsor shop */}
|
||||
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>
|
||||
|
|
@ -895,16 +957,17 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
</p>
|
||||
<CrewSponsorPicker value={sponsorRef} onChange={setSponsorRef} />
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Encrypted chat coordinator (Cordn / MLS) */}
|
||||
const coordinatorBlock = (
|
||||
<div className="space-y-2 rounded-lg border border-emerald-500/30 bg-emerald-500/[0.04] p-4">
|
||||
<Label htmlFor="crew-coordinator" className="flex items-center gap-2 text-emerald-400">
|
||||
<Lock className="h-4 w-4" /> Private Chat Coordinator <span className="font-normal text-gray-500">· optional</span>
|
||||
<Lock className="h-4 w-4" /> Private Chat Coordinator <span className="font-normal text-gray-500">· advanced</span>
|
||||
</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
Point your crew at a <span className="font-semibold text-emerald-300">Cordn</span> coordinator
|
||||
(an MLS delivery service reached over ContextVM) to enable end-to-end encrypted group chat.
|
||||
Nothing crew-specific ever hits a public relay. Paste the coordinator's URL.
|
||||
Most crews can skip this. If you run a <span className="font-semibold text-emerald-300">Cordn</span> coordinator
|
||||
(an MLS delivery service reached over ContextVM), paste its URL to enable end-to-end encrypted group chat —
|
||||
nothing crew-specific ever hits a public relay. Leave blank and your crew still gets a private group chat.
|
||||
</p>
|
||||
<Input
|
||||
id="crew-coordinator"
|
||||
|
|
@ -914,33 +977,9 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
className="bg-gray-800 border-gray-700 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
<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>Home Turf Coordinates (map anchor)</Label>
|
||||
<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>
|
||||
|
||||
{/* Crew NIP-05 identity */}
|
||||
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>
|
||||
|
|
@ -959,8 +998,9 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
className="bg-gray-800 border-gray-700 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Links */}
|
||||
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">
|
||||
|
|
@ -1011,10 +1051,9 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
</div>
|
||||
<NofferField value={noffer} onChange={setNoffer} />
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Crew Band — many crews double as local bands. Optional.
|
||||
Interconnects with the Lab: a serious band can set its Crew Type to
|
||||
"Band & Music" and link a full Musician profile below. */}
|
||||
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" />
|
||||
|
|
@ -1072,34 +1111,211 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
<strong className="text-gray-300">Linked Lab profiles</strong> section above.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Logo */}
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
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 steps: { title: string; hint: string; content: ReactNode }[] = [
|
||||
{
|
||||
title: 'The Basics',
|
||||
hint: 'Just the essentials — you can spin up a crew with only this step.',
|
||||
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',
|
||||
hint: 'How your crew shows up around the app. All optional.',
|
||||
content: (
|
||||
<div className="space-y-6">
|
||||
{vibeBlock}
|
||||
{crewTypeBlock}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Squad & Spots',
|
||||
hint: 'Add your people and your home turf. You can always do this later.',
|
||||
content: (
|
||||
<div className="space-y-6">
|
||||
{membersBlock}
|
||||
{spotsBlock}
|
||||
{openCallBlock}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Extras',
|
||||
hint: 'Power-ups for later — skip anything you’re unsure about.',
|
||||
content: (
|
||||
<div className="space-y-6">
|
||||
{treasuryBlock}
|
||||
{videoBlock}
|
||||
{sponsorBlock}
|
||||
{labLinkBlock}
|
||||
{linksBlock}
|
||||
{bandBlock}
|
||||
{nip05Block}
|
||||
{agentBlock}
|
||||
{coordinatorBlock}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
<Button type="submit" className={cn('w-full uppercase font-black', TEAL)} disabled={isPublishing || isUploading}>
|
||||
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;
|
||||
}
|
||||
setStep((s) => Math.min(s + 1, lastStep));
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
setStep((s) => Math.max(s - 1, 0));
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Progress header */}
|
||||
<div className="mb-5">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[11px] font-black uppercase tracking-widest text-teal-400">
|
||||
Step {step + 1} of {steps.length}
|
||||
</span>
|
||||
<span className="text-[11px] font-bold text-gray-500">{current.title}</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{steps.map((s, i) => (
|
||||
<button
|
||||
key={s.title}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Allow jumping back freely; jumping forward requires a name.
|
||||
if (i > step && !name.trim()) {
|
||||
toast({ title: 'Name your crew first', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setStep(i);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
className={cn(
|
||||
'h-1.5 flex-1 rounded-full transition-colors',
|
||||
i <= step ? 'bg-teal-500' : 'bg-gray-700 hover:bg-gray-600',
|
||||
)}
|
||||
aria-label={`Go to step ${i + 1}: ${s.title}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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" /> {current.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">{current.hint}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>{current.content}</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sticky footer nav */}
|
||||
<div className="sticky bottom-0 z-10 mt-4 flex items-center gap-3 rounded-2xl border border-gray-700 bg-gray-900/95 p-3 backdrop-blur supports-[backdrop-filter]:bg-gray-900/80">
|
||||
{step > 0 ? (
|
||||
<Button type="button" variant="outline" onClick={goBack} disabled={busy} className="border-gray-700">
|
||||
Back
|
||||
</Button>
|
||||
) : (
|
||||
<span className="flex-1" />
|
||||
)}
|
||||
|
||||
{step < lastStep ? (
|
||||
<>
|
||||
{step > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setStep(lastStep)}
|
||||
disabled={busy}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
Skip to end
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" onClick={goNext} disabled={busy} className={cn('flex-1 font-black uppercase', TEAL)}>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button type="submit" disabled={busy} 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" />{existing ? 'Save Crew' : 'Create Crew'}</>
|
||||
<><Users className="h-4 w-4 mr-2" />Create Crew</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export default function CreateCrewPage() {
|
|||
<h1 className="text-3xl font-black uppercase text-teal-400" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
|
||||
Create a Crew
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">Your skate family — the people you skate with. Plant your flag on the map.</p>
|
||||
<p className="text-gray-400 mt-1">Your skate family — the people you skate with. Just a name gets you started; everything else is optional.</p>
|
||||
</div>
|
||||
<CrewForm />
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue