From 551040163566fca2d21534a2f0e14cd5c72abd14 Mon Sep 17 00:00:00 2001 From: Nico Date: Mon, 30 Mar 2026 23:38:05 -0700 Subject: [PATCH] fix: onboard users without households --- .../household/HouseholdSwitcher.jsx | 57 +++++++++++--- .../components/household/NoHouseholdState.jsx | 69 +++++++++++++++++ .../components/manage/CreateJoinHousehold.jsx | 29 ++++--- frontend/src/context/HouseholdContext.jsx | 73 +++++++++++------- frontend/src/pages/GroceryList.jsx | 43 +++++++---- frontend/src/pages/Manage.jsx | 20 ++++- .../styles/components/HouseholdSwitcher.css | 46 +++++++---- .../styles/components/NoHouseholdState.css | 76 +++++++++++++++++++ frontend/tests/household-onboarding.spec.ts | 59 ++++++++++++++ 9 files changed, 393 insertions(+), 79 deletions(-) create mode 100644 frontend/src/components/household/NoHouseholdState.jsx create mode 100644 frontend/src/styles/components/NoHouseholdState.css create mode 100644 frontend/tests/household-onboarding.spec.ts diff --git a/frontend/src/components/household/HouseholdSwitcher.jsx b/frontend/src/components/household/HouseholdSwitcher.jsx index b6cca74..8a01b27 100644 --- a/frontend/src/components/household/HouseholdSwitcher.jsx +++ b/frontend/src/components/household/HouseholdSwitcher.jsx @@ -1,15 +1,47 @@ -import { useContext, useState } from 'react'; -import { HouseholdContext } from '../../context/HouseholdContext'; -import '../../styles/components/HouseholdSwitcher.css'; -import CreateJoinHousehold from '../manage/CreateJoinHousehold'; +import { useContext, useState } from "react"; +import { HouseholdContext } from "../../context/HouseholdContext"; +import "../../styles/components/HouseholdSwitcher.css"; +import CreateJoinHousehold from "../manage/CreateJoinHousehold"; export default function HouseholdSwitcher() { - const { households, activeHousehold, setActiveHousehold, loading } = useContext(HouseholdContext); + const { + households, + activeHousehold, + setActiveHousehold, + loading, + hasLoaded, + } = useContext(HouseholdContext); const [isOpen, setIsOpen] = useState(false); const [showCreateJoin, setShowCreateJoin] = useState(false); + if (!hasLoaded || loading || (households.length > 0 && !activeHousehold)) { + return ( +
+ +
+ ); + } + if (!activeHousehold || households.length === 0) { - return null; + return ( + <> +
+ +
+ + {showCreateJoin && ( + setShowCreateJoin(false)} /> + )} + + ); } const handleSelect = (household) => { @@ -21,32 +53,35 @@ export default function HouseholdSwitcher() {
{isOpen && ( <>
setIsOpen(false)} />
- {households.map(household => ( + {households.map((household) => ( ))}
+ + {error && ( + + )} +
+
+ + + {showCreateJoin && ( + setShowCreateJoin(false)} + /> + )} + + ); +} diff --git a/frontend/src/components/manage/CreateJoinHousehold.jsx b/frontend/src/components/manage/CreateJoinHousehold.jsx index 7e3b1c7..275c152 100644 --- a/frontend/src/components/manage/CreateJoinHousehold.jsx +++ b/frontend/src/components/manage/CreateJoinHousehold.jsx @@ -1,21 +1,26 @@ -import { useContext, useState } from "react"; +import { useContext, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { createHousehold, joinHousehold } from "../../api/households"; +import { joinHousehold } from "../../api/households"; import { HouseholdContext } from "../../context/HouseholdContext"; import useActionToast from "../../hooks/useActionToast"; import getApiErrorMessage from "../../lib/getApiErrorMessage"; import "../../styles/components/manage/CreateJoinHousehold.css"; -export default function CreateJoinHousehold({ onClose }) { +export default function CreateJoinHousehold({ initialMode = "create", onClose }) { const navigate = useNavigate(); const toast = useActionToast(); - const { refreshHouseholds } = useContext(HouseholdContext); - const [mode, setMode] = useState("create"); + const { createHousehold: createHouseholdWithContext, refreshHouseholds } = useContext(HouseholdContext); + const [mode, setMode] = useState(initialMode === "join" ? "join" : "create"); const [householdName, setHouseholdName] = useState(""); const [inviteCode, setInviteCode] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + useEffect(() => { + setMode(initialMode === "join" ? "join" : "create"); + setError(""); + }, [initialMode]); + const extractInviteToken = (value) => { const trimmed = value.trim(); if (!trimmed) return null; @@ -27,7 +32,7 @@ export default function CreateJoinHousehold({ onClose }) { const parsed = new URL(trimmed, window.location.origin); const urlMatch = parsed.pathname.match(/^\/invite\/([a-zA-Z0-9]+)$/); if (urlMatch) return urlMatch[1]; - } catch (error) { + } catch { return null; } @@ -42,8 +47,7 @@ export default function CreateJoinHousehold({ onClose }) { setError(""); try { - await createHousehold(householdName); - await refreshHouseholds(); + await createHouseholdWithContext(householdName); toast.success("Created household", `Created household ${householdName.trim()}`); onClose(); } catch (err) { @@ -91,7 +95,14 @@ export default function CreateJoinHousehold({ onClose }) {
e.stopPropagation()}>

Household

- +
diff --git a/frontend/src/context/HouseholdContext.jsx b/frontend/src/context/HouseholdContext.jsx index 4a97959..326a806 100644 --- a/frontend/src/context/HouseholdContext.jsx +++ b/frontend/src/context/HouseholdContext.jsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useEffect, useState } from 'react'; +import { createContext, useCallback, useContext, useEffect, useState } from 'react'; import { createHousehold as createHouseholdApi, getUserHouseholds } from '../api/households'; import { AuthContext } from './AuthContext'; @@ -6,6 +6,7 @@ export const HouseholdContext = createContext({ households: [], activeHousehold: null, loading: false, + hasLoaded: false, error: null, setActiveHousehold: () => { }, refreshHouseholds: () => { }, @@ -17,27 +18,65 @@ export const HouseholdProvider = ({ children }) => { const [households, setHouseholds] = useState([]); const [activeHousehold, setActiveHouseholdState] = useState(null); const [loading, setLoading] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); const [error, setError] = useState(null); + const clearActiveHousehold = useCallback(() => { + setActiveHouseholdState(null); + localStorage.removeItem('activeHouseholdId'); + }, []); + + const loadHouseholds = useCallback(async () => { + if (!token) return; + + setLoading(true); + setError(null); + try { + console.log('[HouseholdContext] Loading households...'); + const response = await getUserHouseholds(); + const nextHouseholds = Array.isArray(response.data) ? response.data : []; + console.log('[HouseholdContext] Loaded households:', nextHouseholds); + setHouseholds(nextHouseholds); + + if (nextHouseholds.length === 0) { + clearActiveHousehold(); + } + } catch (err) { + console.error('[HouseholdContext] Failed to load households:', err); + setError(err.response?.data?.message || 'Failed to load households'); + setHouseholds([]); + clearActiveHousehold(); + } finally { + setLoading(false); + setHasLoaded(true); + } + }, [clearActiveHousehold, token]); + // Load households on mount and when token changes useEffect(() => { if (token) { + setHasLoaded(false); loadHouseholds(); } else { - // Clear state when logged out setHouseholds([]); - setActiveHouseholdState(null); + clearActiveHousehold(); + setError(null); + setLoading(false); + setHasLoaded(false); } - }, [token]); + }, [clearActiveHousehold, loadHouseholds, token]); // Load active household from localStorage on mount useEffect(() => { - if (households.length === 0) return; + if (households.length === 0) { + clearActiveHousehold(); + return; + } console.log('[HouseholdContext] Setting active household from:', households); const savedHouseholdId = localStorage.getItem('activeHouseholdId'); if (savedHouseholdId) { - const household = households.find(h => h.id === parseInt(savedHouseholdId)); + const household = households.find(h => h.id === parseInt(savedHouseholdId, 10)); if (household) { console.log('[HouseholdContext] Found saved household:', household); setActiveHouseholdState(household); @@ -49,26 +88,7 @@ export const HouseholdProvider = ({ children }) => { console.log('[HouseholdContext] Using first household:', households[0]); setActiveHouseholdState(households[0]); localStorage.setItem('activeHouseholdId', households[0].id); - }, [households]); - - const loadHouseholds = async () => { - if (!token) return; - - setLoading(true); - setError(null); - try { - console.log('[HouseholdContext] Loading households...'); - const response = await getUserHouseholds(); - console.log('[HouseholdContext] Loaded households:', response.data); - setHouseholds(response.data); - } catch (err) { - console.error('[HouseholdContext] Failed to load households:', err); - setError(err.response?.data?.message || 'Failed to load households'); - setHouseholds([]); - } finally { - setLoading(false); - } - }; + }, [clearActiveHousehold, households]); const setActiveHousehold = (household) => { setActiveHouseholdState(household); @@ -101,6 +121,7 @@ export const HouseholdProvider = ({ children }) => { households, activeHousehold, loading, + hasLoaded, error, setActiveHousehold, refreshHouseholds: loadHouseholds, diff --git a/frontend/src/pages/GroceryList.jsx b/frontend/src/pages/GroceryList.jsx index f1ad642..20cad76 100644 --- a/frontend/src/pages/GroceryList.jsx +++ b/frontend/src/pages/GroceryList.jsx @@ -13,6 +13,7 @@ import { import { getHouseholdMembers } from "../api/households"; import SortDropdown from "../components/common/SortDropdown"; import AddItemForm from "../components/forms/AddItemForm"; +import NoHouseholdState from "../components/household/NoHouseholdState"; import GroceryListItem from "../components/items/GroceryListItem"; import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal"; import ConfirmBuyModal from "../components/modals/ConfirmBuyModal"; @@ -80,7 +81,12 @@ function getNextModalItem(sortedItems, currentIndex, excludedItemId) { export default function GroceryList() { const pageTitle = "Grocery List"; const { userId } = useContext(AuthContext); - const { activeHousehold } = useContext(HouseholdContext); + const { + activeHousehold, + households, + loading: householdLoading, + hasLoaded: householdsLoaded + } = useContext(HouseholdContext); const { activeStore, stores, loading: storeLoading } = useContext(StoreContext); const { settings } = useContext(SettingsContext); const toast = useActionToast(); @@ -762,18 +768,29 @@ export default function GroceryList() { }; - if (!activeHousehold) { - return ( -
-
-

{pageTitle}

-

- Loading households... -

-
-
- ); - } + if (!householdsLoaded || householdLoading || (households.length > 0 && !activeHousehold)) { + return ( +
+
+

{pageTitle}

+

+ Loading households... +

+
+
+ ); + } + + if (!activeHousehold) { + return ( +
+
+

{pageTitle}

+ +
+
+ ); + } if (storeLoading) { return ( diff --git a/frontend/src/pages/Manage.jsx b/frontend/src/pages/Manage.jsx index 6406dc2..4bd6837 100644 --- a/frontend/src/pages/Manage.jsx +++ b/frontend/src/pages/Manage.jsx @@ -1,12 +1,13 @@ import { useContext, useEffect, useState } from "react"; import { useSearchParams } from "react-router-dom"; +import NoHouseholdState from "../components/household/NoHouseholdState"; import ManageHousehold from "../components/manage/ManageHousehold"; import ManageStores from "../components/manage/ManageStores"; import { HouseholdContext } from "../context/HouseholdContext"; import "../styles/pages/Manage.css"; export default function Manage() { - const { activeHousehold } = useContext(HouseholdContext); + const { activeHousehold, households, loading, hasLoaded } = useContext(HouseholdContext); const [activeTab, setActiveTab] = useState("household"); const [searchParams] = useSearchParams(); @@ -17,14 +18,25 @@ export default function Manage() { } }, [searchParams]); + if (!hasLoaded || loading || (households.length > 0 && !activeHousehold)) { + return ( +
+
+

Manage

+

+ Loading household... +

+
+
+ ); + } + if (!activeHousehold) { return (

Manage

-

- Loading household... -

+
); diff --git a/frontend/src/styles/components/HouseholdSwitcher.css b/frontend/src/styles/components/HouseholdSwitcher.css index b13ce602..5c94fca 100644 --- a/frontend/src/styles/components/HouseholdSwitcher.css +++ b/frontend/src/styles/components/HouseholdSwitcher.css @@ -1,6 +1,7 @@ .household-switcher { position: relative; display: inline-block; + min-width: 220px; } .household-switcher-toggle { @@ -8,6 +9,7 @@ align-items: center; justify-content: space-between; gap: 0.5rem; + width: 100%; padding: 0.5rem 1rem; background: var(--card-bg); border: 1px solid var(--border); @@ -16,7 +18,6 @@ font-size: 1rem; cursor: pointer; transition: all 0.2s ease; - width: 100%; } .household-switcher-toggle:hover { @@ -29,20 +30,30 @@ cursor: not-allowed; } +.household-switcher-cta { + justify-content: center; + font-weight: 600; + color: var(--primary); +} + +.household-switcher-empty .household-switcher-toggle { + width: 100%; +} + .household-name { - font-weight: 500; flex: 1; - text-align: left; overflow: hidden; + text-align: left; text-overflow: ellipsis; white-space: nowrap; + font-weight: 500; } .dropdown-icon { + margin-left: auto; + flex-shrink: 0; font-size: 0.75rem; transition: transform 0.2s ease; - flex-shrink: 0; - margin-left: auto; } .dropdown-icon.open { @@ -51,10 +62,7 @@ .household-switcher-overlay { position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; + inset: 0; z-index: 999; } @@ -64,12 +72,12 @@ left: 0; right: 0; width: 100%; + overflow: hidden; background: var(--card-bg); border: 2px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); z-index: 1000; - overflow: hidden; } .household-option { @@ -98,19 +106,21 @@ } .household-option.active { - background: rgba(30, 144, 255, 0.15); + background: color-mix(in srgb, var(--primary) 15%, transparent); color: var(--primary); font-weight: 600; } .check-mark { color: var(--primary); - font-weight: bold; font-size: 1.1rem; + font-weight: bold; } -.household-divider {); +.household-divider { + height: 1px; margin: 0.25rem 0; + background: var(--border); } .create-household-btn { @@ -119,7 +129,11 @@ } .create-household-btn:hover { - background: rgba(30, 144, 255, 0.15 -.create-household-btn:hover { - background: var(--primary-color-light); + background: color-mix(in srgb, var(--primary) 15%, transparent); +} + +@media (max-width: 640px) { + .household-switcher { + min-width: 180px; + } } diff --git a/frontend/src/styles/components/NoHouseholdState.css b/frontend/src/styles/components/NoHouseholdState.css new file mode 100644 index 0000000..ef64c6e --- /dev/null +++ b/frontend/src/styles/components/NoHouseholdState.css @@ -0,0 +1,76 @@ +.no-household-state { + display: flex; + justify-content: center; + margin: 2rem auto 0; +} + +.no-household-card { + width: min(100%, 38rem); + padding: 2rem; + border: 1px solid var(--border); + border-radius: 20px; + background: + radial-gradient(circle at top right, color-mix(in srgb, var(--primary) 14%, transparent), transparent 38%), + linear-gradient(180deg, color-mix(in srgb, var(--card-bg) 96%, white), var(--card-bg)); + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.12); + text-align: center; +} + +.no-household-eyebrow { + margin: 0 0 0.5rem; + font-size: 0.85rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--primary); +} + +.no-household-title { + margin: 0; + font-size: clamp(1.75rem, 4vw, 2.4rem); + color: var(--text-primary); +} + +.no-household-description { + margin: 1rem 0 0; + color: var(--text-secondary); + font-size: 1rem; + line-height: 1.6; +} + +.no-household-error { + margin: 1rem 0 0; + padding: 0.85rem 1rem; + border-radius: 12px; + background: var(--danger-light, rgba(220, 53, 69, 0.1)); + color: var(--danger); + border: 1px solid color-mix(in srgb, var(--danger) 50%, transparent); +} + +.no-household-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.75rem; + margin-top: 1.5rem; +} + +.no-household-action { + min-width: 11rem; + min-height: 44px; +} + +@media (max-width: 640px) { + .no-household-card { + padding: 1.5rem; + border-radius: 16px; + } + + .no-household-actions { + flex-direction: column; + } + + .no-household-action { + width: 100%; + } +} diff --git a/frontend/tests/household-onboarding.spec.ts b/frontend/tests/household-onboarding.spec.ts new file mode 100644 index 0000000..6acbefe --- /dev/null +++ b/frontend/tests/household-onboarding.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from "@playwright/test"; + +function seedAuthStorage(page: import("@playwright/test").Page) { + return page.addInitScript(() => { + localStorage.setItem("token", "test-token"); + localStorage.setItem("userId", "1"); + localStorage.setItem("role", "admin"); + localStorage.setItem("username", "new-user"); + }); +} + +async function mockConfig(page: import("@playwright/test").Page) { + await page.route("**/config", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + maxFileSizeMB: 20, + maxImageDimension: 800, + imageQuality: 85, + }), + }); + }); +} + +test("new users with no households see create and join actions instead of a loading dead-end", async ({ page }) => { + await seedAuthStorage(page); + await mockConfig(page); + + await page.route("**/households", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]), + }); + }); + + await page.goto("/"); + + await expect(page.getByRole("button", { name: "Create or Join Household" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "No household yet" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Create Household" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Join Household" })).toBeVisible(); + + await page.getByRole("button", { name: "Join Household" }).click(); + await expect(page.getByLabel("Invite Code or Link")).toBeVisible(); + await page.getByRole("button", { name: "Close household dialog" }).click(); + + await page.getByRole("button", { name: "Create Household" }).click(); + await expect(page.getByLabel("Household Name")).toBeVisible(); + await page.getByRole("button", { name: "Close household dialog" }).click(); + + await page.goto("/manage"); + + await expect(page.getByRole("heading", { name: "Manage" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "No household yet" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Create Household" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Join Household" })).toBeVisible(); +});