feat(30-03): replace OnboardingWizard with catalog-driven OnboardingFlow

Swap old 4-step modal wizard with new full-screen, hobby-personalized
onboarding experience. Delete OnboardingWizard.tsx.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 20:48:41 +02:00
parent 0db8771574
commit 115766cf60
2 changed files with 3 additions and 322 deletions

View File

@@ -1,319 +0,0 @@
import { useState } from "react";
import { useCreateCategory } from "../hooks/useCategories";
import { useCreateItem } from "../hooks/useItems";
import { useUpdateSetting } from "../hooks/useSettings";
import { LucideIcon } from "../lib/iconData";
import { IconPicker } from "./IconPicker";
interface OnboardingWizardProps {
onComplete: () => void;
}
export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
const [step, setStep] = useState(1);
// Step 2 state
const [categoryName, setCategoryName] = useState("");
const [categoryIcon, setCategoryIcon] = useState("");
const [categoryError, setCategoryError] = useState("");
const [createdCategoryId, setCreatedCategoryId] = useState<number | null>(
null,
);
// Step 3 state
const [itemName, setItemName] = useState("");
const [itemWeight, setItemWeight] = useState("");
const [itemPrice, setItemPrice] = useState("");
const [itemError, setItemError] = useState("");
const createCategory = useCreateCategory();
const createItem = useCreateItem();
const updateSetting = useUpdateSetting();
function handleSkip() {
updateSetting.mutate(
{ key: "onboardingComplete", value: "true" },
{ onSuccess: onComplete },
);
}
function handleCreateCategory() {
const name = categoryName.trim();
if (!name) {
setCategoryError("Please enter a category name");
return;
}
setCategoryError("");
createCategory.mutate(
{ name, icon: categoryIcon.trim() || undefined },
{
onSuccess: (created) => {
setCreatedCategoryId(created.id);
setStep(3);
},
onError: (err) => {
setCategoryError(err.message || "Failed to create category");
},
},
);
}
function handleCreateItem() {
const name = itemName.trim();
if (!name) {
setItemError("Please enter an item name");
return;
}
if (!createdCategoryId) return;
setItemError("");
const payload: any = {
name,
categoryId: createdCategoryId,
};
if (itemWeight) payload.weightGrams = Number(itemWeight);
if (itemPrice) payload.priceCents = Math.round(Number(itemPrice) * 100);
createItem.mutate(payload, {
onSuccess: () => setStep(4),
onError: (err) => {
setItemError(err.message || "Failed to add item");
},
});
}
function handleDone() {
updateSetting.mutate(
{ key: "onboardingComplete", value: "true" },
{ onSuccess: onComplete },
);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" />
{/* Card */}
<div className="relative z-10 w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl p-8">
{/* Step indicator */}
<div className="flex items-center justify-center gap-2 mb-6">
{[1, 2, 3].map((s) => (
<div
key={s}
className={`h-1.5 rounded-full transition-all ${
s <= Math.min(step, 3) ? "bg-gray-700 w-8" : "bg-gray-200 w-6"
}`}
/>
))}
</div>
{/* Step 1: Welcome */}
{step === 1 && (
<div className="text-center">
<h2 className="text-2xl font-semibold text-gray-900 mb-2">
Welcome to GearBox!
</h2>
<p className="text-gray-500 mb-8 leading-relaxed">
Track your gear, compare weights, and plan smarter purchases.
Let&apos;s set up your first category and item.
</p>
<button
type="button"
onClick={() => setStep(2)}
className="w-full py-3 px-4 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors"
>
Get Started
</button>
<button
type="button"
onClick={handleSkip}
className="mt-3 text-sm text-gray-400 hover:text-gray-600 transition-colors"
>
Skip setup
</button>
</div>
)}
{/* Step 2: Create category */}
{step === 2 && (
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-1">
Create a category
</h2>
<p className="text-sm text-gray-500 mb-6">
Categories help you organize your gear (e.g. Shelter, Cooking,
Clothing).
</p>
<div className="space-y-4">
<div>
<label
htmlFor="onboard-cat-name"
className="block text-sm font-medium text-gray-700 mb-1"
>
Category name *
</label>
<input
id="onboard-cat-name"
type="text"
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
placeholder="e.g. Shelter"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Icon (optional)
</label>
<IconPicker
value={categoryIcon}
onChange={setCategoryIcon}
size="md"
/>
</div>
{categoryError && (
<p className="text-xs text-red-500">{categoryError}</p>
)}
</div>
<button
type="button"
onClick={handleCreateCategory}
disabled={createCategory.isPending}
className="mt-6 w-full py-3 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
>
{createCategory.isPending ? "Creating..." : "Create Category"}
</button>
<button
type="button"
onClick={handleSkip}
className="mt-3 w-full text-sm text-gray-400 hover:text-gray-600 transition-colors"
>
Skip setup
</button>
</div>
)}
{/* Step 3: Add item */}
{step === 3 && (
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-1">
Add your first item
</h2>
<p className="text-sm text-gray-500 mb-6">
Add a piece of gear to your collection.
</p>
<div className="space-y-4">
<div>
<label
htmlFor="onboard-item-name"
className="block text-sm font-medium text-gray-700 mb-1"
>
Item name *
</label>
<input
id="onboard-item-name"
type="text"
value={itemName}
onChange={(e) => setItemName(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
placeholder="e.g. Big Agnes Copper Spur"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label
htmlFor="onboard-item-weight"
className="block text-sm font-medium text-gray-700 mb-1"
>
Weight (g)
</label>
<input
id="onboard-item-weight"
type="number"
min="0"
step="any"
value={itemWeight}
onChange={(e) => setItemWeight(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
placeholder="e.g. 1200"
/>
</div>
<div>
<label
htmlFor="onboard-item-price"
className="block text-sm font-medium text-gray-700 mb-1"
>
Price ($)
</label>
<input
id="onboard-item-price"
type="number"
min="0"
step="0.01"
value={itemPrice}
onChange={(e) => setItemPrice(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
placeholder="e.g. 349.99"
/>
</div>
</div>
{itemError && <p className="text-xs text-red-500">{itemError}</p>}
</div>
<button
type="button"
onClick={handleCreateItem}
disabled={createItem.isPending}
className="mt-6 w-full py-3 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
>
{createItem.isPending ? "Adding..." : "Add Item"}
</button>
<button
type="button"
onClick={handleSkip}
className="mt-3 w-full text-sm text-gray-400 hover:text-gray-600 transition-colors"
>
Skip setup
</button>
</div>
)}
{/* Step 4: Done */}
{step === 4 && (
<div className="text-center">
<div className="mb-4">
<LucideIcon
name="party-popper"
size={48}
className="text-gray-400 mx-auto"
/>
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
You&apos;re all set!
</h2>
<p className="text-sm text-gray-500 mb-8">
Your first item has been added. You can now browse your
collection, add more gear, and track your setup.
</p>
<button
type="button"
onClick={handleDone}
disabled={updateSetting.isPending}
className="w-full py-3 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
>
{updateSetting.isPending ? "Finishing..." : "Done"}
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -18,7 +18,7 @@ import { CatalogSearchOverlay } from "../components/CatalogSearchOverlay";
import { ConfirmDialog } from "../components/ConfirmDialog"; import { ConfirmDialog } from "../components/ConfirmDialog";
import { ExternalLinkDialog } from "../components/ExternalLinkDialog"; import { ExternalLinkDialog } from "../components/ExternalLinkDialog";
import { FabMenu } from "../components/FabMenu"; import { FabMenu } from "../components/FabMenu";
import { OnboardingWizard } from "../components/OnboardingWizard"; import { OnboardingFlow } from "../components/onboarding/OnboardingFlow";
import { TopNav } from "../components/TopNav"; import { TopNav } from "../components/TopNav";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import { useDeleteCandidate } from "../hooks/useCandidates"; import { useDeleteCandidate } from "../hooks/useCandidates";
@@ -188,9 +188,9 @@ function RootLayout() {
{/* Auth Prompt Modal */} {/* Auth Prompt Modal */}
<AuthPromptModal /> <AuthPromptModal />
{/* Onboarding Wizard */} {/* Onboarding Flow */}
{showWizard && ( {showWizard && (
<OnboardingWizard onComplete={() => setWizardDismissed(true)} /> <OnboardingFlow onComplete={() => setWizardDismissed(true)} />
)} )}
</div> </div>
); );