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:
shakespeare.diy 2026-09-03 00:59:30 +07:00
parent 5716870863
commit e2a0184260

View file

@ -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,64 +1366,117 @@ 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">
{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()) { if (i > step && !name.trim()) {
toast({ title: 'Name your crew first', variant: 'destructive' }); toast({ title: 'Name your crew first', variant: 'destructive' });
return; return;
} }
setStep(i); goToStep(i);
window.scrollTo({ top: 0, behavior: 'smooth' }); };
}} 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( className={cn(
'h-1.5 flex-1 rounded-full transition-colors', 'flex h-9 w-9 items-center justify-center rounded-full border-2 transition-all',
i <= step ? 'bg-teal-500' : 'bg-gray-700 hover:bg-gray-600', 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',
)} )}
aria-label={`Go to step ${i + 1}: ${s.title}`}
/> />
))} )}
</div> </li>
</div> );
})}
</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">
{/* 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 ? ( {step > 0 ? (
<Button type="button" variant="outline" onClick={goBack} disabled={busy} className="border-gray-700"> <Button type="button" variant="outline" onClick={goBack} disabled={busy} className="gap-1.5 border-gray-700">
Back <ArrowLeftIcon className="h-4 w-4" /> Back
</Button> </Button>
) : ( ) : (
<span className="flex-1" /> <span className="flex-1" />
@ -1295,19 +1488,25 @@ export function CrewForm({ existing }: CrewFormProps) {
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
onClick={() => setStep(lastStep)} onClick={() => {
if (!name.trim()) {
toast({ title: 'Name your crew first', variant: 'destructive' });
return;
}
goToStep(lastStep);
}}
disabled={busy} disabled={busy}
className="text-gray-400 hover:text-white" className="hidden text-gray-400 hover:text-white sm:inline-flex"
> >
Skip to end Skip to end
</Button> </Button>
)} )}
<Button type="button" onClick={goNext} disabled={busy} className={cn('flex-1 font-black uppercase', TEAL)}> <Button type="button" onClick={goNext} disabled={busy} className={cn('flex-1 gap-1.5 font-black uppercase', TEAL)}>
Continue Continue <ArrowRight className="h-4 w-4" />
</Button> </Button>
</> </>
) : ( ) : (
<Button type="submit" disabled={busy} className={cn('flex-1 font-black uppercase', TEAL)}> <Button type="submit" disabled={busy || !nameValid} className={cn('flex-1 font-black uppercase', TEAL)}>
{isPublishing ? ( {isPublishing ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Publishing...</> <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Publishing...</>
) : ( ) : (
@ -1316,6 +1515,7 @@ export function CrewForm({ existing }: CrewFormProps) {
</Button> </Button>
)} )}
</div> </div>
</div>
</form> </form>
); );
} }