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 { nip19 } from 'nostr-tools';
|
||||||
import ngeohash from 'ngeohash';
|
import ngeohash from 'ngeohash';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
@ -153,6 +153,12 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||||
const { toast } = useToast();
|
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 [name, setName] = useState(existing?.name ?? '');
|
||||||
const [bio, setBio] = useState(existing?.bio ?? '');
|
const [bio, setBio] = useState(existing?.bio ?? '');
|
||||||
const [location, setLocation] = useState(existing?.location ?? '');
|
const [location, setLocation] = useState(existing?.location ?? '');
|
||||||
|
|
@ -488,26 +494,76 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
// ─── Section blocks ─────────────────────────────────────────────────────
|
||||||
<form onSubmit={handleSubmit}>
|
// Each visible field group is a self-contained element that closes over the
|
||||||
<Card className="bg-gray-900 border-gray-700">
|
// component's state. They're composed into wizard steps (create) or one flat
|
||||||
<CardHeader>
|
// scroll (edit) below — no field or publish logic changes between modes.
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Users className="h-5 w-5 text-teal-400" /> {existing ? 'Edit Crew' : 'Crew Details'}
|
const logoBlock = (
|
||||||
</CardTitle>
|
<div className="space-y-2">
|
||||||
<CardDescription className="text-gray-400">Your crew's identity, home turf and members</CardDescription>
|
<Label>Crew Logo / Photo</Label>
|
||||||
</CardHeader>
|
{image ? (
|
||||||
<CardContent className="space-y-6">
|
<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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="crew-name">Crew Name *</Label>
|
<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" />
|
<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>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const bioBlock = (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="crew-bio">About / Manifesto</Label>
|
<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" />
|
<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>
|
</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 */}
|
{/* Vibe tags */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>Vibe / Focus</Label>
|
<Label>Vibe / Focus</Label>
|
||||||
|
|
@ -571,8 +627,10 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
|
||||||
<div>
|
<div>
|
||||||
<Label className="flex items-center gap-2 text-teal-400">
|
<Label className="flex items-center gap-2 text-teal-400">
|
||||||
|
|
@ -615,8 +673,9 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</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="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="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|
@ -650,8 +709,9 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 className="space-y-3 rounded-lg border border-fuchsia-500/30 bg-fuchsia-500/[0.04] p-4">
|
||||||
<div>
|
<div>
|
||||||
<Label className="flex items-center gap-2 text-fuchsia-400">
|
<Label className="flex items-center gap-2 text-fuchsia-400">
|
||||||
|
|
@ -684,9 +744,9 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
{/* Crew treasury (NIP-75 zap goal) — hidden for now via SHOW_CREW_TREASURY */}
|
const treasuryBlock = SHOW_CREW_TREASURY ? (
|
||||||
{SHOW_CREW_TREASURY && (
|
|
||||||
<div className="space-y-3 rounded-lg border border-orange-500/30 bg-orange-500/[0.04] p-4">
|
<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="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|
@ -749,9 +809,9 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null;
|
||||||
|
|
||||||
{/* Members */}
|
const membersBlock = (
|
||||||
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
|
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
|
||||||
<div>
|
<div>
|
||||||
<Label className="flex items-center gap-2 text-teal-400"><Users className="h-4 w-4" /> Members</Label>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
{/* Home spots */}
|
const spotsBlock = (
|
||||||
<SpotPicker
|
<SpotPicker
|
||||||
selectedSpots={selectedSpots}
|
selectedSpots={selectedSpots}
|
||||||
onSpotsChange={setSelectedSpots}
|
onSpotsChange={setSelectedSpots}
|
||||||
|
|
@ -847,11 +908,11 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
label="Home Spots"
|
label="Home Spots"
|
||||||
description="Link the spots your crew haunts — they'll connect to your crew on the map"
|
description="Link the spots your crew haunts — they'll connect to your crew on the map"
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
{/* Linked Lab profiles (cross-link) */}
|
const labLinkBlock = <LabLinkField value={labRefs} onChange={setLabRefs} />;
|
||||||
<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">
|
<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">
|
<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>
|
<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>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
{/* Sponsor shop */}
|
const sponsorBlock = (
|
||||||
<div className="space-y-2 rounded-lg border border-amber-500/30 bg-amber-500/[0.04] p-4">
|
<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">
|
<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>
|
<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>
|
</p>
|
||||||
<CrewSponsorPicker value={sponsorRef} onChange={setSponsorRef} />
|
<CrewSponsorPicker value={sponsorRef} onChange={setSponsorRef} />
|
||||||
</div>
|
</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">
|
<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">
|
<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>
|
</Label>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
Point your crew at a <span className="font-semibold text-emerald-300">Cordn</span> coordinator
|
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) to enable end-to-end encrypted group chat.
|
(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. Paste the coordinator's URL.
|
nothing crew-specific ever hits a public relay. Leave blank and your crew still gets a private group chat.
|
||||||
</p>
|
</p>
|
||||||
<Input
|
<Input
|
||||||
id="crew-coordinator"
|
id="crew-coordinator"
|
||||||
|
|
@ -914,33 +977,9 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
className="bg-gray-800 border-gray-700 font-mono text-xs"
|
className="bg-gray-800 border-gray-700 font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
const nip05Block = (
|
||||||
<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 */}
|
|
||||||
<div className="space-y-2 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
|
<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">
|
<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>
|
<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"
|
className="bg-gray-800 border-gray-700 font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
{/* Links */}
|
const linksBlock = (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-base font-bold">Links</Label>
|
<Label className="text-base font-bold">Links</Label>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
|
@ -1011,10 +1051,9 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
</div>
|
</div>
|
||||||
<NofferField value={noffer} onChange={setNoffer} />
|
<NofferField value={noffer} onChange={setNoffer} />
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
{/* Crew Band — many crews double as local bands. Optional.
|
const bandBlock = (
|
||||||
Interconnects with the Lab: a serious band can set its Crew Type to
|
|
||||||
"Band & Music" and link a full Musician profile below. */}
|
|
||||||
<div className="space-y-3 rounded-xl border border-purple-500/20 bg-purple-500/[0.04] p-4">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<Music className="h-4 w-4 text-purple-400" />
|
<Music className="h-4 w-4 text-purple-400" />
|
||||||
|
|
@ -1072,30 +1111,45 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
<strong className="text-gray-300">Linked Lab profiles</strong> section above.
|
<strong className="text-gray-300">Linked Lab profiles</strong> section above.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
{/* Logo */}
|
const busy = isPublishing || isUploading;
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" className={cn('w-full uppercase font-black', TEAL)} disabled={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 ? (
|
{isPublishing ? (
|
||||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Publishing...</>
|
<><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" />Save Crew</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -1103,3 +1157,165 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
</form>
|
</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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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" />Create Crew</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</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' }}>
|
<h1 className="text-3xl font-black uppercase text-teal-400" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
|
||||||
Create a Crew
|
Create a Crew
|
||||||
</h1>
|
</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>
|
</div>
|
||||||
<CrewForm />
|
<CrewForm />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue