Compare commits

..

14 Commits

Author SHA1 Message Date
Nico
5a2848ebcf refactor: use slide confirmation for role changes
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 11s
Build & Deploy Costco Grocery List / verify-images (push) Successful in 1s
Build & Deploy Costco Grocery List / deploy (push) Successful in 5s
Build & Deploy Costco Grocery List / notify (push) Successful in 0s
2026-03-31 01:24:22 -07:00
Nico
cfec916392 feat: allow owners to transfer household ownership 2026-03-31 01:19:38 -07:00
Nico
1e63ed9726 style: separate member card actions into footer 2026-03-31 01:12:35 -07:00
Nico
12b514262e fix: persist selected household across refresh 2026-03-31 00:40:50 -07:00
Nico
af0d95432f feat: switch household joins to invite links 2026-03-31 00:32:40 -07:00
Nico
9bdf2247f4 feat: add invite link approval management 2026-03-31 00:26:26 -07:00
Nico
4aff7e78f2 style: remove gray from secondary action buttons 2026-03-31 00:07:18 -07:00
Nico
93e3d42edc style: highlight member role actions 2026-03-31 00:00:28 -07:00
Nico
d31fb6c79f style: fix household management dark card surfaces 2026-03-30 23:57:35 -07:00
Nico
ca0b4897cb style: brighten household settings cards 2026-03-30 23:55:15 -07:00
Nico
043460ac21 style: tighten household settings layout 2026-03-30 23:48:35 -07:00
Nico
74913c3435 style: refresh shared visual system 2026-03-30 23:46:38 -07:00
Nico
5510401635 fix: onboard users without households 2026-03-30 23:38:05 -07:00
Nico
dc422f6127 docs: reinforce frequent checkpoint commits 2026-03-30 23:37:54 -07:00
40 changed files with 2702 additions and 775 deletions

View File

@ -41,6 +41,8 @@
## Working style
- Scan repo first; do not guess file names or patterns.
- Make the smallest change that resolves the issue.
- Religiously commit work in small, verified slices; prefer frequent checkpoint commits over large end-state batches.
- Follow the commit discipline in `PROJECT_INSTRUCTIONS.md` for every slice, including Conventional Commit messages and related-file-only scope.
- Keep touched files free of TS warnings and lint errors.
- Add/update tests when API behavior changes (include negative cases).
- Keep text encoding clean (no mojibake).

View File

