feat(01-04): add onboarding wizard with settings API and persisted state
- Settings API: GET/PUT /api/settings/:key with SQLite persistence - useSettings hook with TanStack Query for settings CRUD - OnboardingWizard: 3-step modal overlay (welcome, create category, add item) - Root layout checks onboarding completion flag before rendering wizard - Skip option available at every step, all paths persist completion to DB Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
322
src/client/components/OnboardingWizard.tsx
Normal file
322
src/client/components/OnboardingWizard.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import { useState } from "react";
|
||||
import { useCreateCategory } from "../hooks/useCategories";
|
||||
import { useCreateItem } from "../hooks/useItems";
|
||||
import { useUpdateSetting } from "../hooks/useSettings";
|
||||
|
||||
interface OnboardingWizardProps {
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
||||
const [step, setStep] = useState(1);
|
||||
|
||||
// Step 2 state
|
||||
const [categoryName, setCategoryName] = useState("");
|
||||
const [categoryEmoji, setCategoryEmoji] = 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, emoji: categoryEmoji.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-blue-600 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-blue-600 hover:bg-blue-700 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-blue-500 focus:border-transparent"
|
||||
placeholder="e.g. Shelter"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="onboard-cat-emoji"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Emoji (optional)
|
||||
</label>
|
||||
<input
|
||||
id="onboard-cat-emoji"
|
||||
type="text"
|
||||
value={categoryEmoji}
|
||||
onChange={(e) => setCategoryEmoji(e.target.value)}
|
||||
className="w-20 px-3 py-2 border border-gray-200 rounded-lg text-center text-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="⛺"
|
||||
maxLength={4}
|
||||
/>
|
||||
</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-blue-600 hover:bg-blue-700 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-blue-500 focus:border-transparent"
|
||||
placeholder="e.g. Big Agnes Copper Spur"
|
||||
autoFocus
|
||||
/>
|
||||
</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-blue-500 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-blue-500 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-blue-600 hover:bg-blue-700 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="text-4xl mb-4">🎉</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-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{updateSetting.isPending ? "Finishing..." : "Done"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user