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:
35
src/client/components/onboarding/HobbyCard.tsx
Normal file
35
src/client/components/onboarding/HobbyCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/client/components/onboarding/OnboardingDone.tsx
Normal file
36
src/client/components/onboarding/OnboardingDone.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
src/client/components/onboarding/OnboardingFlow.tsx
Normal file
160
src/client/components/onboarding/OnboardingFlow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/client/components/onboarding/OnboardingHobbyPicker.tsx
Normal file
47
src/client/components/onboarding/OnboardingHobbyPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
src/client/components/onboarding/OnboardingItemBrowser.tsx
Normal file
96
src/client/components/onboarding/OnboardingItemBrowser.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/client/components/onboarding/OnboardingReview.tsx
Normal file
128
src/client/components/onboarding/OnboardingReview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/client/components/onboarding/OnboardingWelcome.tsx
Normal file
26
src/client/components/onboarding/OnboardingWelcome.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
src/client/components/onboarding/SelectableItemCard.tsx
Normal file
87
src/client/components/onboarding/SelectableItemCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/client/components/onboarding/StepIndicator.tsx
Normal file
14
src/client/components/onboarding/StepIndicator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/client/hooks/useOnboarding.ts
Normal file
44
src/client/hooks/useOnboarding.ts
Normal 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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user