Included household/stores management features

This commit is contained in:
Nico 2026-01-26 00:37:43 -08:00
parent 9fc25f2274
commit 213134c4a5
18 changed files with 1592 additions and 27 deletions

View File

@ -0,0 +1,241 @@
# Household & Store Management - Implementation Summary
## Overview
Built comprehensive household and store management UI for the multi-household grocery list application. Users can now fully manage their households, members, and stores through a polished interface.
## Features Implemented
### 1. Manage Page (`/manage`)
**Location**: [frontend/src/pages/Manage.jsx](frontend/src/pages/Manage.jsx)
- Tab-based interface for Household and Store management
- Context-aware - always operates on the active household
- Accessible via "Manage" link in the navbar
### 2. Household Management
**Component**: [frontend/src/components/manage/ManageHousehold.jsx](frontend/src/components/manage/ManageHousehold.jsx)
**Features**:
- **Edit Household Name**: Admin-only, inline editing
- **Invite Code Management**:
- Show/hide invite code with copy-to-clipboard
- Generate new invite code (invalidates old one)
- Admin-only access
- **Member Management**:
- View all household members with roles
- Promote/demote members between admin and member roles
- Remove members from household
- Cannot remove yourself
- Admin-only actions
- **Delete Household**:
- Admin-only
- Double confirmation required
- Permanently deletes all data
**Permissions**:
- Viewers: Can only see household name and members
- Members: Same as viewers
- Admins: Full access to all features
### 3. Store Management
**Component**: [frontend/src/components/manage/ManageStores.jsx](frontend/src/components/manage/ManageStores.jsx)
**Features**:
- **View Household Stores**:
- Grid layout showing all stores
- Shows store name, location, and default status
- **Add Stores**:
- Select from system-wide store catalog
- Admin-only
- Cannot add already-linked stores
- **Remove Stores**:
- Admin-only
- Cannot remove last store (validation)
- **Set Default Store**:
- Admin-only
- Default store loads automatically
**Permissions**:
- Viewers & Members: Read-only view of stores
- Admins: Full CRUD operations
### 4. Create/Join Household Modal
**Component**: [frontend/src/components/manage/CreateJoinHousehold.jsx](frontend/src/components/manage/CreateJoinHousehold.jsx)
**Features**:
- Tabbed interface: "Create New" or "Join Existing"
- **Create Mode**:
- Enter household name
- Auto-generates invite code
- Creates household with user as admin
- **Join Mode**:
- Enter invite code
- Validates code and adds user as member
- Error handling for invalid codes
**Access**:
- Available from household switcher dropdown
- "+ Create or Join Household" button at bottom
- All authenticated users can access
### 5. Updated Household Switcher
**Component**: [frontend/src/components/household/HouseholdSwitcher.jsx](frontend/src/components/household/HouseholdSwitcher.jsx)
**Enhancements**:
- Added divider between household list and actions
- "+ Create or Join Household" button
- Opens CreateJoinHousehold modal
## Styling
### CSS Files Created
1. **[frontend/src/styles/pages/Manage.css](frontend/src/styles/pages/Manage.css)**
- Page layout and tab navigation
- Responsive design
2. **[frontend/src/styles/components/manage/ManageHousehold.css](frontend/src/styles/components/manage/ManageHousehold.css)**
- Section cards with proper spacing
- Member cards with role badges
- Invite code display
- Danger zone styling
- Button styles (primary, secondary, danger)
3. **[frontend/src/styles/components/manage/ManageStores.css](frontend/src/styles/components/manage/ManageStores.css)**
- Grid layout for store cards
- Default badge styling
- Add store panel
- Available stores grid
4. **[frontend/src/styles/components/manage/CreateJoinHousehold.css](frontend/src/styles/components/manage/CreateJoinHousehold.css)**
- Modal overlay and container
- Mode tabs styling
- Form inputs and buttons
- Error message styling
### Theme Updates
**[frontend/src/styles/theme.css](frontend/src/styles/theme.css)**
Added simplified CSS variable aliases:
```css
--primary: var(--color-primary);
--primary-dark: var(--color-primary-dark);
--primary-light: var(--color-primary-light);
--danger: var(--color-danger);
--danger-dark: var(--color-danger-hover);
--text-primary: var(--color-text-primary);
--text-secondary: var(--color-text-secondary);
--background: var(--color-bg-body);
--border: var(--color-border-light);
--card-hover: var(--color-bg-hover);
```
## Backend Endpoints Used
All endpoints already existed - no backend changes required!
### Household Endpoints
- `GET /households` - Get user's households
- `POST /households` - Create household
- `PATCH /households/:id` - Update household name
- `DELETE /households/:id` - Delete household
- `POST /households/:id/invite/refresh` - Refresh invite code
- `POST /households/join/:inviteCode` - Join via invite code
- `GET /households/:id/members` - Get members
- `PATCH /households/:id/members/:userId/role` - Update member role
- `DELETE /households/:id/members/:userId` - Remove member
### Store Endpoints
- `GET /stores` - Get all stores
- `GET /stores/household/:householdId` - Get household stores
- `POST /stores/household/:householdId` - Add store to household
- `DELETE /stores/household/:householdId/:storeId` - Remove store
- `PATCH /stores/household/:householdId/:storeId/default` - Set default
## User Flow
### Managing Household
1. Click "Manage" in navbar
2. View household overview (name, members, invite code)
3. As admin:
- Edit household name
- Generate new invite codes
- Promote/demote members
- Remove members
- Delete household (danger zone)
### Managing Stores
1. Click "Manage" in navbar
2. Click "Stores" tab
3. View all linked stores with default badge
4. As admin:
- Click "+ Add Store" to see available stores
- Click "Add" on any unlinked store
- Click "Set as Default" on non-default stores
- Click "Remove" to unlink store (except last one)
### Creating/Joining Household
1. Click household name in navbar
2. Click "+ Create or Join Household" at bottom of dropdown
3. Select "Create New" or "Join Existing" tab
4. Fill form and submit
5. New household appears in list and becomes active
## Responsive Design
All components are fully responsive:
- **Desktop**: Grid layouts, side-by-side buttons
- **Tablet**: Adjusted spacing, smaller grids
- **Mobile**:
- Single column layouts
- Full-width buttons
- Stacked form elements
- Optimized spacing
## Permissions Summary
| Feature | Viewer | Member | Admin |
|---------|--------|--------|-------|
| View household info | ✅ | ✅ | ✅ |
| Edit household name | ❌ | ❌ | ✅ |
| View invite code | ❌ | ❌ | ✅ |
| Refresh invite code | ❌ | ❌ | ✅ |
| View members | ✅ | ✅ | ✅ |
| Change member roles | ❌ | ❌ | ✅ |
| Remove members | ❌ | ❌ | ✅ |
| Delete household | ❌ | ❌ | ✅ |
| View stores | ✅ | ✅ | ✅ |
| Add stores | ❌ | ❌ | ✅ |
| Remove stores | ❌ | ❌ | ✅ |
| Set default store | ❌ | ❌ | ✅ |
| Create household | ✅ | ✅ | ✅ |
| Join household | ✅ | ✅ | ✅ |
## Next Steps
Consider adding:
1. **Household Settings**: Description, profile image, preferences
2. **Member Invitations**: Direct user search instead of just invite codes
3. **Store Details**: View item counts, last activity per store
4. **Audit Log**: Track household/store changes
5. **Notifications**: Member added/removed, role changes
6. **Bulk Operations**: Remove multiple members at once
7. **Store Categories**: Group stores by region/type
8. **Export Data**: Download household grocery history
## Testing Checklist
- [ ] Create new household and verify admin role
- [ ] Generate and copy invite code
- [ ] Join household using invite code
- [ ] Edit household name as admin
- [ ] Promote member to admin
- [ ] Demote admin to member
- [ ] Remove member from household
- [ ] Add store to household
- [ ] Set default store
- [ ] Remove store (verify last store protection)
- [ ] Try admin actions as non-admin (should be hidden/disabled)
- [ ] Delete household and verify redirect
- [ ] Test responsive layouts on mobile/tablet/desktop
- [ ] Verify all error messages display properly
- [ ] Test with multiple households

