feat(30-02): build full-screen catalog-driven onboarding flow UI

Implements 5-step onboarding: Welcome, Hobby Picker, Item Browser,
Review, and Done. Includes hobby card selection, popular item grid
with check/uncheck, review list with remove, CSS step transitions,
and responsive grid layout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 20:46:55 +02:00
parent 1de91bc024
commit 5c18a3cd6c
10 changed files with 673 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
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>
);
}

View File

@@ -0,0 +1,36 @@
import { LucideIcon } from "../../lib/iconData";
interface OnboardingDoneProps {
itemsCreated: number;
onFinish: () => void;
}
export function OnboardingDone({ itemsCreated: _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">
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>
);
}

View File

@@ -0,0 +1,160 @@
import { useCallback, 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 handleSkipBrowse = useCallback(() => {
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>
);
}

View File

@@ -0,0 +1,47 @@
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>
);
}

View File

@@ -0,0 +1,96 @@
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>
);
}

View File

@@ -0,0 +1,128 @@
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>
);
}

View File

@@ -0,0 +1,26 @@
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>
);
}

View File

@@ -0,0 +1,87 @@
import { useFormatters } from "../../hooks/useFormatters";
import { LucideIcon } from "../../lib/iconData";
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>
);
}

View File

@@ -0,0 +1,14 @@
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>
);
}

View File

@@ -0,0 +1,44 @@
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"] });
},
});
}