diff --git a/src/client/components/OnboardingWizard.tsx b/src/client/components/OnboardingWizard.tsx new file mode 100644 index 0000000..a77d528 --- /dev/null +++ b/src/client/components/OnboardingWizard.tsx @@ -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(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 ( +
+ {/* Backdrop */} +
+ + {/* Card */} +
+ {/* Step indicator */} +
+ {[1, 2, 3].map((s) => ( +
+ ))} +
+ + {/* Step 1: Welcome */} + {step === 1 && ( +
+

+ Welcome to GearBox! +

+

+ Track your gear, compare weights, and plan smarter purchases. + Let's set up your first category and item. +

+ + +
+ )} + + {/* Step 2: Create category */} + {step === 2 && ( +
+

+ Create a category +

+

+ Categories help you organize your gear (e.g. Shelter, Cooking, + Clothing). +

+ +
+
+ + 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 + /> +
+ +
+ + 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} + /> +
+ + {categoryError && ( +

{categoryError}

+ )} +
+ + + +
+ )} + + {/* Step 3: Add item */} + {step === 3 && ( +
+

+ Add your first item +

+

+ Add a piece of gear to your collection. +

+ +
+
+ + 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 + /> +
+ +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + {itemError && ( +

{itemError}

+ )} +
+ + + +
+ )} + + {/* Step 4: Done */} + {step === 4 && ( +
+
🎉
+

+ You're all set! +

+

+ Your first item has been added. You can now browse your collection, + add more gear, and track your setup. +

+ +
+ )} +
+
+ ); +} diff --git a/src/client/hooks/useSettings.ts b/src/client/hooks/useSettings.ts new file mode 100644 index 0000000..47d9a0a --- /dev/null +++ b/src/client/hooks/useSettings.ts @@ -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(`/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(`/api/settings/${key}`, { value }), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ["settings", variables.key] }); + }, + }); +} + +export function useOnboardingComplete() { + return useSetting("onboardingComplete"); +} diff --git a/src/client/routes/__root.tsx b/src/client/routes/__root.tsx index 4f645ec..108b8f1 100644 --- a/src/client/routes/__root.tsx +++ b/src/client/routes/__root.tsx @@ -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 ( +
+
+
+ ); + } + return (
@@ -59,6 +78,11 @@ function RootLayout() { /> + + {/* Onboarding Wizard */} + {showWizard && ( + setWizardDismissed(true)} /> + )}
); } diff --git a/src/server/index.ts b/src/server/index.ts index fa03f59..20fffcb 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -5,6 +5,7 @@ import { itemRoutes } from "./routes/items.ts"; import { categoryRoutes } from "./routes/categories.ts"; import { totalRoutes } from "./routes/totals.ts"; import { imageRoutes } from "./routes/images.ts"; +import { settingsRoutes } from "./routes/settings.ts"; // Seed default data on startup seedDefaults(); @@ -21,6 +22,7 @@ app.route("/api/items", itemRoutes); app.route("/api/categories", categoryRoutes); app.route("/api/totals", totalRoutes); app.route("/api/images", imageRoutes); +app.route("/api/settings", settingsRoutes); // Serve uploaded images app.use("/uploads/*", serveStatic({ root: "./" })); diff --git a/src/server/routes/settings.ts b/src/server/routes/settings.ts new file mode 100644 index 0000000..9fb66ea --- /dev/null +++ b/src/server/routes/settings.ts @@ -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(); + +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 };