Files
GearBox/.planning/milestones/v2.2-phases/30-onboarding-redesign/30-02-PLAN.md
Jean-Luc Makiola 2853477a75
All checks were successful
CI / ci (push) Successful in 1m15s
CI / e2e (push) Has been skipped
CI / deploy (push) Has been skipped
chore: archive v2.2 User Experience Polish milestone
Phases 28-31 archived to milestones/v2.2-phases/
Requirements and roadmap snapshots archived to milestones/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:00:35 +02:00

33 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements
phase plan type wave depends_on files_modified autonomous requirements
30 02 frontend 2
01
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
true
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`:
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`:
interface StepIndicatorProps {
  progress: number; // 0 to 100
}

export function StepIndicator({ progress }: StepIndicatorProps) {
  return (
    <div className="fixed top-0 left-0 right-0 h-1 bg-gray-100 z-50">
      <div
        className="h-1 bg-gray-700 transition-all duration-500"
        style={{ width: `${progress}%` }}
      />
    </div>
  );
}
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`:
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 (
    <button
      type="button"
      onClick={onClick}
      className={`w-40 h-40 flex flex-col items-center justify-center gap-3 p-5 rounded-2xl cursor-pointer transition-all ${
        selected
          ? "border-gray-700 ring-2 ring-gray-700/20 bg-white border"
          : "bg-gray-50 border border-gray-200 hover:border-gray-300 hover:shadow-sm"
      }`}
    >
      <LucideIcon name={icon} size={32} className="text-gray-700" />
      <div className="text-center">
        <div className="text-sm font-semibold text-gray-900">{name}</div>
        <div className="text-xs text-gray-400">{descriptor}</div>
      </div>
    </button>
  );
}
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`:
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 (
    <button
      type="button"
      onClick={onClick}
      className={`relative bg-white rounded-xl border text-left transition-all ${
        selected
          ? "border-gray-700 ring-2 ring-gray-700/20"
          : "border-gray-100 hover:border-gray-200 hover:shadow-sm"
      }`}
    >
      {/* Selection indicator */}
      <div className="absolute top-2 right-2 z-10">
        <div
          className={`w-6 h-6 rounded-full flex items-center justify-center ${
            selected
              ? "bg-gray-700 border-gray-700"
              : "border-2 border-gray-200 bg-white"
          }`}
        >
          {selected && (
            <LucideIcon name="check" size={14} className="text-white" />
          )}
        </div>
      </div>

      {/* Image */}
      <div className="aspect-square bg-gray-50 rounded-t-xl overflow-hidden">
        {imageUrl ? (
          <img
            src={imageUrl}
            alt={brand ? `${brand} ${model}` : model}
            className="w-full h-full object-cover"
          />
        ) : (
          <div className="w-full h-full flex items-center justify-center">
            <LucideIcon name="package" size={32} className="text-gray-300" />
          </div>
        )}
      </div>

      {/* Info */}
      <div className="p-3">
        {brand && (
          <div className="text-xs text-gray-400 truncate">{brand}</div>
        )}
        <div className="text-sm text-gray-900 font-medium truncate">{model}</div>
        <div className="flex items-center gap-2 mt-1 text-xs text-gray-400">
          {weightGrams != null && <span>{formatWeight(weightGrams)}</span>}
          {priceCents != null && <span>{formatPrice(priceCents)}</span>}
        </div>
        {ownerCount > 0 && (
          <div className="text-xs text-gray-400 mt-1">
            {ownerCount} {ownerCount === 1 ? "owner" : "owners"}
          </div>
        )}
      </div>
    </button>
  );
}
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`:
interface OnboardingWelcomeProps {
  onContinue: () => void;
}

export function OnboardingWelcome({ onContinue }: OnboardingWelcomeProps) {
  return (
    <div className="flex flex-col items-center justify-center min-h-screen px-8">
      <div className="max-w-2xl text-center">
        <h1 className="text-3xl font-bold text-gray-900 mb-4">
          Welcome to GearBox
        </h1>
        <p className="text-base text-gray-500 mb-8 leading-relaxed">
          Tell us what you're into, and we'll help you set up your collection
          with gear that people actually use.
        </p>
        <button
          type="button"
          onClick={onContinue}
          className="px-8 py-3 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
        >
          Let's go
        </button>
      </div>
    </div>
  );
}
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`:
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 (
    <div className="flex flex-col items-center justify-center min-h-screen px-8">
      <div className="max-w-2xl text-center">
        <h1 className="text-3xl font-bold text-gray-900 mb-2">
          What are you into?
        </h1>
        <p className="text-base text-gray-500 mb-8">
          Pick one or more  we'll show you popular gear for each.
        </p>
        <div className="flex flex-wrap justify-center gap-4 mb-8">
          {HOBBIES.map((hobby) => (
            <HobbyCard
              key={hobby.id}
              name={hobby.name}
              icon={hobby.icon}
              descriptor={hobby.descriptor}
              selected={selectedHobbies.includes(hobby.id)}
              onClick={() => onToggleHobby(hobby.id)}
            />
          ))}
        </div>
        <button
          type="button"
          onClick={onContinue}
          disabled={selectedHobbies.length === 0}
          className="px-8 py-3 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
        >
          Continue
        </button>
      </div>
    </div>
  );
}
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`:
import { getTagsForHobbies } from "../../../shared/hobbyConfig";
import { usePopularItems } from "../../hooks/useOnboarding";
import { SelectableItemCard } from "./SelectableItemCard";

interface OnboardingItemBrowserProps {
  selectedHobbies: string[];
  selectedItemIds: Set<number>;
  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 (
    <div className="flex flex-col items-center min-h-screen px-8 py-16">
      <div className="max-w-5xl w-full text-center">
        <h1 className="text-3xl font-bold text-gray-900 mb-2">
          Popular gear for {selectedHobbies.length === 1
            ? selectedHobbies[0]
            : "your hobbies"}
        </h1>
        <p className="text-base text-gray-500 mb-8">
          Tap items you already own. We'll add them to your collection.
        </p>

        {isLoading && (
          <div className="flex justify-center py-12">
            <div className="w-8 h-8 border-2 border-gray-200 border-t-gray-700 rounded-full animate-spin" />
          </div>
        )}

        {!isLoading && !hasItems && (
          <div className="py-12 text-center">
            <h2 className="text-lg font-semibold text-gray-900 mb-2">
              No gear cataloged yet
            </h2>
            <p className="text-base text-gray-500 mb-8">
              We're still building our catalog for this hobby. You can skip
              this step and add gear manually later.
            </p>
          </div>
        )}

        {!isLoading && hasItems && (
          <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 mb-8">
            {items.map((item) => (
              <SelectableItemCard
                key={item.id}
                brand={item.brand}
                model={item.model}
                imageUrl={item.imageUrl}
                weightGrams={item.weightGrams}
                priceCents={item.priceCents}
                ownerCount={item.ownerCount}
                selected={selectedItemIds.has(item.id)}
                onClick={() => onToggleItem(item.id)}
              />
            ))}
          </div>
        )}

        <div className="flex items-center justify-center gap-4">
          {hasItems && selectedItemIds.size > 0 && (
            <button
              type="button"
              onClick={onContinue}
              className="px-8 py-3 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
            >
              Review {selectedItemIds.size} {selectedItemIds.size === 1 ? "item" : "items"}
            </button>
          )}
          <button
            type="button"
            onClick={onSkip}
            className="text-sm text-gray-400 hover:text-gray-600 transition-colors"
          >
            Skip this step
          </button>
        </div>
      </div>
    </div>
  );
}
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`:
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<string, ReviewItem[]>();
  for (const item of items) {
    const cat = item.category || "Uncategorized";
    if (!grouped.has(cat)) grouped.set(cat, []);
    grouped.get(cat)!.push(item);
  }

  return (
    <div className="flex flex-col items-center justify-center min-h-screen px-8">
      <div className="max-w-2xl w-full text-center">
        <h1 className="text-3xl font-bold text-gray-900 mb-2">
          Your starting collection
        </h1>
        <p className="text-base text-gray-500 mb-8">
          {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."}
        </p>

        {items.length > 0 && (
          <div className="text-left mb-8">
            {[...grouped.entries()].map(([category, catItems]) => (
              <div key={category} className="mb-4">
                <div className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-2">
                  {category}
                </div>
                {catItems.map((item) => (
                  <div
                    key={item.id}
                    className="flex items-center gap-3 py-2 border-b border-gray-50"
                  >
                    <div className="w-10 h-10 rounded-lg overflow-hidden bg-gray-50 shrink-0">
                      {item.imageUrl ? (
                        <img
                          src={item.imageUrl}
                          alt={item.model}
                          className="w-full h-full object-cover"
                        />
                      ) : (
                        <div className="w-full h-full flex items-center justify-center">
                          <LucideIcon
                            name="package"
                            size={16}
                            className="text-gray-300"
                          />
                        </div>
                      )}
                    </div>
                    <div className="flex-1 min-w-0">
                      <div className="text-sm text-gray-900 truncate">
                        {item.brand ? `${item.brand} ${item.model}` : item.model}
                      </div>
                    </div>
                    <button
                      type="button"
                      onClick={() => onRemoveItem(item.id)}
                      className="text-gray-300 hover:text-red-500 transition-colors shrink-0"
                    >
                      <LucideIcon name="x" size={16} />
                    </button>
                  </div>
                ))}
              </div>
            ))}
          </div>
        )}

        <div className="flex flex-col items-center gap-3">
          {items.length > 0 ? (
            <button
              type="button"
              onClick={onConfirm}
              disabled={isSubmitting}
              className="px-8 py-3 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
            >
              {isSubmitting ? "Adding..." : "Add to my collection"}
            </button>
          ) : (
            <button
              type="button"
              onClick={onSkip}
              className="px-8 py-3 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
            >
              Continue
            </button>
          )}
          {items.length > 0 && (
            <button
              type="button"
              onClick={onSkip}
              className="text-sm text-gray-400 hover:text-gray-600 transition-colors"
            >
              Skip this step
            </button>
          )}
        </div>
      </div>
    </div>
  );
}
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`:
import { LucideIcon } from "../../lib/iconData";

interface OnboardingDoneProps {
  itemsCreated: number;
  onFinish: () => void;
}

export function OnboardingDone({ itemsCreated, onFinish }: OnboardingDoneProps) {
  return (
    <div className="flex flex-col items-center justify-center min-h-screen px-8">
      <div className="max-w-2xl text-center">
        <div className="mb-6">
          <LucideIcon name="check-circle" size={48} className="text-gray-400 mx-auto" />
        </div>
        <h1 className="text-3xl font-bold text-gray-900 mb-2">
          You're all set!
        </h1>
        <p className="text-base text-gray-500 mb-8">
          {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."}
        </p>
        <button
          type="button"
          onClick={onFinish}
          className="px-8 py-3 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
        >
          Start exploring
        </button>
      </div>
    </div>
  );
}
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`:
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<Step, number> = {
  welcome: 20,
  hobby: 40,
  browse: 60,
  review: 80,
  done: 100,
};

