Polish create-crew wizard: live feedback, smart stepper, member preview
Wizard chrome: - Replace the flat progress bar with a proper numbered stepper — icons per step, a filled/checkmark state when a step has content, past-step checks, and clickable dots (forward jumps still gated on a name). - Direction-aware step transitions (slide from right going forward, left going back) with fade-in. - Sticky footer gets arrow icons, a shadow, a "ready to roll / add a name" hint on the final step, and Create Crew disabled until the name is valid. - Enter / form-submit before the last step now advances instead of publishing a half-filled crew (edit mode unaffected). Field-level feedback: - Crew name: inline "Looks good" + check icon when valid, teal border, helper line, 100-char cap. - About: live character counter (turns amber near the 3000 limit). Member input: - Live preview chip (avatar + name + npub) that resolves the typed npub/nprofile/hex BEFORE adding, so founders confirm the right skater. - Add button disabled until a valid, not-yet-added key is present; amber hint when the text isn't a key yet; "Added" state when already on the roster. - Extracted a shared decodePubkeyInput helper (also now accepts nostr:nprofile). Crew Agent: - Honest beta framing (browser-run, free-tier AI, occasionally slow) and an on-state confirmation line when enabled. No publish/handleSubmit behavior changed; all edits are UX/IA only. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
5716870863
commit
e2a0184260
1 changed files with 295 additions and 95 deletions
|
|
@ -41,7 +41,7 @@ import {
|
||||||
import { useCrewGoal } from '@/hooks/useCrewGoal';
|
import { useCrewGoal } from '@/hooks/useCrewGoal';
|
||||||
import { useAppContext } from '@/hooks/useAppContext';
|
import { useAppContext } from '@/hooks/useAppContext';
|
||||||
import { cn } from '@/lib/utils';
|
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, Lock, Film } from 'lucide-react';
|
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Lock, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon } from 'lucide-react';
|
||||||
|
|
||||||
const FOCUS_ORDER: CrewFocus[] = ['mixed', 'builder', 'photo', 'coaching', 'art', 'music', 'wellness'];
|
const FOCUS_ORDER: CrewFocus[] = ['mixed', 'builder', 'photo', 'coaching', 'art', 'music', 'wellness'];
|
||||||
const FOCUS_ICONS: Record<CrewFocus, typeof Users> = {
|
const FOCUS_ICONS: Record<CrewFocus, typeof Users> = {
|
||||||
|
|
@ -56,6 +56,8 @@ const FOCUS_ICONS: Record<CrewFocus, typeof Users> = {
|
||||||
|
|
||||||
const VIBE_ENTRIES = Object.entries(CREW_VIBE_LABELS);
|
const VIBE_ENTRIES = Object.entries(CREW_VIBE_LABELS);
|
||||||
const TEAL = 'bg-teal-500 hover:bg-teal-600 text-black';
|
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({
|
function MemberRow({
|
||||||
pubkey,
|
pubkey,
|
||||||
|
|
@ -141,6 +143,59 @@ function MemberRow({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
interface CrewFormProps {
|
||||||
/** Existing crew when editing. */
|
/** Existing crew when editing. */
|
||||||
existing?: SkateCrew | null;
|
existing?: SkateCrew | null;
|
||||||
|
|
@ -158,6 +213,13 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
// digestible steps so a first-timer can spin up a crew in seconds.
|
// digestible steps so a first-timer can spin up a crew in seconds.
|
||||||
const isEditing = !!existing;
|
const isEditing = !!existing;
|
||||||
const [step, setStep] = useState(0);
|
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 [name, setName] = useState(existing?.name ?? '');
|
||||||
const [bio, setBio] = useState(existing?.bio ?? '');
|
const [bio, setBio] = useState(existing?.bio ?? '');
|
||||||
|
|
@ -288,23 +350,14 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
setSocials((prev) => [...prev, val]);
|
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 addMember = () => {
|
||||||
const raw = memberInput.trim();
|
const raw = memberInput.trim();
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
let hex: string | null = null;
|
const hex = decodePubkeyInput(raw);
|
||||||
try {
|
|
||||||
if (raw.startsWith('npub1') || raw.startsWith('nostr:npub1')) {
|
|
||||||
const decoded = nip19.decode(raw.replace(/^nostr:/, ''));
|
|
||||||
if (decoded.type === 'npub') hex = decoded.data;
|
|
||||||
} else if (raw.startsWith('nprofile1')) {
|
|
||||||
const decoded = nip19.decode(raw);
|
|
||||||
if (decoded.type === 'nprofile') hex = decoded.data.pubkey;
|
|
||||||
} else if (/^[0-9a-f]{64}$/i.test(raw)) {
|
|
||||||
hex = raw.toLowerCase();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
hex = null;
|
|
||||||
}
|
|
||||||
if (!hex) {
|
if (!hex) {
|
||||||
setMemberError('Enter a valid npub, nprofile, or hex pubkey.');
|
setMemberError('Enter a valid npub, nprofile, or hex pubkey.');
|
||||||
return;
|
return;
|
||||||
|
|
@ -356,6 +409,20 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
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()) {
|
if (!name.trim()) {
|
||||||
toast({ title: 'Name required', description: 'Give your crew a name.', variant: 'destructive' });
|
toast({ title: 'Name required', description: 'Give your crew a name.', variant: 'destructive' });
|
||||||
return;
|
return;
|
||||||
|
|
@ -518,17 +585,49 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const nameValid = name.trim().length >= 2;
|
||||||
const nameBlock = (
|
const nameBlock = (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="crew-name">Crew Name *</Label>
|
<div className="flex items-center justify-between">
|
||||||
<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} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const bioBlock = (
|
const bioBlock = (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="crew-bio">About / Manifesto</Label>
|
<div className="flex items-center justify-between">
|
||||||
<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" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -685,8 +784,8 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
</Label>
|
</Label>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
Add <strong className="text-gray-300">Sasquatch</strong>, an AI guide for your crew. Members can ask it
|
Add <strong className="text-gray-300">Sasquatch</strong>, an AI guide for your crew. Members can ask it
|
||||||
about nearby spots, events, coaches and gear — it answers with live data and real links. Runs on your
|
about nearby spots, events, coaches and gear — it answers with live SkateSpots data and real links.
|
||||||
own device; no extra setup.
|
Runs right in the browser; no keys or extra setup. It's early (free-tier AI, so occasionally slow).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -708,6 +807,11 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -875,12 +979,30 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addMember(); } }}
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addMember(); } }}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
className="bg-gray-800 border-gray-700 font-mono text-sm"
|
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} className={cn('shrink-0 gap-1', TEAL)}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={addMember}
|
||||||
|
disabled={!memberPreviewPk || members.includes(memberPreviewPk)}
|
||||||
|
className={cn('shrink-0 gap-1', TEAL)}
|
||||||
|
>
|
||||||
<Plus className="h-4 w-4" /> Add
|
<Plus className="h-4 w-4" /> Add
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>}
|
{memberError && <p className="text-xs text-red-400">{memberError}</p>}
|
||||||
{members.length > 0 && (
|
{members.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -1159,10 +1281,19 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Create mode: guided 4-step wizard ────────────────────────────────────
|
// ─── Create mode: guided 4-step wizard ────────────────────────────────────
|
||||||
const steps: { title: string; hint: string; content: ReactNode }[] = [
|
const hasExtras =
|
||||||
|
!!videoUrl.trim() || !!sponsorRef.trim() || labRefs.length > 0 ||
|
||||||
|
socials.length > 0 || !!website.trim() || !!instagram.trim() ||
|
||||||
|
!!bandName.trim() || !!nip05.trim() || agentEnabled || !!coordinatorUrl.trim() ||
|
||||||
|
(goalEnabled && !!goalTitle.trim());
|
||||||
|
|
||||||
|
const steps: { title: string; short: string; icon: typeof Users; hint: string; filled: boolean; content: ReactNode }[] = [
|
||||||
{
|
{
|
||||||
title: 'The Basics',
|
title: 'The Basics',
|
||||||
|
short: 'Basics',
|
||||||
|
icon: Sparkles,
|
||||||
hint: 'Just the essentials — you can spin up a crew with only this step.',
|
hint: 'Just the essentials — you can spin up a crew with only this step.',
|
||||||
|
filled: nameValid,
|
||||||
content: (
|
content: (
|
||||||
<div className="space-y-6">
|
<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">
|
<div className="flex flex-col items-center gap-3 text-center sm:flex-row sm:items-start sm:text-left">
|
||||||
|
|
@ -1179,7 +1310,10 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Identity & Vibe',
|
title: 'Identity & Vibe',
|
||||||
|
short: 'Vibe',
|
||||||
|
icon: Palette,
|
||||||
hint: 'How your crew shows up around the app. All optional.',
|
hint: 'How your crew shows up around the app. All optional.',
|
||||||
|
filled: vibes.length > 0 || customVibes.length > 0 || focus !== 'mixed',
|
||||||
content: (
|
content: (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{vibeBlock}
|
{vibeBlock}
|
||||||
|
|
@ -1189,7 +1323,10 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Squad & Spots',
|
title: 'Squad & Spots',
|
||||||
|
short: 'Squad',
|
||||||
|
icon: Users,
|
||||||
hint: 'Add your people and your home turf. You can always do this later.',
|
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: (
|
content: (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{membersBlock}
|
{membersBlock}
|
||||||
|
|
@ -1200,7 +1337,10 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Extras',
|
title: 'Extras',
|
||||||
|
short: 'Extras',
|
||||||
|
icon: Plus,
|
||||||
hint: 'Power-ups for later — skip anything you’re unsure about.',
|
hint: 'Power-ups for later — skip anything you’re unsure about.',
|
||||||
|
filled: hasExtras,
|
||||||
content: (
|
content: (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{treasuryBlock}
|
{treasuryBlock}
|
||||||
|
|
@ -1226,95 +1366,155 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
toast({ title: 'Name your crew', description: 'A crew needs a name before you continue.', variant: 'destructive' });
|
toast({ title: 'Name your crew', description: 'A crew needs a name before you continue.', variant: 'destructive' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStep((s) => Math.min(s + 1, lastStep));
|
goToStep(Math.min(step + 1, lastStep));
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => goToStep(Math.max(step - 1, 0));
|
||||||
setStep((s) => Math.max(s - 1, 0));
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
{/* Progress header */}
|
{/* Progress stepper */}
|
||||||
<div className="mb-5">
|
<nav className="mb-6" aria-label="Progress">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<ol className="flex items-center">
|
||||||
<span className="text-[11px] font-black uppercase tracking-widest text-teal-400">
|
{steps.map((s, i) => {
|
||||||
Step {step + 1} of {steps.length}
|
const isCurrent = i === step;
|
||||||
</span>
|
const isPast = i < step;
|
||||||
<span className="text-[11px] font-bold text-gray-500">{current.title}</span>
|
const StepIcon = s.icon;
|
||||||
</div>
|
const jumpTo = () => {
|
||||||
<div className="flex gap-1.5">
|
if (i > step && !name.trim()) {
|
||||||
{steps.map((s, i) => (
|
toast({ title: 'Name your crew first', variant: 'destructive' });
|
||||||
<button
|
return;
|
||||||
key={s.title}
|
}
|
||||||
type="button"
|
goToStep(i);
|
||||||
onClick={() => {
|
};
|
||||||
// Allow jumping back freely; jumping forward requires a name.
|
return (
|
||||||
if (i > step && !name.trim()) {
|
<li key={s.title} className={cn('flex items-center', i < lastStep && 'flex-1')}>
|
||||||
toast({ title: 'Name your crew first', variant: 'destructive' });
|
<button
|
||||||
return;
|
type="button"
|
||||||
}
|
onClick={jumpTo}
|
||||||
setStep(i);
|
className="group flex flex-col items-center gap-1.5"
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
aria-current={isCurrent ? 'step' : undefined}
|
||||||
}}
|
aria-label={`Step ${i + 1}: ${s.title}`}
|
||||||
className={cn(
|
>
|
||||||
'h-1.5 flex-1 rounded-full transition-colors',
|
<span
|
||||||
i <= step ? 'bg-teal-500' : 'bg-gray-700 hover:bg-gray-600',
|
className={cn(
|
||||||
)}
|
'flex h-9 w-9 items-center justify-center rounded-full border-2 transition-all',
|
||||||
aria-label={`Go to step ${i + 1}: ${s.title}`}
|
isCurrent
|
||||||
/>
|
? 'border-teal-400 bg-teal-500/20 text-teal-300 ring-4 ring-teal-500/15 scale-110'
|
||||||
))}
|
: isPast
|
||||||
</div>
|
? 'border-teal-500 bg-teal-500 text-black'
|
||||||
</div>
|
: 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">
|
<Card className="bg-gray-900 border-gray-700">
|
||||||
<CardHeader>
|
<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">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Users className="h-5 w-5 text-teal-400" /> {current.title}
|
<current.icon className="h-5 w-5 text-teal-400" /> {current.title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-gray-400">{current.hint}</CardDescription>
|
<CardDescription className="text-gray-400">{current.hint}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>{current.content}</CardContent>
|
<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>
|
</Card>
|
||||||
|
|
||||||
{/* Sticky footer nav */}
|
{/* 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">
|
<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">
|
||||||
{step > 0 ? (
|
{/* On the last step, remind them they're ready. */}
|
||||||
<Button type="button" variant="outline" onClick={goBack} disabled={busy} className="border-gray-700">
|
{step === lastStep && (
|
||||||
Back
|
<p className="mb-2.5 flex items-center justify-center gap-1.5 text-center text-[11px] text-gray-400">
|
||||||
</Button>
|
{nameValid ? (
|
||||||
) : (
|
<>
|
||||||
<span className="flex-1" />
|
<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.
|
||||||
|
</>
|
||||||
{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</>
|
<>Add a crew name back in <strong className="text-teal-300">Basics</strong> to finish.</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue