fix: onboard users without households

This commit is contained in:
Nico 2026-03-30 23:38:05 -07:00
parent dc422f6127
commit 5510401635
9 changed files with 393 additions and 79 deletions

View File

@ -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 (
<div className="household-switcher household-switcher-empty">
<button className="household-switcher-toggle" type="button" disabled>
<span className="household-name">Loading households...</span>
</button>
</div>
);
}
if (!activeHousehold || households.length === 0) {
return null;
return (
<>
<div className="household-switcher household-switcher-empty">
<button
className="household-switcher-toggle household-switcher-cta"
type="button"
onClick={() => setShowCreateJoin(true)}
>
<span className="household-name">Create or Join Household</span>
</button>
</div>
{showCreateJoin && (
<CreateJoinHousehold onClose={() => setShowCreateJoin(false)} />
)}
</>
);
}
const handleSelect = (household) => {
@ -21,32 +53,35 @@ export default function HouseholdSwitcher() {
<div className="household-switcher">
<button
className="household-switcher-toggle"
onClick={() => setIsOpen(!isOpen)}
type="button"
onClick={() => setIsOpen((current) => !current)}
disabled={loading}
>
<span className="household-name">{activeHousehold.name}</span>
<span className={`dropdown-icon ${isOpen ? 'open' : ''}`}></span>
<span className={`dropdown-icon ${isOpen ? "open" : ""}`}>&#9662;</span>
</button>
{isOpen && (
<>
<div className="household-switcher-overlay" onClick={() => setIsOpen(false)} />
<div className="household-switcher-dropdown">
{households.map(household => (
{households.map((household) => (
<button
key={household.id}
className={`household-option ${household.id === activeHousehold.id ? 'active' : ''}`}
className={`household-option ${household.id === activeHousehold.id ? "active" : ""}`}
type="button"
onClick={() => handleSelect(household)}
>
{household.name}
{household.id === activeHousehold.id && (
<span className="check-mark"></span>
<span className="check-mark">&#10003;</span>
)}
</button>
))}
<div className="household-divider"></div>
<button
className="household-option create-household-btn"
type="button"
onClick={() => {
setIsOpen(false);
setShowCreateJoin(true);

View File

@ -0,0 +1,69 @@
import { useContext, useState } from "react";
import { HouseholdContext } from "../../context/HouseholdContext";
import "../../styles/components/NoHouseholdState.css";
import CreateJoinHousehold from "../manage/CreateJoinHousehold";
export default function NoHouseholdState({
title = "No household yet",
description = "Create a household to start building lists, or join one with an invite code or invite link.",
}) {
const { error, refreshHouseholds } = useContext(HouseholdContext);
const [showCreateJoin, setShowCreateJoin] = useState(false);
const [modalMode, setModalMode] = useState("create");
const openModal = (nextMode) => {
setModalMode(nextMode);
setShowCreateJoin(true);
};
return (
<>
<section className="no-household-state" aria-live="polite">
<div className="no-household-card">
<p className="no-household-eyebrow">Welcome</p>
<h2 className="no-household-title">{title}</h2>
<p className="no-household-description">{description}</p>
{error && (
<p className="no-household-error">
We couldn&apos;t load households: {error}
</p>
)}
<div className="no-household-actions">
<button
type="button"
className="btn-primary no-household-action"
onClick={() => openModal("create")}
>
Create Household
</button>
<button
type="button"
className="btn-secondary no-household-action"
onClick={() => openModal("join")}
>
Join Household
</button>
{error && (
<button
type="button"
className="btn-secondary no-household-action"
onClick={refreshHouseholds}
>
Retry Loading
</button>
)}
</div>
</div>
</section>
{showCreateJoin && (
<CreateJoinHousehold
initialMode={modalMode}
onClose={() => setShowCreateJoin(false)}
/>
)}
</>
);
}

View File

@ -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 }) {
<div className="create-join-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Household</h2>
<button className="close-btn" onClick={onClose}>&times;</button>
<button
className="close-btn"
type="button"
aria-label="Close household dialog"
onClick={onClose}
>
&times;
</button>
</div>
<div className="mode-tabs">

View File

@ -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,

View File

@ -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 (
<div className="glist-body">
<div className="glist-container">
<h1 className="glist-title">{pageTitle}</h1>
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
Loading households...
</p>
</div>
</div>
);
}
if (!householdsLoaded || householdLoading || (households.length > 0 && !activeHousehold)) {
return (
<div className="glist-body">
<div className="glist-container">
<h1 className="glist-title">{pageTitle}</h1>
<p style={{ textAlign: "center", marginTop: "2rem", color: "var(--text-secondary)" }}>
Loading households...
</p>
</div>
</div>
);
}
if (!activeHousehold) {
return (
<div className="glist-body">
<div className="glist-container">
<h1 className="glist-title">{pageTitle}</h1>
<NoHouseholdState />
</div>
</div>
);
}
if (storeLoading) {
return (

View File

@ -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 (
<div className="manage-body">
<div className="manage-container">
<h1 className="manage-title">Manage</h1>
<p style={{ textAlign: "center", marginTop: "2rem", color: "var(--text-secondary)" }}>
Loading household...
</p>
</div>
</div>
);
}
if (!activeHousehold) {
return (
<div className="manage-body">
<div className="manage-container">
<h1 className="manage-title">Manage</h1>
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
Loading household...
</p>
<NoHouseholdState />
</div>
</div>
);

View File

@ -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;
}
}

View File

@ -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%;
}
}

View File

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