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 (
+
+ );
+ }
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();
+});