View File

@ -40,5 +40,5 @@ exports.login = async (req, res) => {
{ expiresIn: "1 year" }
);
res.json({ token, username, role: user.role });
res.json({ token, userId: user.id, username, role: user.role });
};

View File

@ -9,6 +9,7 @@ import { StoreProvider } from "./context/StoreContext.jsx";
import AdminPanel from "./pages/AdminPanel.jsx";
import GroceryList from "./pages/GroceryList.jsx";
import Login from "./pages/Login.jsx";
import Manage from "./pages/Manage.jsx";
import Register from "./pages/Register.jsx";
import Settings from "./pages/Settings.jsx";
@ -41,6 +42,7 @@ function App() {
}
>
<Route path="/" element={<GroceryList />} />
<Route path="/manage" element={<Manage />} />
<Route path="/settings" element={<Settings />} />
<Route

View File

@ -1,10 +1,12 @@
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 [isOpen, setIsOpen] = useState(false);
const [showCreateJoin, setShowCreateJoin] = useState(false);
if (!activeHousehold || households.length === 0) {
return null;
@ -42,9 +44,23 @@ export default function HouseholdSwitcher() {
)}
</button>
))}
<div className="household-divider"></div>
<button
className="household-option create-household-btn"
onClick={() => {
setIsOpen(false);
setShowCreateJoin(true);
}}
>
+ Create or Join Household
</button>
</div>
</>
)}
{showCreateJoin && (
<CreateJoinHousehold onClose={() => setShowCreateJoin(false)} />
)}
</div>
);
}