@ -79,6 +79,20 @@ exports.createInviteLink = async (req, res) => {
}
};
exports.listPendingJoinRequests = async (req, res) => {
try {
const requestedGroupId = parseRequestedGroupId(req);
const groupId = await invitesService.resolveManagedGroupId(
req.user.id,
requestedGroupId
);
const requests = await invitesService.listPendingJoinRequests(req.user.id, groupId);
res.json({ requests });
} catch (error) {
return mapServiceError(req, res, error, "groupInvites.listPendingJoinRequests");
}
};
exports.revokeInviteLink = async (req, res) => {
try {
const requestedGroupId = parseRequestedGroupId(req);
@ -180,6 +194,28 @@ exports.setJoinPolicy = async (req, res) => {
}
};
exports.decideJoinRequest = async (req, res) => {
try {
const requestedGroupId = parseRequestedGroupId(req);
const groupId = await invitesService.resolveManagedGroupId(
req.user.id,
requestedGroupId
);
const decision = await invitesService.decideJoinRequest(
req.user.id,
groupId,
req.body?.requestId,
req.body?.decision,
req.request_id,
getClientIp(req),
req.headers["user-agent"] || null
);
res.json({ request: decision });
} catch (error) {
return mapServiceError(req, res, error, "groupInvites.decideJoinRequest");
}
};
exports.getInviteLinkSummary = async (req, res) => {
const token = req.params.token;
const inviteLast4 = inviteCodeLast4(token);

View File

@ -165,8 +165,8 @@ exports.updateMemberRole = async (req, res) => {
const { userId } = req.params;
const { role } = req.body;
if (!role || !['admin', 'member'].includes(role)) {
return sendError(res, 400, "Invalid role. Must be 'admin' or 'member'");
if (!role || !["owner", "admin", "member"].includes(role)) {
return sendError(res, 400, "Invalid role. Must be 'owner', 'admin', or 'member'");
}
// Can't change own role
@ -182,14 +182,29 @@ exports.updateMemberRole = async (req, res) => {
return sendError(res, 403, "Owner role cannot be changed");
}
const updated = await householdModel.updateMemberRole(
req.params.householdId,
userId,
role
);
let updated;
if (role === "owner") {
if (req.household.role !== "owner") {
return sendError(res, 403, "Only the household owner can transfer ownership");
}
updated = await householdModel.transferOwnership(
req.params.householdId,
req.user.id,
parseInt(userId, 10)
);
} else {
updated = await householdModel.updateMemberRole(
req.params.householdId,
userId,
role
);
}
res.json({
message: "Member role updated successfully",
message: role === "owner"
? "Household ownership transferred successfully"
: "Member role updated successfully",
member: updated
});
} catch (error) {

View File

@ -237,6 +237,53 @@ async function getPendingJoinRequest(groupId, userId, client) {
return result.rows[0] || null;
}
async function listPendingJoinRequests(groupId, client) {
const result = await getExecutor(client).query(
`SELECT
gjr.id,
gjr.group_id,
gjr.user_id,
gjr.status,
gjr.created_at,
gjr.updated_at,
u.username,
u.name,
u.display_name
FROM group_join_requests gjr
JOIN users u ON u.id = gjr.user_id
WHERE gjr.group_id = $1
AND gjr.status = 'PENDING'
ORDER BY gjr.created_at ASC`,
[groupId]
);
return result.rows;
}
async function getPendingJoinRequestById(groupId, requestId, client, forUpdate = false) {
const result = await getExecutor(client).query(
`SELECT
gjr.id,
gjr.group_id,
gjr.user_id,
gjr.status,
gjr.decided_by,
gjr.decided_at,
gjr.created_at,
gjr.updated_at,
u.username,
u.name,
u.display_name
FROM group_join_requests gjr
JOIN users u ON u.id = gjr.user_id
WHERE gjr.group_id = $1
AND gjr.id = $2
AND gjr.status = 'PENDING'
${forUpdate ? "FOR UPDATE OF gjr" : ""}`,
[groupId, requestId]
);
return result.rows[0] || null;
}
async function createOrTouchPendingJoinRequest(groupId, userId, client) {
const executor = getExecutor(client);
const existing = await executor.query(
@ -277,6 +324,22 @@ async function createOrTouchPendingJoinRequest(groupId, userId, client) {
}
}
async function updateJoinRequestDecision(groupId, requestId, status, decidedBy, client) {
const result = await getExecutor(client).query(
`UPDATE group_join_requests
SET status = $3,
decided_by = $4,
decided_at = NOW(),
updated_at = NOW()
WHERE group_id = $1
AND id = $2
AND status = 'PENDING'
RETURNING id, group_id, user_id, status, decided_by, decided_at, created_at, updated_at`,
[groupId, requestId, status, decidedBy]
);
return result.rows[0] || null;
}
async function addGroupMember(groupId, userId, role = "member", client) {
const result = await getExecutor(client).query(
`INSERT INTO household_members (household_id, user_id, role)
@ -381,12 +444,15 @@ module.exports = {
getInviteLinkById,
getInviteLinkSummaryByToken,
getManageableGroupsForUser,
getPendingJoinRequestById,
getPendingJoinRequest,
getUserGroupRole,
isGroupMember,
listPendingJoinRequests,
listInviteLinks,
revokeInviteLink,
reviveInviteLink,
updateJoinRequestDecision,
upsertGroupSettings,
withTransaction,
};

View File

@ -162,6 +162,47 @@ exports.updateMemberRole = async (householdId, userId, newRole) => {
return result.rows[0];
};
// Transfer household ownership from one member to another atomically.
exports.transferOwnership = async (householdId, currentOwnerUserId, nextOwnerUserId) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const promoteResult = await client.query(
`UPDATE household_members
SET role = 'owner'
WHERE household_id = $1 AND user_id = $2
RETURNING user_id, role`,
[householdId, nextOwnerUserId]
);
if (promoteResult.rows.length === 0) {
throw new Error("TARGET_MEMBER_NOT_FOUND");
}
const demoteResult = await client.query(
`UPDATE household_members
SET role = 'admin'
WHERE household_id = $1 AND user_id = $2
RETURNING user_id, role`,
[householdId, currentOwnerUserId]
);
if (demoteResult.rows.length === 0) {
throw new Error("CURRENT_OWNER_NOT_FOUND");
}
await client.query("COMMIT");
return promoteResult.rows[0];
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
};
// Remove member from household
exports.removeMember = async (householdId, userId) => {
await pool.query(

View File

@ -28,6 +28,13 @@ const inviteWriteUserRateLimit = createRateLimit({
router.get("/groups/invites", auth, controller.listInviteLinks);
router.post("/groups/invites", auth, inviteWriteUserRateLimit, controller.createInviteLink);
router.get("/groups/join-requests", auth, controller.listPendingJoinRequests);
router.post(
"/groups/join-requests/decision",
auth,
inviteWriteUserRateLimit,
controller.decideJoinRequest
);
router.post(
"/groups/invites/revoke",
auth,

View File

@ -179,6 +179,12 @@ async function listInviteLinks(userId, groupId) {
return invitesModel.listInviteLinks(resolvedGroupId);
}
async function listPendingJoinRequests(userId, groupId) {
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
await ensureGroupAndManagerRole(userId, resolvedGroupId);
return invitesModel.listPendingJoinRequests(resolvedGroupId);
}
async function revokeInviteLink(
userId,
groupId,
@ -314,6 +320,116 @@ async function deleteInviteLink(
});
}
async function decideJoinRequest(
userId,
groupId,
requestId,
decision,
requestIdForAudit,
ip,
userAgent
) {
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
const resolvedRequestId = ensurePositiveInteger(requestId, "requestId");
const normalizedDecision = typeof decision === "string" ? decision.trim().toUpperCase() : "";
if (!["APPROVE", "DENY"].includes(normalizedDecision)) {
throw new InviteServiceError("INVALID_INPUT", "Decision is required", 400);
}
return invitesModel.withTransaction(async (client) => {
const { actorRole } = await ensureGroupAndManagerRole(
userId,
resolvedGroupId,
client
);
const pendingRequest = await invitesModel.getPendingJoinRequestById(
resolvedGroupId,
resolvedRequestId,
client,
true
);
if (!pendingRequest) {
throw new InviteServiceError(
"JOIN_REQUEST_NOT_FOUND",
"Pending join request not found",
404
);
}
if (normalizedDecision === "APPROVE") {
const isExistingMember = await invitesModel.isGroupMember(
resolvedGroupId,
pendingRequest.user_id,
client
);
if (!isExistingMember) {
await invitesModel.addGroupMember(
resolvedGroupId,
pendingRequest.user_id,
"member",
client
);
}
const approvedRequest = await invitesModel.updateJoinRequestDecision(
resolvedGroupId,
resolvedRequestId,
"APPROVED",
userId,
client
);
await invitesModel.createGroupAuditLog(
{
groupId: resolvedGroupId,
actorUserId: userId,
actorRole,
eventType: "GROUP_JOIN_REQUEST_APPROVED",
requestId: requestIdForAudit,
ip: normalizeIp(ip),
userAgent: userAgent || null,
metadata: {
joinRequestId: approvedRequest.id,
targetUserId: approvedRequest.user_id,
},
},
client
);
return approvedRequest;
}
const deniedRequest = await invitesModel.updateJoinRequestDecision(
resolvedGroupId,
resolvedRequestId,
"DENIED",
userId,
client
);
await invitesModel.createGroupAuditLog(
{
groupId: resolvedGroupId,
actorUserId: userId,
actorRole,
eventType: "GROUP_JOIN_REQUEST_DENIED",
requestId: requestIdForAudit,
ip: normalizeIp(ip),
userAgent: userAgent || null,
metadata: {
joinRequestId: deniedRequest.id,
targetUserId: deniedRequest.user_id,
},
},
client
);
return deniedRequest;
});
}
function getInviteStatus(link) {
const now = Date.now();
if (link.single_use && link.used_at) return "USED";
@ -546,9 +662,11 @@ module.exports = {
JOIN_RESULTS,
acceptInviteLink,
createInviteLink,
decideJoinRequest,
deleteInviteLink,
getGroupJoinPolicy,
getInviteLinkSummaryByToken,
listPendingJoinRequests,
listInviteLinks,
resolveManagedGroupId,
revokeInviteLink,

View File

@ -12,8 +12,10 @@ jest.mock("../services/group-invites.service", () => {
acceptInviteLink: jest.fn(),
createInviteLink: jest.fn(),
deleteInviteLink: jest.fn(),
decideJoinRequest: jest.fn(),
getGroupJoinPolicy: jest.fn(),
getInviteLinkSummaryByToken: jest.fn(),
listPendingJoinRequests: jest.fn(),
listInviteLinks: jest.fn(),
resolveManagedGroupId: jest.fn(),
revokeInviteLink: jest.fn(),
@ -28,8 +30,10 @@ const app = require("../app");
describe("group invites routes", () => {
beforeEach(() => {
jest.clearAllMocks();
invitesService.resolveManagedGroupId.mockResolvedValue(1);
invitesService.listInviteLinks.mockResolvedValue([]);
invitesService.listPendingJoinRequests.mockResolvedValue([]);
invitesService.createInviteLink.mockResolvedValue({
id: 1,
token: "abcd",
@ -71,4 +75,36 @@ describe("group invites routes", () => {
expect(response.body.request_id).toBeTruthy();
expect(response.body.link).toBeTruthy();
});
test("pending join requests can be listed with request_id", async () => {
invitesService.listPendingJoinRequests.mockResolvedValue([
{ id: 12, user_id: 77, username: "pending-user", status: "PENDING" },
]);
const response = await request(app).get("/api/groups/join-requests");
expect(response.status).toBe(200);
expect(response.body.request_id).toBeTruthy();
expect(response.body.requests).toEqual([
{ id: 12, user_id: 77, username: "pending-user", status: "PENDING" },
]);
});
test("decision route maps service validation errors", async () => {
invitesService.decideJoinRequest.mockRejectedValue(
new invitesService.InviteServiceError(
"JOIN_REQUEST_NOT_FOUND",
"Pending join request not found",
404
)
);
const response = await request(app)
.post("/api/groups/join-requests/decision")
.send({ requestId: 99, decision: "APPROVE" });
expect(response.status).toBe(404);
expect(response.body.request_id).toBeTruthy();
expect(response.body.error.code).toBe("JOIN_REQUEST_NOT_FOUND");
});
});

View File

@ -10,12 +10,15 @@ jest.mock("../models/group-invites.model", () => ({
getInviteLinkById: jest.fn(),
getInviteLinkSummaryByToken: jest.fn(),
getManageableGroupsForUser: jest.fn(),
getPendingJoinRequestById: jest.fn(),
getPendingJoinRequest: jest.fn(),
getUserGroupRole: jest.fn(),
isGroupMember: jest.fn(),
listPendingJoinRequests: jest.fn(),
listInviteLinks: jest.fn(),
revokeInviteLink: jest.fn(),
reviveInviteLink: jest.fn(),
updateJoinRequestDecision: jest.fn(),
upsertGroupSettings: jest.fn(),
withTransaction: jest.fn(),
}));
@ -41,6 +44,7 @@ function inviteSummary(overrides = {}) {
describe("group invites service", () => {
beforeEach(() => {
jest.clearAllMocks();
invitesModel.withTransaction.mockImplementation(async (handler) => handler({}));
});
@ -186,4 +190,120 @@ describe("group invites service", () => {
expect(result.status).toBe("PENDING");
expect(invitesModel.addGroupMember).not.toHaveBeenCalled();
});
test("listPendingJoinRequests requires manager role and returns pending requests", async () => {
invitesModel.getGroupById.mockResolvedValue({ id: 10, name: "Test Group" });
invitesModel.getUserGroupRole.mockResolvedValue("owner");
invitesModel.listPendingJoinRequests.mockResolvedValue([
{ id: 12, user_id: 88, username: "pending-user", status: "PENDING" },
]);
const result = await invitesService.listPendingJoinRequests(99, 10);
expect(invitesModel.listPendingJoinRequests).toHaveBeenCalledWith(10);
expect(result).toEqual([
{ id: 12, user_id: 88, username: "pending-user", status: "PENDING" },
]);
});
test("approve join request adds membership, updates request, and audits decision", async () => {
invitesModel.getGroupById.mockResolvedValue({ id: 10, name: "Test Group" });
invitesModel.getUserGroupRole.mockResolvedValue("admin");
invitesModel.getPendingJoinRequestById.mockResolvedValue({
id: 77,
group_id: 10,
user_id: 55,
username: "pending-user",
status: "PENDING",
});
invitesModel.isGroupMember.mockResolvedValue(false);
invitesModel.addGroupMember.mockResolvedValue(true);
invitesModel.updateJoinRequestDecision.mockResolvedValue({
id: 77,
group_id: 10,
user_id: 55,
status: "APPROVED",
decided_by: 99,
});
const result = await invitesService.decideJoinRequest(
99,
10,
77,
"APPROVE",
"req-approve",
"127.0.0.1",
"ua"
);
expect(invitesModel.getPendingJoinRequestById).toHaveBeenCalledWith(
10,
77,
{},
true
);
expect(invitesModel.addGroupMember).toHaveBeenCalledWith(10, 55, "member", {});
expect(invitesModel.updateJoinRequestDecision).toHaveBeenCalledWith(
10,
77,
"APPROVED",
99,
{}
);
expect(invitesModel.createGroupAuditLog.mock.calls[0][0]).toMatchObject({
eventType: "GROUP_JOIN_REQUEST_APPROVED",
requestId: "req-approve",
metadata: {
joinRequestId: 77,
targetUserId: 55,
},
});
expect(result.status).toBe("APPROVED");
});
test("deny join request updates request and audits decision", async () => {
invitesModel.getGroupById.mockResolvedValue({ id: 10, name: "Test Group" });
invitesModel.getUserGroupRole.mockResolvedValue("owner");
invitesModel.getPendingJoinRequestById.mockResolvedValue({
id: 78,
group_id: 10,
user_id: 56,
status: "PENDING",
});
invitesModel.updateJoinRequestDecision.mockResolvedValue({
id: 78,
group_id: 10,
user_id: 56,
status: "DENIED",
decided_by: 99,
});
const result = await invitesService.decideJoinRequest(
99,
10,
78,
"DENY",
"req-deny",
"127.0.0.1",
"ua"
);
expect(invitesModel.addGroupMember).not.toHaveBeenCalled();
expect(invitesModel.updateJoinRequestDecision).toHaveBeenCalledWith(
10,
78,
"DENIED",
99,
{}
);
expect(invitesModel.createGroupAuditLog.mock.calls[0][0]).toMatchObject({
eventType: "GROUP_JOIN_REQUEST_DENIED",
requestId: "req-deny",
metadata: {
joinRequestId: 78,
targetUserId: 56,
},
});
expect(result.status).toBe("DENIED");
});
});

View File

@ -0,0 +1,88 @@
jest.mock("../models/household.model", () => ({
getUserRole: jest.fn(),
transferOwnership: jest.fn(),
updateMemberRole: jest.fn(),
}));
jest.mock("../utils/logger", () => ({
logError: jest.fn(),
}));
const householdModel = require("../models/household.model");
const controller = require("../controllers/households.controller");
function createResponse() {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
}
describe("households.controller updateMemberRole", () => {
beforeEach(() => {
jest.clearAllMocks();
householdModel.getUserRole.mockResolvedValue("member");
householdModel.transferOwnership.mockResolvedValue({ user_id: 7, role: "owner" });
householdModel.updateMemberRole.mockResolvedValue({ user_id: 7, role: "admin" });
});
test("owner can transfer household ownership", async () => {
const req = {
params: { householdId: "3", userId: "7" },
body: { role: "owner" },
user: { id: 1 },
household: { id: 3, role: "owner" },
};
const res = createResponse();
await controller.updateMemberRole(req, res);
expect(householdModel.transferOwnership).toHaveBeenCalledWith("3", 1, 7);
expect(householdModel.updateMemberRole).not.toHaveBeenCalled();
expect(res.json).toHaveBeenCalledWith({
message: "Household ownership transferred successfully",
member: { user_id: 7, role: "owner" },
});
});
test("admin cannot transfer household ownership", async () => {
const req = {
params: { householdId: "3", userId: "7" },
body: { role: "owner" },
user: { id: 1 },
household: { id: 3, role: "admin" },
};
const res = createResponse();
await controller.updateMemberRole(req, res);
expect(householdModel.transferOwnership).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: "Only the household owner can transfer ownership",
}),
})
);
});
test("owner can still update a member to admin without transfer flow", async () => {
const req = {
params: { householdId: "3", userId: "7" },
body: { role: "admin" },
user: { id: 1 },
household: { id: 3, role: "owner" },
};
const res = createResponse();
await controller.updateMemberRole(req, res);
expect(householdModel.updateMemberRole).toHaveBeenCalledWith("3", "7", "admin");
expect(householdModel.transferOwnership).not.toHaveBeenCalled();
expect(res.json).toHaveBeenCalledWith({
message: "Member role updated successfully",
member: { user_id: 7, role: "admin" },
});
});
});

View File

@ -27,18 +27,6 @@ export const updateHousehold = (householdId, name) =>
export const deleteHousehold = (householdId) =>
api.delete(`/households/${householdId}`);
/**
* Refresh household invite code
*/
export const refreshInviteCode = (householdId) =>
api.post(`/households/${householdId}/invite/refresh`);
/**
* Join a household using invite code
*/
export const joinHousehold = (inviteCode) =>
api.post(`/households/join/${inviteCode}`);
/**
* Get household members
*/
@ -68,9 +56,19 @@ function groupHeaders(groupId) {
export const getGroupInviteLinks = (groupId) =>
api.get("/api/groups/invites", groupHeaders(groupId));
export const getPendingGroupJoinRequests = (groupId) =>
api.get("/api/groups/join-requests", groupHeaders(groupId));
export const createGroupInviteLink = (groupId, payload) =>
api.post("/api/groups/invites", payload, groupHeaders(groupId));
export const decideGroupJoinRequest = (groupId, requestId, decision) =>
api.post(
"/api/groups/join-requests/decision",
{ requestId, decision },
groupHeaders(groupId)
);
export const revokeGroupInviteLink = (groupId, linkId) =>
api.post("/api/groups/invites/revoke", { linkId }, groupHeaders(groupId));

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 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,38 +1,42 @@
import { useContext, useState } from "react";
import { useContext, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { createHousehold, 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 }) {
function extractInviteToken(value) {
const trimmed = value.trim();
if (!trimmed) return null;
const directMatch = trimmed.match(/^\/?invite\/([a-zA-Z0-9]+)$/);
if (directMatch) return directMatch[1];
try {
const parsed = new URL(trimmed, window.location.origin);
const urlMatch = parsed.pathname.match(/^\/invite\/([a-zA-Z0-9]+)$/);
if (urlMatch) return urlMatch[1];
} catch {
return null;
}
return null;
}
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 } = useContext(HouseholdContext);
const [mode, setMode] = useState(initialMode === "join" ? "join" : "create");
const [householdName, setHouseholdName] = useState("");
const [inviteCode, setInviteCode] = useState("");
const [inviteLink, setInviteLink] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const extractInviteToken = (value) => {
const trimmed = value.trim();
if (!trimmed) return null;
const directMatch = trimmed.match(/^\/?invite\/([a-zA-Z0-9]+)$/);
if (directMatch) return directMatch[1];
try {
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) {
return null;
}
return null;
};
useEffect(() => {
setMode(initialMode === "join" ? "join" : "create");
setError("");
}, [initialMode]);
const handleCreate = async (e) => {
e.preventDefault();
@ -42,8 +46,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) {
@ -58,29 +61,23 @@ export default function CreateJoinHousehold({ onClose }) {
const handleJoin = async (e) => {
e.preventDefault();
if (!inviteCode.trim()) return;
if (!inviteLink.trim()) return;
setLoading(true);
setError("");
try {
const inviteToken = extractInviteToken(inviteCode);
if (inviteToken) {
toast.info("Invite link detected", "Opening invite details");
onClose();
navigate(`/invite/${inviteToken}`);
const inviteToken = extractInviteToken(inviteLink);
if (!inviteToken) {
const message = "Use a household invite link like /invite/abcd1234.";
setError(message);
toast.error("Open invite link failed", message);
return;
}
await joinHousehold(inviteCode);
await refreshHouseholds();
toast.success("Joined household", "Joined household successfully");
toast.info("Opening invite link", "Checking invite details");
onClose();
} catch (err) {
console.error("Failed to join household:", err);
const message = getApiErrorMessage(err, "Failed to join household");
setError(message);
toast.error("Join household failed", `Join household failed: ${message}`);
navigate(`/invite/${inviteToken}`);
} finally {
setLoading(false);
}
@ -91,7 +88,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">
@ -137,18 +141,18 @@ export default function CreateJoinHousehold({ onClose }) {
) : (
<form onSubmit={handleJoin} className="household-form">
<div className="form-group">
<label htmlFor="inviteCode">Invite Code or Link</label>
<label htmlFor="inviteLink">Invite Link</label>
<input
id="inviteCode"
id="inviteLink"
type="text"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
placeholder="Invite code or /invite URL"
value={inviteLink}
onChange={(e) => setInviteLink(e.target.value)}
placeholder="https://.../invite/your-token"
required
autoFocus
/>
<p className="form-hint">
Paste a raw invite code or full invite link URL
Paste the full invite URL or a local path like /invite/your-token
</p>
</div>
<div className="form-actions">
@ -156,7 +160,7 @@ export default function CreateJoinHousehold({ onClose }) {
Cancel
</button>
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? "Joining..." : "Join Household"}
{loading ? "Opening..." : "Open Invite"}
</button>
</div>
</form>

View File

@ -1,18 +1,19 @@
import React, { useContext, useEffect, useState } from "react";
import {
createGroupInviteLink,
decideGroupJoinRequest,
deleteGroupInviteLink,
deleteHousehold,
getGroupInviteLinks,
getGroupJoinPolicy,
getHouseholdMembers,
refreshInviteCode,
getPendingGroupJoinRequests,
removeMember,
revokeGroupInviteLink,
reviveGroupInviteLink,
setGroupJoinPolicy,
updateHousehold,
updateMemberRole
updateMemberRole,
} from "../../api/households";
import { ToggleButtonGroup } from "../common";
import ConfirmSlideModal from "../modals/ConfirmSlideModal";
@ -28,6 +29,29 @@ const JOIN_POLICY_OPTIONS = [
{ label: "Manual", value: "APPROVAL_REQUIRED" },
];
const ROLE_METADATA = {
owner: { icon: "👑", label: "Owner" },
admin: { icon: "🛠️", label: "Admin" },
member: { icon: "🙂", label: "Member" },
viewer: { icon: "👀", label: "Viewer" },
};
const STATUS_METADATA = {
Active: { tone: "active", icon: "🟢" },
Used: { tone: "used", icon: "⚪" },
Revoked: { tone: "revoked", icon: "🔴" },
Expired: { tone: "expired", icon: "🟠" },
};
function getRequesterLabel(request) {
return (
request.display_name?.trim() ||
request.name?.trim() ||
request.username?.trim() ||
`User #${request.user_id}`
);
}
export default function ManageHousehold() {
const { userId } = useContext(AuthContext);
const { activeHousehold, refreshHouseholds } = useContext(HouseholdContext);
@ -36,22 +60,27 @@ export default function ManageHousehold() {
const [loading, setLoading] = useState(true);
const [editingName, setEditingName] = useState(false);
const [newName, setNewName] = useState("");
const [showInviteCode, setShowInviteCode] = useState(false);
const [joinPolicy, setJoinPolicyValue] = useState("NOT_ACCEPTING");
const [inviteLinks, setInviteLinks] = useState([]);
const [pendingRequests, setPendingRequests] = useState([]);
const [inviteLoading, setInviteLoading] = useState(false);
const [inviteError, setInviteError] = useState("");
const [ttlDays, setTtlDays] = useState(7);
const [singleUseMode, setSingleUseMode] = useState("UNLIMITED");
const [pendingDecisionId, setPendingDecisionId] = useState(null);
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
const [pendingRoleChange, setPendingRoleChange] = useState(null);
const isManager = ["owner", "admin"].includes(activeHousehold?.role);
const isOwner = activeHousehold?.role === "owner";
const isMemberOnly = activeHousehold?.role === "member";
useEffect(() => {
loadMembers();
if (isManager) {
loadJoinAndInvites();
} else {
setPendingRequests([]);
}
}, [activeHousehold?.id, isManager]);
@ -73,12 +102,14 @@ export default function ManageHousehold() {
setInviteLoading(true);
setInviteError("");
try {
const [policyResponse, linksResponse] = await Promise.all([
const [policyResponse, linksResponse, requestsResponse] = await Promise.all([
getGroupJoinPolicy(activeHousehold.id),
getGroupInviteLinks(activeHousehold.id),
getPendingGroupJoinRequests(activeHousehold.id),
]);
setJoinPolicyValue(policyResponse.data.joinPolicy || "NOT_ACCEPTING");
setInviteLinks(linksResponse.data.links || []);
setPendingRequests(requestsResponse.data.requests || []);
} catch (error) {
setInviteError(error.response?.data?.error?.message || "Failed to load invite links");
} finally {
@ -169,6 +200,27 @@ export default function ManageHousehold() {
}
};
const handleJoinRequestDecision = async (request, decision) => {
const requesterName = getRequesterLabel(request);
setPendingDecisionId(request.id);
try {
setInviteError("");
await decideGroupJoinRequest(activeHousehold.id, request.id, decision);
await Promise.all([loadJoinAndInvites(), loadMembers()]);
if (decision === "APPROVE") {
toast.success("Approved join request", `Approved ${requesterName}`);
} else {
toast.info("Denied join request", `Denied ${requesterName}`);
}
} catch (error) {
const message = getApiErrorMessage(error, "Failed to update join request");
setInviteError(message);
toast.error("Join request update failed", `Join request update failed: ${message}`);
} finally {
setPendingDecisionId(null);
}
};
const handleRevokeInvite = async (linkId) => {
try {
setInviteError("");
@ -226,33 +278,35 @@ export default function ManageHousehold() {
}
};
const handleRefreshInvite = async () => {
if (!confirm("Generate a new invite code? The old code will no longer work.")) return;
const handleConfirmRoleChange = async () => {
if (!pendingRoleChange) return;
const { memberId, nextRole, memberName } = pendingRoleChange;
try {
await refreshInviteCode(activeHousehold.id);
await refreshHouseholds();
toast.success("Generated new invite code", "Generated a new invite code");
} catch (error) {
const message = getApiErrorMessage(error, "Failed to refresh invite code");
toast.error("Refresh invite code failed", `Refresh invite code failed: ${message}`);
}
};
const handleUpdateRole = async (memberId, currentRole, memberName) => {
if (currentRole === "owner") return;
const newRole = currentRole === "admin" ? "member" : "admin";
try {
await updateMemberRole(activeHousehold.id, memberId, newRole);
await loadMembers();
toast.success("Updated member role", `Updated role for ${memberName} to ${newRole}`);
await updateMemberRole(activeHousehold.id, memberId, nextRole);
await Promise.all([
loadMembers(),
nextRole === "owner" ? refreshHouseholds() : Promise.resolve(),
]);
if (nextRole === "owner") {
toast.success("Transferred household ownership", `Transferred ownership to ${memberName}`);
} else {
toast.success("Updated member role", `Updated role for ${memberName} to ${nextRole}`);
}
setPendingRoleChange(null);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to update member role");
toast.error("Update member role failed", `Update member role failed: ${message}`);
}
};
const handleUpdateRole = (memberId, nextRole, memberName) => {
if (!nextRole) return;
setPendingRoleChange({ memberId, nextRole, memberName });
};
const handleRemoveMember = async (memberId, username) => {
if (!confirm(`Remove ${username} from this household?`)) return;
@ -296,23 +350,21 @@ export default function ManageHousehold() {
}
};
const copyInviteCode = async () => {
const copied = await copyTextToClipboard(activeHousehold.invite_code);
if (copied) {
toast.info("Copied invite code", "Copied invite code to clipboard");
return;
}
toast.error(
"Copy invite code failed",
"Copy invite code failed: unable to access clipboard. Copy manually."
);
};
const managerCount = members.filter((member) => ["owner", "admin"].includes(member.role)).length;
const memberCount = members.filter((member) => member.role === "member").length;
return (
<div className="manage-household">
<section key="household-name" className="manage-section">
<h2>Household Name</h2>
<div className="manage-section-header">
<div>
<p className="manage-section-eyebrow">Household</p>
<h2>Identity</h2>
<p className="section-description">
Keep the household name crisp and easy to recognize across invites and shared lists.
</p>
</div>
</div>
{editingName ? (
<div className="edit-name-form">
<input
@ -327,7 +379,14 @@ export default function ManageHousehold() {
</div>
) : (
<div className="name-display">
<h3>{activeHousehold.name}</h3>
<div className="name-display-copy">
<h3>{activeHousehold.name}</h3>
<div className="household-summary-chips">
<span className="household-summary-chip">🏠 {members.length} people</span>
<span className="household-summary-chip">🛡 {managerCount} managers</span>
<span className="household-summary-chip">🛒 {memberCount} shoppers</span>
</div>
</div>
{isManager && (
<button
onClick={() => {
@ -343,32 +402,17 @@ export default function ManageHousehold() {
)}
</section>
{isManager && (
<section key="invite-code" className="manage-section">
<h2>Legacy Invite Code</h2>
<p className="section-description">
Share this code for legacy join-by-code flows.
</p>
<div className="invite-actions">
<button onClick={() => setShowInviteCode(!showInviteCode)} className="btn-secondary">
{showInviteCode ? "Hide Code" : "Show Code"}
</button>
{showInviteCode && (
<React.Fragment key="invite-code-display">
<code className="invite-code">{activeHousehold.invite_code}</code>
<button onClick={copyInviteCode} className="btn-secondary">Copy</button>
</React.Fragment>
)}
<button onClick={handleRefreshInvite} className="btn-secondary">
Generate New Code
</button>
</div>
</section>
)}
{isManager && (
<section key="join-and-invites" className="manage-section">
<h2>Join and Invites</h2>
<div className="manage-section-header">
<div>
<p className="manage-section-eyebrow">Entry Rules</p>
<h2>Invite Links</h2>
<p className="section-description">
Decide how new people can enter, review manual approvals, then create invite links for the flow you want.
</p>
</div>
</div>
{inviteError && <p className="section-error">{inviteError}</p>}
<ToggleButtonGroup
@ -377,14 +421,61 @@ export default function ManageHousehold() {
className="tbg-group manage-household-join-policy-toggle"
options={JOIN_POLICY_OPTIONS.map((option) => ({
...option,
disabled: inviteLoading
disabled: inviteLoading,
}))}
onChange={handleUpdateJoinPolicy}
/>
<div className="pending-requests-summary">
<span className="pending-requests-summary-label">Pending approvals</span>
<span className="pending-requests-summary-count">{pendingRequests.length}</span>
</div>
{inviteLoading ? (
<p>Loading invite settings...</p>
) : pendingRequests.length === 0 ? (
<p className="section-description">No pending join requests right now.</p>
) : (
<div className="pending-requests-list">
{pendingRequests.map((request) => {
const requesterLabel = getRequesterLabel(request);
const isBusy = pendingDecisionId === request.id;
return (
<div key={request.id} className="pending-request-card">
<div className="pending-request-main">
<div className="pending-request-topline">
<p className="pending-request-name">{requesterLabel}</p>
<span className="pending-request-badge">🕒 Pending</span>
</div>
<p className="pending-request-meta">
@{request.username} Requested {new Date(request.created_at).toLocaleString()}
</p>
</div>
<div className="pending-request-actions">
<button
className="btn-secondary btn-small member-role-action"
onClick={() => handleJoinRequestDecision(request, "APPROVE")}
disabled={isBusy}
>
{isBusy ? "Working..." : "Approve"}
</button>
<button
className="btn-danger btn-small"
onClick={() => handleJoinRequestDecision(request, "DENY")}
disabled={isBusy}
>
Deny
</button>
</div>
</div>
);
})}
</div>
)}
<div className="invite-controls">
<label>
TTL
<span className="invite-control-label">TTL</span>
<select value={ttlDays} onChange={(e) => setTtlDays(Number(e.target.value))}>
{[1, 2, 3, 4, 5, 6, 7].map((day) => (
<option key={day} value={day}>{day} day{day > 1 ? "s" : ""}</option>
@ -392,7 +483,7 @@ export default function ManageHousehold() {
</select>
</label>
<label>
Usage
<span className="invite-control-label">Usage</span>
<select value={singleUseMode} onChange={(e) => setSingleUseMode(e.target.value)}>
<option value="UNLIMITED">Unlimited</option>
<option value="ONE_TIME">1 use</option>
@ -412,12 +503,18 @@ export default function ManageHousehold() {
{inviteLinks.map((link) => {
const status = getLinkStatus(link);
const isActive = status === "Active";
const statusMeta = STATUS_METADATA[status] || STATUS_METADATA.Active;
return (
<div key={link.id} className="invite-link-card">
<div>
<p className="invite-link-token">Token ending in {String(link.token).slice(-4)}</p>
<div className="invite-link-main">
<div className="invite-link-topline">
<p className="invite-link-token">Invite ending in {String(link.token).slice(-4)}</p>
<span className={`invite-status-badge is-${statusMeta.tone}`}>
{statusMeta.icon} {status}
</span>
</div>
<p className="invite-link-meta">
Status: <strong>{status}</strong> | Policy: {link.policy} | TTL: until {new Date(link.expires_at).toLocaleString()}
Policy: {link.policy} Expires {new Date(link.expires_at).toLocaleString()}
</p>
</div>
<div className="invite-link-actions">
@ -446,58 +543,95 @@ export default function ManageHousehold() {
)}
<section key="members" className="manage-section">
<h2>Members ({members.length})</h2>
<div className="manage-section-header">
<div>
<p className="manage-section-eyebrow">People</p>
<h2>Members ({members.length})</h2>
<p className="section-description">
Role badges and compact actions make it easier to see who runs the household and who just shops.
</p>
</div>
</div>
{loading ? (
<p>Loading members...</p>
) : (
<div className="members-list">
{members.map((member) => (
<div key={member.id} className="member-card">
<div className="member-info">
<span className="member-role">{member.role}</span>
<span className="member-name">
{member.username} [{member.id}] {member.id === parseInt(userId, 10) ? "(You)" : ""}
</span>
</div>
{isManager && member.id !== parseInt(userId, 10) && member.role !== "owner" && (
<div className="member-actions">
<button
onClick={() => handleUpdateRole(member.id, member.role, member.username)}
className="btn-secondary btn-small"
>
{member.role === "admin" ? "Make Member" : "Make Admin"}
</button>
<button
onClick={() => handleRemoveMember(member.id, member.username)}
className="btn-danger btn-small"
>
Remove
</button>
{members.map((member) => {
const roleMeta = ROLE_METADATA[member.role] || { icon: "👤", label: member.role };
const isSelf = member.id === parseInt(userId, 10);
return (
<div key={member.id} className="member-card">
<div className="member-main">
<div className="member-avatar" aria-hidden="true">{roleMeta.icon}</div>
<div className="member-info">
<div className="member-topline">
<span className={`member-role member-role-${member.role}`}>
{roleMeta.icon} {roleMeta.label}
</span>
{isSelf && <span className="member-self-pill"> You</span>}
</div>
<span className="member-name">{member.username}</span>
<span className="member-meta">ID #{member.id}</span>
</div>
</div>
)}
</div>
))}
{isManager && !isSelf && member.role !== "owner" && (
<div className="member-actions">
{isOwner && (
<button
onClick={() => handleUpdateRole(member.id, "owner", member.username)}
className="btn-primary btn-small member-owner-action"
>
Make Owner
</button>
)}
<button
onClick={() => handleUpdateRole(
member.id,
member.role === "admin" ? "member" : "admin",
member.username
)}
className="btn-secondary btn-small member-role-action"
>
{member.role === "admin" ? "Make Member" : "Make Admin"}
</button>
<button
onClick={() => handleRemoveMember(member.id, member.username)}
className="btn-danger btn-small"
>
Remove
</button>
</div>
)}
</div>
);
})}
</div>
)}
</section>
{(isManager || isMemberOnly) && (
<section key="danger-zone" className="manage-section danger-zone">
<h2>Danger Zone</h2>
<p className="section-description">
{isMemberOnly
? "Leaving removes your access to this household."
: "Deleting a household is permanent and will delete all lists, items, and history."}
</p>
{isMemberOnly ? (
<button onClick={() => setIsLeaveModalOpen(true)} className="btn-danger">
Leave Household
</button>
) : (
<button onClick={handleDeleteHousehold} className="btn-danger">
Delete Household
</button>
)}
<div className="manage-section-header">
<div>
<p className="manage-section-eyebrow">Final Actions</p>
<h2>Danger Zone</h2>
<p className="section-description">
{isMemberOnly
? "Leaving removes your access to this household."
: "Deleting a household is permanent and will delete all lists, items, and history."}
</p>
</div>
{isMemberOnly ? (
<button onClick={() => setIsLeaveModalOpen(true)} className="btn-danger">
Leave Household
</button>
) : (
<button onClick={handleDeleteHousehold} className="btn-danger">
Delete Household
</button>
)}
</div>
</section>
)}
@ -509,6 +643,27 @@ export default function ManageHousehold() {
onClose={() => setIsLeaveModalOpen(false)}
onConfirm={handleLeaveHousehold}
/>
<ConfirmSlideModal
isOpen={Boolean(pendingRoleChange)}
title={
pendingRoleChange?.nextRole === "owner"
? `Transfer ownership to ${pendingRoleChange?.memberName || "this member"}?`
: `Change ${pendingRoleChange?.memberName || "this member"} to ${pendingRoleChange?.nextRole || "member"}?`
}
description={
pendingRoleChange?.nextRole === "owner"
? "Slide to confirm. They will become the household owner and you will become an admin."
: "Slide to confirm this household role change."
}
confirmLabel={
pendingRoleChange?.nextRole === "owner"
? "Transfer Ownership"
: `Make ${pendingRoleChange?.nextRole === "admin" ? "Admin" : "Member"}`
}
onClose={() => setPendingRoleChange(null)}
onConfirm={handleConfirmRoleChange}
/>
</div>
);
}

View File

@ -1,11 +1,14 @@
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';
const ACTIVE_HOUSEHOLD_STORAGE_KEY = 'activeHouseholdId';
export const HouseholdContext = createContext({
households: [],
activeHousehold: null,
loading: false,
hasLoaded: false,
error: null,
setActiveHousehold: () => { },
refreshHouseholds: () => { },
@ -17,65 +20,79 @@ 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(ACTIVE_HOUSEHOLD_STORAGE_KEY);
}, []);
const loadHouseholds = useCallback(async () => {
if (!token) return;
setLoading(true);
setError(null);
try {
const response = await getUserHouseholds();
const nextHouseholds = Array.isArray(response.data) ? response.data : [];
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) {
setActiveHouseholdState(null);
return;
}
console.log('[HouseholdContext] Setting active household from:', households);
const savedHouseholdId = localStorage.getItem('activeHouseholdId');
const savedHouseholdId = localStorage.getItem(ACTIVE_HOUSEHOLD_STORAGE_KEY);
if (savedHouseholdId) {
const household = households.find(h => h.id === parseInt(savedHouseholdId));
const household = households.find((candidate) => String(candidate.id) === savedHouseholdId);
if (household) {
console.log('[HouseholdContext] Found saved household:', household);
setActiveHouseholdState(household);
return;
}
}
// No saved household or not found, use first one
console.log('[HouseholdContext] Using first household:', households[0]);
setActiveHouseholdState(households[0]);
localStorage.setItem('activeHouseholdId', households[0].id);
localStorage.setItem(ACTIVE_HOUSEHOLD_STORAGE_KEY, String(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);
}
};
const setActiveHousehold = (household) => {
setActiveHouseholdState(household);
if (household) {
localStorage.setItem('activeHouseholdId', household.id);
localStorage.setItem(ACTIVE_HOUSEHOLD_STORAGE_KEY, String(household.id));
} else {
localStorage.removeItem('activeHouseholdId');
localStorage.removeItem(ACTIVE_HOUSEHOLD_STORAGE_KEY);
}
};
@ -101,6 +118,7 @@ export const HouseholdProvider = ({ children }) => {
households,
activeHousehold,
loading,
hasLoaded,
error,
setActiveHousehold,
refreshHouseholds: loadHouseholds,

View File

@ -1,73 +1,3 @@
/* :root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
} */
/**
* Global Base Styles
* Uses theme variables defined in theme.css
@ -77,12 +7,20 @@ button:focus-visible {
box-sizing: border-box;
}
html {
min-width: 320px;
scroll-behavior: smooth;
}
body {
font-family: var(--font-family-base);
font-size: var(--font-size-base);
line-height: var(--line-height-normal);
color: var(--color-text-primary);
background: var(--color-bg-body);
background:
radial-gradient(circle at top left, rgba(15, 118, 110, 0.14), transparent 34%),
radial-gradient(circle at top right, rgba(245, 158, 11, 0.14), transparent 28%),
linear-gradient(180deg, #faf8f3 0%, var(--color-bg-body) 42%, #efe8dc 100%);
margin: 0;
padding: 0;
-webkit-font-smoothing: antialiased;
@ -90,44 +28,275 @@ body {
transition: background-color 0.3s ease, color 0.3s ease;
}
[data-theme="dark"] body,
body.dark-mode {
background:
radial-gradient(circle at top left, rgba(45, 212, 191, 0.12), transparent 28%),
radial-gradient(circle at top right, rgba(251, 191, 36, 0.08), transparent 24%),
linear-gradient(180deg, #101823 0%, var(--color-bg-body) 44%, #0b1220 100%);
}
#root {
min-height: 100vh;
}
.container {
max-width: var(--container-max-width);
margin: auto;
padding: var(--container-padding);
a {
color: var(--color-primary);
text-decoration: none;
transition: color var(--transition-base), opacity var(--transition-base);
}
h1 {
text-align: center;
font-size: 1.5em;
a:hover {
color: var(--color-primary-hover);
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
font-family: var(--font-family-heading);
line-height: var(--line-height-tight);
letter-spacing: -0.02em;
}
p {
margin: 0;
}
input,
button,
select {
font-size: 1em;
margin: 0.3em 0;
padding: 0.5em;
select,
textarea {
width: 100%;
box-sizing: border-box;
font: inherit;
}
button {
font: inherit;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
padding: 0.5em;
background: #e9ecef;
margin-bottom: 0.5em;
border-radius: 4px;
.page-shell {
width: min(100%, var(--page-max-width));
margin: 0 auto;
padding: clamp(1rem, 2vw, 1.75rem);
}
.page-shell--narrow {
max-width: 560px;
}
.page-shell--center {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.page-panel {
position: relative;
overflow: hidden;
width: 100%;
border: 1px solid var(--color-border-light);
border-radius: var(--border-radius-xl);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(255, 255, 255, 0.88)),
var(--color-bg-surface);
box-shadow: var(--shadow-xl);
backdrop-filter: blur(18px);
}
[data-theme="dark"] .page-panel,
body.dark-mode .page-panel {
background:
linear-gradient(180deg, rgba(20, 29, 42, 0.96), rgba(15, 23, 34, 0.9)),
var(--color-bg-surface);
}
.page-panel::before {
content: "";
position: absolute;
inset: 0 auto auto 0;
width: 100%;
height: 10px;
background: linear-gradient(90deg, var(--color-primary), var(--color-accent));
opacity: 0.9;
}
.page-panel-inner {
padding: clamp(1.25rem, 3vw, 2.4rem);
}
.page-panel--compact .page-panel-inner {
padding: clamp(1.4rem, 4vw, 2.1rem);
}
.page-hero {
display: flex;
flex-direction: column;
gap: 0.65rem;
margin-bottom: 1.5rem;
}
.page-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.45rem;
width: fit-content;
padding: 0.45rem 0.8rem;
border-radius: var(--border-radius-full);
background: var(--color-primary-light);
color: var(--color-primary-dark);
font-size: 0.78rem;
font-weight: var(--font-weight-bold);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.page-title {
font-size: clamp(2rem, 4vw, 3.2rem);
color: var(--color-text-primary);
}
.page-subtitle {
max-width: 44rem;
color: var(--color-text-secondary);
font-size: 1.02rem;
line-height: 1.7;
}
.page-tabs {
display: inline-flex;
flex-wrap: wrap;
gap: 0.6rem;
padding: 0.45rem;
border: 1px solid var(--color-border-light);
border-radius: var(--border-radius-full);
background: rgba(255, 255, 255, 0.62);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
[data-theme="dark"] .page-tabs,
body.dark-mode .page-tabs {
background: rgba(15, 23, 34, 0.72);
}
.page-tab {
min-width: 120px;
padding: 0.75rem 1.15rem;
border: none;
border-radius: var(--border-radius-full);
background: transparent;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
cursor: pointer;
transition: background var(--transition-base), color var(--transition-base), transform var(--transition-base);
}
li:hover {
background: #dee2e6;
.page-tab:hover {
background: rgba(15, 118, 110, 0.08);
color: var(--color-text-primary);
}
.page-tab.active {
background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark));
color: var(--color-text-inverse);
box-shadow: var(--shadow-sm);
}
.surface-note {
border: 1px solid var(--color-border-light);
border-radius: var(--border-radius-lg);
background: rgba(255, 255, 255, 0.64);
padding: 1rem 1.1rem;
color: var(--color-text-secondary);
}
[data-theme="dark"] .surface-note,
body.dark-mode .surface-note {
background: rgba(15, 23, 34, 0.72);
}
.error-message,
.success-message {
margin-bottom: 1rem;
padding: 0.9rem 1rem;
border-radius: var(--border-radius-md);
border: 1px solid;
font-size: var(--font-size-sm);
line-height: 1.55;
}
.error-message {
color: var(--color-danger);
background: var(--color-danger-light);
border-color: color-mix(in srgb, var(--color-danger) 35%, transparent);
}
.success-message {
color: var(--color-success);
background: var(--color-success-light);
border-color: color-mix(in srgb, var(--color-success) 35%, transparent);
}
.auth-shell {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.auth-card {
width: min(100%, 30rem);
}
.auth-card .page-hero {
margin-bottom: 1.25rem;
}
.auth-subtitle {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
line-height: 1.7;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.auth-footer {
margin-top: 1.25rem;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
text-align: center;
}
@media (max-width: 640px) {
.page-shell {
padding: 0.85rem;
}
.page-tabs {
width: 100%;
}
.page-tab {
flex: 1 1 calc(50% - 0.6rem);
min-width: 0;
}
.page-title {
font-size: clamp(1.8rem, 8vw, 2.4rem);
}
}

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

@ -181,12 +181,14 @@
}
.confirm-buy-cancel {
background: var(--color-gray-200);
color: var(--color-text-primary);
background: var(--button-secondary-bg);
color: var(--button-secondary-text);
border: 1px solid var(--button-secondary-border);
}
.confirm-buy-cancel:hover {
background: var(--color-gray-300);
background: var(--button-secondary-hover-bg);
border-color: var(--button-secondary-border-hover);
}
.confirm-buy-confirm {

View File

@ -52,9 +52,9 @@
.image-upload-option-btn {
padding: 1.2em;
border: 2px solid #ddd;
border: 2px solid var(--button-secondary-border);
border-radius: 8px;
background: white;
background: var(--button-ghost-bg);
font-size: 1.1em;
cursor: pointer;
transition: all 0.2s;
@ -65,8 +65,8 @@
}
.image-upload-option-btn:hover {
border-color: #007bff;
background: #f8f9fa;
border-color: var(--button-secondary-border-hover);
background: var(--button-secondary-hover-bg);
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.2);
}
@ -160,12 +160,13 @@
}
.image-upload-skip {
background: #f0f0f0;
color: #333;
background: var(--button-secondary-bg);
color: var(--button-secondary-text);
border: 1px solid var(--button-secondary-border);
}
.image-upload-skip:hover {
background: #e0e0e0;
background: var(--button-secondary-hover-bg);
}
.image-upload-confirm {

View File

@ -84,12 +84,14 @@
}
.classification-modal-btn-skip {
background: #6c757d;
color: white;
background: var(--button-secondary-bg);
color: var(--button-secondary-text);
border: 1px solid var(--button-secondary-border);
}
.classification-modal-btn-skip:hover {
background: #5a6268;
background: var(--button-secondary-hover-bg);
border-color: var(--button-secondary-border-hover);
}
.classification-modal-btn-confirm {

View File

@ -1,6 +1,7 @@
.household-switcher {
position: relative;
display: inline-block;
min-width: 220px;
}
.household-switcher-toggle {
@ -8,20 +9,20 @@
align-items: center;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 1rem;
background: var(--card-bg);
background: var(--button-ghost-bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
width: 100%;
}
.household-switcher-toggle:hover {
background: var(--card-hover);
border-color: var(--primary);
background: var(--button-ghost-hover-bg);
border-color: var(--button-ghost-border-hover);
}
.household-switcher-toggle:disabled {
@ -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 {
@ -93,24 +101,26 @@
}
.household-option:hover {
background: var(--card-hover);
background: var(--button-secondary-bg);
border-color: var(--primary);
}
.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

@ -54,12 +54,14 @@
}
.image-upload-btn.gallery {
background: #6c757d;
color: white;
background: var(--button-secondary-bg);
color: var(--button-secondary-text);
border: 1px solid var(--button-secondary-border);
}
.image-upload-btn.gallery:hover {
background: #545b62;
background: var(--button-secondary-hover-bg);
border-color: var(--button-secondary-border-hover);
}
.image-upload-preview {

View File

@ -66,8 +66,8 @@
justify-content: center;
text-decoration: none;
color: #ffffff;
background: #495057;
border: 1px solid #5a6268;
background: rgba(30, 144, 255, 0.18);
border: 1px solid rgba(95, 178, 255, 0.32);
border-radius: 0;
font-size: 1.2rem;
line-height: 1;
@ -75,7 +75,7 @@
}
.navbar-icon-link:hover {
background: #5a6268;
background: rgba(30, 144, 255, 0.3);
}
.navbar-icon-link:focus-visible {
@ -103,9 +103,9 @@
/* User Button */
.navbar-user-btn {
background: #495057;
background: rgba(30, 144, 255, 0.18);
color: white;
border: none;
border: 1px solid rgba(95, 178, 255, 0.32);
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
@ -119,7 +119,7 @@
}
.navbar-user-btn:hover {
background: #5a6268;
background: rgba(30, 144, 255, 0.3);
}
.navbar-user-icon {

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

@ -69,11 +69,11 @@
.tbg-button.is-inactive:hover:not(:disabled) {
color: var(--text-primary);
background: rgba(0, 0, 0, 0.04);
background: var(--button-secondary-bg);
}
[data-theme="dark"] .tbg-button.is-inactive:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
background: var(--button-secondary-bg);
}
.tbg-button:focus-visible {

View File

@ -55,9 +55,9 @@
}
.upload-toast-actions button {
border: 1px solid var(--color-border-light);
background: var(--color-bg-surface);
color: var(--color-text-primary);
border: 1px solid var(--button-secondary-border);
background: var(--button-secondary-bg);
color: var(--button-secondary-text);
border-radius: 6px;
padding: 0.3rem 0.55rem;
font-size: 0.8rem;
@ -65,8 +65,9 @@
}
.upload-toast-actions button:hover {
border-color: var(--color-primary);
color: var(--color-primary);
background: var(--button-secondary-hover-bg);
border-color: var(--button-secondary-border-hover);
color: var(--button-secondary-text);
}
.upload-toast-success .upload-toast-progress-fill {

View File

@ -55,7 +55,7 @@
}
.close-btn:hover {
background: var(--card-hover);
background: var(--button-ghost-bg);
color: var(--text-primary);
}
@ -80,7 +80,7 @@
.mode-tab:hover {
color: var(--text-primary);
background: var(--card-hover);
background: var(--button-secondary-bg);
}
.mode-tab.active {

View File

@ -2,139 +2,316 @@
.manage-household {
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 800px;
gap: 1rem;
max-width: 900px;
margin: 0 auto;
width: 100%;
}
/* Section Styling */
.manage-section {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
box-sizing: border-box;
padding: 1.2rem 1.25rem;
border: 1px solid var(--color-border-light);
border-radius: var(--border-radius-lg);
background:
linear-gradient(180deg, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0.97)),
var(--card-bg);
box-shadow: var(--shadow-sm);
}
.manage-section h2 {
font-size: 1.3rem;
font-weight: 600;
[data-theme="dark"] .manage-section,
body.dark-mode .manage-section {
background:
linear-gradient(180deg, rgba(26, 37, 52, 0.98), rgba(18, 27, 40, 0.94)),
var(--card-bg);
border-color: var(--color-border-medium);
}
.manage-section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.manage-section-header h2 {
margin: 0.15rem 0 0;
font-size: 1.2rem;
color: var(--text-primary);
margin-bottom: 1rem;
}
.manage-section-eyebrow {
margin: 0;
color: var(--primary);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.section-description {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 1rem;
font-size: 0.92rem;
line-height: 1.55;
margin: 0.45rem 0 0;
}
.section-error {
color: var(--danger);
margin: 0;
padding: 0.8rem 0.95rem;
border-radius: var(--border-radius-md);
border: 1px solid color-mix(in srgb, var(--danger) 28%, transparent);
background: color-mix(in srgb, var(--danger-light) 78%, white);
}
/* Household Name Section */
.name-display {
display: flex;
flex-direction: column;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.name-display-copy {
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.name-display h3 {
font-size: 1.5rem;
font-size: 1.55rem;
color: var(--text-primary);
margin: 0;
}
.edit-name-form {
.household-summary-chips {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
}
.household-summary-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.75rem;
border-radius: var(--border-radius-full);
background: var(--primary-light);
color: var(--primary-dark);
font-size: 0.82rem;
font-weight: 600;
}
.edit-name-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.edit-name-form input {
flex: 1;
min-width: 200px;
padding: 0.75rem;
min-width: 0;
padding: 0.85rem 1rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 1rem;
background: var(--background);
border-radius: var(--border-radius-md);
font-size: 0.98rem;
background: rgba(255, 255, 255, 0.82);
color: var(--text-primary);
}
/* Invite Code Section */
.invite-actions {
display: flex;
flex-direction: column;
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;
}
.section-error {
color: var(--danger);
margin: 0 0 0.75rem 0;
[data-theme="dark"] .edit-name-form input,
body.dark-mode .edit-name-form input {
background: rgba(12, 19, 30, 0.92);
border-color: var(--color-border-medium);
}
.manage-household-join-policy-toggle {
margin-bottom: 1rem;
margin-bottom: 0.2rem;
}
.pending-requests-summary {
display: inline-flex;
align-items: center;
gap: 0.65rem;
width: fit-content;
padding: 0.45rem 0.8rem;
border-radius: var(--border-radius-full);
background: rgba(30, 144, 255, 0.1);
color: var(--primary-dark);
}
.pending-requests-summary-label {
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.pending-requests-summary-count {
min-width: 1.85rem;
height: 1.85rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: var(--primary);
color: var(--color-text-inverse);
font-size: 0.85rem;
font-weight: 700;
}
[data-theme="dark"] .pending-requests-summary,
body.dark-mode .pending-requests-summary {
background: rgba(95, 178, 255, 0.14);
color: #d8ecff;
}
.pending-requests-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.pending-request-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.85rem;
align-items: center;
padding: 0.95rem 1rem;
border: 1px solid var(--border);
border-radius: var(--border-radius-lg);
background: color-mix(in srgb, var(--primary-light) 40%, white);
}
[data-theme="dark"] .pending-request-card,
body.dark-mode .pending-request-card {
background: rgba(14, 27, 45, 0.96);
border-color: var(--color-border-medium);
}
.pending-request-main {
min-width: 0;
}
.pending-request-topline {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
margin-bottom: 0.35rem;
}
.pending-request-name,
.pending-request-meta {
margin: 0;
}
.pending-request-name {
font-weight: 700;
color: var(--text-primary);
}
.pending-request-meta {
color: var(--text-secondary);
font-size: 0.84rem;
line-height: 1.5;
}
.pending-request-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.28rem 0.6rem;
border-radius: var(--border-radius-full);
background: rgba(245, 158, 11, 0.18);
color: #b45309;
font-size: 0.78rem;
font-weight: 700;
}
.pending-request-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.invite-controls {
display: flex;
display: grid;
grid-template-columns: repeat(2, minmax(140px, 180px)) auto;
gap: 0.8rem;
align-items: end;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.invite-controls label {
display: flex;
flex-direction: column;
gap: 0.3rem;
gap: 0.35rem;
color: var(--text-primary);
font-size: 0.9rem;
}
.invite-control-label {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text-secondary);
text-transform: uppercase;
}
.invite-controls select {
min-width: 120px;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.45rem 0.6rem;
background: var(--background);
border-radius: var(--border-radius-md);
padding: 0.7rem 0.75rem;
background: rgba(255, 255, 255, 1);
color: var(--text-primary);
}
[data-theme="dark"] .invite-controls select,
body.dark-mode .invite-controls select {
background: rgba(12, 19, 30, 0.92);
border-color: var(--color-border-medium);
}
.invite-links-list {
display: flex;
flex-direction: column;
gap: 0.8rem;
gap: 0.75rem;
}
.invite-link-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.85rem;
align-items: center;
padding: 0.9rem 1rem;
border: 1px solid var(--border);
background: var(--background);
border-radius: 8px;
padding: 0.9rem;
border-radius: var(--border-radius-lg);
background: rgba(255, 255, 255, 1);
}
[data-theme="dark"] .invite-link-card,
body.dark-mode .invite-link-card {
background: rgba(12, 19, 30, 0.9);
border-color: var(--color-border-medium);
}
.invite-link-main {
min-width: 0;
}
.invite-link-topline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
gap: 0.6rem;
flex-wrap: wrap;
margin-bottom: 0.35rem;
}
.invite-link-token,
@ -143,118 +320,262 @@
}
.invite-link-token {
font-weight: 600;
font-weight: 700;
color: var(--text-primary);
}
.invite-link-meta {
color: var(--text-secondary);
font-size: 0.85rem;
font-size: 0.84rem;
line-height: 1.5;
}
.invite-status-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.28rem 0.6rem;
border-radius: var(--border-radius-full);
font-size: 0.78rem;
font-weight: 700;
}
.invite-status-badge.is-active {
background: var(--success-light);
color: var(--success);
}
.invite-status-badge.is-used {
background: color-mix(in srgb, var(--color-gray-200) 74%, white);
color: var(--color-gray-700);
}
[data-theme="dark"] .invite-status-badge.is-used,
body.dark-mode .invite-status-badge.is-used {
background: rgba(100, 116, 139, 0.22);
color: #d9e2ef;
}
.invite-status-badge.is-revoked {
background: var(--danger-light);
color: var(--danger);
}
.invite-status-badge.is-expired {
background: var(--color-warning-light);
color: var(--color-warning);
}
.invite-link-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
/* Members Section */
.members-list {
display: flex;
flex-direction: column;
gap: 1rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
gap: 0.85rem;
}
.member-card {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 1.25rem;
background: var(--background);
flex-direction: column;
gap: 0.9rem;
padding: 0.95rem 1rem;
background: rgba(255, 255, 255, 1);
border: 1px solid var(--border);
border-radius: 8px;
border-radius: var(--border-radius-lg);
transition: all 0.2s;
gap: 1rem;
}
[data-theme="dark"] .member-card,
body.dark-mode .member-card {
background: rgba(12, 19, 30, 0.9);
border-color: var(--color-border-medium);
}
.member-card:hover {
background: var(--card-hover);
border-color: var(--primary);
transform: translateY(-1px);
}
[data-theme="dark"] .member-card:hover,
body.dark-mode .member-card:hover {
background: rgba(20, 32, 48, 0.98);
}
.member-avatar {
width: 2.6rem;
height: 2.6rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: var(--primary-light);
font-size: 1.15rem;
}
.member-main {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.85rem;
align-items: flex-start;
}
.member-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: 0.35rem;
min-width: 0;
}
.member-topline {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.member-name {
font-weight: 500;
font-weight: 700;
color: var(--text-primary);
font-size: 1rem;
}
.member-meta {
color: var(--text-secondary);
font-size: 0.82rem;
}
.member-role {
font-size: 0.85rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.78rem;
padding: 0.24rem 0.55rem;
border-radius: var(--border-radius-full);
width: fit-content;
text-transform: capitalize;
background: var(--primary-light, rgba(0, 122, 255, 0.1));
color: var(--primary);
font-weight: 700;
}
.member-role-owner {
background: rgba(245, 158, 11, 0.18);
color: #b45309;
}
.member-role-admin {
background: rgba(30, 144, 255, 0.16);
color: var(--primary-dark);
}
.member-role-member,
.member-role-viewer {
background: rgba(139, 92, 246, 0.12);
color: #6d28d9;
}
.member-self-pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.24rem 0.5rem;
border-radius: var(--border-radius-full);
background: rgba(245, 158, 11, 0.16);
color: #a16207;
font-size: 0.75rem;
font-weight: 700;
}
.member-actions {
display: flex;
gap: 0.75rem;
gap: 0.55rem;
flex-wrap: wrap;
justify-content: flex-start;
align-items: center;
padding-top: 0.75rem;
border-top: 1px solid color-mix(in srgb, var(--color-border-light) 82%, transparent);
}
.member-owner-action {
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
}
.member-role-action {
background: rgba(30, 144, 255, 0.14);
color: var(--primary-dark);
border-color: rgba(30, 144, 255, 0.34);
}
.member-role-action:hover:not(:disabled) {
background: rgba(30, 144, 255, 0.22);
border-color: rgba(30, 144, 255, 0.54);
color: var(--primary-dark);
}
[data-theme="dark"] .member-role-action,
body.dark-mode .member-role-action {
background: rgba(30, 144, 255, 0.22);
color: #d8ecff;
border-color: rgba(95, 178, 255, 0.4);
}
[data-theme="dark"] .member-role-action:hover:not(:disabled),
body.dark-mode .member-role-action:hover:not(:disabled) {
background: rgba(30, 144, 255, 0.32);
border-color: rgba(95, 178, 255, 0.6);
color: #f3f9ff;
}
[data-theme="dark"] .member-actions,
body.dark-mode .member-actions {
border-top-color: color-mix(in srgb, var(--color-border-medium) 88%, transparent);
}
/* Danger Zone */
.danger-zone {
border-color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 30%, transparent);
background:
linear-gradient(180deg, rgba(254, 242, 242, 0.95), rgba(255, 255, 255, 0.78)),
var(--card-bg);
}
.danger-zone h2 {
[data-theme="dark"] .danger-zone,
body.dark-mode .danger-zone {
background:
linear-gradient(180deg, rgba(70, 26, 32, 0.92), rgba(28, 14, 19, 0.94)),
var(--card-bg);
border-color: color-mix(in srgb, var(--danger) 42%, transparent);
}
.danger-zone h2,
.danger-zone .manage-section-eyebrow {
color: var(--danger);
}
.danger-zone .manage-section-header {
align-items: center;
}
/* 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);
min-height: 40px;
padding: 0.58rem 0.95rem;
border-radius: var(--border-radius-full);
font-size: 0.88rem;
font-weight: 600;
}
.btn-small {
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
min-height: 34px;
padding: 0.38rem 0.72rem;
font-size: 0.8rem;
}
.btn-danger:disabled {
@ -264,62 +585,50 @@
}
/* Responsive */
@media (max-width: 900px) {
.invite-controls {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.invite-controls .btn-primary {
grid-column: 1 / -1;
}
.invite-link-card {
grid-template-columns: 1fr;
}
.invite-link-actions {
justify-content: flex-start;
}
.pending-request-card {
grid-template-columns: 1fr;
}
.pending-request-actions {
justify-content: flex-start;
}
}
@media (max-width: 768px) {
.manage-section {
padding: 1.25rem;
padding: 1rem;
}
.name-display {
.manage-section-header,
.name-display,
.danger-zone .manage-section-header {
flex-direction: column;
align-items: flex-start;
align-items: stretch;
}
.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%;
grid-template-columns: 1fr;
}
.invite-controls {
flex-direction: column;
align-items: stretch;
grid-template-columns: 1fr;
}
.invite-controls label,
@ -328,11 +637,17 @@
width: 100%;
}
.invite-link-actions {
width: 100%;
.members-list {
grid-template-columns: 1fr;
}
.invite-link-actions button {
flex: 1;
.member-actions {
width: 100%;
justify-content: flex-start;
}
.member-actions button,
.pending-request-actions button {
flex: 1 1 100%;
}
}

View File

@ -44,7 +44,7 @@
.admin-tab:hover {
color: var(--text-primary);
background: var(--card-hover);
background: var(--button-secondary-bg);
}
.admin-tab.active {

View File

@ -61,8 +61,9 @@
}
.invite-btn-secondary {
background: var(--card-hover);
color: var(--text-primary);
background: var(--button-secondary-bg);
color: var(--button-secondary-text);
border: 1px solid var(--button-secondary-border);
}
@media (max-width: 640px) {

View File

@ -14,8 +14,8 @@
}
.login-password-toggle {
background: #f0f0f0;
border: 1px solid #ccc;
background: var(--button-ghost-bg);
border: 1px solid var(--button-ghost-border);
border-radius: 4px;
cursor: pointer;
font-size: 1.2em;
@ -31,6 +31,6 @@
.login-password-toggle:hover {
opacity: 1;
background: #e8e8e8;
background: var(--button-ghost-hover-bg);
}

View File

@ -44,7 +44,7 @@
.manage-tab:hover {
color: var(--text-primary);
background: var(--card-hover);
background: var(--button-secondary-bg);
}
.manage-tab.active {

View File

@ -83,7 +83,7 @@
.settings-tab:hover {
color: var(--color-primary);
background: var(--color-bg-hover);
background: var(--button-secondary-bg);
}
.settings-tab.active {

View File

@ -1,9 +1,9 @@
/**
* Global Theme Variables
*
*
* This file defines the design system for the entire application.
* All colors, spacing, typography, and other design tokens are centralized here.
*
*
* Usage: var(--variable-name)
* Example: color: var(--color-primary);
*/
@ -12,135 +12,139 @@
/* ============================================
COLOR PALETTE
============================================ */
/* Primary Colors */
--color-primary: dodgerblue;
--color-primary-hover: #0066cc;
--color-primary-light: #e7f3ff;
--color-primary-dark: #0056b3;
--color-primary-hover: #1677d2;
--color-primary-light: #dceeff;
--color-primary-dark: #0f5db4;
/* Secondary Colors */
--color-secondary: #6c757d;
--color-secondary-hover: #545b62;
--color-secondary-light: #f8f9fa;
--color-secondary: #7c5a3c;
--color-secondary-hover: #64462e;
--color-secondary-light: #f5ede4;
/* Accent Colors */
--color-accent: #f59e0b;
--color-accent-light: #fff2d8;
/* Semantic Colors */
--color-success: #28a745;
--color-success-hover: #218838;
--color-success-light: #d4edda;
--color-danger: #dc3545;
--color-danger-hover: #c82333;
--color-danger-light: #f8d7da;
--color-warning: #ffc107;
--color-warning-hover: #e0a800;
--color-warning-light: #fff3cd;
--color-info: #17a2b8;
--color-info-hover: #138496;
--color-info-light: #d1ecf1;
--color-success: #15803d;
--color-success-hover: #166534;
--color-success-light: #dcfce7;
--color-danger: #dc2626;
--color-danger-hover: #b91c1c;
--color-danger-light: #fee2e2;
--color-warning: #d97706;
--color-warning-hover: #b45309;
--color-warning-light: #ffedd5;
--color-info: #0369a1;
--color-info-hover: #075985;
--color-info-light: #dbeafe;
/* Neutral Colors */
--color-white: #ffffff;
--color-black: #000000;
--color-gray-50: #f9f9f9;
--color-gray-100: #f8f9fa;
--color-gray-200: #e9ecef;
--color-gray-300: #dee2e6;
--color-gray-400: #ced4da;
--color-gray-500: #adb5bd;
--color-gray-600: #6c757d;
--color-gray-700: #495057;
--color-gray-800: #343a40;
--color-gray-900: #212529;
--color-black: #0f172a;
--color-gray-50: #fcfbf8;
--color-gray-100: #f6f3ee;
--color-gray-200: #ebe6dd;
--color-gray-300: #ddd4c7;
--color-gray-400: #b6ab9a;
--color-gray-500: #8e8579;
--color-gray-600: #6b645b;
--color-gray-700: #47423d;
--color-gray-800: #2d2a27;
--color-gray-900: #1c1917;
/* Text Colors */
--color-text-primary: #212529;
--color-text-secondary: #6c757d;
--color-text-muted: #adb5bd;
--color-text-inverse: #ffffff;
--color-text-disabled: #6c757d;
--color-text-primary: #1f2937;
--color-text-secondary: #5b6473;
--color-text-muted: #8e98a8;
--color-text-inverse: #f8fafc;
--color-text-disabled: #9aa4b2;
/* Background Colors */
--color-bg-body: #f8f9fa;
--color-bg-surface: #ffffff;
--color-bg-hover: #f5f5f5;
--color-bg-disabled: #e9ecef;
--color-bg-body: #f4f1ea;
--color-bg-surface: rgba(255, 255, 255, 0.9);
--color-bg-elevated: rgba(255, 255, 255, 0.98);
--color-bg-hover: #f2f7f6;
--color-bg-disabled: #ece7de;
--color-bg-hero: linear-gradient(135deg, rgba(15, 118, 110, 0.12), rgba(245, 158, 11, 0.12));
/* Border Colors */
--color-border-light: #e0e0e0;
--color-border-medium: #ccc;
--color-border-dark: #999;
--color-border-disabled: #dee2e6;
--color-border-light: rgba(119, 107, 91, 0.18);
--color-border-medium: rgba(119, 107, 91, 0.32);
--color-border-dark: rgba(91, 81, 69, 0.55);
--color-border-disabled: rgba(148, 163, 184, 0.35);
/* ============================================
SPACING
============================================ */
--spacing-xs: 0.25rem; /* 4px */
--spacing-sm: 0.5rem; /* 8px */
--spacing-md: 1rem; /* 16px */
--spacing-lg: 1.5rem; /* 24px */
--spacing-xl: 2rem; /* 32px */
--spacing-2xl: 3rem; /* 48px */
--spacing-3xl: 4rem; /* 64px */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
--spacing-3xl: 4rem;
/* ============================================
TYPOGRAPHY
============================================ */
--font-family-base: Arial, sans-serif;
--font-family-heading: Arial, sans-serif;
--font-family-mono: 'Courier New', monospace;
/* Font Sizes */
--font-size-xs: 0.75rem; /* 12px */
--font-size-sm: 0.875rem; /* 14px */
--font-size-base: 1rem; /* 16px */
--font-size-lg: 1.125rem; /* 18px */
--font-size-xl: 1.25rem; /* 20px */
--font-size-2xl: 1.5rem; /* 24px */
--font-size-3xl: 2rem; /* 32px */
/* Font Weights */
--font-family-base: "Aptos", "Segoe UI Variable Text", "Segoe UI", sans-serif;
--font-family-heading: "Aptos Display", "Aptos", "Segoe UI Variable Display", "Segoe UI", sans-serif;
--font-family-mono: "IBM Plex Mono", "Cascadia Code", "Consolas", monospace;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 2rem;
--font-size-4xl: 2.75rem;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* Line Heights */
--line-height-tight: 1.2;
--line-height-tight: 1.15;
--line-height-normal: 1.5;
--line-height-relaxed: 1.75;
/* ============================================
BORDERS & RADIUS
============================================ */
--border-width-thin: 1px;
--border-width-medium: 2px;
--border-width-thick: 4px;
--border-radius-sm: 4px;
--border-radius-md: 6px;
--border-radius-lg: 8px;
--border-radius-xl: 12px;
--border-radius-full: 50%;
--border-radius-sm: 10px;
--border-radius-md: 14px;
--border-radius-lg: 20px;
--border-radius-xl: 28px;
--border-radius-full: 999px;
/* ============================================
SHADOWS
============================================ */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
--shadow-card: 0 0 10px rgba(0, 0, 0, 0.08);
--shadow-sm: 0 8px 20px rgba(36, 33, 28, 0.06);
--shadow-md: 0 16px 34px rgba(36, 33, 28, 0.1);
--shadow-lg: 0 24px 56px rgba(36, 33, 28, 0.14);
--shadow-xl: 0 32px 80px rgba(36, 33, 28, 0.18);
--shadow-card: 0 14px 36px rgba(36, 33, 28, 0.09);
/* ============================================
TRANSITIONS
============================================ */
--transition-fast: 0.15s ease;
--transition-base: 0.2s ease;
--transition-slow: 0.3s ease;
--transition-base: 0.24s ease;
--transition-slow: 0.35s ease;
/* ============================================
Z-INDEX LAYERS
============================================ */
@ -150,52 +154,62 @@
--z-modal-backdrop: 900;
--z-modal: 1000;
--z-tooltip: 1100;
/* ============================================
LAYOUT
============================================ */
--container-max-width: 480px;
--container-max-width: 560px;
--page-max-width: 1180px;
--container-padding: var(--spacing-md);
/* ============================================
COMPONENT-SPECIFIC
============================================ */
/* Buttons */
--button-padding-y: 0.6rem;
--button-padding-x: 1.5rem;
--button-border-radius: var(--border-radius-sm);
--button-font-weight: var(--font-weight-medium);
/* Inputs */
--input-padding-y: 0.6rem;
--input-padding-x: 0.75rem;
--input-border-color: var(--color-border-medium);
--input-border-radius: var(--border-radius-sm);
--button-padding-y: 0.8rem;
--button-padding-x: 1.25rem;
--button-border-radius: var(--border-radius-full);
--button-font-weight: var(--font-weight-semibold);
--input-padding-y: 0.85rem;
--input-padding-x: 1rem;
--input-border-color: var(--color-border-light);
--input-border-radius: var(--border-radius-md);
--input-focus-border-color: var(--color-primary);
--input-focus-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
/* Cards */
--input-focus-shadow: 0 0 0 4px rgba(15, 118, 110, 0.12);
--card-bg: var(--color-bg-surface);
--card-padding: var(--spacing-md);
--card-padding: var(--spacing-lg);
--card-border-radius: var(--border-radius-lg);
--card-shadow: var(--shadow-card);
/* Modals */
--modal-backdrop-bg: rgba(0, 0, 0, 0.5);
--modal-bg: var(--color-white);
--button-secondary-bg: rgba(30, 144, 255, 0.12);
--button-secondary-hover-bg: rgba(30, 144, 255, 0.2);
--button-secondary-border: rgba(30, 144, 255, 0.26);
--button-secondary-border-hover: rgba(30, 144, 255, 0.42);
--button-secondary-text: var(--color-primary-dark);
--button-ghost-bg: rgba(30, 144, 255, 0.08);
--button-ghost-hover-bg: rgba(30, 144, 255, 0.16);
--button-ghost-border: rgba(30, 144, 255, 0.22);
--button-ghost-border-hover: rgba(30, 144, 255, 0.38);
--button-ghost-text: var(--color-primary-dark);
--modal-backdrop-bg: rgba(15, 23, 42, 0.48);
--modal-bg: var(--color-bg-elevated);
--modal-border-radius: var(--border-radius-lg);
--modal-padding: var(--spacing-lg);
--modal-max-width: 500px;
/* ============================================
SIMPLIFIED ALIASES (for component convenience)
SIMPLIFIED ALIASES
============================================ */
--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);
--success: var(--color-success);
--success-light: var(--color-success-light);
--text-primary: var(--color-text-primary);
--text-secondary: var(--color-text-secondary);
--background: var(--color-bg-body);
@ -203,108 +217,104 @@
--card-hover: var(--color-bg-hover);
}
/* ============================================
DARK MODE
============================================ */
[data-theme="dark"] {
/* Primary Colors */
--color-primary: #4da3ff;
--color-primary-hover: #66b3ff;
--color-primary-light: #1a3a52;
--color-primary-dark: #3d8fdb;
--color-primary: #5fb2ff;
--color-primary-hover: #83c4ff;
--color-primary-light: rgba(95, 178, 255, 0.14);
--color-primary-dark: #2d8ff0;
--color-secondary: #f4c27a;
--color-secondary-hover: #ffd59e;
--color-secondary-light: rgba(244, 194, 122, 0.12);
--color-accent: #fbbf24;
--color-accent-light: rgba(251, 191, 36, 0.16);
/* Semantic Colors */
--color-success: #4ade80;
--color-success-hover: #5fe88d;
--color-success-light: #1a3a28;
--color-success-hover: #86efac;
--color-success-light: rgba(74, 222, 128, 0.16);
--color-danger: #f87171;
--color-danger-hover: #fa8585;
--color-danger-light: #4a2020;
--color-danger-hover: #fca5a5;
--color-danger-light: rgba(248, 113, 113, 0.16);
--color-warning: #fbbf24;
--color-warning-hover: #fcd34d;
--color-warning-light: #3a2f0f;
--color-warning-light: rgba(251, 191, 36, 0.16);
--color-info: #38bdf8;
--color-info-hover: #5dc9fc;
--color-info-light: #1a2f3a;
--color-info-hover: #7dd3fc;
--color-info-light: rgba(56, 189, 248, 0.16);
/* Text Colors */
--color-text-primary: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-text-muted: #64748b;
--color-text-inverse: #1e293b;
--color-text-disabled: #475569;
--color-text-primary: #f4f7fb;
--color-text-secondary: #b2bccb;
--color-text-muted: #7f8aa0;
--color-text-inverse: #0f172a;
--color-text-disabled: #667085;
/* Background Colors */
--color-bg-body: #0f172a;
--color-bg-surface: #1e293b;
--color-bg-hover: #334155;
--color-bg-disabled: #1e293b;
--color-bg-body: #0f1722;
--color-bg-surface: rgba(15, 23, 34, 0.84);
--color-bg-elevated: rgba(20, 29, 42, 0.96);
--color-bg-hover: rgba(30, 41, 59, 0.95);
--color-bg-disabled: rgba(30, 41, 59, 0.7);
--color-bg-hero: linear-gradient(135deg, rgba(45, 212, 191, 0.18), rgba(251, 191, 36, 0.16));
/* Border Colors */
--color-border-light: #334155;
--color-border-medium: #475569;
--color-border-dark: #64748b;
--color-border-disabled: #334155;
--color-border-light: rgba(148, 163, 184, 0.18);
--color-border-medium: rgba(148, 163, 184, 0.32);
--color-border-dark: rgba(203, 213, 225, 0.48);
--color-border-disabled: rgba(100, 116, 139, 0.3);
/* Neutral Colors - Dark adjusted */
--color-gray-50: #1e293b;
--color-gray-100: #1e293b;
--color-gray-200: #334155;
--color-gray-300: #475569;
--color-gray-400: #64748b;
--color-gray-500: #94a3b8;
--color-gray-600: #cbd5e1;
--color-gray-700: #e2e8f0;
--color-gray-800: #f1f5f9;
--color-gray-50: #111827;
--color-gray-100: #172030;
--color-gray-200: #1f2937;
--color-gray-300: #334155;
--color-gray-400: #475569;
--color-gray-500: #64748b;
--color-gray-600: #94a3b8;
--color-gray-700: #cbd5e1;
--color-gray-800: #e2e8f0;
--color-gray-900: #f8fafc;
/* Shadows - Lighter for dark mode */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6);
--shadow-card: 0 0 10px rgba(0, 0, 0, 0.5);
--shadow-sm: 0 10px 24px rgba(2, 6, 23, 0.24);
--shadow-md: 0 18px 40px rgba(2, 6, 23, 0.34);
--shadow-lg: 0 28px 60px rgba(2, 6, 23, 0.42);
--shadow-xl: 0 42px 90px rgba(2, 6, 23, 0.5);
--shadow-card: 0 18px 44px rgba(2, 6, 23, 0.34);
/* Modals */
--modal-backdrop-bg: rgba(0, 0, 0, 0.8);
--modal-bg: var(--color-bg-surface);
/* Inputs */
--input-border-color: var(--color-border-medium);
--input-focus-shadow: 0 0 0 2px rgba(77, 163, 255, 0.3);
/* Cards */
--modal-backdrop-bg: rgba(2, 6, 23, 0.72);
--modal-bg: var(--color-bg-elevated);
--input-focus-shadow: 0 0 0 4px rgba(45, 212, 191, 0.18);
--card-bg: var(--color-bg-surface);
--button-secondary-bg: rgba(95, 178, 255, 0.18);
--button-secondary-hover-bg: rgba(95, 178, 255, 0.28);
--button-secondary-border: rgba(95, 178, 255, 0.3);
--button-secondary-border-hover: rgba(131, 196, 255, 0.5);
--button-secondary-text: #d8ecff;
--button-ghost-bg: rgba(95, 178, 255, 0.12);
--button-ghost-hover-bg: rgba(95, 178, 255, 0.22);
--button-ghost-border: rgba(95, 178, 255, 0.24);
--button-ghost-border-hover: rgba(131, 196, 255, 0.4);
--button-ghost-text: #d8ecff;
}
/* ============================================
DARK MODE SUPPORT (Future Implementation)
============================================ */
@media (prefers-color-scheme: dark) {
/* Auto mode will use data-theme attribute set by JS */
}
/* Manual dark mode class override (deprecated - use data-theme) */
.dark-mode {
--color-text-primary: #f8f9fa;
--color-text-secondary: #adb5bd;
--color-bg-body: #212529;
--color-bg-surface: #343a40;
--color-border-light: #495057;
--color-border-medium: #6c757d;
--color-text-primary: #f4f7fb;
--color-text-secondary: #b2bccb;
--color-bg-body: #0f1722;
--color-bg-surface: rgba(15, 23, 34, 0.84);
--color-border-light: rgba(148, 163, 184, 0.18);
--color-border-medium: rgba(148, 163, 184, 0.32);
}
/* ============================================
UTILITY CLASSES
============================================ */
/* Spacing Utilities */
.m-0 { margin: 0 !important; }
.mt-1 { margin-top: var(--spacing-xs) !important; }
.mt-2 { margin-top: var(--spacing-sm) !important; }
@ -322,7 +332,6 @@
.p-3 { padding: var(--spacing-md) !important; }
.p-4 { padding: var(--spacing-lg) !important; }
/* Text Utilities */
.text-center { text-align: center !important; }
.text-left { text-align: left !important; }
.text-right { text-align: right !important; }
@ -338,13 +347,11 @@
.font-weight-semibold { font-weight: var(--font-weight-semibold) !important; }
.font-weight-bold { font-weight: var(--font-weight-bold) !important; }
/* Display Utilities */
.d-none { display: none !important; }
.d-block { display: block !important; }
.d-flex { display: flex !important; }
.d-inline-block { display: inline-block !important; }
/* Flex Utilities */
.flex-column { flex-direction: column !important; }
.flex-row { flex-direction: row !important; }
.justify-center { justify-content: center !important; }

View File

@ -61,17 +61,21 @@
============================================ */
.card {
background: var(--color-bg-surface);
background: var(--card-bg);
border: 1px solid var(--color-border-light);
border-radius: var(--card-border-radius);
padding: var(--card-padding);
box-shadow: var(--shadow-card);
backdrop-filter: blur(14px);
}
.card-elevated {
background: var(--color-bg-surface);
border-radius: var(--card-border-radius);
padding: var(--spacing-lg);
box-shadow: var(--shadow-lg);
background: var(--color-bg-elevated);
border: 1px solid var(--color-border-light);
border-radius: var(--border-radius-xl);
padding: clamp(1.5rem, 3vw, 2.25rem);
box-shadow: var(--shadow-xl);
backdrop-filter: blur(18px);
}
.card-title {
@ -87,74 +91,83 @@
.btn {
padding: var(--button-padding-y) var(--button-padding-x);
border: none;
border: 1px solid transparent;
border-radius: var(--button-border-radius);
font-size: var(--font-size-base);
font-weight: var(--button-font-weight);
cursor: pointer;
transition: var(--transition-base);
transition: transform var(--transition-base), box-shadow var(--transition-base), background var(--transition-base), color var(--transition-base), border-color var(--transition-base);
text-align: center;
display: inline-block;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
min-height: 46px;
}
.btn-primary {
background: var(--color-primary);
background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark));
color: var(--color-text-inverse);
box-shadow: 0 14px 30px rgba(30, 144, 255, 0.22);
}
.btn-primary:hover:not(:disabled) {
background: var(--color-primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
box-shadow: 0 18px 34px rgba(30, 144, 255, 0.28);
}
.btn-secondary {
background: var(--color-primary);
color: var(--color-text-inverse);
background: var(--button-secondary-bg);
color: var(--button-secondary-text);
border-color: var(--button-secondary-border);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-primary-hover);
background: var(--button-secondary-hover-bg);
border-color: var(--button-secondary-border-hover);
transform: translateY(-1px);
}
.btn-danger {
background: var(--color-danger);
background: linear-gradient(135deg, var(--color-danger), var(--color-danger-hover));
color: var(--color-text-inverse);
}
.btn-danger:hover:not(:disabled) {
background: var(--color-danger-hover);
transform: translateY(-1px);
box-shadow: 0 16px 30px rgba(220, 38, 38, 0.18);
}
.btn-success {
background: var(--color-success);
background: linear-gradient(135deg, var(--color-success), var(--color-success-hover));
color: var(--color-text-inverse);
}
.btn-success:hover:not(:disabled) {
background: var(--color-success-hover);
transform: translateY(-1px);
box-shadow: 0 16px 30px rgba(21, 128, 61, 0.18);
}
.btn-outline {
background: transparent;
color: var(--color-primary);
background: var(--button-ghost-bg);
color: var(--button-ghost-text);
border: var(--border-width-thin) solid var(--color-primary);
}
.btn-outline:hover:not(:disabled) {
background: var(--color-primary);
color: var(--color-text-inverse);
background: var(--button-ghost-hover-bg);
transform: translateY(-1px);
}
.btn-ghost {
background: var(--color-bg-surface);
color: var(--color-text-primary);
border: var(--border-width-thin) solid var(--color-border-medium);
background: var(--button-ghost-bg);
color: var(--button-ghost-text);
border: var(--border-width-thin) solid var(--button-ghost-border);
}
.btn-ghost:hover:not(:disabled) {
background: var(--color-bg-hover);
border-color: var(--color-border-dark);
background: var(--button-ghost-hover-bg);
border-color: var(--button-ghost-border-hover);
}
.btn-sm {
@ -175,6 +188,8 @@
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
/* ============================================
@ -200,8 +215,8 @@
border-radius: var(--input-border-radius);
font-size: var(--font-size-base);
color: var(--color-text-primary);
background: var(--color-bg-surface);
transition: var(--transition-base);
background: rgba(255, 255, 255, 0.72);
transition: border-color var(--transition-base), box-shadow var(--transition-base), background var(--transition-base), transform var(--transition-base);
box-sizing: border-box;
}
@ -209,6 +224,8 @@
outline: none;
border-color: var(--input-focus-border-color);
box-shadow: var(--input-focus-shadow);
background: var(--color-bg-elevated);
transform: translateY(-1px);
}
.form-input::placeholder {
@ -222,7 +239,7 @@
border-radius: var(--input-border-radius);
font-size: var(--font-size-base);
color: var(--color-text-primary);
background: var(--color-bg-surface);
background: rgba(255, 255, 255, 0.72);
cursor: pointer;
transition: var(--transition-base);
}
@ -231,6 +248,7 @@
outline: none;
border-color: var(--input-focus-border-color);
box-shadow: var(--input-focus-shadow);
background: var(--color-bg-elevated);
}
/* ============================================

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

View File

@ -0,0 +1,103 @@
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", "persistent-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("selected household stays active after refreshing on settings and home pages", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
const households = [
{ id: 1, name: "Alpha Home", role: "owner" },
{ id: 2, name: "Bravo Home", role: "admin" },
];
const storesByHousehold = {
1: [{ id: 101, name: "Costco", is_default: true }],
2: [{ id: 201, name: "Trader Joe's", is_default: true }],
};
await page.route("**/households", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(households),
});
});
await page.route("**/stores/household/*", async (route) => {
const householdId = Number(route.request().url().split("/").pop());
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
storesByHousehold[householdId as keyof typeof storesByHousehold] ?? []
),
});
});
await page.route("**/households/*/stores/*/list/recent", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/households/*/stores/*/list", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [] }),
});
});
await page.route("**/households/*/members", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: 1, username: "persistent-user", role: "owner" }]),
});
});
await page.goto("/");
await expect(page.getByRole("button", { name: "Alpha Home" })).toBeVisible();
await page.getByRole("button", { name: "Alpha Home" }).click();
await page.getByRole("button", { name: "Bravo Home" }).click();
await expect(page.getByRole("button", { name: "Bravo Home" })).toBeVisible();
await expect.poll(() => page.evaluate(() => localStorage.getItem("activeHouseholdId"))).toBe("2");
await page.goto("/settings");
await expect(page.getByRole("button", { name: "Bravo Home" })).toBeVisible();
await page.reload();
await expect(page.getByRole("button", { name: "Bravo Home" })).toBeVisible();
await expect.poll(() => page.evaluate(() => localStorage.getItem("activeHouseholdId"))).toBe("2");
await page.goto("/");
await expect(page.getByRole("button", { name: "Bravo Home" })).toBeVisible();
await expect(page.getByRole("button", { name: "Trader Joe's" })).toBeVisible();
});

View File

@ -0,0 +1,320 @@
import { expect, test } from "@playwright/test";
function seedAuthStorage(page: import("@playwright/test").Page, role = "admin") {
return page.addInitScript((seedRole) => {
localStorage.setItem("token", "test-token");
localStorage.setItem("userId", "1");
localStorage.setItem("role", seedRole);
localStorage.setItem("username", "manager-user");
}, role);
}
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,
}),
});
});
}
async function confirmSlide(page: import("@playwright/test").Page) {
const confirmModal = page.locator(".confirm-slide-modal");
await expect(confirmModal).toBeVisible();
const slider = confirmModal.locator(".confirm-slide-handle");
const track = confirmModal.locator(".confirm-slide-track");
const sliderBox = await slider.boundingBox();
const trackBox = await track.boundingBox();
if (!sliderBox || !trackBox) {
throw new Error("Confirm slide control was not measurable");
}
await page.mouse.move(sliderBox.x + sliderBox.width / 2, sliderBox.y + sliderBox.height / 2);
await page.mouse.down();
await page.mouse.move(trackBox.x + trackBox.width - 4, sliderBox.y + sliderBox.height / 2, { steps: 8 });
await page.mouse.up();
}
test("join household modal accepts invite links but rejects legacy invite codes", 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.route("**/api/invite-links/approval-token", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
link: {
token: "approval-token",
status: "ACTIVE",
viewerStatus: null,
active_policy: "APPROVAL_REQUIRED",
group_name: "Approval Home",
},
}),
});
});
await page.goto("/manage");
await page.getByRole("button", { name: "Join Household" }).click();
await page.getByLabel("Invite Link").fill("HABC123");
await page.getByRole("button", { name: "Open Invite" }).click();
await expect(page.getByText("Use a household invite link like /invite/abcd1234.")).toBeVisible();
await page.getByLabel("Invite Link").fill("/invite/approval-token");
await page.getByRole("button", { name: "Open Invite" }).click();
await expect(page).toHaveURL(/\/invite\/approval-token$/);
await expect(page.getByRole("heading", { name: "Join Approval Home" })).toBeVisible();
});
test("household management shows pending invite approvals and can approve them", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
let members = [
{ id: 1, username: "manager-user", role: "owner" },
];
let pendingRequests = [
{
id: 41,
user_id: 7,
username: "pending-pal",
name: "Pending Pal",
display_name: "",
created_at: "2026-03-31T12:00:00.000Z",
status: "PENDING",
},
];
await page.route("**/households", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: 1, name: "Approval Home", role: "owner", invite_code: "ABCD1234" }]),
});
});
await page.route("**/households/1/members", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(members),
});
});
await page.route("**/api/groups/join-policy", async (route) => {
const request = route.request();
if (request.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ joinPolicy: "APPROVAL_REQUIRED" }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
});
await page.route("**/api/groups/invites", async (route) => {
const request = route.request();
if (request.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
links: [
{
id: 9,
token: "invite-token-1234",
policy: "APPROVAL_REQUIRED",
single_use: false,
expires_at: "2030-01-01T00:00:00.000Z",
used_at: null,
revoked_at: null,
},
],
}),
});
return;
}
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ link: { id: 10, token: "new-token" } }),
});
});
await page.route("**/api/groups/join-requests", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ requests: pendingRequests }),
});
});
await page.route("**/api/groups/join-requests/decision", async (route) => {
const body = route.request().postDataJSON() as { requestId?: number; decision?: string };
if (body.requestId === 41 && body.decision === "APPROVE") {
pendingRequests = [];
members = [
...members,
{ id: 7, username: "pending-pal", role: "member" },
];
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
request: {
id: 41,
user_id: 7,
status: body.decision === "APPROVE" ? "APPROVED" : "DENIED",
},
}),
});
});
await page.goto("/manage?tab=household");
await expect(page.getByRole("heading", { name: "Invite Links" })).toBeVisible();
await expect(page.getByText("Pending Pal")).toBeVisible();
await expect(page.getByText("Invite Code")).toHaveCount(0);
await page.getByRole("button", { name: "Approve" }).click();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Approved join request");
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Pending Pal");
await expect(page.getByText("No pending join requests right now.")).toBeVisible();
await expect(page.getByText("Members (2)")).toBeVisible();
});
test("household owner can transfer ownership from household settings", async ({ page }) => {
await seedAuthStorage(page, "owner");
await mockConfig(page);
let households = [{ id: 1, name: "Approval Home", role: "owner", invite_code: "ABCD1234" }];
let members = [
{ id: 1, username: "manager-user", role: "owner" },
{ id: 2, username: "nico-admin", role: "admin" },
];
await page.route("**/households", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(households),
});
});
await page.route("**/households/1/members", async (route) => {
const request = route.request();
if (request.method() === "PATCH") {
const body = request.postDataJSON() as { role?: string };
if (body.role === "owner") {
households = [{ id: 1, name: "Approval Home", role: "admin", invite_code: "ABCD1234" }];
members = [
{ id: 1, username: "manager-user", role: "admin" },
{ id: 2, username: "nico-admin", role: "owner" },
];
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
message: "Household ownership transferred successfully",
member: { user_id: 2, role: body.role || "member" },
}),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(members),
});
});
await page.route("**/api/groups/join-policy", async (route) => {
const request = route.request();
if (request.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ joinPolicy: "APPROVAL_REQUIRED" }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
});
await page.route("**/api/groups/invites", async (route) => {
const request = route.request();
if (request.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ links: [] }),
});
return;
}
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ link: { id: 10, token: "new-token" } }),
});
});
await page.route("**/api/groups/join-requests", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ requests: [] }),
});
});
await page.goto("/manage?tab=household");
await expect(page.getByRole("button", { name: "Make Owner" })).toBeVisible();
await page.getByRole("button", { name: "Make Owner" }).click();
await expect(page.getByText("Transfer ownership to nico-admin?")).toBeVisible();
await confirmSlide(page);
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Transferred household ownership");
await expect(page.locator(".action-toast.action-toast-success")).toContainText("nico-admin");
await expect(page.getByText("👑 Owner")).toContainText("Owner");
await expect(page.getByText("🛠️ Admin")).toContainText("Admin");
await expect(page.getByRole("button", { name: "Make Owner" })).toHaveCount(0);
});