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:
977
.planning/phases/30-onboarding-redesign/30-02-PLAN.md
Normal file
977
.planning/phases/30-onboarding-redesign/30-02-PLAN.md
Normal 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>
|
||||
Reference in New Issue
Block a user