View File

@ -12,6 +12,7 @@ export default function Navbar() {
<nav className="navbar">
<div className="navbar-links">
<Link to="/">Home</Link>
<Link to="/manage">Manage</Link>
<Link to="/settings">Settings</Link>
{role === "admin" && <Link to="/admin">Admin</Link>}

View File

@ -0,0 +1,130 @@
import { useContext, useState } from "react";
import { createHousehold, joinHousehold } from "../../api/households";
import { HouseholdContext } from "../../context/HouseholdContext";
import "../../styles/components/manage/CreateJoinHousehold.css";
export default function CreateJoinHousehold({ onClose }) {
const { loadHouseholds } = useContext(HouseholdContext);
const [mode, setMode] = useState("create"); // "create" or "join"
const [householdName, setHouseholdName] = useState("");
const [inviteCode, setInviteCode] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleCreate = async (e) => {
e.preventDefault();
if (!householdName.trim()) return;
setLoading(true);
setError("");
try {
await createHousehold(householdName);
await loadHouseholds();
onClose();
} catch (err) {
console.error("Failed to create household:", err);
setError(err.response?.data?.message || "Failed to create household");
} finally {
setLoading(false);
}
};
const handleJoin = async (e) => {
e.preventDefault();
if (!inviteCode.trim()) return;
setLoading(true);
setError("");
try {
await joinHousehold(inviteCode);
await loadHouseholds();
onClose();
} catch (err) {
console.error("Failed to join household:", err);
setError(err.response?.data?.message || "Failed to join household");
} finally {
setLoading(false);
}
};
return (
<div className="create-join-modal-overlay" onClick={onClose}>
<div className="create-join-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Household</h2>
<button className="close-btn" onClick={onClose}>×</button>
</div>
<div className="mode-tabs">
<button
className={`mode-tab ${mode === "create" ? "active" : ""}`}
onClick={() => setMode("create")}
>
Create New
</button>
<button
className={`mode-tab ${mode === "join" ? "active" : ""}`}
onClick={() => setMode("join")}
>
Join Existing
</button>
</div>
{error && <div className="error-message">{error}</div>}
{mode === "create" ? (
<form onSubmit={handleCreate} className="household-form">
<div className="form-group">
<label htmlFor="householdName">Household Name</label>
<input
id="householdName"
type="text"
value={householdName}
onChange={(e) => setHouseholdName(e.target.value)}
placeholder="e.g., Smith Family"
required
autoFocus
/>
</div>
<div className="form-actions">
<button type="button" onClick={onClose} className="btn-secondary">
Cancel
</button>
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? "Creating..." : "Create Household"}
</button>
</div>
</form>
) : (
<form onSubmit={handleJoin} className="household-form">
<div className="form-group">
<label htmlFor="inviteCode">Invite Code</label>
<input
id="inviteCode"
type="text"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
placeholder="Enter invite code"
required
autoFocus
/>
<p className="form-hint">
Ask the household admin for the invite code
</p>
</div>
<div className="form-actions">
<button type="button" onClick={onClose} className="btn-secondary">
Cancel
</button>
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? "Joining..." : "Join Household"}
</button>
</div>
</form>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,229 @@
import { useContext, useEffect, useState } from "react";
import {
deleteHousehold,
getHouseholdMembers,
refreshInviteCode,
removeMember,
updateHousehold,
updateMemberRole
} from "../../api/households";
import { AuthContext } from "../../context/AuthContext";
import { HouseholdContext } from "../../context/HouseholdContext";
import "../../styles/components/manage/ManageHousehold.css";
export default function ManageHousehold() {
const { userId } = useContext(AuthContext);
const { activeHousehold, refreshHouseholds } = useContext(HouseholdContext);
const [members, setMembers] = useState([]);
const [loading, setLoading] = useState(true);
const [editingName, setEditingName] = useState(false);
const [newName, setNewName] = useState("");
const [showInviteCode, setShowInviteCode] = useState(false);
const isAdmin = activeHousehold?.role === "admin";
useEffect(() => {
loadMembers();
}, [activeHousehold?.id]);
const loadMembers = async () => {
if (!activeHousehold?.id) return;
setLoading(true);
try {
const response = await getHouseholdMembers(activeHousehold.id);
setMembers(response.data);
} catch (error) {
console.error("Failed to load members:", error);
} finally {
setLoading(false);
}
};
const handleUpdateName = async () => {
if (!newName.trim() || newName === activeHousehold.name) {
setEditingName(false);
return;
}
try {
console.log('Updating household:', activeHousehold.id, 'with name:', newName);
const response = await updateHousehold(activeHousehold.id, newName);
console.log('Update response:', response);
await refreshHouseholds();
setEditingName(false);
} catch (error) {
console.error("Failed to update household name:", error);
console.error("Error response:", error.response?.data);
alert(`Failed to update household name: ${error.response?.data?.error || error.message}`);
}
};
const handleRefreshInvite = async () => {
if (!confirm("Generate a new invite code? The old code will no longer work.")) return;
try {
const response = await refreshInviteCode(activeHousehold.id);
await refreshHouseholds();
alert(`New invite code: ${response.data.inviteCode}`);
} catch (error) {
console.error("Failed to refresh invite code:", error);
alert("Failed to refresh invite code");
}
};
const handleUpdateRole = async (userId, currentRole) => {
const newRole = currentRole === "admin" ? "member" : "admin";
try {
await updateMemberRole(activeHousehold.id, userId, newRole);
await loadMembers();
} catch (error) {
console.error("Failed to update role:", error);
alert("Failed to update member role");
}
};
const handleRemoveMember = async (userId, username) => {
if (!confirm(`Remove ${username} from this household?`)) return;
try {
await removeMember(activeHousehold.id, userId);
await loadMembers();
} catch (error) {
console.error("Failed to remove member:", error);
alert("Failed to remove member");
}
};
const handleDeleteHousehold = async () => {
if (!confirm(`Delete "${activeHousehold.name}"? This will delete all lists and data. This cannot be undone.`)) return;
if (!confirm("Are you absolutely sure? Type DELETE to confirm.")) return;
try {
await deleteHousehold(activeHousehold.id);
await refreshHouseholds();
} catch (error) {
console.error("Failed to delete household:", error);
alert("Failed to delete household");
}
};
const copyInviteCode = () => {
navigator.clipboard.writeText(activeHousehold.invite_code);
alert("Invite code copied to clipboard!");
};
return (
<div className="manage-household">
{/* Household Name Section */}
<section className="manage-section">
<h2>Household Name</h2>
{editingName ? (
<div className="edit-name-form">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Household name"
autoFocus
/>
<button onClick={handleUpdateName} className="btn-primary">Save</button>
<button onClick={() => setEditingName(false)} className="btn-secondary">Cancel</button>
</div>
) : (
<div className="name-display">
<h3>{activeHousehold.name}</h3>
{isAdmin && (
<button
onClick={() => {
setNewName(activeHousehold.name);
setEditingName(true);
}}
className="btn-secondary"
>
Edit Name
</button>
)}
</div>
)}
</section>
{/* Invite Code Section */}
{isAdmin && (
<section className="manage-section">
<h2>Invite Code</h2>
<p className="section-description">
Share this code with others to invite them to your household.
</p>
<div className="invite-actions">
<button onClick={() => setShowInviteCode(!showInviteCode)} className="btn-secondary">
{showInviteCode ? "Hide Code" : "Show Code"}
</button>
{showInviteCode && (
<>
<code className="invite-code">{activeHousehold.invite_code}</code>
<button onClick={copyInviteCode} className="btn-secondary">Copy</button>
</>
)}
<button onClick={handleRefreshInvite} className="btn-secondary">
Generate New Code
</button>
</div>
</section>
)}
{/* Members Section */}
<section className="manage-section">
<h2>Members ({members.length})</h2>
{loading ? (
<p>Loading members...</p>
) : (
<div className="members-list">
{members.map((member) => (
<div key={member.user_id} className="member-card">
<div className="member-info">
<span className="member-name">
{member.username}
{member.user_id === parseInt(userId) && " (You)"}
</span>
<span className={`member-role ${member.role}`}>
{member.role}
</span>
</div>
{isAdmin && member.user_id !== parseInt(userId) && (
<div className="member-actions">
<button
onClick={() => handleUpdateRole(member.user_id, member.role)}
className="btn-secondary btn-small"
>
{member.role === "admin" ? "Make Member" : "Make Admin"}
</button>
<button
onClick={() => handleRemoveMember(member.user_id, member.username)}
className="btn-danger btn-small"
>
Remove
</button>
</div>
)}
</div>
))}
</div>
)}
</section>
{/* Danger Zone */}
{isAdmin && (
<section className="manage-section danger-zone">
<h2>Danger Zone</h2>
<p className="section-description">
Deleting a household is permanent and will delete all lists, items, and history.
</p>
<button onClick={handleDeleteHousehold} className="btn-danger">
Delete Household
</button>
</section>
)}
</div>
);
}

View File

@ -0,0 +1,157 @@
import { useContext, useEffect, useState } from "react";
import {
addStoreToHousehold,
getAllStores,
removeStoreFromHousehold,
setDefaultStore
} from "../../api/stores";
import { HouseholdContext } from "../../context/HouseholdContext";
import { StoreContext } from "../../context/StoreContext";
import "../../styles/components/manage/ManageStores.css";
export default function ManageStores() {
const { activeHousehold } = useContext(HouseholdContext);
const { stores: householdStores, loadStores } = useContext(StoreContext);
const [allStores, setAllStores] = useState([]);
const [loading, setLoading] = useState(true);
const [showAddStore, setShowAddStore] = useState(false);
const isAdmin = activeHousehold?.role === "admin";
useEffect(() => {
loadAllStores();
}, []);
const loadAllStores = async () => {
setLoading(true);
try {
const response = await getAllStores();
setAllStores(response.data);
} catch (error) {
console.error("Failed to load stores:", error);
} finally {
setLoading(false);
}
};
const handleAddStore = async (storeId) => {
try {
await addStoreToHousehold(activeHousehold.id, storeId, false);
await loadStores();
setShowAddStore(false);
} catch (error) {
console.error("Failed to add store:", error);
alert("Failed to add store");
}
};
const handleRemoveStore = async (storeId, storeName) => {
if (!confirm(`Remove ${storeName} from this household?`)) return;
try {
await removeStoreFromHousehold(activeHousehold.id, storeId);
await loadStores();
} catch (error) {
console.error("Failed to remove store:", error);
alert("Failed to remove store");
}
};
const handleSetDefault = async (storeId) => {
try {
await setDefaultStore(activeHousehold.id, storeId);
await loadStores();
} catch (error) {
console.error("Failed to set default store:", error);
alert("Failed to set default store");
}
};
const availableStores = allStores.filter(
store => !householdStores.some(hs => hs.id === store.id)
);
return (
<div className="manage-stores">
{/* Current Stores Section */}
<section className="manage-section">
<h2>Your Stores ({householdStores.length})</h2>
{householdStores.length === 0 ? (
<p className="empty-message">No stores added yet.</p>
) : (
<div className="stores-list">
{householdStores.map((store) => (
<div key={store.id} className="store-card">
<div className="store-info">
<h3>{store.name}</h3>
{store.location && <p className="store-location">{store.location}</p>}
{store.is_default && <span className="default-badge">Default</span>}
</div>
{isAdmin && (
<div className="store-actions">
{!store.is_default && (
<button
onClick={() => handleSetDefault(store.id)}
className="btn-secondary btn-small"
>
Set as Default
</button>
)}
<button
onClick={() => handleRemoveStore(store.id, store.name)}
className="btn-danger btn-small"
disabled={householdStores.length === 1}
title={householdStores.length === 1 ? "Cannot remove last store" : ""}
>
Remove
</button>
</div>
)}
</div>
))}
</div>
)}
</section>
{/* Add Store Section */}
{isAdmin && (
<section className="manage-section">
<h2>Add Store</h2>
{!showAddStore ? (
<button onClick={() => setShowAddStore(true)} className="btn-primary">
+ Add Store
</button>
) : (
<div className="add-store-panel">
<button onClick={() => setShowAddStore(false)} className="btn-secondary">
Cancel
</button>
{loading ? (
<p>Loading stores...</p>
) : availableStores.length === 0 ? (
<p className="empty-message">All available stores have been added.</p>
) : (
<div className="available-stores">
{availableStores.map((store) => (
<div key={store.id} className="available-store-card">
<div className="store-info">
<h3>{store.name}</h3>
{store.location && <p className="store-location">{store.location}</p>}
</div>
<button
onClick={() => handleAddStore(store.id)}
className="btn-primary btn-small"
>
Add
</button>
</div>
))}
</div>
)}
</div>
)}
</section>
)}
</div>
);
}

View File

@ -0,0 +1,4 @@
export { default as CreateJoinHousehold } from './CreateJoinHousehold';
export { default as ManageHousehold } from './ManageHousehold';
export { default as ManageStores } from './ManageStores';

View File

@ -2,6 +2,7 @@ import { createContext, useState } from 'react';
export const AuthContext = createContext({
token: null,
userId: null,
role: null,
username: null,
login: () => { },
@ -10,14 +11,17 @@ export const AuthContext = createContext({
export const AuthProvider = ({ children }) => {
const [token, setToken] = useState(localStorage.getItem('token') || null);
const [userId, setUserId] = useState(localStorage.getItem('userId') || null);
const [role, setRole] = useState(localStorage.getItem('role') || null);
const [username, setUsername] = useState(localStorage.getItem('username') || null);
const login = (data) => {
localStorage.setItem('token', data.token);
localStorage.setItem('userId', data.userId);
localStorage.setItem('role', data.role);
localStorage.setItem('username', data.username);
setToken(data.token);
setUserId(data.userId);
setRole(data.role);
setUsername(data.username);
};
@ -26,12 +30,14 @@ export const AuthProvider = ({ children }) => {
localStorage.clear();
setToken(null);
setUserId(null);
setRole(null);
setUsername(null);
};
const value = {
token,
userId,
role,
username,
login,

View File

@ -0,0 +1,51 @@
import { useContext, useState } from "react";
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 [activeTab, setActiveTab] = useState("household");
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>
</div>
</div>
);
}
return (
<div className="manage-body">
<div className="manage-container">
<h1 className="manage-title">Manage {activeHousehold.name}</h1>
<div className="manage-tabs">
<button
className={`manage-tab ${activeTab === "household" ? "active" : ""}`}
onClick={() => setActiveTab("household")}
>
Household
</button>
<button
className={`manage-tab ${activeTab === "stores" ? "active" : ""}`}
onClick={() => setActiveTab("stores")}
>
Stores
</button>
</div>
<div className="manage-content">
{activeTab === "household" && <ManageHousehold />}
{activeTab === "stores" && <ManageStores />}
</div>
</div>
</div>
);
}

View File

@ -8,18 +8,18 @@
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
color: var(--text-color);
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
}
.household-switcher-toggle:hover {
background: var(--hover-color);
border-color: var(--primary-color);
background: var(--card-hover);
border-color: var(--primary);
}
.household-switcher-toggle:disabled {
@ -53,11 +53,11 @@
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
min-width: 200px;
background: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 220px;
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;
}
@ -67,15 +67,15 @@
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.75rem 1rem;
background: transparent;
padding: 0.875rem 1rem;
background: var(--card-bg);
border: none;
border-bottom: 1px solid var(--border-color);
color: var(--text-color);
border-bottom: 1px solid var(--border);
color: var(--text-primary);
font-size: 1rem;
text-align: left;
cursor: pointer;
transition: background 0.2s ease;
transition: all 0.2s ease;
}
.household-option:last-child {
@ -83,16 +83,33 @@
}
.household-option:hover {
background: var(--hover-color);
background: var(--card-hover);
border-color: var(--primary);
}
.household-option.active {
background: var(--primary-color-light);
color: var(--primary-color);
font-weight: 500;
background: rgba(30, 144, 255, 0.15);
color: var(--primary);
font-weight: 600;
}
.check-mark {
color: var(--primary-color);
color: var(--primary);
font-weight: bold;
font-size: 1.1rem;
}
.household-divider {);
margin: 0.25rem 0;
}
.create-household-btn {
color: var(--primary);
font-weight: 600;
}
.create-household-btn:hover {
background: rgba(30, 144, 255, 0.15
.create-household-btn:hover {
background: var(--primary-color-light);
}

View File

@ -0,0 +1,165 @@
/* Create/Join Household Modal */
.create-join-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.create-join-modal {
background: var(--card-bg);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
max-width: 500px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--border);
}
.modal-header h2 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.close-btn {
background: none;
border: none;
font-size: 2rem;
color: var(--text-secondary);
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s;
}
.close-btn:hover {
background: var(--card-hover);
color: var(--text-primary);
}
/* Mode Tabs */
.mode-tabs {
display: flex;
border-bottom: 2px solid var(--border);
}
.mode-tab {
flex: 1;
padding: 1rem;
background: none;
border: none;
border-bottom: 3px solid transparent;
color: var(--text-secondary);
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.mode-tab:hover {
color: var(--text-primary);
background: var(--card-hover);
}
.mode-tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
/* Form */
.household-form {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 1rem;
background: var(--background);
color: var(--text-primary);
transition: border-color 0.2s;
}
.form-group input:focus {
outline: none;
border-color: var(--primary);
}
.form-hint {
margin-top: 0.5rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.error-message {
margin: 1rem 1.5rem;
padding: 0.75rem;
background: var(--danger-light, rgba(220, 53, 69, 0.1));
border: 1px solid var(--danger);
border-radius: 6px;
color: var(--danger);
font-size: 0.9rem;
}
.form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.form-actions button {
min-width: 100px;
}
/* Responsive */
@media (max-width: 600px) {
.create-join-modal {
margin: 0;
border-radius: 0;
max-height: 100vh;
height: 100%;
}
.form-actions {
flex-direction: column-reverse;
}
.form-actions button {
width: 100%;
}
}

View File

@ -0,0 +1,252 @@
/* Manage Household Component */
.manage-household {
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 800px;
margin: 0 auto;
width: 100%;
}
/* Section Styling */
.manage-section {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 2rem;
width: 100%;
box-sizing: border-box;
}
.manage-section h2 {
font-size: 1.3rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 1rem;
}
.section-description {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 1rem;
}
/* Household Name Section */
.name-display {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.name-display h3 {
font-size: 1.5rem;
color: var(--text-primary);
margin: 0;
}
.edit-name-form {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.edit-name-form input {
flex: 1;
min-width: 200px;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 1rem;
background: var(--background);
color: var(--text-primary);
}
/* Invite Code Section */
.invite-actions {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.invite-code {
background: var(--background);
padding: 0.75rem 1rem;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 1rem;
color: var(--primary);
border: 2px solid var(--border);
font-weight: 600;
letter-spacing: 0.5px;
}
/* Members Section */
.members-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.member-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem;
background: var(--background);
border: 1px solid var(--border);
border-radius: 8px;
transition: all 0.2s;
gap: 1rem;
}
.member-card:hover {
background: var(--card-hover);
border-color: var(--primary);
}
.member-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.member-name {
font-weight: 500;
color: var(--text-primary);
font-size: 1rem;
}
.member-role {
font-size: 0.85rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
width: fit-content;
text-transform: capitalize;
}
.member-role.admin {
background: var(--primary-light, rgba(0, 122, 255, 0.1));
color: var(--primary);
}
.member-role.member {
background: var(--text-secondary-light, rgba(128, 128, 128, 0.1));
color: var(--text-secondary);
}
.member-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
/* Danger Zone */
.danger-zone {
border-color: var(--danger);
}
.danger-zone h2 {
color: var(--danger);
}
/* Buttons */
.btn-primary,
.btn-secondary,
.btn-danger {
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary,
.btn-secondary {
background: var(--primary);
color: white;
}
.btn-primary:hover,
.btn-secondary:hover {
background: var(--primary-dark, #0056b3);
}
.btn-danger {
background: var(--danger);
color: white;
}
.btn-danger:hover {
background: var(--danger-dark, #c82333);
}
.btn-small {
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
}
.btn-danger:disabled {
background: var(--text-secondary);
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive */
@media (max-width: 768px) {
.manage-section {
padding: 1.25rem;
}
.name-display {
flex-direction: column;
align-items: flex-start;
}
.edit-name-form {
flex-direction: column;
width: 100%;
align-items: stretch;
}
.edit-name-form input {
width: 100%;
min-width: unset;
}
.edit-name-form button {
width: 100%;
}
.member-card {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.member-actions {
width: 100%;
}
.member-actions button {
flex: 1;
}
.invite-actions {
flex-direction: column;
align-items: stretch;
}
.invite-actions button {
width: 100%;
}
.invite-code {
text-align: center;
width: 100%;
}
}

View File

@ -0,0 +1,161 @@
/* Manage Stores Component */
.manage-stores {
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 800px;
margin: 0 auto;
width: 100%;
}
/* Section Styling */
.manage-section {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 2rem;
width: 100%;
box-sizing: border-box;
}
.manage-section h2 {
font-size: 1.3rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 1rem;
}
/* Stores List */
.stores-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.store-card {
background: var(--background);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.25rem;
transition: all 0.2s;
display: flex;
flex-direction: column;
gap: 1rem;
}
.store-card:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.store-info h3 {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.5rem 0;
}
.store-location {
color: var(--text-secondary);
font-size: 0.9rem;
margin: 0;
}
.default-badge {
display: inline-block;
background: var(--primary);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
margin-top: 0.5rem;
}
.store-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Add Store Panel */
.add-store-panel {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
.available-stores {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.available-store-card {
background: var(--background);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.25rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
transition: all 0.2s;
}
.available-store-card:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.available-store-card .store-info {
flex: 1;
}
.available-store-card h3 {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.25rem 0;
}
.available-store-card .store-location {
color: var(--text-secondary);
font-size: 0.85rem;
margin: 0;
}
/* Empty State */
.empty-message {
color: var(--text-secondary);
text-align: center;
padding: 2rem;
font-style: italic;
}
/* Responsive */
@media (max-width: 600px) {
.stores-list,
.available-stores {
grid-template-columns: 1fr;
}
.store-actions {
width: 100%;
}
.store-actions button {
flex: 1;
}
.available-store-card {
flex-direction: column;
align-items: flex-start;
}
.available-store-card button {
width: 100%;
}
}

View File

@ -0,0 +1,119 @@
/* Manage Page Layout */
.manage-body {
min-height: 100vh;
background: var(--background);
padding: 2rem 1rem;
display: flex;
justify-content: center;
}
.manage-container {
max-width: 850px;
width: 100%;
margin: 0 auto;
padding: 0 1rem;
}
.manage-title {
font-size: 2rem;
font-weight: 600;
color: var(--text-primary);
text-align: center;
margin-bottom: 2rem;
}
/* Tabs */
.manage-tabs {
display: flex;
gap: 0.5rem;
border-bottom: 2px solid var(--border);
margin-bottom: 2rem;
}
.manage-tab {
padding: 0.75rem 1.5rem;
background: none;
border: none;
border-bottom: 3px solid transparent;
color: var(--text-secondary);
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.manage-tab:hover {
color: var(--text-primary);
background: var(--card-hover);
}
.manage-tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
/* Content Area */
.manage-content {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Button Styles */
.btn-primary,
.btn-secondary {
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
background: var(--primary);
color: white;
}
.btn-primary:hover,
.btn-secondary:hover {
background: var(--primary-dark, #0056b3);
}
/* Responsive */
@media (max-width: 768px) {
.manage-body {
padding: 1rem 0.75rem;
}
.manage-title {
font-size: 1.75rem;
}
.manage-tab {
padding: 0.65rem 1rem;
font-size: 0.9rem;
}
}
@media (max-width: 480px) {
.manage-body {
padding: 1rem 0.5rem;
}
.manage-title {
font-size: 1.5rem;
}
.manage-tab {
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
}
}

View File

@ -14,10 +14,10 @@
============================================ */
/* Primary Colors */
--color-primary: #007bff;
--color-primary-hover: #0056b3;
--color-primary: dodgerblue;
--color-primary-hover: #0066cc;
--color-primary-light: #e7f3ff;
--color-primary-dark: #0067d8;
--color-primary-dark: #0056b3;
/* Secondary Colors */
--color-secondary: #6c757d;
@ -187,6 +187,20 @@
--modal-border-radius: var(--border-radius-lg);
--modal-padding: var(--spacing-lg);
--modal-max-width: 500px;
/* ============================================
SIMPLIFIED ALIASES (for component convenience)
============================================ */
--primary: var(--color-primary);
--primary-dark: var(--color-primary-dark);
--primary-light: var(--color-primary-light);
--danger: var(--color-danger);
--danger-dark: var(--color-danger-hover);
--text-primary: var(--color-text-primary);
--text-secondary: var(--color-text-secondary);
--background: var(--color-bg-body);
--border: var(--color-border-light);
--card-hover: var(--color-bg-hover);
}

View File

@ -109,12 +109,12 @@
}
.btn-secondary {
background: var(--color-secondary);
background: var(--color-primary);
color: var(--color-text-inverse);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-secondary-hover);
background: var(--color-primary-hover);
}
.btn-danger {