interface OnboardingFlowProps {
  onComplete: () => void;
}

export function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
  const [step, setStep] = useState<Step>("welcome");
  const [transitioning, setTransitioning] = useState(false);
  const [selectedHobbies, setSelectedHobbies] = useState<string[]>([]);
  const [selectedItemIds, setSelectedItemIds] = useState<Set<number>>(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 (
    <div className="fixed inset-0 z-50 bg-white overflow-y-auto">
      <StepIndicator progress={STEP_PROGRESS[step]} />

      <div
        className={`transition-all duration-300 ${
          transitioning
            ? "opacity-0 -translate-y-4"
            : "opacity-100 translate-y-0"
        }`}
      >
        {step === "welcome" && (
          <OnboardingWelcome onContinue={() => goToStep("hobby")} />
        )}

        {step === "hobby" && (
          <OnboardingHobbyPicker
            selectedHobbies={selectedHobbies}
            onToggleHobby={handleToggleHobby}
            onContinue={() => goToStep("browse")}
          />
        )}

        {step === "browse" && (
          <OnboardingItemBrowser
            selectedHobbies={selectedHobbies}
            selectedItemIds={selectedItemIds}
            onToggleItem={handleToggleItem}
            onContinue={() => goToStep("review")}
            onSkip={handleSkipBrowse}
          />
        )}

        {step === "review" && (
          <OnboardingReview
            items={reviewItems}
            onRemoveItem={handleRemoveItem}
            onConfirm={handleConfirm}
            onSkip={handleSkipBrowse}
            isSubmitting={completeOnboarding.isPending}
          />
        )}

        {step === "done" && (
          <OnboardingDone
            itemsCreated={itemsCreated}
            onFinish={onComplete}
          />
        )}
      </div>
    </div>
  );
}
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

<success_criteria>

  • 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 </success_criteria>

<threat_model>

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
</threat_model>

<must_haves>

  • 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 </must_haves>