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 ## Working style
- Scan repo first; do not guess file names or patterns. - Scan repo first; do not guess file names or patterns.
- Make the smallest change that resolves the issue. - 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. - Keep touched files free of TS warnings and lint errors.
- Add/update tests when API behavior changes (include negative cases). - Add/update tests when API behavior changes (include negative cases).
- Keep text encoding clean (no mojibake). - 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) => { exports.revokeInviteLink = async (req, res) => {
try { try {
const requestedGroupId = parseRequestedGroupId(req); 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) => { exports.getInviteLinkSummary = async (req, res) => {
const token = req.params.token; const token = req.params.token;
const inviteLast4 = inviteCodeLast4(token); const inviteLast4 = inviteCodeLast4(token);

View File

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

View File

@ -237,6 +237,53 @@ async function getPendingJoinRequest(groupId, userId, client) {
return result.rows[0] || null; 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) { async function createOrTouchPendingJoinRequest(groupId, userId, client) {
const executor = getExecutor(client); const executor = getExecutor(client);
const existing = await executor.query( 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) { async function addGroupMember(groupId, userId, role = "member", client) {
const result = await getExecutor(client).query( const result = await getExecutor(client).query(
`INSERT INTO household_members (household_id, user_id, role) `INSERT INTO household_members (household_id, user_id, role)
@ -381,12 +444,15 @@ module.exports = {
getInviteLinkById, getInviteLinkById,
getInviteLinkSummaryByToken, getInviteLinkSummaryByToken,
getManageableGroupsForUser, getManageableGroupsForUser,
getPendingJoinRequestById,
getPendingJoinRequest, getPendingJoinRequest,
getUserGroupRole, getUserGroupRole,
isGroupMember, isGroupMember,
listPendingJoinRequests,
listInviteLinks, listInviteLinks,
revokeInviteLink, revokeInviteLink,
reviveInviteLink, reviveInviteLink,
updateJoinRequestDecision,
upsertGroupSettings, upsertGroupSettings,
withTransaction, withTransaction,
}; };

View File

@ -162,6 +162,47 @@ exports.updateMemberRole = async (householdId, userId, newRole) => {
return result.rows[0]; 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 // Remove member from household
exports.removeMember = async (householdId, userId) => { exports.removeMember = async (householdId, userId) => {
await pool.query( await pool.query(

View File

@ -28,6 +28,13 @@ const inviteWriteUserRateLimit = createRateLimit({
router.get("/groups/invites", auth, controller.listInviteLinks); router.get("/groups/invites", auth, controller.listInviteLinks);
router.post("/groups/invites", auth, inviteWriteUserRateLimit, controller.createInviteLink); 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( router.post(
"/groups/invites/revoke", "/groups/invites/revoke",
auth, auth,

View File

@ -179,6 +179,12 @@ async function listInviteLinks(userId, groupId) {
return invitesModel.listInviteLinks(resolvedGroupId); 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( async function revokeInviteLink(
userId, userId,
groupId, 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) { function getInviteStatus(link) {
const now = Date.now(); const now = Date.now();
if (link.single_use && link.used_at) return "USED"; if (link.single_use && link.used_at) return "USED";
@ -546,9 +662,11 @@ module.exports = {
JOIN_RESULTS, JOIN_RESULTS,
acceptInviteLink, acceptInviteLink,
createInviteLink, createInviteLink,
decideJoinRequest,
deleteInviteLink, deleteInviteLink,
getGroupJoinPolicy, getGroupJoinPolicy,
getInviteLinkSummaryByToken, getInviteLinkSummaryByToken,
listPendingJoinRequests,
listInviteLinks, listInviteLinks,
resolveManagedGroupId, resolveManagedGroupId,
revokeInviteLink, revokeInviteLink,

View File

@ -12,8 +12,10 @@ jest.mock("../services/group-invites.service", () => {
acceptInviteLink: jest.fn(), acceptInviteLink: jest.fn(),
createInviteLink: jest.fn(), createInviteLink: jest.fn(),
deleteInviteLink: jest.fn(), deleteInviteLink: jest.fn(),
decideJoinRequest: jest.fn(),
getGroupJoinPolicy: jest.fn(), getGroupJoinPolicy: jest.fn(),
getInviteLinkSummaryByToken: jest.fn(), getInviteLinkSummaryByToken: jest.fn(),
listPendingJoinRequests: jest.fn(),
listInviteLinks: jest.fn(), listInviteLinks: jest.fn(),
resolveManagedGroupId: jest.fn(), resolveManagedGroupId: jest.fn(),
revokeInviteLink: jest.fn(), revokeInviteLink: jest.fn(),
@ -28,8 +30,10 @@ const app = require("../app");
describe("group invites routes", () => { describe("group invites routes", () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks();
invitesService.resolveManagedGroupId.mockResolvedValue(1); invitesService.resolveManagedGroupId.mockResolvedValue(1);
invitesService.listInviteLinks.mockResolvedValue([]); invitesService.listInviteLinks.mockResolvedValue([]);
invitesService.listPendingJoinRequests.mockResolvedValue([]);
invitesService.createInviteLink.mockResolvedValue({ invitesService.createInviteLink.mockResolvedValue({
id: 1, id: 1,
token: "abcd", token: "abcd",
@ -71,4 +75,36 @@ describe("group invites routes", () => {
expect(response.body.request_id).toBeTruthy(); expect(response.body.request_id).toBeTruthy();
expect(response.body.link).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(), getInviteLinkById: jest.fn(),
getInviteLinkSummaryByToken: jest.fn(), getInviteLinkSummaryByToken: jest.fn(),
getManageableGroupsForUser: jest.fn(), getManageableGroupsForUser: jest.fn(),
getPendingJoinRequestById: jest.fn(),
getPendingJoinRequest: jest.fn(), getPendingJoinRequest: jest.fn(),
getUserGroupRole: jest.fn(), getUserGroupRole: jest.fn(),
isGroupMember: jest.fn(), isGroupMember: jest.fn(),
listPendingJoinRequests: jest.fn(),
listInviteLinks: jest.fn(), listInviteLinks: jest.fn(),
revokeInviteLink: jest.fn(), revokeInviteLink: jest.fn(),
reviveInviteLink: jest.fn(), reviveInviteLink: jest.fn(),
updateJoinRequestDecision: jest.fn(),
upsertGroupSettings: jest.fn(), upsertGroupSettings: jest.fn(),
withTransaction: jest.fn(), withTransaction: jest.fn(),
})); }));
@ -41,6 +44,7 @@ function inviteSummary(overrides = {}) {
describe("group invites service", () => { describe("group invites service", () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks();
invitesModel.withTransaction.mockImplementation(async (handler) => handler({})); invitesModel.withTransaction.mockImplementation(async (handler) => handler({}));
}); });
@ -186,4 +190,120 @@ describe("group invites service", () => {
expect(result.status).toBe("PENDING"); expect(result.status).toBe("PENDING");
expect(invitesModel.addGroupMember).not.toHaveBeenCalled(); 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) => export const deleteHousehold = (householdId) =>
api.delete(`/households/${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 * Get household members
*/ */
@ -68,9 +56,19 @@ function groupHeaders(groupId) {
export const getGroupInviteLinks = (groupId) => export const getGroupInviteLinks = (groupId) =>
api.get("/api/groups/invites", groupHeaders(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) => export const createGroupInviteLink = (groupId, payload) =>
api.post("/api/groups/invites", payload, groupHeaders(groupId)); 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) => export const revokeGroupInviteLink = (groupId, linkId) =>
api.post("/api/groups/invites/revoke", { linkId }, groupHeaders(groupId)); api.post("/api/groups/invites/revoke", { linkId }, groupHeaders(groupId));

View File

@ -1,15 +1,47 @@
import { useContext, useState } from 'react'; import { useContext, useState } from "react";
import { HouseholdContext } from '../../context/HouseholdContext'; import { HouseholdContext } from "../../context/HouseholdContext";
import '../../styles/components/HouseholdSwitcher.css'; import "../../styles/components/HouseholdSwitcher.css";
import CreateJoinHousehold from '../manage/CreateJoinHousehold'; import CreateJoinHousehold from "../manage/CreateJoinHousehold";
export default function HouseholdSwitcher() { export default function HouseholdSwitcher() {
const { households, activeHousehold, setActiveHousehold, loading } = useContext(HouseholdContext); const {
households,
activeHousehold,
setActiveHousehold,
loading,
hasLoaded,
} = useContext(HouseholdContext);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [showCreateJoin, setShowCreateJoin] = useState(false); const [showCreateJoin, setShowCreateJoin] = useState(false);
if (!hasLoaded || loading || (households.length > 0 && !activeHousehold)) {
return (
<div className="household-switcher household-switcher-empty">
<button className="household-switcher-toggle" type="button" disabled>
<span className="household-name">Loading households...</span>
</button>
</div>
);
}
if (!activeHousehold || households.length === 0) { if (!activeHousehold || households.length === 0) {
return null; return (
<>
<div className="household-switcher household-switcher-empty">
<button
className="household-switcher-toggle household-switcher-cta"
type="button"
onClick={() => setShowCreateJoin(true)}
>
<span className="household-name">Create or Join Household</span>
</button>
</div>
{showCreateJoin && (
<CreateJoinHousehold onClose={() => setShowCreateJoin(false)} />
)}
</>
);
} }
const handleSelect = (household) => { const handleSelect = (household) => {
@ -21,32 +53,35 @@ export default function HouseholdSwitcher() {
<div className="household-switcher"> <div className="household-switcher">
<button <button
className="household-switcher-toggle" className="household-switcher-toggle"
onClick={() => setIsOpen(!isOpen)} type="button"
onClick={() => setIsOpen((current) => !current)}
disabled={loading} disabled={loading}
> >
<span className="household-name">{activeHousehold.name}</span> <span className="household-name">{activeHousehold.name}</span>
<span className={`dropdown-icon ${isOpen ? 'open' : ''}`}></span> <span className={`dropdown-icon ${isOpen ? "open" : ""}`}>&#9662;</span>
</button> </button>
{isOpen && ( {isOpen && (
<> <>
<div className="household-switcher-overlay" onClick={() => setIsOpen(false)} /> <div className="household-switcher-overlay" onClick={() => setIsOpen(false)} />
<div className="household-switcher-dropdown"> <div className="household-switcher-dropdown">
{households.map(household => ( {households.map((household) => (
<button <button
key={household.id} key={household.id}
className={`household-option ${household.id === activeHousehold.id ? 'active' : ''}`} className={`household-option ${household.id === activeHousehold.id ? "active" : ""}`}
type="button"
onClick={() => handleSelect(household)} onClick={() => handleSelect(household)}
> >
{household.name} {household.name}
{household.id === activeHousehold.id && ( {household.id === activeHousehold.id && (
<span className="check-mark"></span> <span className="check-mark">&#10003;</span>
)} )}
</button> </button>
))} ))}
<div className="household-divider"></div> <div className="household-divider"></div>
<button <button
className="household-option create-household-btn" className="household-option create-household-btn"
type="button"
onClick={() => { onClick={() => {
setIsOpen(false); setIsOpen(false);
setShowCreateJoin(true); setShowCreateJoin(true);

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

View File

@ -1,18 +1,19 @@
import React, { useContext, useEffect, useState } from "react"; import React, { useContext, useEffect, useState } from "react";
import { import {
createGroupInviteLink, createGroupInviteLink,
decideGroupJoinRequest,
deleteGroupInviteLink, deleteGroupInviteLink,
deleteHousehold, deleteHousehold,
getGroupInviteLinks, getGroupInviteLinks,
getGroupJoinPolicy, getGroupJoinPolicy,
getHouseholdMembers, getHouseholdMembers,
refreshInviteCode, getPendingGroupJoinRequests,
removeMember, removeMember,
revokeGroupInviteLink, revokeGroupInviteLink,
reviveGroupInviteLink, reviveGroupInviteLink,
setGroupJoinPolicy, setGroupJoinPolicy,
updateHousehold, updateHousehold,
updateMemberRole updateMemberRole,
} from "../../api/households"; } from "../../api/households";
import { ToggleButtonGroup } from "../common"; import { ToggleButtonGroup } from "../common";
import ConfirmSlideModal from "../modals/ConfirmSlideModal"; import ConfirmSlideModal from "../modals/ConfirmSlideModal";
@ -28,6 +29,29 @@ const JOIN_POLICY_OPTIONS = [
{ label: "Manual", value: "APPROVAL_REQUIRED" }, { 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() { export default function ManageHousehold() {
const { userId } = useContext(AuthContext); const { userId } = useContext(AuthContext);
const { activeHousehold, refreshHouseholds } = useContext(HouseholdContext); const { activeHousehold, refreshHouseholds } = useContext(HouseholdContext);
@ -36,22 +60,27 @@ export default function ManageHousehold() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [editingName, setEditingName] = useState(false); const [editingName, setEditingName] = useState(false);
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [showInviteCode, setShowInviteCode] = useState(false);
const [joinPolicy, setJoinPolicyValue] = useState("NOT_ACCEPTING"); const [joinPolicy, setJoinPolicyValue] = useState("NOT_ACCEPTING");
const [inviteLinks, setInviteLinks] = useState([]); const [inviteLinks, setInviteLinks] = useState([]);
const [pendingRequests, setPendingRequests] = useState([]);
const [inviteLoading, setInviteLoading] = useState(false); const [inviteLoading, setInviteLoading] = useState(false);
const [inviteError, setInviteError] = useState(""); const [inviteError, setInviteError] = useState("");
const [ttlDays, setTtlDays] = useState(7); const [ttlDays, setTtlDays] = useState(7);
const [singleUseMode, setSingleUseMode] = useState("UNLIMITED"); const [singleUseMode, setSingleUseMode] = useState("UNLIMITED");
const [pendingDecisionId, setPendingDecisionId] = useState(null);
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
const [pendingRoleChange, setPendingRoleChange] = useState(null);
const isManager = ["owner", "admin"].includes(activeHousehold?.role); const isManager = ["owner", "admin"].includes(activeHousehold?.role);
const isOwner = activeHousehold?.role === "owner";
const isMemberOnly = activeHousehold?.role === "member"; const isMemberOnly = activeHousehold?.role === "member";
useEffect(() => { useEffect(() => {
loadMembers(); loadMembers();
if (isManager) { if (isManager) {
loadJoinAndInvites(); loadJoinAndInvites();
} else {
setPendingRequests([]);
} }
}, [activeHousehold?.id, isManager]); }, [activeHousehold?.id, isManager]);
@ -73,12 +102,14 @@ export default function ManageHousehold() {
setInviteLoading(true); setInviteLoading(true);
setInviteError(""); setInviteError("");
try { try {
const [policyResponse, linksResponse] = await Promise.all([ const [policyResponse, linksResponse, requestsResponse] = await Promise.all([
getGroupJoinPolicy(activeHousehold.id), getGroupJoinPolicy(activeHousehold.id),
getGroupInviteLinks(activeHousehold.id), getGroupInviteLinks(activeHousehold.id),
getPendingGroupJoinRequests(activeHousehold.id),
]); ]);
setJoinPolicyValue(policyResponse.data.joinPolicy || "NOT_ACCEPTING"); setJoinPolicyValue(policyResponse.data.joinPolicy || "NOT_ACCEPTING");
setInviteLinks(linksResponse.data.links || []); setInviteLinks(linksResponse.data.links || []);
setPendingRequests(requestsResponse.data.requests || []);
} catch (error) { } catch (error) {
setInviteError(error.response?.data?.error?.message || "Failed to load invite links"); setInviteError(error.response?.data?.error?.message || "Failed to load invite links");
} finally { } 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) => { const handleRevokeInvite = async (linkId) => {
try { try {
setInviteError(""); setInviteError("");
@ -226,33 +278,35 @@ export default function ManageHousehold() {
} }
}; };
const handleRefreshInvite = async () => { const handleConfirmRoleChange = async () => {
if (!confirm("Generate a new invite code? The old code will no longer work.")) return; if (!pendingRoleChange) return;
const { memberId, nextRole, memberName } = pendingRoleChange;
try { try {
await refreshInviteCode(activeHousehold.id); await updateMemberRole(activeHousehold.id, memberId, nextRole);
await refreshHouseholds(); await Promise.all([
toast.success("Generated new invite code", "Generated a new invite code"); loadMembers(),
} catch (error) { nextRole === "owner" ? refreshHouseholds() : Promise.resolve(),
const message = getApiErrorMessage(error, "Failed to refresh invite code"); ]);
toast.error("Refresh invite code failed", `Refresh invite code failed: ${message}`); 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);
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}`);
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, "Failed to update member role"); const message = getApiErrorMessage(error, "Failed to update member role");
toast.error("Update member role failed", `Update member role failed: ${message}`); 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) => { const handleRemoveMember = async (memberId, username) => {
if (!confirm(`Remove ${username} from this household?`)) return; if (!confirm(`Remove ${username} from this household?`)) return;
@ -296,23 +350,21 @@ export default function ManageHousehold() {
} }
}; };
const copyInviteCode = async () => { const managerCount = members.filter((member) => ["owner", "admin"].includes(member.role)).length;
const copied = await copyTextToClipboard(activeHousehold.invite_code); const memberCount = members.filter((member) => member.role === "member").length;
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."
);
};
return ( return (
<div className="manage-household"> <div className="manage-household">
<section key="household-name" className="manage-section"> <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 ? ( {editingName ? (
<div className="edit-name-form"> <div className="edit-name-form">
<input <input
@ -327,7 +379,14 @@ export default function ManageHousehold() {
</div> </div>
) : ( ) : (
<div className="name-display"> <div className="name-display">
<div className="name-display-copy">
<h3>{activeHousehold.name}</h3> <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 && ( {isManager && (
<button <button
onClick={() => { onClick={() => {
@ -343,32 +402,17 @@ export default function ManageHousehold() {
)} )}
</section> </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 && ( {isManager && (
<section key="join-and-invites" className="manage-section"> <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>} {inviteError && <p className="section-error">{inviteError}</p>}
<ToggleButtonGroup <ToggleButtonGroup
@ -377,14 +421,61 @@ export default function ManageHousehold() {
className="tbg-group manage-household-join-policy-toggle" className="tbg-group manage-household-join-policy-toggle"
options={JOIN_POLICY_OPTIONS.map((option) => ({ options={JOIN_POLICY_OPTIONS.map((option) => ({
...option, ...option,
disabled: inviteLoading disabled: inviteLoading,
}))} }))}
onChange={handleUpdateJoinPolicy} 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"> <div className="invite-controls">
<label> <label>
TTL <span className="invite-control-label">TTL</span>
<select value={ttlDays} onChange={(e) => setTtlDays(Number(e.target.value))}> <select value={ttlDays} onChange={(e) => setTtlDays(Number(e.target.value))}>
{[1, 2, 3, 4, 5, 6, 7].map((day) => ( {[1, 2, 3, 4, 5, 6, 7].map((day) => (
<option key={day} value={day}>{day} day{day > 1 ? "s" : ""}</option> <option key={day} value={day}>{day} day{day > 1 ? "s" : ""}</option>
@ -392,7 +483,7 @@ export default function ManageHousehold() {
</select> </select>
</label> </label>
<label> <label>
Usage <span className="invite-control-label">Usage</span>
<select value={singleUseMode} onChange={(e) => setSingleUseMode(e.target.value)}> <select value={singleUseMode} onChange={(e) => setSingleUseMode(e.target.value)}>
<option value="UNLIMITED">Unlimited</option> <option value="UNLIMITED">Unlimited</option>
<option value="ONE_TIME">1 use</option> <option value="ONE_TIME">1 use</option>
@ -412,12 +503,18 @@ export default function ManageHousehold() {
{inviteLinks.map((link) => { {inviteLinks.map((link) => {
const status = getLinkStatus(link); const status = getLinkStatus(link);
const isActive = status === "Active"; const isActive = status === "Active";
const statusMeta = STATUS_METADATA[status] || STATUS_METADATA.Active;
return ( return (
<div key={link.id} className="invite-link-card"> <div key={link.id} className="invite-link-card">
<div> <div className="invite-link-main">
<p className="invite-link-token">Token ending in {String(link.token).slice(-4)}</p> <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"> <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> </p>
</div> </div>
<div className="invite-link-actions"> <div className="invite-link-actions">
@ -446,24 +543,55 @@ export default function ManageHousehold() {
)} )}
<section key="members" className="manage-section"> <section key="members" className="manage-section">
<div className="manage-section-header">
<div>
<p className="manage-section-eyebrow">People</p>
<h2>Members ({members.length})</h2> <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 ? ( {loading ? (
<p>Loading members...</p> <p>Loading members...</p>
) : ( ) : (
<div className="members-list"> <div className="members-list">
{members.map((member) => ( {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 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-info">
<span className="member-role">{member.role}</span> <div className="member-topline">
<span className="member-name"> <span className={`member-role member-role-${member.role}`}>
{member.username} [{member.id}] {member.id === parseInt(userId, 10) ? "(You)" : ""} {roleMeta.icon} {roleMeta.label}
</span> </span>
{isSelf && <span className="member-self-pill"> You</span>}
</div> </div>
{isManager && member.id !== parseInt(userId, 10) && member.role !== "owner" && ( <span className="member-name">{member.username}</span>
<span className="member-meta">ID #{member.id}</span>
</div>
</div>
{isManager && !isSelf && member.role !== "owner" && (
<div className="member-actions"> <div className="member-actions">
{isOwner && (
<button <button
onClick={() => handleUpdateRole(member.id, member.role, member.username)} onClick={() => handleUpdateRole(member.id, "owner", member.username)}
className="btn-secondary btn-small" 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"} {member.role === "admin" ? "Make Member" : "Make Admin"}
</button> </button>
@ -476,19 +604,24 @@ export default function ManageHousehold() {
</div> </div>
)} )}
</div> </div>
))} );
})}
</div> </div>
)} )}
</section> </section>
{(isManager || isMemberOnly) && ( {(isManager || isMemberOnly) && (
<section key="danger-zone" className="manage-section danger-zone"> <section key="danger-zone" className="manage-section danger-zone">
<div className="manage-section-header">
<div>
<p className="manage-section-eyebrow">Final Actions</p>
<h2>Danger Zone</h2> <h2>Danger Zone</h2>
<p className="section-description"> <p className="section-description">
{isMemberOnly {isMemberOnly
? "Leaving removes your access to this household." ? "Leaving removes your access to this household."
: "Deleting a household is permanent and will delete all lists, items, and history."} : "Deleting a household is permanent and will delete all lists, items, and history."}
</p> </p>
</div>
{isMemberOnly ? ( {isMemberOnly ? (
<button onClick={() => setIsLeaveModalOpen(true)} className="btn-danger"> <button onClick={() => setIsLeaveModalOpen(true)} className="btn-danger">
Leave Household Leave Household
@ -498,6 +631,7 @@ export default function ManageHousehold() {
Delete Household Delete Household
</button> </button>
)} )}
</div>
</section> </section>
)} )}
@ -509,6 +643,27 @@ export default function ManageHousehold() {
onClose={() => setIsLeaveModalOpen(false)} onClose={() => setIsLeaveModalOpen(false)}
onConfirm={handleLeaveHousehold} 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> </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 { createHousehold as createHouseholdApi, getUserHouseholds } from '../api/households';
import { AuthContext } from './AuthContext'; import { AuthContext } from './AuthContext';
const ACTIVE_HOUSEHOLD_STORAGE_KEY = 'activeHouseholdId';
export const HouseholdContext = createContext({ export const HouseholdContext = createContext({
households: [], households: [],
activeHousehold: null, activeHousehold: null,
loading: false, loading: false,
hasLoaded: false,
error: null, error: null,
setActiveHousehold: () => { }, setActiveHousehold: () => { },
refreshHouseholds: () => { }, refreshHouseholds: () => { },
@ -17,65 +20,79 @@ export const HouseholdProvider = ({ children }) => {
const [households, setHouseholds] = useState([]); const [households, setHouseholds] = useState([]);
const [activeHousehold, setActiveHouseholdState] = useState(null); const [activeHousehold, setActiveHouseholdState] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [hasLoaded, setHasLoaded] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const clearActiveHousehold = useCallback(() => {
setActiveHouseholdState(null);
localStorage.removeItem(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 // Load households on mount and when token changes
useEffect(() => { useEffect(() => {
if (token) { if (token) {
setHasLoaded(false);
loadHouseholds(); loadHouseholds();
} else { } else {
// Clear state when logged out
setHouseholds([]); setHouseholds([]);
setActiveHouseholdState(null); clearActiveHousehold();
setError(null);
setLoading(false);
setHasLoaded(false);
} }
}, [token]); }, [clearActiveHousehold, loadHouseholds, token]);
// Load active household from localStorage on mount // Load active household from localStorage on mount
useEffect(() => { useEffect(() => {
if (households.length === 0) return; if (households.length === 0) {
setActiveHouseholdState(null);
return;
}
console.log('[HouseholdContext] Setting active household from:', households); const savedHouseholdId = localStorage.getItem(ACTIVE_HOUSEHOLD_STORAGE_KEY);
const savedHouseholdId = localStorage.getItem('activeHouseholdId');
if (savedHouseholdId) { if (savedHouseholdId) {
const household = households.find(h => h.id === parseInt(savedHouseholdId)); const household = households.find((candidate) => String(candidate.id) === savedHouseholdId);
if (household) { if (household) {
console.log('[HouseholdContext] Found saved household:', household);
setActiveHouseholdState(household); setActiveHouseholdState(household);
return; return;
} }
} }
// No saved household or not found, use first one // No saved household or not found, use first one
console.log('[HouseholdContext] Using first household:', households[0]);
setActiveHouseholdState(households[0]); setActiveHouseholdState(households[0]);
localStorage.setItem('activeHouseholdId', households[0].id); localStorage.setItem(ACTIVE_HOUSEHOLD_STORAGE_KEY, String(households[0].id));
}, [households]); }, [households]);
const loadHouseholds = async () => {
if (!token) return;
setLoading(true);
setError(null);
try {
console.log('[HouseholdContext] Loading households...');
const response = await getUserHouseholds();
console.log('[HouseholdContext] Loaded households:', response.data);
setHouseholds(response.data);
} catch (err) {
console.error('[HouseholdContext] Failed to load households:', err);
setError(err.response?.data?.message || 'Failed to load households');
setHouseholds([]);
} finally {
setLoading(false);
}
};
const setActiveHousehold = (household) => { const setActiveHousehold = (household) => {
setActiveHouseholdState(household); setActiveHouseholdState(household);
if (household) { if (household) {
localStorage.setItem('activeHouseholdId', household.id); localStorage.setItem(ACTIVE_HOUSEHOLD_STORAGE_KEY, String(household.id));
} else { } else {
localStorage.removeItem('activeHouseholdId'); localStorage.removeItem(ACTIVE_HOUSEHOLD_STORAGE_KEY);
} }
}; };
@ -101,6 +118,7 @@ export const HouseholdProvider = ({ children }) => {
households, households,
activeHousehold, activeHousehold,
loading, loading,
hasLoaded,
error, error,
setActiveHousehold, setActiveHousehold,
refreshHouseholds: loadHouseholds, 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 * Global Base Styles
* Uses theme variables defined in theme.css * Uses theme variables defined in theme.css
@ -77,12 +7,20 @@ button:focus-visible {
box-sizing: border-box; box-sizing: border-box;
} }
html {
min-width: 320px;
scroll-behavior: smooth;
}
body { body {
font-family: var(--font-family-base); font-family: var(--font-family-base);
font-size: var(--font-size-base); font-size: var(--font-size-base);
line-height: var(--line-height-normal); line-height: var(--line-height-normal);
color: var(--color-text-primary); 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; margin: 0;
padding: 0; padding: 0;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@ -90,44 +28,275 @@ body {
transition: background-color 0.3s ease, color 0.3s ease; 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 { #root {
min-height: 100vh; min-height: 100vh;
} }
.container { a {
max-width: var(--container-max-width); color: var(--color-primary);
margin: auto; text-decoration: none;
padding: var(--container-padding); transition: color var(--transition-base), opacity var(--transition-base);
} }
h1 { a:hover {
text-align: center; color: var(--color-primary-hover);
font-size: 1.5em; }
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, input,
button, select,
select { textarea {
font-size: 1em;
margin: 0.3em 0;
padding: 0.5em;
width: 100%; width: 100%;
box-sizing: border-box; font: inherit;
}
button {
font: inherit;
} }
ul { ul {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0;
} }
li { .page-shell {
padding: 0.5em; width: min(100%, var(--page-max-width));
background: #e9ecef; margin: 0 auto;
margin-bottom: 0.5em; padding: clamp(1rem, 2vw, 1.75rem);
border-radius: 4px; }
.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; cursor: pointer;
transition: background var(--transition-base), color var(--transition-base), transform var(--transition-base);
} }
li:hover { .page-tab:hover {
background: #dee2e6; 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 { getHouseholdMembers } from "../api/households";
import SortDropdown from "../components/common/SortDropdown"; import SortDropdown from "../components/common/SortDropdown";
import AddItemForm from "../components/forms/AddItemForm"; import AddItemForm from "../components/forms/AddItemForm";
import NoHouseholdState from "../components/household/NoHouseholdState";
import GroceryListItem from "../components/items/GroceryListItem"; import GroceryListItem from "../components/items/GroceryListItem";
import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal"; import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal";
import ConfirmBuyModal from "../components/modals/ConfirmBuyModal"; import ConfirmBuyModal from "../components/modals/ConfirmBuyModal";
@ -80,7 +81,12 @@ function getNextModalItem(sortedItems, currentIndex, excludedItemId) {
export default function GroceryList() { export default function GroceryList() {
const pageTitle = "Grocery List"; const pageTitle = "Grocery List";
const { userId } = useContext(AuthContext); const { userId } = useContext(AuthContext);
const { activeHousehold } = useContext(HouseholdContext); const {
activeHousehold,
households,
loading: householdLoading,
hasLoaded: householdsLoaded
} = useContext(HouseholdContext);
const { activeStore, stores, loading: storeLoading } = useContext(StoreContext); const { activeStore, stores, loading: storeLoading } = useContext(StoreContext);
const { settings } = useContext(SettingsContext); const { settings } = useContext(SettingsContext);
const toast = useActionToast(); const toast = useActionToast();
@ -762,14 +768,25 @@ export default function GroceryList() {
}; };
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) { if (!activeHousehold) {
return ( return (
<div className="glist-body"> <div className="glist-body">
<div className="glist-container"> <div className="glist-container">
<h1 className="glist-title">{pageTitle}</h1> <h1 className="glist-title">{pageTitle}</h1>
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}> <NoHouseholdState />
Loading households...
</p>
</div> </div>
</div> </div>
); );

View File

@ -1,12 +1,13 @@
import { useContext, useEffect, useState } from "react"; import { useContext, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import NoHouseholdState from "../components/household/NoHouseholdState";
import ManageHousehold from "../components/manage/ManageHousehold"; import ManageHousehold from "../components/manage/ManageHousehold";
import ManageStores from "../components/manage/ManageStores"; import ManageStores from "../components/manage/ManageStores";
import { HouseholdContext } from "../context/HouseholdContext"; import { HouseholdContext } from "../context/HouseholdContext";
import "../styles/pages/Manage.css"; import "../styles/pages/Manage.css";
export default function Manage() { export default function Manage() {
const { activeHousehold } = useContext(HouseholdContext); const { activeHousehold, households, loading, hasLoaded } = useContext(HouseholdContext);
const [activeTab, setActiveTab] = useState("household"); const [activeTab, setActiveTab] = useState("household");
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@ -17,14 +18,25 @@ export default function Manage() {
} }
}, [searchParams]); }, [searchParams]);
if (!hasLoaded || loading || (households.length > 0 && !activeHousehold)) {
return (
<div className="manage-body">
<div className="manage-container">
<h1 className="manage-title">Manage</h1>
<p style={{ textAlign: "center", marginTop: "2rem", color: "var(--text-secondary)" }}>
Loading household...
</p>
</div>
</div>
);
}
if (!activeHousehold) { if (!activeHousehold) {
return ( return (
<div className="manage-body"> <div className="manage-body">
<div className="manage-container"> <div className="manage-container">
<h1 className="manage-title">Manage</h1> <h1 className="manage-title">Manage</h1>
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}> <NoHouseholdState />
Loading household...
</p>
</div> </div>
</div> </div>
); );

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
.household-switcher { .household-switcher {
position: relative; position: relative;
display: inline-block; display: inline-block;
min-width: 220px;
} }
.household-switcher-toggle { .household-switcher-toggle {
@ -8,20 +9,20 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.5rem; gap: 0.5rem;
width: 100%;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: var(--card-bg); background: var(--button-ghost-bg);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
color: var(--text-primary); color: var(--text-primary);
font-size: 1rem; font-size: 1rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
width: 100%;
} }
.household-switcher-toggle:hover { .household-switcher-toggle:hover {
background: var(--card-hover); background: var(--button-ghost-hover-bg);
border-color: var(--primary); border-color: var(--button-ghost-border-hover);
} }
.household-switcher-toggle:disabled { .household-switcher-toggle:disabled {
@ -29,20 +30,30 @@
cursor: not-allowed; cursor: not-allowed;
} }
.household-switcher-cta {
justify-content: center;
font-weight: 600;
color: var(--primary);
}
.household-switcher-empty .household-switcher-toggle {
width: 100%;
}
.household-name { .household-name {
font-weight: 500;
flex: 1; flex: 1;
text-align: left;
overflow: hidden; overflow: hidden;
text-align: left;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-weight: 500;
} }
.dropdown-icon { .dropdown-icon {
margin-left: auto;
flex-shrink: 0;
font-size: 0.75rem; font-size: 0.75rem;
transition: transform 0.2s ease; transition: transform 0.2s ease;
flex-shrink: 0;
margin-left: auto;
} }
.dropdown-icon.open { .dropdown-icon.open {
@ -51,10 +62,7 @@
.household-switcher-overlay { .household-switcher-overlay {
position: fixed; position: fixed;
top: 0; inset: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999; z-index: 999;
} }
@ -64,12 +72,12 @@
left: 0; left: 0;
right: 0; right: 0;
width: 100%; width: 100%;
overflow: hidden;
background: var(--card-bg); background: var(--card-bg);
border: 2px solid var(--border); border: 2px solid var(--border);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
z-index: 1000; z-index: 1000;
overflow: hidden;
} }
.household-option { .household-option {
@ -93,24 +101,26 @@
} }
.household-option:hover { .household-option:hover {
background: var(--card-hover); background: var(--button-secondary-bg);
border-color: var(--primary); border-color: var(--primary);
} }
.household-option.active { .household-option.active {
background: rgba(30, 144, 255, 0.15); background: color-mix(in srgb, var(--primary) 15%, transparent);
color: var(--primary); color: var(--primary);
font-weight: 600; font-weight: 600;
} }
.check-mark { .check-mark {
color: var(--primary); color: var(--primary);
font-weight: bold;
font-size: 1.1rem; font-size: 1.1rem;
font-weight: bold;
} }
.household-divider {); .household-divider {
height: 1px;
margin: 0.25rem 0; margin: 0.25rem 0;
background: var(--border);
} }
.create-household-btn { .create-household-btn {
@ -119,7 +129,11 @@
} }
.create-household-btn:hover { .create-household-btn:hover {
background: rgba(30, 144, 255, 0.15 background: color-mix(in srgb, var(--primary) 15%, transparent);
.create-household-btn:hover { }
background: var(--primary-color-light);
@media (max-width: 640px) {
.household-switcher {
min-width: 180px;
}
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -2,139 +2,316 @@
.manage-household { .manage-household {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 1rem;
max-width: 800px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
width: 100%; width: 100%;
} }
/* Section Styling */
.manage-section { .manage-section {
background: var(--card-bg); display: flex;
border: 1px solid var(--border); flex-direction: column;
border-radius: 8px; gap: 1rem;
padding: 2rem;
width: 100%; width: 100%;
box-sizing: border-box; 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 { [data-theme="dark"] .manage-section,
font-size: 1.3rem; body.dark-mode .manage-section {
font-weight: 600; 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); 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 { .section-description {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.9rem; font-size: 0.92rem;
margin-bottom: 1rem; 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 */ /* Household Name Section */
.name-display { .name-display {
display: flex; display: flex;
flex-direction: column; align-items: flex-start;
align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
} }
.name-display-copy {
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.name-display h3 { .name-display h3 {
font-size: 1.5rem; font-size: 1.55rem;
color: var(--text-primary); color: var(--text-primary);
margin: 0; margin: 0;
} }
.edit-name-form { .household-summary-chips {
display: flex; 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; gap: 0.75rem;
align-items: center; align-items: center;
flex-wrap: wrap;
} }
.edit-name-form input { .edit-name-form input {
flex: 1; min-width: 0;
min-width: 200px; padding: 0.85rem 1rem;
padding: 0.75rem;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: var(--border-radius-md);
font-size: 1rem; font-size: 0.98rem;
background: var(--background); background: rgba(255, 255, 255, 0.82);
color: var(--text-primary); color: var(--text-primary);
} }
/* Invite Code Section */ [data-theme="dark"] .edit-name-form input,
.invite-actions { body.dark-mode .edit-name-form input {
display: flex; background: rgba(12, 19, 30, 0.92);
flex-direction: column; border-color: var(--color-border-medium);
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;
} }
.manage-household-join-policy-toggle { .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 { .invite-controls {
display: flex; display: grid;
grid-template-columns: repeat(2, minmax(140px, 180px)) auto;
gap: 0.8rem; gap: 0.8rem;
align-items: end; align-items: end;
flex-wrap: wrap;
margin-bottom: 1rem;
} }
.invite-controls label { .invite-controls label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.3rem; gap: 0.35rem;
color: var(--text-primary); color: var(--text-primary);
font-size: 0.9rem; 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 { .invite-controls select {
min-width: 120px; min-width: 120px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: var(--border-radius-md);
padding: 0.45rem 0.6rem; padding: 0.7rem 0.75rem;
background: var(--background); background: rgba(255, 255, 255, 1);
color: var(--text-primary); 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 { .invite-links-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.8rem; gap: 0.75rem;
} }
.invite-link-card { .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); border: 1px solid var(--border);
background: var(--background); border-radius: var(--border-radius-lg);
border-radius: 8px; background: rgba(255, 255, 255, 1);
padding: 0.9rem; }
[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; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 0.6rem;
gap: 0.75rem;
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: 0.35rem;
} }
.invite-link-token, .invite-link-token,
@ -143,118 +320,262 @@
} }
.invite-link-token { .invite-link-token {
font-weight: 600; font-weight: 700;
color: var(--text-primary);
} }
.invite-link-meta { .invite-link-meta {
color: var(--text-secondary); 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 { .invite-link-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end;
} }
/* Members Section */ /* Members Section */
.members-list { .members-list {
display: flex; display: grid;
flex-direction: column; grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
gap: 1rem; gap: 0.85rem;
} }
.member-card { .member-card {
display: flex; display: flex;
flex-direction: row; flex-direction: column;
justify-content: space-between; gap: 0.9rem;
align-items: center; padding: 0.95rem 1rem;
padding: 1.25rem; background: rgba(255, 255, 255, 1);
background: var(--background);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: var(--border-radius-lg);
transition: all 0.2s; 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 { .member-card:hover {
background: var(--card-hover); background: var(--card-hover);
border-color: var(--primary); 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 { .member-info {
display: flex; display: flex;
flex-direction: column; 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 { .member-name {
font-weight: 500; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
font-size: 1rem; font-size: 1rem;
} }
.member-meta {
color: var(--text-secondary);
font-size: 0.82rem;
}
.member-role { .member-role {
font-size: 0.85rem; display: inline-flex;
padding: 0.2rem 0.5rem; align-items: center;
border-radius: 4px; gap: 0.35rem;
font-size: 0.78rem;
padding: 0.24rem 0.55rem;
border-radius: var(--border-radius-full);
width: fit-content; width: fit-content;
text-transform: capitalize; text-transform: capitalize;
background: var(--primary-light, rgba(0, 122, 255, 0.1)); font-weight: 700;
color: var(--primary); }
.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 { .member-actions {
display: flex; display: flex;
gap: 0.75rem; gap: 0.55rem;
flex-wrap: wrap; 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 */
.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); color: var(--danger);
} }
.danger-zone .manage-section-header {
align-items: center;
}
/* Buttons */ /* Buttons */
.btn-primary, .btn-primary,
.btn-secondary, .btn-secondary,
.btn-danger { .btn-danger {
padding: 0.5rem 1rem; min-height: 40px;
border: none; padding: 0.58rem 0.95rem;
border-radius: 6px; border-radius: var(--border-radius-full);
font-size: 0.9rem; font-size: 0.88rem;
font-weight: 500; font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary,
.btn-secondary {
background: var(--primary);
color: white;
}
.btn-primary:hover,
.btn-secondary:hover {
background: var(--primary-dark, #0056b3);
}
.btn-danger {
background: var(--danger);
color: white;
}
.btn-danger:hover {
background: var(--danger-dark, #c82333);
} }
.btn-small { .btn-small {
padding: 0.4rem 0.75rem; min-height: 34px;
font-size: 0.85rem; padding: 0.38rem 0.72rem;
font-size: 0.8rem;
} }
.btn-danger:disabled { .btn-danger:disabled {
@ -264,62 +585,50 @@
} }
/* Responsive */ /* Responsive */
@media (max-width: 768px) { @media (max-width: 900px) {
.manage-section { .invite-controls {
padding: 1.25rem; grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.name-display { .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: 1rem;
}
.manage-section-header,
.name-display,
.danger-zone .manage-section-header {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: stretch;
} }
.edit-name-form { .edit-name-form {
flex-direction: column; grid-template-columns: 1fr;
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%;
} }
.invite-controls { .invite-controls {
flex-direction: column; grid-template-columns: 1fr;
align-items: stretch;
} }
.invite-controls label, .invite-controls label,
@ -328,11 +637,17 @@
width: 100%; width: 100%;
} }
.invite-link-actions { .members-list {
width: 100%; grid-template-columns: 1fr;
} }
.invite-link-actions button { .member-actions {
flex: 1; 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 { .admin-tab:hover {
color: var(--text-primary); color: var(--text-primary);
background: var(--card-hover); background: var(--button-secondary-bg);
} }
.admin-tab.active { .admin-tab.active {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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