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:
@@ -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'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'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user