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:
2026-03-14 22:51:25 +01:00
parent 0084cc0608
commit 9fcbf0bab5
5 changed files with 422 additions and 0 deletions

View 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&apos;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="&#9978;"
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">&#127881;</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-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>
);
}

View File

@@ -0,0 +1,37 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiGet, apiPut } from "../lib/api";
interface Setting {
key: string;
value: string;
}
export function useSetting(key: string) {
return useQuery({
queryKey: ["settings", key],
queryFn: async () => {
try {
const result = await apiGet<Setting>(`/api/settings/${key}`);
return result.value;
} catch (err: any) {
if (err?.status === 404) return null;
throw err;
}
},
});
}
export function useUpdateSetting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ key, value }: { key: string; value: string }) =>
apiPut<Setting>(`/api/settings/${key}`, { value }),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["settings", variables.key] });
},
});
}
export function useOnboardingComplete() {
return useSetting("onboardingComplete");
}

View File

@@ -1,10 +1,13 @@
import { useState } from "react";
import { createRootRoute, Outlet } from "@tanstack/react-router";
import "../app.css";
import { TotalsBar } from "../components/TotalsBar";
import { SlideOutPanel } from "../components/SlideOutPanel";
import { ItemForm } from "../components/ItemForm";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { OnboardingWizard } from "../components/OnboardingWizard";
import { useUIStore } from "../stores/uiStore";
import { useOnboardingComplete } from "../hooks/useSettings";
export const Route = createRootRoute({
component: RootLayout,
@@ -16,8 +19,24 @@ function RootLayout() {
const openAddPanel = useUIStore((s) => s.openAddPanel);
const closePanel = useUIStore((s) => s.closePanel);
const { data: onboardingComplete, isLoading: onboardingLoading } =
useOnboardingComplete();
const [wizardDismissed, setWizardDismissed] = useState(false);
const showWizard =
!onboardingLoading && onboardingComplete !== "true" && !wizardDismissed;
const isOpen = panelMode !== "closed";
// Show a minimal loading state while checking onboarding status
if (onboardingLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<TotalsBar />
@@ -59,6 +78,11 @@ function RootLayout() {
/>
</svg>
</button>
{/* Onboarding Wizard */}
{showWizard && (
<OnboardingWizard onComplete={() => setWizardDismissed(true)} />
)}
</div>
);
}