From e2a01842604d36ad473b402fbe40738718c9da26 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 3 Sep 2026 00:59:30 +0700 Subject: [PATCH] Polish create-crew wizard: live feedback, smart stepper, member preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/CrewForm.tsx | 390 +++++++++++++++++++++++++++--------- 1 file changed, 295 insertions(+), 95 deletions(-) diff --git a/src/components/CrewForm.tsx b/src/components/CrewForm.tsx index 3e1478b..003b0cb 100644 --- a/src/components/CrewForm.tsx +++ b/src/components/CrewForm.tsx @@ -41,7 +41,7 @@ import { import { useCrewGoal } from '@/hooks/useCrewGoal'; import { useAppContext } from '@/hooks/useAppContext'; 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_ICONS: Record = { @@ -56,6 +56,8 @@ const FOCUS_ICONS: Record = { const VIBE_ENTRIES = Object.entries(CREW_VIBE_LABELS); const TEAL = 'bg-teal-500 hover:bg-teal-600 text-black'; +/** Number of steps in the create wizard (0-indexed last step). Keep in sync with `steps`. */ +const WIZARD_LAST_STEP = 3; function MemberRow({ pubkey, @@ -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 ( +
+ + + {name.slice(0, 2).toUpperCase()} + +
+

{name}

+

{npub.slice(0, 20)}…

+
+ {alreadyAdded ? ( + + Added + + ) : ( + + Ready + + )} +
+ ); +} + interface CrewFormProps { /** Existing crew when editing. */ 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. const isEditing = !!existing; const [step, setStep] = useState(0); + // Track nav direction so step transitions slide in from the correct side. + const [navDir, setNavDir] = useState<'forward' | 'back'>('forward'); + const goToStep = (target: number) => { + setNavDir(target >= step ? 'forward' : 'back'); + setStep(target); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; const [name, setName] = useState(existing?.name ?? ''); const [bio, setBio] = useState(existing?.bio ?? ''); @@ -288,23 +350,14 @@ export function CrewForm({ existing }: CrewFormProps) { setSocials((prev) => [...prev, val]); }; + // Live-decode whatever's typed in the member field for the preview chip. + const memberPreviewPk = decodePubkeyInput(memberInput); + const memberInputDirty = memberInput.trim().length > 0; + const addMember = () => { const raw = memberInput.trim(); if (!raw) return; - let hex: string | null = null; - 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; - } + const hex = decodePubkeyInput(raw); if (!hex) { setMemberError('Enter a valid npub, nprofile, or hex pubkey.'); return; @@ -356,6 +409,20 @@ export function CrewForm({ existing }: CrewFormProps) { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + + // In the create wizard, Enter/submit before the final step should just + // advance rather than publish a half-filled crew. (Edit mode has no steps.) + if (!existing && step < WIZARD_LAST_STEP) { + if (step === 0 && !name.trim()) { + toast({ title: 'Name your crew', description: 'A crew needs a name before you continue.', variant: 'destructive' }); + return; + } + setNavDir('forward'); + setStep((s) => Math.min(s + 1, WIZARD_LAST_STEP)); + window.scrollTo({ top: 0, behavior: 'smooth' }); + return; + } + if (!name.trim()) { toast({ title: 'Name required', description: 'Give your crew a name.', variant: 'destructive' }); return; @@ -518,17 +585,49 @@ export function CrewForm({ existing }: CrewFormProps) { ); + const nameValid = name.trim().length >= 2; const nameBlock = (
- - setName(e.target.value)} className="bg-gray-800 border-gray-700 text-base" autoFocus={!isEditing} /> +
+ + {nameValid && ( + + Looks good + + )} +
+
+ 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 && ( + + )} +
+

The only thing you truly need. You can change it later.

); const bioBlock = (
- -