--- phase: 30 plan: 02 type: frontend wave: 2 depends_on: [01] files_modified: - src/client/components/onboarding/OnboardingFlow.tsx - src/client/components/onboarding/OnboardingWelcome.tsx - src/client/components/onboarding/OnboardingHobbyPicker.tsx - src/client/components/onboarding/OnboardingItemBrowser.tsx - src/client/components/onboarding/OnboardingReview.tsx - src/client/components/onboarding/OnboardingDone.tsx - src/client/components/onboarding/StepIndicator.tsx - src/client/components/onboarding/SelectableItemCard.tsx - src/client/components/onboarding/HobbyCard.tsx - src/client/hooks/useOnboarding.ts autonomous: true requirements: [] --- Build the full-screen, catalog-driven onboarding flow UI with five steps: Welcome, Hobby Picker, Item Browser, Review, and Done. Includes hobby card selection, popular item grid with check/uncheck, review list with remove, and smooth CSS transitions between steps. All components follow the UI-SPEC design contract exactly. ### Task 1: Create onboarding hooks for data fetching and mutations - src/client/hooks/useGlobalItems.ts - src/client/hooks/useSettings.ts - src/client/lib/api.ts Create `src/client/hooks/useOnboarding.ts`: ```ts import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiGet, apiPost } from "../lib/api"; interface PopularItem { id: number; brand: string | null; model: string; category: string | null; weightGrams: number | null; priceCents: number | null; imageFilename: string | null; imageUrl: string | null; description: string | null; ownerCount: number; } /** Fetch popular catalog items for the given tags */ export function usePopularItems(tags: string[]) { return useQuery({ queryKey: ["popular-items", tags], queryFn: () => apiGet<{ items: PopularItem[] }>( `/api/discovery/popular-items?tags=${tags.join(",")}&limit=24`, ).then((res) => res.items), enabled: tags.length > 0, }); } /** Complete onboarding by batch-adding selected items */ export function useCompleteOnboarding() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (globalItemIds: number[]) => apiPost<{ itemsCreated: number; categoriesCreated: string[] }>( "/api/onboarding/complete", { globalItemIds }, ), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["settings"] }); queryClient.invalidateQueries({ queryKey: ["items"] }); queryClient.invalidateQueries({ queryKey: ["categories"] }); }, }); } ``` grep "usePopularItems" src/client/hooks/useOnboarding.ts && grep "useCompleteOnboarding" src/client/hooks/useOnboarding.ts && echo "PASS" || echo "FAIL" - `usePopularItems` hook accepts `tags: string[]` and fetches from `/api/discovery/popular-items` - Query is disabled when tags array is empty (`enabled: tags.length > 0`) - `useCompleteOnboarding` mutation POSTs to `/api/onboarding/complete` - On success, invalidates `settings`, `items`, and `categories` query keys - Both hooks use `apiGet`/`apiPost` from `lib/api` ### Task 2: Create StepIndicator component - src/client/components/OnboardingWizard.tsx Create `src/client/components/onboarding/StepIndicator.tsx`: ```tsx interface StepIndicatorProps { progress: number; // 0 to 100 } export function StepIndicator({ progress }: StepIndicatorProps) { return (
); } ``` grep "StepIndicator" src/client/components/onboarding/StepIndicator.tsx && grep "bg-gray-700" src/client/components/onboarding/StepIndicator.tsx && echo "PASS" || echo "FAIL" - `StepIndicator` component renders a fixed top bar with `h-1 bg-gray-100` - Progress fill uses `bg-gray-700` with `transition-all duration-500` - Width set via inline style `width: {progress}%` - Container has `z-50` for layering above content ### Task 3: Create HobbyCard component - src/client/lib/iconData.ts - src/shared/hobbyConfig.ts Create `src/client/components/onboarding/HobbyCard.tsx`: ```tsx import { LucideIcon } from "../../lib/iconData"; interface HobbyCardProps { name: string; icon: string; descriptor: string; selected: boolean; onClick: () => void; } export function HobbyCard({ name, icon, descriptor, selected, onClick }: HobbyCardProps) { return ( ); } ``` grep "HobbyCard" src/client/components/onboarding/HobbyCard.tsx && grep "ring-gray-700/20" src/client/components/onboarding/HobbyCard.tsx && echo "PASS" || echo "FAIL" - `HobbyCard` renders a 40x40 (w-40 h-40) button with rounded-2xl - Default state: `bg-gray-50 border border-gray-200` - Hover state: `border-gray-300 shadow-sm` - Selected state: `border-gray-700 ring-2 ring-gray-700/20 bg-white` - Shows `LucideIcon` at size 32, name text as `text-sm font-semibold`, descriptor as `text-xs text-gray-400` - Uses `p-5` internal padding (20px) per UI-SPEC exception ### Task 4: Create SelectableItemCard component - src/client/components/GlobalItemCard.tsx Create `src/client/components/onboarding/SelectableItemCard.tsx`: ```tsx import { LucideIcon } from "../../lib/iconData"; import { useFormatters } from "../../hooks/useFormatters"; interface SelectableItemCardProps { brand: string | null; model: string; imageUrl: string | null; weightGrams: number | null; priceCents: number | null; ownerCount: number; selected: boolean; onClick: () => void; } export function SelectableItemCard({ brand, model, imageUrl, weightGrams, priceCents, ownerCount, selected, onClick, }: SelectableItemCardProps) { const { formatWeight, formatPrice } = useFormatters(); return ( ); } ``` grep "SelectableItemCard" src/client/components/onboarding/SelectableItemCard.tsx && grep "ring-gray-700/20" src/client/components/onboarding/SelectableItemCard.tsx && echo "PASS" || echo "FAIL" - `SelectableItemCard` renders card with `bg-white rounded-xl border border-gray-100` - Selected state: `border-gray-700 ring-2 ring-gray-700/20` - Selection indicator: absolute top-2 right-2, 24x24 circle (w-6 h-6) - Unselected circle: `border-2 border-gray-200 bg-white rounded-full` - Selected circle: `bg-gray-700` with white check icon at size 14 - Shows image (or package fallback), brand, model, weight, price, owner count - Uses `useFormatters` hook for weight/price display ### Task 5: Create OnboardingWelcome step component - src/client/components/onboarding/StepIndicator.tsx Create `src/client/components/onboarding/OnboardingWelcome.tsx`: ```tsx interface OnboardingWelcomeProps { onContinue: () => void; } export function OnboardingWelcome({ onContinue }: OnboardingWelcomeProps) { return (

Welcome to GearBox

Tell us what you're into, and we'll help you set up your collection with gear that people actually use.

); } ```
grep "Welcome to GearBox" src/client/components/onboarding/OnboardingWelcome.tsx && grep "Let's go" src/client/components/onboarding/OnboardingWelcome.tsx && echo "PASS" || echo "FAIL" - Heading: "Welcome to GearBox" in `text-3xl font-bold text-gray-900` - Body: exact copy from UI-SPEC copywriting contract - CTA button: "Let's go" with `bg-gray-700 hover:bg-gray-800` - Layout: `min-h-screen`, centered with `max-w-2xl`
### Task 6: Create OnboardingHobbyPicker step component - src/shared/hobbyConfig.ts - src/client/components/onboarding/HobbyCard.tsx Create `src/client/components/onboarding/OnboardingHobbyPicker.tsx`: ```tsx import { HOBBIES } from "../../../shared/hobbyConfig"; import { HobbyCard } from "./HobbyCard"; interface OnboardingHobbyPickerProps { selectedHobbies: string[]; onToggleHobby: (hobbyId: string) => void; onContinue: () => void; } export function OnboardingHobbyPicker({ selectedHobbies, onToggleHobby, onContinue, }: OnboardingHobbyPickerProps) { return (

What are you into?

Pick one or more — we'll show you popular gear for each.

{HOBBIES.map((hobby) => ( onToggleHobby(hobby.id)} /> ))}
); } ```
grep "OnboardingHobbyPicker" src/client/components/onboarding/OnboardingHobbyPicker.tsx && grep "What are you into" src/client/components/onboarding/OnboardingHobbyPicker.tsx && echo "PASS" || echo "FAIL" - Heading: "What are you into?" per UI-SPEC copy - Body: "Pick one or more — we'll show you popular gear for each." - Renders all 6 hobbies from `HOBBIES` config as `HobbyCard` components - Cards in `flex flex-wrap justify-center gap-4` layout - Continue button disabled when no hobbies selected (`disabled:opacity-50`) - `onToggleHobby` callback toggles hobby selection
### Task 7: Create OnboardingItemBrowser step component - src/client/hooks/useOnboarding.ts - src/client/components/onboarding/SelectableItemCard.tsx - src/shared/hobbyConfig.ts Create `src/client/components/onboarding/OnboardingItemBrowser.tsx`: ```tsx import { getTagsForHobbies } from "../../../shared/hobbyConfig"; import { usePopularItems } from "../../hooks/useOnboarding"; import { SelectableItemCard } from "./SelectableItemCard"; interface OnboardingItemBrowserProps { selectedHobbies: string[]; selectedItemIds: Set; onToggleItem: (itemId: number) => void; onContinue: () => void; onSkip: () => void; } export function OnboardingItemBrowser({ selectedHobbies, selectedItemIds, onToggleItem, onContinue, onSkip, }: OnboardingItemBrowserProps) { const tags = getTagsForHobbies(selectedHobbies); const { data: items, isLoading } = usePopularItems(tags); const hasItems = items && items.length > 0; return (

Popular gear for {selectedHobbies.length === 1 ? selectedHobbies[0] : "your hobbies"}

Tap items you already own. We'll add them to your collection.

{isLoading && (
)} {!isLoading && !hasItems && (

No gear cataloged yet

We're still building our catalog for this hobby. You can skip this step and add gear manually later.

)} {!isLoading && hasItems && (
{items.map((item) => ( onToggleItem(item.id)} /> ))}
)}
{hasItems && selectedItemIds.size > 0 && ( )}
); } ``` grep "OnboardingItemBrowser" src/client/components/onboarding/OnboardingItemBrowser.tsx && grep "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4" src/client/components/onboarding/OnboardingItemBrowser.tsx && echo "PASS" || echo "FAIL" - Heading: "Popular gear for {hobby}" per UI-SPEC copy - Body: "Tap items you already own. We'll add them to your collection." - Grid: `grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4` per responsive spec - Max content width: `max-w-5xl` (1024px) for item grid per UI-SPEC - Loading state shows spinner - Empty state shows "No gear cataloged yet" heading and body per UI-SPEC copy - Selected items count shown on continue button: "Review N items" - "Skip this step" link always visible - Uses `usePopularItems` hook with tags from `getTagsForHobbies` ### Task 8: Create OnboardingReview step component - src/client/hooks/useOnboarding.ts - src/client/lib/iconData.ts Create `src/client/components/onboarding/OnboardingReview.tsx`: ```tsx import { LucideIcon } from "../../lib/iconData"; interface ReviewItem { id: number; brand: string | null; model: string; imageUrl: string | null; category: string | null; } interface OnboardingReviewProps { items: ReviewItem[]; onRemoveItem: (itemId: number) => void; onConfirm: () => void; onSkip: () => void; isSubmitting: boolean; } export function OnboardingReview({ items, onRemoveItem, onConfirm, onSkip, isSubmitting, }: OnboardingReviewProps) { // Group by category const grouped = new Map(); for (const item of items) { const cat = item.category || "Uncategorized"; if (!grouped.has(cat)) grouped.set(cat, []); grouped.get(cat)!.push(item); } return (

Your starting collection

{items.length > 0 ? `${items.length} ${items.length === 1 ? "item" : "items"} ready to add` : "No items selected — you can always add gear later from the catalog."}

{items.length > 0 && (
{[...grouped.entries()].map(([category, catItems]) => (
{category}
{catItems.map((item) => (
{item.imageUrl ? ( {item.model} ) : (
)}
{item.brand ? `${item.brand} ${item.model}` : item.model}
))}
))}
)}
{items.length > 0 ? ( ) : ( )} {items.length > 0 && ( )}
); } ```
grep "OnboardingReview" src/client/components/onboarding/OnboardingReview.tsx && grep "Your starting collection" src/client/components/onboarding/OnboardingReview.tsx && echo "PASS" || echo "FAIL" - Heading: "Your starting collection" per UI-SPEC copy - Body: "{N} items ready to add" or "No items selected — you can always add gear later from the catalog." per UI-SPEC - Items grouped by category with `text-xs font-medium text-gray-400 uppercase tracking-wide` headings - Item rows: `flex items-center gap-3 py-2 border-b border-gray-50` - Image: `w-10 h-10 rounded-lg object-cover bg-gray-50` - Remove button: `text-gray-300 hover:text-red-500` with X icon size 16 - CTA: "Add to my collection" per UI-SPEC, disabled during submission - "Skip this step" link available when items are selected
### Task 9: Create OnboardingDone step component - src/client/components/onboarding/OnboardingWelcome.tsx Create `src/client/components/onboarding/OnboardingDone.tsx`: ```tsx import { LucideIcon } from "../../lib/iconData"; interface OnboardingDoneProps { itemsCreated: number; onFinish: () => void; } export function OnboardingDone({ itemsCreated, onFinish }: OnboardingDoneProps) { return (

You're all set!

{itemsCreated > 0 ? "Your collection is ready. Browse the catalog anytime to discover more gear." : "Your collection is ready. Browse the catalog anytime to discover more gear."}

); } ```
grep "You're all set" src/client/components/onboarding/OnboardingDone.tsx && grep "Start exploring" src/client/components/onboarding/OnboardingDone.tsx && echo "PASS" || echo "FAIL" - Heading: "You're all set!" per UI-SPEC copy - Body: "Your collection is ready. Browse the catalog anytime to discover more gear." per UI-SPEC - CTA: "Start exploring" per UI-SPEC - Check-circle icon at size 48 in `text-gray-400` - Same layout as Welcome step: `min-h-screen`, centered, `max-w-2xl`
### Task 10: Create OnboardingFlow orchestrator component - src/client/components/OnboardingWizard.tsx - src/client/hooks/useOnboarding.ts - src/shared/hobbyConfig.ts Create `src/client/components/onboarding/OnboardingFlow.tsx`: ```tsx import { useCallback, useRef, useState } from "react"; import { getTagsForHobbies } from "../../../shared/hobbyConfig"; import { useCompleteOnboarding, usePopularItems } from "../../hooks/useOnboarding"; import { useUpdateSetting } from "../../hooks/useSettings"; import { OnboardingDone } from "./OnboardingDone"; import { OnboardingHobbyPicker } from "./OnboardingHobbyPicker"; import { OnboardingItemBrowser } from "./OnboardingItemBrowser"; import { OnboardingReview } from "./OnboardingReview"; import { OnboardingWelcome } from "./OnboardingWelcome"; import { StepIndicator } from "./StepIndicator"; type Step = "welcome" | "hobby" | "browse" | "review" | "done"; const STEP_PROGRESS: Record = { welcome: 20, hobby: 40, browse: 60, review: 80, done: 100, }; interface OnboardingFlowProps { onComplete: () => void; } export function OnboardingFlow({ onComplete }: OnboardingFlowProps) { const [step, setStep] = useState("welcome"); const [transitioning, setTransitioning] = useState(false); const [selectedHobbies, setSelectedHobbies] = useState([]); const [selectedItemIds, setSelectedItemIds] = useState>(new Set()); const [itemsCreated, setItemsCreated] = useState(0); const completeOnboarding = useCompleteOnboarding(); const updateSetting = useUpdateSetting(); // Fetch items for review step data const tags = getTagsForHobbies(selectedHobbies); const { data: popularItems } = usePopularItems(tags); const goToStep = useCallback((nextStep: Step) => { setTransitioning(true); setTimeout(() => { setStep(nextStep); setTransitioning(false); }, 200); }, []); const handleToggleHobby = useCallback((hobbyId: string) => { setSelectedHobbies((prev) => prev.includes(hobbyId) ? prev.filter((h) => h !== hobbyId) : [...prev, hobbyId], ); // Reset item selections when hobbies change setSelectedItemIds(new Set()); }, []); const handleToggleItem = useCallback((itemId: number) => { setSelectedItemIds((prev) => { const next = new Set(prev); if (next.has(itemId)) next.delete(itemId); else next.add(itemId); return next; }); }, []); const handleRemoveItem = useCallback((itemId: number) => { setSelectedItemIds((prev) => { const next = new Set(prev); next.delete(itemId); return next; }); }, []); const handleConfirm = useCallback(() => { const ids = [...selectedItemIds]; completeOnboarding.mutate(ids, { onSuccess: (result) => { setItemsCreated(result.itemsCreated); goToStep("done"); }, }); }, [selectedItemIds, completeOnboarding, goToStep]); const handleSkip = useCallback(() => { updateSetting.mutate( { key: "onboardingComplete", value: "true" }, { onSuccess: onComplete }, ); }, [updateSetting, onComplete]); const handleSkipBrowse = useCallback(() => { // Skip browse and review — just mark complete updateSetting.mutate( { key: "onboardingComplete", value: "true" }, { onSuccess: onComplete }, ); }, [updateSetting, onComplete]); // Build review items from selected IDs const reviewItems = (popularItems || []) .filter((item) => selectedItemIds.has(item.id)) .map((item) => ({ id: item.id, brand: item.brand, model: item.model, imageUrl: item.imageUrl, category: item.category, })); return (
{step === "welcome" && ( goToStep("hobby")} /> )} {step === "hobby" && ( goToStep("browse")} /> )} {step === "browse" && ( goToStep("review")} onSkip={handleSkipBrowse} /> )} {step === "review" && ( )} {step === "done" && ( )}
); } ```
grep "OnboardingFlow" src/client/components/onboarding/OnboardingFlow.tsx && grep "transitioning" src/client/components/onboarding/OnboardingFlow.tsx && grep "StepIndicator" src/client/components/onboarding/OnboardingFlow.tsx && echo "PASS" || echo "FAIL" - `OnboardingFlow` manages 5 steps: welcome, hobby, browse, review, done - Full-screen overlay: `fixed inset-0 z-50 bg-white overflow-y-auto` - Step transitions: opacity-0/-translate-y-4 to opacity-100/translate-y-0 with 200ms exit + 300ms enter - StepIndicator shows progress: welcome=20%, hobby=40%, browse=60%, review=80%, done=100% - Hobby selection resets item selections when changed - Review step gets items from popularItems filtered by selectedItemIds - Confirm calls `useCompleteOnboarding` mutation, then transitions to done step - Skip calls `useUpdateSetting` to set onboardingComplete and triggers onComplete - `onComplete` prop called on final "Start exploring" click and all skip paths
1. `bun run lint` passes 2. `bun test` passes (existing tests not broken) 3. All onboarding components exist in `src/client/components/onboarding/` 4. `OnboardingFlow` renders full-screen overlay with step transitions 5. HobbyCard has correct selected/unselected visual states per UI-SPEC 6. SelectableItemCard has checkmark overlay per UI-SPEC 7. ReviewList groups items by category with correct styling - All 10 components created in src/client/components/onboarding/ - Hooks for popular items fetching and onboarding completion - Full-screen flow with CSS step transitions - Copy matches UI-SPEC copywriting contract exactly - Visual states match UI-SPEC color and spacing specs - Responsive grid: 2/3/4 columns per breakpoint | Threat | Severity | Mitigation | |--------|----------|------------| | XSS via catalog item model/brand names | Low | React auto-escapes JSX text content; no dangerouslySetInnerHTML used | | Stale popular items cache showing removed items | Low | React Query default staleTime; items fetched fresh on hobby change | | UI state manipulation via browser devtools | Low | Server-side validation on /api/onboarding/complete; UI state is convenience only | - [ ] Full-screen onboarding flow with 5 steps - [ ] Hobby picker with card-based selection (multi-select) - [ ] Item browser with selectable item grid - [ ] Review screen with grouped items and remove - [ ] CSS step transitions (no framer-motion) - [ ] Copy matches UI-SPEC exactly