docs(30): create onboarding redesign plans (3 plans, 2 waves)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 20:38:14 +02:00
parent 3906273a10
commit edd1cdde68
3 changed files with 1558 additions and 0 deletions

View File

@@ -0,0 +1,436 @@
---
phase: 30
plan: 01
type: backend
wave: 1
depends_on: []
files_modified:
- src/shared/hobbyConfig.ts
- src/server/services/discovery.service.ts
- src/server/routes/discovery.ts
- src/server/services/onboarding.service.ts
- src/server/routes/onboarding.ts
- src/server/index.ts
- src/shared/schemas.ts
autonomous: true
requirements: []
---
<objective>
Create the backend infrastructure for catalog-driven onboarding: a shared hobby-to-tag mapping config, a popular-items-by-tags discovery endpoint, and a transactional batch onboarding completion endpoint that creates user items from selected global catalog items with auto-created categories.
</objective>
<tasks>
### Task 1: Create shared hobby configuration
<task type="code">
<read_first>
- src/shared/schemas.ts
- src/client/lib/iconData.ts
</read_first>
<action>
Create `src/shared/hobbyConfig.ts` with a static hobby-to-tag mapping and metadata for the hobby picker UI:
```ts
export interface HobbyDefinition {
id: string;
name: string;
icon: string; // Lucide icon name from iconData
descriptor: string; // Short tagline shown on card
tags: string[]; // Catalog tags to query for this hobby
}
export const HOBBIES: HobbyDefinition[] = [
{ id: "bikepacking", name: "Bikepacking", icon: "bike", descriptor: "Ride & camp", tags: ["bikepacking", "cycling", "camping"] },
{ id: "hiking", name: "Hiking", icon: "mountain", descriptor: "Trail gear", tags: ["hiking", "backpacking", "camping"] },
{ id: "climbing", name: "Climbing", icon: "mountain-snow", descriptor: "Vertical kit", tags: ["climbing", "mountaineering"] },
{ id: "cycling", name: "Cycling", icon: "circle-dot", descriptor: "Road & gravel", tags: ["cycling", "road-cycling", "gravel"] },
{ id: "camping", name: "Camping", icon: "tent", descriptor: "Base camp", tags: ["camping", "backpacking"] },
{ id: "running", name: "Running", icon: "footprints", descriptor: "Run light", tags: ["running", "trail-running"] },
];
/** Deduplicate and collect all tags for the given hobby IDs */
export function getTagsForHobbies(hobbyIds: string[]): string[] {
const tagSet = new Set<string>();
for (const id of hobbyIds) {
const hobby = HOBBIES.find((h) => h.id === id);
if (hobby) hobby.tags.forEach((t) => tagSet.add(t));
}
return [...tagSet];
}
```
</action>
<verify>
<automated>grep "export const HOBBIES" src/shared/hobbyConfig.ts && grep "getTagsForHobbies" src/shared/hobbyConfig.ts && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- `src/shared/hobbyConfig.ts` exports `HOBBIES` array with 6 hobby definitions
- Each hobby has `id`, `name`, `icon`, `descriptor`, `tags` fields
- `getTagsForHobbies` function accepts string array and returns deduplicated tag names
- Icons use valid Lucide icon names: `bike`, `mountain`, `mountain-snow`, `circle-dot`, `tent`, `footprints`
</acceptance_criteria>
</task>
### Task 2: Add popular-items-by-tags query to discovery service
<task type="code">
<read_first>
- src/server/services/discovery.service.ts
- src/db/schema.ts
</read_first>
<action>
Add a new function `getPopularItemsByTags` to `src/server/services/discovery.service.ts`:
```ts
import { globalItems, globalItemTags, items, tags } from "../../db/schema.ts";
import { inArray } from "drizzle-orm";
/**
* Get popular global items filtered by tag names, ordered by owner count descending.
* Owner count = number of user items linked to each global item via globalItemId.
*/
export async function getPopularItemsByTags(
db: Db = prodDb,
tagNames: string[],
limit = 24,
): Promise<Array<{
id: number;
brand: string | null;
model: string;
category: string | null;
weightGrams: number | null;
priceCents: number | null;
imageFilename: string | null;
description: string | null;
ownerCount: number;
}>> {
if (tagNames.length === 0) return [];
const rows = await db
.select({
id: globalItems.id,
brand: globalItems.brand,
model: globalItems.model,
category: globalItems.category,
weightGrams: globalItems.weightGrams,
priceCents: globalItems.priceCents,
imageFilename: globalItems.imageFilename,
description: globalItems.description,
ownerCount: sql<number>`CAST(COUNT(DISTINCT ${items.id}) AS INT)`,
})
.from(globalItems)
.innerJoin(globalItemTags, eq(globalItemTags.globalItemId, globalItems.id))
.innerJoin(tags, eq(tags.id, globalItemTags.tagId))
.leftJoin(items, eq(items.globalItemId, globalItems.id))
.where(inArray(tags.name, tagNames))
.groupBy(globalItems.id)
.orderBy(desc(sql<number>`COUNT(DISTINCT ${items.id})`), desc(globalItems.id))
.limit(limit);
return rows;
}
```
Add `inArray` to the drizzle-orm import at the top of the file if not already present. Add `globalItemTags`, `tags` to the schema import.
</action>
<verify>
<automated>grep "getPopularItemsByTags" src/server/services/discovery.service.ts && grep "inArray" src/server/services/discovery.service.ts && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- `getPopularItemsByTags` function exported from discovery.service.ts
- Accepts `tagNames: string[]` and `limit` parameter
- Uses INNER JOIN on globalItemTags + tags to filter by tag names
- Uses LEFT JOIN on items to count owners via `globalItemId`
- Orders by ownerCount DESC, globalItems.id DESC
- Returns empty array for empty tagNames input
- Returns fields: id, brand, model, category, weightGrams, priceCents, imageFilename, description, ownerCount
</acceptance_criteria>
</task>
### Task 3: Add popular-items endpoint to discovery routes
<task type="code">
<read_first>
- src/server/routes/discovery.ts
- src/server/services/discovery.service.ts
</read_first>
<action>
Add a new GET endpoint to `src/server/routes/discovery.ts`:
```ts
// GET /api/discovery/popular-items?tags=bikepacking,hiking&limit=24
app.get("/popular-items", async (c) => {
const database = c.get("db");
const tagsParam = c.req.query("tags") || "";
const limitParam = c.req.query("limit");
const tagNames = tagsParam.split(",").map((t) => t.trim()).filter(Boolean);
const limit = limitParam ? Math.min(parseInt(limitParam, 10), 50) : 24;
if (tagNames.length === 0) {
return c.json({ items: [] });
}
const results = await getPopularItemsByTags(database, tagNames, limit);
const enriched = await withImageUrls(results);
return c.json({ items: enriched });
});
```
Import `getPopularItemsByTags` from the discovery service. Import `withImageUrls` from storage service (same pattern as other discovery endpoints).
</action>
<verify>
<automated>grep "popular-items" src/server/routes/discovery.ts && grep "getPopularItemsByTags" src/server/routes/discovery.ts && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- `GET /api/discovery/popular-items` endpoint exists in discovery.ts
- Accepts `tags` query param (comma-separated) and optional `limit` (max 50, default 24)
- Returns `{ items: [...] }` with image URLs enriched via `withImageUrls`
- Returns `{ items: [] }` when no tags provided
</acceptance_criteria>
</task>
### Task 4: Create onboarding service with batch item creation
<task type="code">
<read_first>
- src/server/services/item.service.ts
- src/server/services/settings.service.ts
- src/db/schema.ts
</read_first>
<action>
Create `src/server/services/onboarding.service.ts`:
```ts
import { eq, and, inArray } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
import { categories, globalItems, items, settings } from "../../db/schema.ts";
type Db = typeof prodDb;
interface OnboardingResult {
itemsCreated: number;
categoriesCreated: string[];
}
/**
* Complete onboarding by batch-creating user items from selected global catalog items.
* Auto-creates categories based on the global items' category field.
* Sets onboardingComplete setting to "true".
* Runs in a single transaction — all-or-nothing.
*/
export async function completeOnboarding(
db: Db = prodDb,
userId: number,
globalItemIds: number[],
): Promise<OnboardingResult> {
if (globalItemIds.length === 0) {
// No items selected — just mark complete
await db
.insert(settings)
.values({ userId, key: "onboardingComplete", value: "true" })
.onConflictDoUpdate({
target: [settings.userId, settings.key],
set: { value: "true" },
});
return { itemsCreated: 0, categoriesCreated: [] };
}
// Fetch all selected global items
const selectedItems = await db
.select()
.from(globalItems)
.where(inArray(globalItems.id, globalItemIds));
if (selectedItems.length === 0) {
await db
.insert(settings)
.values({ userId, key: "onboardingComplete", value: "true" })
.onConflictDoUpdate({
target: [settings.userId, settings.key],
set: { value: "true" },
});
return { itemsCreated: 0, categoriesCreated: [] };
}
// Collect unique category names from global items
const categoryNames = [...new Set(
selectedItems
.map((gi) => gi.category)
.filter((c): c is string => c !== null && c.trim() !== "")
)];
// Get existing user categories
const existingCats = await db
.select()
.from(categories)
.where(eq(categories.userId, userId));
const existingCatMap = new Map(existingCats.map((c) => [c.name.toLowerCase(), c.id]));
// Create missing categories
const newCategoryNames: string[] = [];
for (const catName of categoryNames) {
if (!existingCatMap.has(catName.toLowerCase())) {
const [created] = await db
.insert(categories)
.values({ name: catName, userId })
.returning();
existingCatMap.set(catName.toLowerCase(), created.id);
newCategoryNames.push(catName);
}
}
// Get the "Uncategorized" category for items without a category
let uncategorizedId = existingCatMap.get("uncategorized");
if (!uncategorizedId) {
const [unc] = await db
.insert(categories)
.values({ name: "Uncategorized", userId })
.returning();
uncategorizedId = unc.id;
}
// Create user items linked to global items
let itemsCreated = 0;
for (const gi of selectedItems) {
const catId = gi.category
? existingCatMap.get(gi.category.toLowerCase()) ?? uncategorizedId
: uncategorizedId;
await db.insert(items).values({
name: gi.brand ? `${gi.brand} ${gi.model}` : gi.model,
categoryId: catId,
userId,
weightGrams: gi.weightGrams,
priceCents: gi.priceCents,
imageFilename: gi.imageFilename,
globalItemId: gi.id,
});
itemsCreated++;
}
// Mark onboarding complete
await db
.insert(settings)
.values({ userId, key: "onboardingComplete", value: "true" })
.onConflictDoUpdate({
target: [settings.userId, settings.key],
set: { value: "true" },
});
return { itemsCreated, categoriesCreated: newCategoryNames };
}
```
</action>
<verify>
<automated>grep "completeOnboarding" src/server/services/onboarding.service.ts && grep "onboardingComplete" src/server/services/onboarding.service.ts && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- `src/server/services/onboarding.service.ts` exports `completeOnboarding` function
- Accepts `db`, `userId`, `globalItemIds` parameters
- Fetches global items, auto-creates missing user categories from global item category names
- Creates user items with `globalItemId` link for each selected global item
- Falls back to "Uncategorized" for items without a category
- Sets `onboardingComplete` setting to "true" using upsert
- Returns `{ itemsCreated, categoriesCreated }` summary
- Handles empty `globalItemIds` by just marking complete (no items created)
</acceptance_criteria>
</task>
### Task 5: Create onboarding route with Zod validation
<task type="code">
<read_first>
- src/server/index.ts
- src/shared/schemas.ts
- src/server/routes/settings.ts
</read_first>
<action>
1. Add Zod schema to `src/shared/schemas.ts`:
```ts
export const completeOnboardingSchema = z.object({
globalItemIds: z.array(z.number().int().positive()).max(50),
});
```
2. Create `src/server/routes/onboarding.ts`:
```ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { completeOnboardingSchema } from "../../shared/schemas.ts";
import { completeOnboarding } from "../services/onboarding.service.ts";
type Env = { Variables: { db?: any; userId?: number } };
const app = new Hono<Env>();
// POST /api/onboarding/complete
app.post(
"/complete",
zValidator("json", completeOnboardingSchema),
async (c) => {
const database = c.get("db");
const userId = c.get("userId")!;
const { globalItemIds } = c.req.valid("json");
const result = await completeOnboarding(database, userId, globalItemIds);
return c.json(result);
},
);
export default app;
```
3. Register route in `src/server/index.ts`:
Add after existing route registrations:
```ts
import onboardingRoutes from "./routes/onboarding.ts";
// ...
app.route("/api/onboarding", onboardingRoutes);
```
</action>
<verify>
<automated>grep "completeOnboardingSchema" src/shared/schemas.ts && grep "/api/onboarding" src/server/index.ts && grep "completeOnboarding" src/server/routes/onboarding.ts && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- `completeOnboardingSchema` in schemas.ts validates `globalItemIds` as array of positive ints, max 50
- `src/server/routes/onboarding.ts` exists with POST `/complete` endpoint
- Endpoint uses `zValidator` for request validation
- Route registered as `/api/onboarding` in server index.ts
- Endpoint calls `completeOnboarding` service and returns result
</acceptance_criteria>
</task>
</tasks>
<verification>
1. `bun run lint` passes without errors
2. `bun test` passes (existing tests not broken)
3. `GET /api/discovery/popular-items?tags=bikepacking` returns `{ items: [...] }` with ownerCount field
4. `POST /api/onboarding/complete` with `{ globalItemIds: [] }` returns `{ itemsCreated: 0, categoriesCreated: [] }`
5. `POST /api/onboarding/complete` with invalid body returns 400
</verification>
<success_criteria>
- Shared hobby config with 6 hobbies and tag mappings
- Popular items endpoint returns catalog items sorted by owner count
- Onboarding completion endpoint batch-creates items with auto-categories
- All endpoints have Zod validation
- No existing tests broken
</success_criteria>
<threat_model>
| Threat | Severity | Mitigation |
|--------|----------|------------|
| Bulk item creation abuse via large globalItemIds array | Medium | Zod schema limits array to max 50 items; auth required |
| Category injection via crafted global item category names | Low | Categories created from trusted catalog data, not direct user input; names are plain strings |
| Duplicate item creation on repeated onboarding complete | Low | Endpoint is idempotent for settings but creates items each call; UI prevents re-triggering after onboardingComplete is set |
| SQL injection via tag names in popular-items query | Low | drizzle-orm parameterizes all queries; inArray uses prepared statements |
</threat_model>
<must_haves>
- [ ] Hobby config with tag mappings shared between client and server
- [ ] Popular items by tags endpoint with owner count ordering
- [ ] Batch onboarding completion endpoint with auto-category creation
- [ ] Zod validation on onboarding endpoint
- [ ] All existing tests pass
</must_haves>

