chore: harden reliability checks #2
@ -1,15 +1,47 @@
|
|||||||
import { useContext, useState } from 'react';
|
import { useContext, useState } from "react";
|
||||||
import { HouseholdContext } from '../../context/HouseholdContext';
|
import { HouseholdContext } from "../../context/HouseholdContext";
|
||||||
import '../../styles/components/HouseholdSwitcher.css';
|
import "../../styles/components/HouseholdSwitcher.css";
|
||||||
import CreateJoinHousehold from '../manage/CreateJoinHousehold';
|
import CreateJoinHousehold from "../manage/CreateJoinHousehold";
|
||||||
|
|
||||||
export default function HouseholdSwitcher() {
|
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 [isOpen, setIsOpen] = useState(false);
|
||||||
const [showCreateJoin, setShowCreateJoin] = 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) {
|
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) => {
|
const handleSelect = (household) => {
|
||||||
@ -21,32 +53,35 @@ export default function HouseholdSwitcher() {
|
|||||||
<div className="household-switcher">
|
<div className="household-switcher">
|
||||||
<button
|
<button
|
||||||
className="household-switcher-toggle"
|
className="household-switcher-toggle"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
type="button"
|
||||||
|
onClick={() => setIsOpen((current) => !current)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<span className="household-name">{activeHousehold.name}</span>
|
<span className="household-name">{activeHousehold.name}</span>
|
||||||
<span className={`dropdown-icon ${isOpen ? 'open' : ''}`}>▼</span>
|
<span className={`dropdown-icon ${isOpen ? "open" : ""}`}>▾</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<>
|
<>
|
||||||
<div className="household-switcher-overlay" onClick={() => setIsOpen(false)} />
|
<div className="household-switcher-overlay" onClick={() => setIsOpen(false)} />
|
||||||
<div className="household-switcher-dropdown">
|
<div className="household-switcher-dropdown">
|
||||||
{households.map(household => (
|
{households.map((household) => (
|
||||||
<button
|
<button
|
||||||
key={household.id}
|
key={household.id}
|
||||||
className={`household-option ${household.id === activeHousehold.id ? 'active' : ''}`}
|
className={`household-option ${household.id === activeHousehold.id ? "active" : ""}`}
|
||||||
|
type="button"
|
||||||
onClick={() => handleSelect(household)}
|
onClick={() => handleSelect(household)}
|
||||||
>
|
>
|
||||||
{household.name}
|
{household.name}
|
||||||
{household.id === activeHousehold.id && (
|
{household.id === activeHousehold.id && (
|
||||||
<span className="check-mark">✓</span>
|
<span className="check-mark">✓</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<div className="household-divider"></div>
|
<div className="household-divider"></div>
|
||||||
<button
|
<button
|
||||||
className="household-option create-household-btn"
|
className="household-option create-household-btn"
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setShowCreateJoin(true);
|
setShowCreateJoin(true);
|
||||||
|
|||||||
69
frontend/src/components/household/NoHouseholdState.jsx
Normal file
69
frontend/src/components/household/NoHouseholdState.jsx
Normal 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'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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,21 +1,26 @@
|
|||||||
import { useContext, useState } from "react";
|
import { useContext, useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { createHousehold, joinHousehold } from "../../api/households";
|
import { joinHousehold } from "../../api/households";
|
||||||
import { HouseholdContext } from "../../context/HouseholdContext";
|
import { HouseholdContext } from "../../context/HouseholdContext";
|
||||||
import useActionToast from "../../hooks/useActionToast";
|
import useActionToast from "../../hooks/useActionToast";
|
||||||
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
||||||
import "../../styles/components/manage/CreateJoinHousehold.css";
|
import "../../styles/components/manage/CreateJoinHousehold.css";
|
||||||
|
|
||||||
export default function CreateJoinHousehold({ onClose }) {
|
export default function CreateJoinHousehold({ initialMode = "create", onClose }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const toast = useActionToast();
|
const toast = useActionToast();
|
||||||
const { refreshHouseholds } = useContext(HouseholdContext);
|
const { createHousehold: createHouseholdWithContext, refreshHouseholds } = useContext(HouseholdContext);
|
||||||
const [mode, setMode] = useState("create");
|
const [mode, setMode] = useState(initialMode === "join" ? "join" : "create");
|
||||||
const [householdName, setHouseholdName] = useState("");
|
const [householdName, setHouseholdName] = useState("");
|
||||||
const [inviteCode, setInviteCode] = useState("");
|
const [inviteCode, setInviteCode] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMode(initialMode === "join" ? "join" : "create");
|
||||||
|
setError("");
|
||||||
|
}, [initialMode]);
|
||||||
|
|
||||||
const extractInviteToken = (value) => {
|
const extractInviteToken = (value) => {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
@ -27,7 +32,7 @@ export default function CreateJoinHousehold({ onClose }) {
|
|||||||
const parsed = new URL(trimmed, window.location.origin);
|
const parsed = new URL(trimmed, window.location.origin);
|
||||||
const urlMatch = parsed.pathname.match(/^\/invite\/([a-zA-Z0-9]+)$/);
|
const urlMatch = parsed.pathname.match(/^\/invite\/([a-zA-Z0-9]+)$/);
|
||||||
if (urlMatch) return urlMatch[1];
|
if (urlMatch) return urlMatch[1];
|
||||||
} catch (error) {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -42,8 +47,7 @@ export default function CreateJoinHousehold({ onClose }) {
|
|||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createHousehold(householdName);
|
await createHouseholdWithContext(householdName);
|
||||||
await refreshHouseholds();
|
|
||||||
toast.success("Created household", `Created household ${householdName.trim()}`);
|
toast.success("Created household", `Created household ${householdName.trim()}`);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -91,7 +95,14 @@ export default function CreateJoinHousehold({ onClose }) {
|
|||||||
<div className="create-join-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="create-join-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h2>Household</h2>
|
<h2>Household</h2>
|
||||||
<button className="close-btn" onClick={onClose}>×</button>
|
<button
|
||||||
|
className="close-btn"
|
||||||
|
type="button"
|
||||||
|
aria-label="Close household dialog"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mode-tabs">
|
<div className="mode-tabs">
|
||||||
|
|||||||
@ -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 { createHousehold as createHouseholdApi, getUserHouseholds } from '../api/households';
|
||||||
import { AuthContext } from './AuthContext';
|
import { AuthContext } from './AuthContext';
|
||||||
|
|
||||||
@ -6,6 +6,7 @@ export const HouseholdContext = createContext({
|
|||||||
households: [],
|
households: [],
|
||||||
activeHousehold: null,
|
activeHousehold: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
hasLoaded: false,
|
||||||
error: null,
|
error: null,
|
||||||
setActiveHousehold: () => { },
|
setActiveHousehold: () => { },
|
||||||
refreshHouseholds: () => { },
|
refreshHouseholds: () => { },
|
||||||
@ -17,27 +18,65 @@ export const HouseholdProvider = ({ children }) => {
|
|||||||
const [households, setHouseholds] = useState([]);
|
const [households, setHouseholds] = useState([]);
|
||||||
const [activeHousehold, setActiveHouseholdState] = useState(null);
|
const [activeHousehold, setActiveHouseholdState] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
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
|
// Load households on mount and when token changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
if (token) {
|
||||||
|
setHasLoaded(false);
|
||||||
loadHouseholds();
|
loadHouseholds();
|
||||||
} else {
|
} else {
|
||||||
// Clear state when logged out
|
|
||||||
setHouseholds([]);
|
setHouseholds([]);
|
||||||
setActiveHouseholdState(null);
|
clearActiveHousehold();
|
||||||
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
|
setHasLoaded(false);
|
||||||
}
|
}
|
||||||
}, [token]);
|
}, [clearActiveHousehold, loadHouseholds, token]);
|
||||||
|
|
||||||
// Load active household from localStorage on mount
|
// Load active household from localStorage on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (households.length === 0) return;
|
if (households.length === 0) {
|
||||||
|
clearActiveHousehold();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[HouseholdContext] Setting active household from:', households);
|
console.log('[HouseholdContext] Setting active household from:', households);
|
||||||
const savedHouseholdId = localStorage.getItem('activeHouseholdId');
|
const savedHouseholdId = localStorage.getItem('activeHouseholdId');
|
||||||
if (savedHouseholdId) {
|
if (savedHouseholdId) {
|
||||||
const household = households.find(h => h.id === parseInt(savedHouseholdId));
|
const household = households.find(h => h.id === parseInt(savedHouseholdId, 10));
|
||||||
if (household) {
|
if (household) {
|
||||||
console.log('[HouseholdContext] Found saved household:', household);
|
console.log('[HouseholdContext] Found saved household:', household);
|
||||||
setActiveHouseholdState(household);
|
setActiveHouseholdState(household);
|
||||||
@ -49,26 +88,7 @@ export const HouseholdProvider = ({ children }) => {
|
|||||||
console.log('[HouseholdContext] Using first household:', households[0]);
|
console.log('[HouseholdContext] Using first household:', households[0]);
|
||||||
setActiveHouseholdState(households[0]);
|
setActiveHouseholdState(households[0]);
|
||||||
localStorage.setItem('activeHouseholdId', households[0].id);
|
localStorage.setItem('activeHouseholdId', households[0].id);
|
||||||
}, [households]);
|
}, [clearActiveHousehold, 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const setActiveHousehold = (household) => {
|
const setActiveHousehold = (household) => {
|
||||||
setActiveHouseholdState(household);
|
setActiveHouseholdState(household);
|
||||||
@ -101,6 +121,7 @@ export const HouseholdProvider = ({ children }) => {
|
|||||||
households,
|
households,
|
||||||
activeHousehold,
|
activeHousehold,
|
||||||
loading,
|
loading,
|
||||||
|
hasLoaded,
|
||||||
error,
|
error,
|
||||||
setActiveHousehold,
|
setActiveHousehold,
|
||||||
refreshHouseholds: loadHouseholds,
|
refreshHouseholds: loadHouseholds,
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
import { getHouseholdMembers } from "../api/households";
|
import { getHouseholdMembers } from "../api/households";
|
||||||
import SortDropdown from "../components/common/SortDropdown";
|
import SortDropdown from "../components/common/SortDropdown";
|
||||||
import AddItemForm from "../components/forms/AddItemForm";
|
import AddItemForm from "../components/forms/AddItemForm";
|
||||||
|
import NoHouseholdState from "../components/household/NoHouseholdState";
|
||||||
import GroceryListItem from "../components/items/GroceryListItem";
|
import GroceryListItem from "../components/items/GroceryListItem";
|
||||||
import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal";
|
import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal";
|
||||||
import ConfirmBuyModal from "../components/modals/ConfirmBuyModal";
|
import ConfirmBuyModal from "../components/modals/ConfirmBuyModal";
|
||||||
@ -80,7 +81,12 @@ function getNextModalItem(sortedItems, currentIndex, excludedItemId) {
|
|||||||
export default function GroceryList() {
|
export default function GroceryList() {
|
||||||
const pageTitle = "Grocery List";
|
const pageTitle = "Grocery List";
|
||||||
const { userId } = useContext(AuthContext);
|
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 { activeStore, stores, loading: storeLoading } = useContext(StoreContext);
|
||||||
const { settings } = useContext(SettingsContext);
|
const { settings } = useContext(SettingsContext);
|
||||||
const toast = useActionToast();
|
const toast = useActionToast();
|
||||||
@ -762,18 +768,29 @@ export default function GroceryList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
if (!activeHousehold) {
|
if (!householdsLoaded || householdLoading || (households.length > 0 && !activeHousehold)) {
|
||||||
return (
|
return (
|
||||||
<div className="glist-body">
|
<div className="glist-body">
|
||||||
<div className="glist-container">
|
<div className="glist-container">
|
||||||
<h1 className="glist-title">{pageTitle}</h1>
|
<h1 className="glist-title">{pageTitle}</h1>
|
||||||
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
|
<p style={{ textAlign: "center", marginTop: "2rem", color: "var(--text-secondary)" }}>
|
||||||
Loading households...
|
Loading households...
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!activeHousehold) {
|
||||||
|
return (
|
||||||
|
<div className="glist-body">
|
||||||
|
<div className="glist-container">
|
||||||
|
<h1 className="glist-title">{pageTitle}</h1>
|
||||||
|
<NoHouseholdState />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (storeLoading) {
|
if (storeLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import { useContext, useEffect, useState } from "react";
|
import { useContext, useEffect, useState } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import NoHouseholdState from "../components/household/NoHouseholdState";
|
||||||
import ManageHousehold from "../components/manage/ManageHousehold";
|
import ManageHousehold from "../components/manage/ManageHousehold";
|
||||||
import ManageStores from "../components/manage/ManageStores";
|
import ManageStores from "../components/manage/ManageStores";
|
||||||
import { HouseholdContext } from "../context/HouseholdContext";
|
import { HouseholdContext } from "../context/HouseholdContext";
|
||||||
import "../styles/pages/Manage.css";
|
import "../styles/pages/Manage.css";
|
||||||
|
|
||||||
export default function Manage() {
|
export default function Manage() {
|
||||||
const { activeHousehold } = useContext(HouseholdContext);
|
const { activeHousehold, households, loading, hasLoaded } = useContext(HouseholdContext);
|
||||||
const [activeTab, setActiveTab] = useState("household");
|
const [activeTab, setActiveTab] = useState("household");
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
@ -17,14 +18,25 @@ export default function Manage() {
|
|||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [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) {
|
if (!activeHousehold) {
|
||||||
return (
|
return (
|
||||||
<div className="manage-body">
|
<div className="manage-body">
|
||||||
<div className="manage-container">
|
<div className="manage-container">
|
||||||
<h1 className="manage-title">Manage</h1>
|
<h1 className="manage-title">Manage</h1>
|
||||||
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
|
<NoHouseholdState />
|
||||||
Loading household...
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
.household-switcher {
|
.household-switcher {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
min-width: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.household-switcher-toggle {
|
.household-switcher-toggle {
|
||||||
@ -8,6 +9,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@ -16,7 +18,6 @@
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.household-switcher-toggle:hover {
|
.household-switcher-toggle:hover {
|
||||||
@ -29,20 +30,30 @@
|
|||||||
cursor: not-allowed;
|
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 {
|
.household-name {
|
||||||
font-weight: 500;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: left;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
text-align: left;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-icon {
|
.dropdown-icon {
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
flex-shrink: 0;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-icon.open {
|
.dropdown-icon.open {
|
||||||
@ -51,10 +62,7 @@
|
|||||||
|
|
||||||
.household-switcher-overlay {
|
.household-switcher-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
inset: 0;
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,12 +72,12 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border: 2px solid var(--border);
|
border: 2px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.household-option {
|
.household-option {
|
||||||
@ -98,19 +106,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.household-option.active {
|
.household-option.active {
|
||||||
background: rgba(30, 144, 255, 0.15);
|
background: color-mix(in srgb, var(--primary) 15%, transparent);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.check-mark {
|
.check-mark {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
font-weight: bold;
|
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.household-divider {);
|
.household-divider {
|
||||||
|
height: 1px;
|
||||||
margin: 0.25rem 0;
|
margin: 0.25rem 0;
|
||||||
|
background: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-household-btn {
|
.create-household-btn {
|
||||||
@ -119,7 +129,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.create-household-btn:hover {
|
.create-household-btn:hover {
|
||||||
background: rgba(30, 144, 255, 0.15
|
background: color-mix(in srgb, var(--primary) 15%, transparent);
|
||||||
.create-household-btn:hover {
|
}
|
||||||
background: var(--primary-color-light);
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.household-switcher {
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
76
frontend/src/styles/components/NoHouseholdState.css
Normal file
76
frontend/src/styles/components/NoHouseholdState.css
Normal 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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
frontend/tests/household-onboarding.spec.ts
Normal file
59
frontend/tests/household-onboarding.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user