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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/client/hooks/useSettings.ts
Normal file
37
src/client/hooks/useSettings.ts
Normal 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");
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||||
import "../app.css";
|
import "../app.css";
|
||||||
import { TotalsBar } from "../components/TotalsBar";
|
import { TotalsBar } from "../components/TotalsBar";
|
||||||
import { SlideOutPanel } from "../components/SlideOutPanel";
|
import { SlideOutPanel } from "../components/SlideOutPanel";
|
||||||
import { ItemForm } from "../components/ItemForm";
|
import { ItemForm } from "../components/ItemForm";
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
|
import { OnboardingWizard } from "../components/OnboardingWizard";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
import { useOnboardingComplete } from "../hooks/useSettings";
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
@@ -16,8 +19,24 @@ function RootLayout() {
|
|||||||
const openAddPanel = useUIStore((s) => s.openAddPanel);
|
const openAddPanel = useUIStore((s) => s.openAddPanel);
|
||||||
const closePanel = useUIStore((s) => s.closePanel);
|
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";
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<TotalsBar />
|
<TotalsBar />
|
||||||
@@ -59,6 +78,11 @@ function RootLayout() {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Onboarding Wizard */}
|
||||||
|
{showWizard && (
|
||||||
|
<OnboardingWizard onComplete={() => setWizardDismissed(true)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { itemRoutes } from "./routes/items.ts";
|
|||||||
import { categoryRoutes } from "./routes/categories.ts";
|
import { categoryRoutes } from "./routes/categories.ts";
|
||||||
import { totalRoutes } from "./routes/totals.ts";
|
import { totalRoutes } from "./routes/totals.ts";
|
||||||
import { imageRoutes } from "./routes/images.ts";
|
import { imageRoutes } from "./routes/images.ts";
|
||||||
|
import { settingsRoutes } from "./routes/settings.ts";
|
||||||
|
|
||||||
// Seed default data on startup
|
// Seed default data on startup
|
||||||
seedDefaults();
|
seedDefaults();
|
||||||
@@ -21,6 +22,7 @@ app.route("/api/items", itemRoutes);
|
|||||||
app.route("/api/categories", categoryRoutes);
|
app.route("/api/categories", categoryRoutes);
|
||||||
app.route("/api/totals", totalRoutes);
|
app.route("/api/totals", totalRoutes);
|
||||||
app.route("/api/images", imageRoutes);
|
app.route("/api/images", imageRoutes);
|
||||||
|
app.route("/api/settings", settingsRoutes);
|
||||||
|
|
||||||
// Serve uploaded images
|
// Serve uploaded images
|
||||||
app.use("/uploads/*", serveStatic({ root: "./" }));
|
app.use("/uploads/*", serveStatic({ root: "./" }));
|
||||||
|
|||||||
37
src/server/routes/settings.ts
Normal file
37
src/server/routes/settings.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db as prodDb } from "../../db/index.ts";
|
||||||
|
import { settings } from "../../db/schema.ts";
|
||||||
|
|
||||||
|
type Env = { Variables: { db?: any } };
|
||||||
|
|
||||||
|
const app = new Hono<Env>();
|
||||||
|
|
||||||
|
app.get("/:key", (c) => {
|
||||||
|
const database = c.get("db") ?? prodDb;
|
||||||
|
const key = c.req.param("key");
|
||||||
|
const row = database.select().from(settings).where(eq(settings.key, key)).get();
|
||||||
|
if (!row) return c.json({ error: "Setting not found" }, 404);
|
||||||
|
return c.json(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/:key", async (c) => {
|
||||||
|
const database = c.get("db") ?? prodDb;
|
||||||
|
const key = c.req.param("key");
|
||||||
|
const body = await c.req.json<{ value: string }>();
|
||||||
|
|
||||||
|
if (!body.value && body.value !== "") {
|
||||||
|
return c.json({ error: "value is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
database
|
||||||
|
.insert(settings)
|
||||||
|
.values({ key, value: body.value })
|
||||||
|
.onConflictDoUpdate({ target: settings.key, set: { value: body.value } })
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const row = database.select().from(settings).where(eq(settings.key, key)).get();
|
||||||
|
return c.json(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
export { app as settingsRoutes };
|
||||||
Reference in New Issue
Block a user