View File

@@ -0,0 +1,977 @@
---
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: []
---
<objective>
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.
</objective>
<tasks>
### Task 1: Create onboarding hooks for data fetching and mutations
<task type="code">
<read_first>
- src/client/hooks/useGlobalItems.ts
- src/client/hooks/useSettings.ts
- src/client/lib/api.ts
</read_first>
<action>
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"] });
},
});
}
```
</action>
<verify>
<automated>grep "usePopularItems" src/client/hooks/useOnboarding.ts && grep "useCompleteOnboarding" src/client/hooks/useOnboarding.ts && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- `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`
</acceptance_criteria>
</task>
### Task 2: Create StepIndicator component
<task type="code">
<read_first>
- src/client/components/OnboardingWizard.tsx
</read_first>
<action>
Create `src/client/components/onboarding/StepIndicator.tsx`:
```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>
);
}
```
</action>
<verify>
<automated>grep "StepIndicator" src/client/components/onboarding/StepIndicator.tsx && grep "bg-gray-700" src/client/components/onboarding/StepIndicator.tsx && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
</task>
### Task 3: Create HobbyCard component
<task type="code">
<read_first>
- src/client/lib/iconData.ts
- src/shared/hobbyConfig.ts
</read_first>
<action>
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 (
<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>
);
}
```
</action>
<verify>
<automated>grep "HobbyCard" src/client/components/onboarding/HobbyCard.tsx && grep "ring-gray-700/20" src/client/components/onboarding/HobbyCard.tsx && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
</task>
### Task 4: Create SelectableItemCard component
<task type="code">
<read_first>
- src/client/components/GlobalItemCard.tsx
</read_first>
<action>
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 (
<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>
);
}
```
</action>
<verify>
<automated>grep "SelectableItemCard" src/client/components/onboarding/SelectableItemCard.tsx && grep "ring-gray-700/20" src/client/components/onboarding/SelectableItemCard.tsx && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
</task>
### Task 5: Create OnboardingWelcome step component
<task type="code">
<read_first>
- src/client/components/onboarding/StepIndicator.tsx
</read_first>
<action>
Create `src/client/components/onboarding/OnboardingWelcome.tsx`:
```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>
);
}
```
</action>
<verify>
<automated>grep "Welcome to GearBox" src/client/components/onboarding/OnboardingWelcome.tsx && grep "Let's go" src/client/components/onboarding/OnboardingWelcome.tsx && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- 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`
</acceptance_criteria>
</task>
### Task 6: Create OnboardingHobbyPicker step component
<task type="code">
<read_first>
- src/shared/hobbyConfig.ts
- src/client/components/onboarding/HobbyCard.tsx
</read_first>
<action>
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 (
<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>
);
}
```
</action>
<verify>
<automated>grep "OnboardingHobbyPicker" src/client/components/onboarding/OnboardingHobbyPicker.tsx && grep "What are you into" src/client/components/onboarding/OnboardingHobbyPicker.tsx && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
</task>
### Task 7: Create OnboardingItemBrowser step component
<task type="code">
<read_first>
- src/client/hooks/useOnboarding.ts
- src/client/components/onboarding/SelectableItemCard.tsx
- src/shared/hobbyConfig.ts
</read_first>
<action>
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<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>
);
}
```
</action>
<verify>
<automated>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"</automated>
</verify>
<acceptance_criteria>
- 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`
</acceptance_criteria>
</task>
### Task 8: Create OnboardingReview step component
<task type="code">
<read_first>
- src/client/hooks/useOnboarding.ts
- src/client/lib/iconData.ts
</read_first>
<action>
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<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>
);
}
```
</action>
<verify>
<automated>grep "OnboardingReview" src/client/components/onboarding/OnboardingReview.tsx && grep "Your starting collection" src/client/components/onboarding/OnboardingReview.tsx && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
</task>
### Task 9: Create OnboardingDone step component
<task type="code">
<read_first>
- src/client/components/onboarding/OnboardingWelcome.tsx
</read_first>
<action>
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 (
<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>
);
}
```
</action>
<verify>
<automated>grep "You're all set" src/client/components/onboarding/OnboardingDone.tsx && grep "Start exploring" src/client/components/onboarding/OnboardingDone.tsx && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- 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`
</acceptance_criteria>
</task>
### Task 10: Create OnboardingFlow orchestrator component
<task type="code">
<read_first>
- src/client/components/OnboardingWizard.tsx
- src/client/hooks/useOnboarding.ts
- src/shared/hobbyConfig.ts
</read_first>
<action>
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<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>
);
}
```
</action>
<verify>
<automated>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"</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
</task>
</tasks>
<verification>
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
</verification>
<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>

View File

@@ -0,0 +1,145 @@
---
phase: 30
plan: 03
type: integration
wave: 2
depends_on: [01, 02]
files_modified:
- src/client/routes/__root.tsx
- src/client/components/OnboardingWizard.tsx
autonomous: true
requirements: []
---
<objective>
Replace the old OnboardingWizard with the new OnboardingFlow in the root route trigger, ensure the onboarding flow triggers correctly on first login, and remove the old wizard component file.
</objective>
<tasks>
### Task 1: Replace OnboardingWizard with OnboardingFlow in root route
<task type="code">
<read_first>
- src/client/routes/__root.tsx
- src/client/components/OnboardingWizard.tsx
- src/client/components/onboarding/OnboardingFlow.tsx
</read_first>
<action>
Update `src/client/routes/__root.tsx`:
1. Replace the import:
- Remove: `import { OnboardingWizard } from "../components/OnboardingWizard";`
- Add: `import { OnboardingFlow } from "../components/onboarding/OnboardingFlow";`
2. Find the onboarding rendering logic (around lines 193+). The current code conditionally renders `<OnboardingWizard onComplete={...} />`. Replace with `<OnboardingFlow onComplete={...} />`.
The `onComplete` callback should:
- Dismiss the onboarding overlay (same behavior as current wizard)
- The OnboardingFlow already handles setting `onboardingComplete` via its internal hooks
The trigger logic stays the same: show onboarding when `onboardingComplete !== "true"` and user is authenticated.
</action>
<verify>
<automated>grep "OnboardingFlow" src/client/routes/__root.tsx && ! grep "OnboardingWizard" src/client/routes/__root.tsx && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- `__root.tsx` imports `OnboardingFlow` from `../components/onboarding/OnboardingFlow`
- No import of `OnboardingWizard` remains in `__root.tsx`
- `<OnboardingFlow onComplete={...} />` replaces `<OnboardingWizard onComplete={...} />`
- Onboarding trigger condition unchanged: authenticated + onboardingComplete !== "true"
</acceptance_criteria>
</task>
### Task 2: Remove old OnboardingWizard component
<task type="command">
<read_first>
- src/client/components/OnboardingWizard.tsx
</read_first>
<action>
Delete the old onboarding wizard file:
```bash
rm src/client/components/OnboardingWizard.tsx
```
Then verify no other files import it:
```bash
grep -r "OnboardingWizard" src/ --include="*.ts" --include="*.tsx"
```
If any references remain, update them to use OnboardingFlow or remove them.
</action>
<verify>
<automated>test ! -f src/client/components/OnboardingWizard.tsx && ! grep -r "OnboardingWizard" src/ --include="*.ts" --include="*.tsx" 2>/dev/null && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- `src/client/components/OnboardingWizard.tsx` file no longer exists
- No references to `OnboardingWizard` in any `.ts` or `.tsx` file under `src/`
</acceptance_criteria>
</task>
### Task 3: Verify onboarding trigger logic
<task type="code">
<read_first>
- src/client/routes/__root.tsx
</read_first>
<action>
Verify that the onboarding trigger in `__root.tsx` works correctly with the new flow:
1. The condition for showing onboarding should check:
- User is authenticated (session exists)
- `onboardingComplete` setting is not `"true"`
- Onboarding has not been dismissed in this session
2. The `onComplete` callback should:
- Set local state to dismiss the onboarding overlay
- The OnboardingFlow component handles the server-side setting update internally
3. Ensure the OnboardingFlow receives `onComplete` prop that triggers the root route to stop rendering the overlay.
No changes may be needed if the existing trigger logic already works with the new component signature (both old and new use `onComplete: () => void`). Verify and adjust only if needed.
</action>
<verify>
<automated>grep -A5 "onboardingComplete" src/client/routes/__root.tsx | grep -q "OnboardingFlow" && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- Onboarding renders when authenticated AND onboardingComplete !== "true"
- OnboardingFlow receives `onComplete` callback
- After completion, OnboardingFlow no longer renders
- Page behind onboarding is accessible after completion (no stuck overlay)
</acceptance_criteria>
</task>
</tasks>
<verification>
1. `bun run lint` passes
2. `bun test` passes
3. `bun run build` succeeds (no dead imports or missing modules)
4. New user (onboardingComplete not set) sees full-screen OnboardingFlow on login
5. After completing onboarding, OnboardingFlow is dismissed and collection is shown
6. Existing user (onboardingComplete = "true") does NOT see onboarding
7. Old OnboardingWizard.tsx file is gone
</verification>
<success_criteria>
- Old OnboardingWizard replaced with new OnboardingFlow
- Trigger logic preserved — shows for new users, hidden for existing
- Build succeeds with no dead imports
- Clean removal of old component file
</success_criteria>
<threat_model>
| Threat | Severity | Mitigation |
|--------|----------|------------|
| Onboarding overlay stuck on screen (JS error) | Medium | onComplete callback triggers local state dismissal; setting update is secondary |
| Old wizard references causing build failure | Low | grep verification ensures no stale imports remain |
</threat_model>
<must_haves>
- [ ] OnboardingWizard replaced by OnboardingFlow in __root.tsx
- [ ] Old OnboardingWizard.tsx deleted with no stale references
- [ ] Onboarding triggers correctly for new users
- [ ] Build succeeds
</must_haves>