Compare commits
No commits in common. "5a2848ebcfdbbe1dc054b26ec4802f7e4a75ae7a" and "bd945568c8c0050998ddeb86458c36d3d7e6fd6c" have entirely different histories.
5a2848ebcf
...
bd945568c8
@ -41,8 +41,6 @@
|
|||||||
## 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).
|
||||||
|
|||||||
@ -79,20 +79,6 @@ 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);
|
||||||
@ -194,28 +180,6 @@ 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);
|
||||||
|
|||||||
@ -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 || !["owner", "admin", "member"].includes(role)) {
|
if (!role || !['admin', 'member'].includes(role)) {
|
||||||
return sendError(res, 400, "Invalid role. Must be 'owner', 'admin', or 'member'");
|
return sendError(res, 400, "Invalid role. Must be 'admin' or 'member'");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Can't change own role
|
// Can't change own role
|
||||||
@ -182,29 +182,14 @@ exports.updateMemberRole = async (req, res) => {
|
|||||||
return sendError(res, 403, "Owner role cannot be changed");
|
return sendError(res, 403, "Owner role cannot be changed");
|
||||||
}
|
}
|
||||||
|
|
||||||
let updated;
|
const updated = await householdModel.updateMemberRole(
|
||||||
if (role === "owner") {
|
req.params.householdId,
|
||||||
if (req.household.role !== "owner") {
|
userId,
|
||||||
return sendError(res, 403, "Only the household owner can transfer ownership");
|
role
|
||||||
}
|
);
|
||||||
|
|
||||||
updated = await householdModel.transferOwnership(
|
|
||||||
req.params.householdId,
|
|
||||||
req.user.id,
|
|
||||||
parseInt(userId, 10)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
updated = await householdModel.updateMemberRole(
|
|
||||||
req.params.householdId,
|
|
||||||
userId,
|
|
||||||
role
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: role === "owner"
|
message: "Member role updated successfully",
|
||||||
? "Household ownership transferred successfully"
|
|
||||||
: "Member role updated successfully",
|
|
||||||
member: updated
|
member: updated
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -237,53 +237,6 @@ 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(
|
||||||
@ -324,22 +277,6 @@ 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)
|
||||||
@ -444,15 +381,12 @@ 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -162,47 +162,6 @@ 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(
|
||||||
|
|||||||
@ -28,13 +28,6 @@ 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,
|
||||||
|
|||||||
@ -179,12 +179,6 @@ 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,
|
||||||
@ -320,116 +314,6 @@ 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";
|
||||||
@ -662,11 +546,9 @@ module.exports = {
|
|||||||
JOIN_RESULTS,
|
JOIN_RESULTS,
|
||||||
acceptInviteLink,
|
acceptInviteLink,
|
||||||
createInviteLink,
|
createInviteLink,
|
||||||
decideJoinRequest,
|
|
||||||
deleteInviteLink,
|
deleteInviteLink,
|
||||||
getGroupJoinPolicy,
|
getGroupJoinPolicy,
|
||||||
getInviteLinkSummaryByToken,
|
getInviteLinkSummaryByToken,
|
||||||
listPendingJoinRequests,
|
|
||||||
listInviteLinks,
|
listInviteLinks,
|
||||||
resolveManagedGroupId,
|
resolveManagedGroupId,
|
||||||
revokeInviteLink,
|
revokeInviteLink,
|
||||||
|
|||||||
@ -12,10 +12,8 @@ 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(),
|
||||||
@ -30,10 +28,8 @@ 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",
|
||||||
@ -75,36 +71,4 @@ 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");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,15 +10,12 @@ 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(),
|
||||||
}));
|
}));
|
||||||
@ -44,7 +41,6 @@ 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({}));
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -190,120 +186,4 @@ 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");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,88 +0,0 @@
|
|||||||
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" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -27,6 +27,18 @@ 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
|
||||||
*/
|
*/
|
||||||
@ -56,19 +68,9 @@ 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));
|
||||||
|
|
||||||
|
|||||||
@ -1,47 +1,15 @@
|
|||||||
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 {
|
const { households, activeHousehold, setActiveHousehold, loading } = useContext(HouseholdContext);
|
||||||
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 (
|
return null;
|
||||||
<>
|
|
||||||
<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) => {
|
||||||
@ -53,35 +21,32 @@ export default function HouseholdSwitcher() {
|
|||||||
<div className="household-switcher">
|
<div className="household-switcher">
|
||||||
<button
|
<button
|
||||||
className="household-switcher-toggle"
|
className="household-switcher-toggle"
|
||||||
type="button"
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
onClick={() => setIsOpen((current) => !current)}
|
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<span className="household-name">{activeHousehold.name}</span>
|
<span className="household-name">{activeHousehold.name}</span>
|
||||||
<span className={`dropdown-icon ${isOpen ? "open" : ""}`}>▾</span>
|
<span className={`dropdown-icon ${isOpen ? 'open' : ''}`}>▼</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<>
|
<>
|
||||||
<div className="household-switcher-overlay" onClick={() => setIsOpen(false)} />
|
<div className="household-switcher-overlay" onClick={() => setIsOpen(false)} />
|
||||||
<div className="household-switcher-dropdown">
|
<div className="household-switcher-dropdown">
|
||||||
{households.map((household) => (
|
{households.map(household => (
|
||||||
<button
|
<button
|
||||||
key={household.id}
|
key={household.id}
|
||||||
className={`household-option ${household.id === activeHousehold.id ? "active" : ""}`}
|
className={`household-option ${household.id === activeHousehold.id ? 'active' : ''}`}
|
||||||
type="button"
|
|
||||||
onClick={() => handleSelect(household)}
|
onClick={() => handleSelect(household)}
|
||||||
>
|
>
|
||||||
{household.name}
|
{household.name}
|
||||||
{household.id === activeHousehold.id && (
|
{household.id === activeHousehold.id && (
|
||||||
<span className="check-mark">✓</span>
|
<span className="check-mark">✓</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<div className="household-divider"></div>
|
<div className="household-divider"></div>
|
||||||
<button
|
<button
|
||||||
className="household-option create-household-btn"
|
className="household-option create-household-btn"
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setShowCreateJoin(true);
|
setShowCreateJoin(true);
|
||||||
|
|||||||
@ -1,69 +0,0 @@
|
|||||||
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't load households: {error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="no-household-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-primary no-household-action"
|
|
||||||
onClick={() => openModal("create")}
|
|
||||||
>
|
|
||||||
Create Household
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-secondary no-household-action"
|
|
||||||
onClick={() => openModal("join")}
|
|
||||||
>
|
|
||||||
Join Household
|
|
||||||
</button>
|
|
||||||
{error && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-secondary no-household-action"
|
|
||||||
onClick={refreshHouseholds}
|
|
||||||
>
|
|
||||||
Retry Loading
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{showCreateJoin && (
|
|
||||||
<CreateJoinHousehold
|
|
||||||
initialMode={modalMode}
|
|
||||||
onClose={() => setShowCreateJoin(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,42 +1,38 @@
|
|||||||
import { useContext, useEffect, useState } from "react";
|
import { useContext, 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";
|
||||||
|
|
||||||
function extractInviteToken(value) {
|
export default function CreateJoinHousehold({ onClose }) {
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
|
|
||||||
const directMatch = trimmed.match(/^\/?invite\/([a-zA-Z0-9]+)$/);
|
|
||||||
if (directMatch) return directMatch[1];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = new URL(trimmed, window.location.origin);
|
|
||||||
const urlMatch = parsed.pathname.match(/^\/invite\/([a-zA-Z0-9]+)$/);
|
|
||||||
if (urlMatch) return urlMatch[1];
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CreateJoinHousehold({ initialMode = "create", onClose }) {
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const toast = useActionToast();
|
const toast = useActionToast();
|
||||||
const { createHousehold: createHouseholdWithContext } = useContext(HouseholdContext);
|
const { refreshHouseholds } = useContext(HouseholdContext);
|
||||||
const [mode, setMode] = useState(initialMode === "join" ? "join" : "create");
|
const [mode, setMode] = useState("create");
|
||||||
const [householdName, setHouseholdName] = useState("");
|
const [householdName, setHouseholdName] = useState("");
|
||||||
const [inviteLink, setInviteLink] = useState("");
|
const [inviteCode, setInviteCode] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
const extractInviteToken = (value) => {
|
||||||
setMode(initialMode === "join" ? "join" : "create");
|
const trimmed = value.trim();
|
||||||
setError("");
|
if (!trimmed) return null;
|
||||||
}, [initialMode]);
|
|
||||||
|
const directMatch = trimmed.match(/^\/?invite\/([a-zA-Z0-9]+)$/);
|
||||||
|
if (directMatch) return directMatch[1];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed, window.location.origin);
|
||||||
|
const urlMatch = parsed.pathname.match(/^\/invite\/([a-zA-Z0-9]+)$/);
|
||||||
|
if (urlMatch) return urlMatch[1];
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreate = async (e) => {
|
const handleCreate = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -46,7 +42,8 @@ export default function CreateJoinHousehold({ initialMode = "create", onClose })
|
|||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createHouseholdWithContext(householdName);
|
await createHousehold(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) {
|
||||||
@ -61,23 +58,29 @@ export default function CreateJoinHousehold({ initialMode = "create", onClose })
|
|||||||
|
|
||||||
const handleJoin = async (e) => {
|
const handleJoin = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!inviteLink.trim()) return;
|
if (!inviteCode.trim()) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const inviteToken = extractInviteToken(inviteLink);
|
const inviteToken = extractInviteToken(inviteCode);
|
||||||
if (!inviteToken) {
|
if (inviteToken) {
|
||||||
const message = "Use a household invite link like /invite/abcd1234.";
|
toast.info("Invite link detected", "Opening invite details");
|
||||||
setError(message);
|
onClose();
|
||||||
toast.error("Open invite link failed", message);
|
navigate(`/invite/${inviteToken}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.info("Opening invite link", "Checking invite details");
|
await joinHousehold(inviteCode);
|
||||||
|
await refreshHouseholds();
|
||||||
|
toast.success("Joined household", "Joined household successfully");
|
||||||
onClose();
|
onClose();
|
||||||
navigate(`/invite/${inviteToken}`);
|
} catch (err) {
|
||||||
|
console.error("Failed to join household:", err);
|
||||||
|
const message = getApiErrorMessage(err, "Failed to join household");
|
||||||
|
setError(message);
|
||||||
|
toast.error("Join household failed", `Join household failed: ${message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -88,14 +91,7 @@ export default function CreateJoinHousehold({ initialMode = "create", 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
|
<button className="close-btn" onClick={onClose}>×</button>
|
||||||
className="close-btn"
|
|
||||||
type="button"
|
|
||||||
aria-label="Close household dialog"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mode-tabs">
|
<div className="mode-tabs">
|
||||||
@ -141,18 +137,18 @@ export default function CreateJoinHousehold({ initialMode = "create", onClose })
|
|||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleJoin} className="household-form">
|
<form onSubmit={handleJoin} className="household-form">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="inviteLink">Invite Link</label>
|
<label htmlFor="inviteCode">Invite Code or Link</label>
|
||||||
<input
|
<input
|
||||||
id="inviteLink"
|
id="inviteCode"
|
||||||
type="text"
|
type="text"
|
||||||
value={inviteLink}
|
value={inviteCode}
|
||||||
onChange={(e) => setInviteLink(e.target.value)}
|
onChange={(e) => setInviteCode(e.target.value)}
|
||||||
placeholder="https://.../invite/your-token"
|
placeholder="Invite code or /invite URL"
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<p className="form-hint">
|
<p className="form-hint">
|
||||||
Paste the full invite URL or a local path like /invite/your-token
|
Paste a raw invite code or full invite link URL
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
@ -160,7 +156,7 @@ export default function CreateJoinHousehold({ initialMode = "create", onClose })
|
|||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn-primary" disabled={loading}>
|
<button type="submit" className="btn-primary" disabled={loading}>
|
||||||
{loading ? "Opening..." : "Open Invite"}
|
{loading ? "Joining..." : "Join Household"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -1,19 +1,18 @@
|
|||||||
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,
|
||||||
getPendingGroupJoinRequests,
|
refreshInviteCode,
|
||||||
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";
|
||||||
@ -29,29 +28,6 @@ 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);
|
||||||
@ -60,27 +36,22 @@ 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]);
|
||||||
|
|
||||||
@ -102,14 +73,12 @@ export default function ManageHousehold() {
|
|||||||
setInviteLoading(true);
|
setInviteLoading(true);
|
||||||
setInviteError("");
|
setInviteError("");
|
||||||
try {
|
try {
|
||||||
const [policyResponse, linksResponse, requestsResponse] = await Promise.all([
|
const [policyResponse, linksResponse] = 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 {
|
||||||
@ -200,27 +169,6 @@ 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("");
|
||||||
@ -278,35 +226,33 @@ export default function ManageHousehold() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmRoleChange = async () => {
|
const handleRefreshInvite = async () => {
|
||||||
if (!pendingRoleChange) return;
|
if (!confirm("Generate a new invite code? The old code will no longer work.")) return;
|
||||||
|
|
||||||
const { memberId, nextRole, memberName } = pendingRoleChange;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateMemberRole(activeHousehold.id, memberId, nextRole);
|
await refreshInviteCode(activeHousehold.id);
|
||||||
await Promise.all([
|
await refreshHouseholds();
|
||||||
loadMembers(),
|
toast.success("Generated new invite code", "Generated a new invite code");
|
||||||
nextRole === "owner" ? refreshHouseholds() : Promise.resolve(),
|
} catch (error) {
|
||||||
]);
|
const message = getApiErrorMessage(error, "Failed to refresh invite code");
|
||||||
if (nextRole === "owner") {
|
toast.error("Refresh invite code failed", `Refresh invite code failed: ${message}`);
|
||||||
toast.success("Transferred household ownership", `Transferred ownership to ${memberName}`);
|
}
|
||||||
} else {
|
};
|
||||||
toast.success("Updated member role", `Updated role for ${memberName} to ${nextRole}`);
|
|
||||||
}
|
const handleUpdateRole = async (memberId, currentRole, memberName) => {
|
||||||
setPendingRoleChange(null);
|
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;
|
||||||
|
|
||||||
@ -350,21 +296,23 @@ export default function ManageHousehold() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const managerCount = members.filter((member) => ["owner", "admin"].includes(member.role)).length;
|
const copyInviteCode = async () => {
|
||||||
const memberCount = members.filter((member) => member.role === "member").length;
|
const copied = await copyTextToClipboard(activeHousehold.invite_code);
|
||||||
|
if (copied) {
|
||||||
|
toast.info("Copied invite code", "Copied invite code to clipboard");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error(
|
||||||
|
"Copy invite code failed",
|
||||||
|
"Copy invite code failed: unable to access clipboard. Copy manually."
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="manage-household">
|
<div className="manage-household">
|
||||||
<section key="household-name" className="manage-section">
|
<section key="household-name" className="manage-section">
|
||||||
<div className="manage-section-header">
|
<h2>Household Name</h2>
|
||||||
<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
|
||||||
@ -379,14 +327,7 @@ 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={() => {
|
||||||
@ -403,16 +344,31 @@ export default function ManageHousehold() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{isManager && (
|
{isManager && (
|
||||||
<section key="join-and-invites" className="manage-section">
|
<section key="invite-code" className="manage-section">
|
||||||
<div className="manage-section-header">
|
<h2>Legacy Invite Code</h2>
|
||||||
<div>
|
<p className="section-description">
|
||||||
<p className="manage-section-eyebrow">Entry Rules</p>
|
Share this code for legacy join-by-code flows.
|
||||||
<h2>Invite Links</h2>
|
</p>
|
||||||
<p className="section-description">
|
<div className="invite-actions">
|
||||||
Decide how new people can enter, review manual approvals, then create invite links for the flow you want.
|
<button onClick={() => setShowInviteCode(!showInviteCode)} className="btn-secondary">
|
||||||
</p>
|
{showInviteCode ? "Hide Code" : "Show Code"}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isManager && (
|
||||||
|
<section key="join-and-invites" className="manage-section">
|
||||||
|
<h2>Join and Invites</h2>
|
||||||
{inviteError && <p className="section-error">{inviteError}</p>}
|
{inviteError && <p className="section-error">{inviteError}</p>}
|
||||||
|
|
||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
@ -421,61 +377,14 @@ 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>
|
||||||
<span className="invite-control-label">TTL</span>
|
TTL
|
||||||
<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>
|
||||||
@ -483,7 +392,7 @@ export default function ManageHousehold() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span className="invite-control-label">Usage</span>
|
Usage
|
||||||
<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>
|
||||||
@ -503,18 +412,12 @@ 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 className="invite-link-main">
|
<div>
|
||||||
<div className="invite-link-topline">
|
<p className="invite-link-token">Token ending in {String(link.token).slice(-4)}</p>
|
||||||
<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">
|
||||||
Policy: {link.policy} • Expires {new Date(link.expires_at).toLocaleString()}
|
Status: <strong>{status}</strong> | Policy: {link.policy} | TTL: until {new Date(link.expires_at).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="invite-link-actions">
|
<div className="invite-link-actions">
|
||||||
@ -543,95 +446,58 @@ export default function ManageHousehold() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<section key="members" className="manage-section">
|
<section key="members" className="manage-section">
|
||||||
<div className="manage-section-header">
|
<h2>Members ({members.length})</h2>
|
||||||
<div>
|
|
||||||
<p className="manage-section-eyebrow">People</p>
|
|
||||||
<h2>Members ({members.length})</h2>
|
|
||||||
<p className="section-description">
|
|
||||||
Role badges and compact actions make it easier to see who runs the household and who just shops.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{loading ? (
|
{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 };
|
<div key={member.id} className="member-card">
|
||||||
const isSelf = member.id === parseInt(userId, 10);
|
<div className="member-info">
|
||||||
|
<span className="member-role">{member.role}</span>
|
||||||
return (
|
<span className="member-name">
|
||||||
<div key={member.id} className="member-card">
|
{member.username} [{member.id}] {member.id === parseInt(userId, 10) ? "(You)" : ""}
|
||||||
<div className="member-main">
|
</span>
|
||||||
<div className="member-avatar" aria-hidden="true">{roleMeta.icon}</div>
|
|
||||||
<div className="member-info">
|
|
||||||
<div className="member-topline">
|
|
||||||
<span className={`member-role member-role-${member.role}`}>
|
|
||||||
{roleMeta.icon} {roleMeta.label}
|
|
||||||
</span>
|
|
||||||
{isSelf && <span className="member-self-pill">✨ You</span>}
|
|
||||||
</div>
|
|
||||||
<span className="member-name">{member.username}</span>
|
|
||||||
<span className="member-meta">ID #{member.id}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isManager && !isSelf && member.role !== "owner" && (
|
|
||||||
<div className="member-actions">
|
|
||||||
{isOwner && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleUpdateRole(member.id, "owner", member.username)}
|
|
||||||
className="btn-primary btn-small member-owner-action"
|
|
||||||
>
|
|
||||||
Make Owner
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => handleUpdateRole(
|
|
||||||
member.id,
|
|
||||||
member.role === "admin" ? "member" : "admin",
|
|
||||||
member.username
|
|
||||||
)}
|
|
||||||
className="btn-secondary btn-small member-role-action"
|
|
||||||
>
|
|
||||||
{member.role === "admin" ? "Make Member" : "Make Admin"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRemoveMember(member.id, member.username)}
|
|
||||||
className="btn-danger btn-small"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
{isManager && member.id !== parseInt(userId, 10) && member.role !== "owner" && (
|
||||||
})}
|
<div className="member-actions">
|
||||||
|
<button
|
||||||
|
onClick={() => handleUpdateRole(member.id, member.role, member.username)}
|
||||||
|
className="btn-secondary btn-small"
|
||||||
|
>
|
||||||
|
{member.role === "admin" ? "Make Member" : "Make Admin"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveMember(member.id, member.username)}
|
||||||
|
className="btn-danger btn-small"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</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">
|
<h2>Danger Zone</h2>
|
||||||
<div>
|
<p className="section-description">
|
||||||
<p className="manage-section-eyebrow">Final Actions</p>
|
{isMemberOnly
|
||||||
<h2>Danger Zone</h2>
|
? "Leaving removes your access to this household."
|
||||||
<p className="section-description">
|
: "Deleting a household is permanent and will delete all lists, items, and history."}
|
||||||
{isMemberOnly
|
</p>
|
||||||
? "Leaving removes your access to this household."
|
{isMemberOnly ? (
|
||||||
: "Deleting a household is permanent and will delete all lists, items, and history."}
|
<button onClick={() => setIsLeaveModalOpen(true)} className="btn-danger">
|
||||||
</p>
|
Leave Household
|
||||||
</div>
|
</button>
|
||||||
{isMemberOnly ? (
|
) : (
|
||||||
<button onClick={() => setIsLeaveModalOpen(true)} className="btn-danger">
|
<button onClick={handleDeleteHousehold} className="btn-danger">
|
||||||
Leave Household
|
Delete Household
|
||||||
</button>
|
</button>
|
||||||
) : (
|
)}
|
||||||
<button onClick={handleDeleteHousehold} className="btn-danger">
|
|
||||||
Delete Household
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -643,27 +509,6 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,11 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
import { createContext, 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: () => { },
|
||||||
@ -20,79 +17,65 @@ export const HouseholdProvider = ({ children }) => {
|
|||||||
const [households, setHouseholds] = useState([]);
|
const [households, setHouseholds] = useState([]);
|
||||||
const [activeHousehold, setActiveHouseholdState] = useState(null);
|
const [activeHousehold, setActiveHouseholdState] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [hasLoaded, setHasLoaded] = useState(false);
|
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
const clearActiveHousehold = useCallback(() => {
|
|
||||||
setActiveHouseholdState(null);
|
|
||||||
localStorage.removeItem(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([]);
|
||||||
clearActiveHousehold();
|
setActiveHouseholdState(null);
|
||||||
setError(null);
|
|
||||||
setLoading(false);
|
|
||||||
setHasLoaded(false);
|
|
||||||
}
|
}
|
||||||
}, [clearActiveHousehold, loadHouseholds, token]);
|
}, [token]);
|
||||||
|
|
||||||
// Load active household from localStorage on mount
|
// Load active household from localStorage on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (households.length === 0) {
|
if (households.length === 0) return;
|
||||||
setActiveHouseholdState(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedHouseholdId = localStorage.getItem(ACTIVE_HOUSEHOLD_STORAGE_KEY);
|
console.log('[HouseholdContext] Setting active household from:', households);
|
||||||
|
const savedHouseholdId = localStorage.getItem('activeHouseholdId');
|
||||||
if (savedHouseholdId) {
|
if (savedHouseholdId) {
|
||||||
const household = households.find((candidate) => String(candidate.id) === savedHouseholdId);
|
const household = households.find(h => h.id === parseInt(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(ACTIVE_HOUSEHOLD_STORAGE_KEY, String(households[0].id));
|
localStorage.setItem('activeHouseholdId', 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(ACTIVE_HOUSEHOLD_STORAGE_KEY, String(household.id));
|
localStorage.setItem('activeHouseholdId', household.id);
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem(ACTIVE_HOUSEHOLD_STORAGE_KEY);
|
localStorage.removeItem('activeHouseholdId');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -118,7 +101,6 @@ export const HouseholdProvider = ({ children }) => {
|
|||||||
households,
|
households,
|
||||||
activeHousehold,
|
activeHousehold,
|
||||||
loading,
|
loading,
|
||||||
hasLoaded,
|
|
||||||
error,
|
error,
|
||||||
setActiveHousehold,
|
setActiveHousehold,
|
||||||
refreshHouseholds: loadHouseholds,
|
refreshHouseholds: loadHouseholds,
|
||||||
|
|||||||
@ -1,3 +1,73 @@
|
|||||||
|
/* :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
|
||||||
@ -7,20 +77,12 @@
|
|||||||
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:
|
background: var(--color-bg-body);
|
||||||
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;
|
||||||
@ -28,275 +90,44 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
.container {
|
||||||
color: var(--color-primary);
|
max-width: var(--container-max-width);
|
||||||
text-decoration: none;
|
margin: auto;
|
||||||
transition: color var(--transition-base), opacity var(--transition-base);
|
padding: var(--container-padding);
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
h1 {
|
||||||
color: var(--color-primary-hover);
|
text-align: center;
|
||||||
}
|
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,
|
||||||
select,
|
button,
|
||||||
textarea {
|
select {
|
||||||
|
font-size: 1em;
|
||||||
|
margin: 0.3em 0;
|
||||||
|
padding: 0.5em;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font: inherit;
|
box-sizing: border-box;
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
font: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-shell {
|
li {
|
||||||
width: min(100%, var(--page-max-width));
|
padding: 0.5em;
|
||||||
margin: 0 auto;
|
background: #e9ecef;
|
||||||
padding: clamp(1rem, 2vw, 1.75rem);
|
margin-bottom: 0.5em;
|
||||||
}
|
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-tab:hover {
|
li:hover {
|
||||||
background: rgba(15, 118, 110, 0.08);
|
background: #dee2e6;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,6 @@ 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";
|
||||||
@ -81,12 +80,7 @@ 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 {
|
const { activeHousehold } = useContext(HouseholdContext);
|
||||||
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();
|
||||||
@ -768,25 +762,14 @@ 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>
|
||||||
<NoHouseholdState />
|
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
|
||||||
|
Loading households...
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
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, households, loading, hasLoaded } = useContext(HouseholdContext);
|
const { activeHousehold } = useContext(HouseholdContext);
|
||||||
const [activeTab, setActiveTab] = useState("household");
|
const [activeTab, setActiveTab] = useState("household");
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
@ -18,25 +17,14 @@ 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>
|
||||||
<NoHouseholdState />
|
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
|
||||||
|
Loading household...
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -181,14 +181,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-cancel {
|
.confirm-buy-cancel {
|
||||||
background: var(--button-secondary-bg);
|
background: var(--color-gray-200);
|
||||||
color: var(--button-secondary-text);
|
color: var(--color-text-primary);
|
||||||
border: 1px solid var(--button-secondary-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-cancel:hover {
|
.confirm-buy-cancel:hover {
|
||||||
background: var(--button-secondary-hover-bg);
|
background: var(--color-gray-300);
|
||||||
border-color: var(--button-secondary-border-hover);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-confirm {
|
.confirm-buy-confirm {
|
||||||
|
|||||||
@ -52,9 +52,9 @@
|
|||||||
|
|
||||||
.image-upload-option-btn {
|
.image-upload-option-btn {
|
||||||
padding: 1.2em;
|
padding: 1.2em;
|
||||||
border: 2px solid var(--button-secondary-border);
|
border: 2px solid #ddd;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--button-ghost-bg);
|
background: white;
|
||||||
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: var(--button-secondary-border-hover);
|
border-color: #007bff;
|
||||||
background: var(--button-secondary-hover-bg);
|
background: #f8f9fa;
|
||||||
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,13 +160,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.image-upload-skip {
|
.image-upload-skip {
|
||||||
background: var(--button-secondary-bg);
|
background: #f0f0f0;
|
||||||
color: var(--button-secondary-text);
|
color: #333;
|
||||||
border: 1px solid var(--button-secondary-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-upload-skip:hover {
|
.image-upload-skip:hover {
|
||||||
background: var(--button-secondary-hover-bg);
|
background: #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-upload-confirm {
|
.image-upload-confirm {
|
||||||
|
|||||||
@ -84,14 +84,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.classification-modal-btn-skip {
|
.classification-modal-btn-skip {
|
||||||
background: var(--button-secondary-bg);
|
background: #6c757d;
|
||||||
color: var(--button-secondary-text);
|
color: white;
|
||||||
border: 1px solid var(--button-secondary-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.classification-modal-btn-skip:hover {
|
.classification-modal-btn-skip:hover {
|
||||||
background: var(--button-secondary-hover-bg);
|
background: #5a6268;
|
||||||
border-color: var(--button-secondary-border-hover);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.classification-modal-btn-confirm {
|
.classification-modal-btn-confirm {
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
.household-switcher {
|
.household-switcher {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
min-width: 220px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.household-switcher-toggle {
|
.household-switcher-toggle {
|
||||||
@ -9,20 +8,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(--button-ghost-bg);
|
background: var(--card-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(--button-ghost-hover-bg);
|
background: var(--card-hover);
|
||||||
border-color: var(--button-ghost-border-hover);
|
border-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.household-switcher-toggle:disabled {
|
.household-switcher-toggle:disabled {
|
||||||
@ -30,30 +29,20 @@
|
|||||||
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;
|
||||||
overflow: hidden;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
overflow: hidden;
|
||||||
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 {
|
||||||
@ -62,7 +51,10 @@
|
|||||||
|
|
||||||
.household-switcher-overlay {
|
.household-switcher-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,12 +64,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 {
|
||||||
@ -101,26 +93,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.household-option:hover {
|
.household-option:hover {
|
||||||
background: var(--button-secondary-bg);
|
background: var(--card-hover);
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.household-option.active {
|
.household-option.active {
|
||||||
background: color-mix(in srgb, var(--primary) 15%, transparent);
|
background: rgba(30, 144, 255, 0.15);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.check-mark {
|
.check-mark {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.household-divider {
|
.household-divider {);
|
||||||
height: 1px;
|
|
||||||
margin: 0.25rem 0;
|
margin: 0.25rem 0;
|
||||||
background: var(--border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-household-btn {
|
.create-household-btn {
|
||||||
@ -129,11 +119,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.create-household-btn:hover {
|
.create-household-btn:hover {
|
||||||
background: color-mix(in srgb, var(--primary) 15%, transparent);
|
background: rgba(30, 144, 255, 0.15
|
||||||
}
|
.create-household-btn:hover {
|
||||||
|
background: var(--primary-color-light);
|
||||||
@media (max-width: 640px) {
|
|
||||||
.household-switcher {
|
|
||||||
min-width: 180px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,14 +54,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.image-upload-btn.gallery {
|
.image-upload-btn.gallery {
|
||||||
background: var(--button-secondary-bg);
|
background: #6c757d;
|
||||||
color: var(--button-secondary-text);
|
color: white;
|
||||||
border: 1px solid var(--button-secondary-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-upload-btn.gallery:hover {
|
.image-upload-btn.gallery:hover {
|
||||||
background: var(--button-secondary-hover-bg);
|
background: #545b62;
|
||||||
border-color: var(--button-secondary-border-hover);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-upload-preview {
|
.image-upload-preview {
|
||||||
|
|||||||
@ -66,8 +66,8 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
background: rgba(30, 144, 255, 0.18);
|
background: #495057;
|
||||||
border: 1px solid rgba(95, 178, 255, 0.32);
|
border: 1px solid #5a6268;
|
||||||
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: rgba(30, 144, 255, 0.3);
|
background: #5a6268;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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: rgba(30, 144, 255, 0.18);
|
background: #495057;
|
||||||
color: white;
|
color: white;
|
||||||
border: 1px solid rgba(95, 178, 255, 0.32);
|
border: none;
|
||||||
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: rgba(30, 144, 255, 0.3);
|
background: #5a6268;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-user-icon {
|
.navbar-user-icon {
|
||||||
|
|||||||
@ -1,76 +0,0 @@
|
|||||||
.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%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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: var(--button-secondary-bg);
|
background: rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .tbg-button.is-inactive:hover:not(:disabled) {
|
[data-theme="dark"] .tbg-button.is-inactive:hover:not(:disabled) {
|
||||||
background: var(--button-secondary-bg);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tbg-button:focus-visible {
|
.tbg-button:focus-visible {
|
||||||
|
|||||||
@ -55,9 +55,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.upload-toast-actions button {
|
.upload-toast-actions button {
|
||||||
border: 1px solid var(--button-secondary-border);
|
border: 1px solid var(--color-border-light);
|
||||||
background: var(--button-secondary-bg);
|
background: var(--color-bg-surface);
|
||||||
color: var(--button-secondary-text);
|
color: var(--color-text-primary);
|
||||||
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,9 +65,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.upload-toast-actions button:hover {
|
.upload-toast-actions button:hover {
|
||||||
background: var(--button-secondary-hover-bg);
|
border-color: var(--color-primary);
|
||||||
border-color: var(--button-secondary-border-hover);
|
color: var(--color-primary);
|
||||||
color: var(--button-secondary-text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-toast-success .upload-toast-progress-fill {
|
.upload-toast-success .upload-toast-progress-fill {
|
||||||
|
|||||||
@ -55,7 +55,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.close-btn:hover {
|
.close-btn:hover {
|
||||||
background: var(--button-ghost-bg);
|
background: var(--card-hover);
|
||||||
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(--button-secondary-bg);
|
background: var(--card-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-tab.active {
|
.mode-tab.active {
|
||||||
|
|||||||
@ -2,316 +2,139 @@
|
|||||||
.manage-household {
|
.manage-household {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1.5rem;
|
||||||
max-width: 900px;
|
max-width: 800px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Section Styling */
|
||||||
.manage-section {
|
.manage-section {
|
||||||
display: flex;
|
background: var(--card-bg);
|
||||||
flex-direction: column;
|
border: 1px solid var(--border);
|
||||||
gap: 1rem;
|
border-radius: 8px;
|
||||||
|
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .manage-section,
|
.manage-section h2 {
|
||||||
body.dark-mode .manage-section {
|
font-size: 1.3rem;
|
||||||
background:
|
font-weight: 600;
|
||||||
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.92rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.55;
|
margin-bottom: 1rem;
|
||||||
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;
|
||||||
align-items: flex-start;
|
flex-direction: column;
|
||||||
|
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.55rem;
|
font-size: 1.5rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.household-summary-chips {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.55rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.household-summary-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
padding: 0.4rem 0.75rem;
|
|
||||||
border-radius: var(--border-radius-full);
|
|
||||||
background: var(--primary-light);
|
|
||||||
color: var(--primary-dark);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-name-form {
|
.edit-name-form {
|
||||||
display: grid;
|
display: flex;
|
||||||
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 {
|
||||||
min-width: 0;
|
flex: 1;
|
||||||
padding: 0.85rem 1rem;
|
min-width: 200px;
|
||||||
|
padding: 0.75rem;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--border-radius-md);
|
border-radius: 6px;
|
||||||
font-size: 0.98rem;
|
font-size: 1rem;
|
||||||
background: rgba(255, 255, 255, 0.82);
|
background: var(--background);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .edit-name-form input,
|
/* Invite Code Section */
|
||||||
body.dark-mode .edit-name-form input {
|
.invite-actions {
|
||||||
background: rgba(12, 19, 30, 0.92);
|
|
||||||
border-color: var(--color-border-medium);
|
|
||||||
}
|
|
||||||
|
|
||||||
.manage-household-join-policy-toggle {
|
|
||||||
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
|
||||||
|
|
||||||
.pending-request-card {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
gap: 0.85rem;
|
|
||||||
align-items: center;
|
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;
|
flex-wrap: wrap;
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pending-request-name,
|
.invite-code {
|
||||||
.pending-request-meta {
|
background: var(--background);
|
||||||
margin: 0;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pending-request-name {
|
.section-error {
|
||||||
font-weight: 700;
|
color: var(--danger);
|
||||||
color: var(--text-primary);
|
margin: 0 0 0.75rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pending-request-meta {
|
.manage-household-join-policy-toggle {
|
||||||
color: var(--text-secondary);
|
margin-bottom: 1rem;
|
||||||
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: grid;
|
display: flex;
|
||||||
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.35rem;
|
gap: 0.3rem;
|
||||||
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: var(--border-radius-md);
|
border-radius: 6px;
|
||||||
padding: 0.7rem 0.75rem;
|
padding: 0.45rem 0.6rem;
|
||||||
background: rgba(255, 255, 255, 1);
|
background: var(--background);
|
||||||
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.75rem;
|
gap: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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);
|
||||||
border-radius: var(--border-radius-lg);
|
background: var(--background);
|
||||||
background: rgba(255, 255, 255, 1);
|
border-radius: 8px;
|
||||||
}
|
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;
|
||||||
gap: 0.6rem;
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.invite-link-token,
|
.invite-link-token,
|
||||||
@ -320,262 +143,118 @@ body.dark-mode .invite-link-card {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.invite-link-token {
|
.invite-link-token {
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.invite-link-meta {
|
.invite-link-meta {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.84rem;
|
font-size: 0.85rem;
|
||||||
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: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
|
flex-direction: column;
|
||||||
gap: 0.85rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-card {
|
.member-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
gap: 0.9rem;
|
justify-content: space-between;
|
||||||
padding: 0.95rem 1rem;
|
align-items: center;
|
||||||
background: rgba(255, 255, 255, 1);
|
padding: 1.25rem;
|
||||||
|
background: var(--background);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: 8px;
|
||||||
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.35rem;
|
gap: 0.25rem;
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-topline {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.45rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-name {
|
.member-name {
|
||||||
font-weight: 700;
|
font-weight: 500;
|
||||||
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 {
|
||||||
display: inline-flex;
|
font-size: 0.85rem;
|
||||||
align-items: center;
|
padding: 0.2rem 0.5rem;
|
||||||
gap: 0.35rem;
|
border-radius: 4px;
|
||||||
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;
|
||||||
font-weight: 700;
|
background: var(--primary-light, rgba(0, 122, 255, 0.1));
|
||||||
}
|
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.55rem;
|
gap: 0.75rem;
|
||||||
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: color-mix(in srgb, var(--danger) 30%, transparent);
|
border-color: var(--danger);
|
||||||
background:
|
|
||||||
linear-gradient(180deg, rgba(254, 242, 242, 0.95), rgba(255, 255, 255, 0.78)),
|
|
||||||
var(--card-bg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .danger-zone,
|
.danger-zone h2 {
|
||||||
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 {
|
||||||
min-height: 40px;
|
padding: 0.5rem 1rem;
|
||||||
padding: 0.58rem 0.95rem;
|
border: none;
|
||||||
border-radius: var(--border-radius-full);
|
border-radius: 6px;
|
||||||
font-size: 0.88rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover,
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--primary-dark, #0056b3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: var(--danger-dark, #c82333);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-small {
|
.btn-small {
|
||||||
min-height: 34px;
|
padding: 0.4rem 0.75rem;
|
||||||
padding: 0.38rem 0.72rem;
|
font-size: 0.85rem;
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger:disabled {
|
.btn-danger:disabled {
|
||||||
@ -585,50 +264,62 @@ body.dark-mode .danger-zone {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 900px) {
|
|
||||||
.invite-controls {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.invite-controls .btn-primary {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.invite-link-card {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.invite-link-actions {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pending-request-card {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pending-request-actions {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.manage-section {
|
.manage-section {
|
||||||
padding: 1rem;
|
padding: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.manage-section-header,
|
.name-display {
|
||||||
.name-display,
|
flex-direction: column;
|
||||||
.danger-zone .manage-section-header {
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-name-form {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-name-form input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-name-form button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-card {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-actions button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-actions {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-name-form {
|
.invite-actions button {
|
||||||
grid-template-columns: 1fr;
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-code {
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invite-controls {
|
.invite-controls {
|
||||||
grid-template-columns: 1fr;
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invite-controls label,
|
.invite-controls label,
|
||||||
@ -637,17 +328,11 @@ body.dark-mode .danger-zone {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.members-list {
|
.invite-link-actions {
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-actions {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-actions button,
|
.invite-link-actions button {
|
||||||
.pending-request-actions button {
|
flex: 1;
|
||||||
flex: 1 1 100%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,7 +44,7 @@
|
|||||||
|
|
||||||
.admin-tab:hover {
|
.admin-tab:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: var(--button-secondary-bg);
|
background: var(--card-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-tab.active {
|
.admin-tab.active {
|
||||||
|
|||||||
@ -61,9 +61,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.invite-btn-secondary {
|
.invite-btn-secondary {
|
||||||
background: var(--button-secondary-bg);
|
background: var(--card-hover);
|
||||||
color: var(--button-secondary-text);
|
color: var(--text-primary);
|
||||||
border: 1px solid var(--button-secondary-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
|||||||
@ -14,8 +14,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-password-toggle {
|
.login-password-toggle {
|
||||||
background: var(--button-ghost-bg);
|
background: #f0f0f0;
|
||||||
border: 1px solid var(--button-ghost-border);
|
border: 1px solid #ccc;
|
||||||
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: var(--button-ghost-hover-bg);
|
background: #e8e8e8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -44,7 +44,7 @@
|
|||||||
|
|
||||||
.manage-tab:hover {
|
.manage-tab:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: var(--button-secondary-bg);
|
background: var(--card-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.manage-tab.active {
|
.manage-tab.active {
|
||||||
|
|||||||
@ -83,7 +83,7 @@
|
|||||||
|
|
||||||
.settings-tab:hover {
|
.settings-tab:hover {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
background: var(--button-secondary-bg);
|
background: var(--color-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-tab.active {
|
.settings-tab.active {
|
||||||
|
|||||||
@ -15,104 +15,100 @@
|
|||||||
|
|
||||||
/* Primary Colors */
|
/* Primary Colors */
|
||||||
--color-primary: dodgerblue;
|
--color-primary: dodgerblue;
|
||||||
--color-primary-hover: #1677d2;
|
--color-primary-hover: #0066cc;
|
||||||
--color-primary-light: #dceeff;
|
--color-primary-light: #e7f3ff;
|
||||||
--color-primary-dark: #0f5db4;
|
--color-primary-dark: #0056b3;
|
||||||
|
|
||||||
/* Secondary Colors */
|
/* Secondary Colors */
|
||||||
--color-secondary: #7c5a3c;
|
--color-secondary: #6c757d;
|
||||||
--color-secondary-hover: #64462e;
|
--color-secondary-hover: #545b62;
|
||||||
--color-secondary-light: #f5ede4;
|
--color-secondary-light: #f8f9fa;
|
||||||
|
|
||||||
/* Accent Colors */
|
|
||||||
--color-accent: #f59e0b;
|
|
||||||
--color-accent-light: #fff2d8;
|
|
||||||
|
|
||||||
/* Semantic Colors */
|
/* Semantic Colors */
|
||||||
--color-success: #15803d;
|
--color-success: #28a745;
|
||||||
--color-success-hover: #166534;
|
--color-success-hover: #218838;
|
||||||
--color-success-light: #dcfce7;
|
--color-success-light: #d4edda;
|
||||||
|
|
||||||
--color-danger: #dc2626;
|
--color-danger: #dc3545;
|
||||||
--color-danger-hover: #b91c1c;
|
--color-danger-hover: #c82333;
|
||||||
--color-danger-light: #fee2e2;
|
--color-danger-light: #f8d7da;
|
||||||
|
|
||||||
--color-warning: #d97706;
|
--color-warning: #ffc107;
|
||||||
--color-warning-hover: #b45309;
|
--color-warning-hover: #e0a800;
|
||||||
--color-warning-light: #ffedd5;
|
--color-warning-light: #fff3cd;
|
||||||
|
|
||||||
--color-info: #0369a1;
|
--color-info: #17a2b8;
|
||||||
--color-info-hover: #075985;
|
--color-info-hover: #138496;
|
||||||
--color-info-light: #dbeafe;
|
--color-info-light: #d1ecf1;
|
||||||
|
|
||||||
/* Neutral Colors */
|
/* Neutral Colors */
|
||||||
--color-white: #ffffff;
|
--color-white: #ffffff;
|
||||||
--color-black: #0f172a;
|
--color-black: #000000;
|
||||||
--color-gray-50: #fcfbf8;
|
--color-gray-50: #f9f9f9;
|
||||||
--color-gray-100: #f6f3ee;
|
--color-gray-100: #f8f9fa;
|
||||||
--color-gray-200: #ebe6dd;
|
--color-gray-200: #e9ecef;
|
||||||
--color-gray-300: #ddd4c7;
|
--color-gray-300: #dee2e6;
|
||||||
--color-gray-400: #b6ab9a;
|
--color-gray-400: #ced4da;
|
||||||
--color-gray-500: #8e8579;
|
--color-gray-500: #adb5bd;
|
||||||
--color-gray-600: #6b645b;
|
--color-gray-600: #6c757d;
|
||||||
--color-gray-700: #47423d;
|
--color-gray-700: #495057;
|
||||||
--color-gray-800: #2d2a27;
|
--color-gray-800: #343a40;
|
||||||
--color-gray-900: #1c1917;
|
--color-gray-900: #212529;
|
||||||
|
|
||||||
/* Text Colors */
|
/* Text Colors */
|
||||||
--color-text-primary: #1f2937;
|
--color-text-primary: #212529;
|
||||||
--color-text-secondary: #5b6473;
|
--color-text-secondary: #6c757d;
|
||||||
--color-text-muted: #8e98a8;
|
--color-text-muted: #adb5bd;
|
||||||
--color-text-inverse: #f8fafc;
|
--color-text-inverse: #ffffff;
|
||||||
--color-text-disabled: #9aa4b2;
|
--color-text-disabled: #6c757d;
|
||||||
|
|
||||||
/* Background Colors */
|
/* Background Colors */
|
||||||
--color-bg-body: #f4f1ea;
|
--color-bg-body: #f8f9fa;
|
||||||
--color-bg-surface: rgba(255, 255, 255, 0.9);
|
--color-bg-surface: #ffffff;
|
||||||
--color-bg-elevated: rgba(255, 255, 255, 0.98);
|
--color-bg-hover: #f5f5f5;
|
||||||
--color-bg-hover: #f2f7f6;
|
--color-bg-disabled: #e9ecef;
|
||||||
--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: rgba(119, 107, 91, 0.18);
|
--color-border-light: #e0e0e0;
|
||||||
--color-border-medium: rgba(119, 107, 91, 0.32);
|
--color-border-medium: #ccc;
|
||||||
--color-border-dark: rgba(91, 81, 69, 0.55);
|
--color-border-dark: #999;
|
||||||
--color-border-disabled: rgba(148, 163, 184, 0.35);
|
--color-border-disabled: #dee2e6;
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
SPACING
|
SPACING
|
||||||
============================================ */
|
============================================ */
|
||||||
--spacing-xs: 0.25rem;
|
--spacing-xs: 0.25rem; /* 4px */
|
||||||
--spacing-sm: 0.5rem;
|
--spacing-sm: 0.5rem; /* 8px */
|
||||||
--spacing-md: 1rem;
|
--spacing-md: 1rem; /* 16px */
|
||||||
--spacing-lg: 1.5rem;
|
--spacing-lg: 1.5rem; /* 24px */
|
||||||
--spacing-xl: 2rem;
|
--spacing-xl: 2rem; /* 32px */
|
||||||
--spacing-2xl: 3rem;
|
--spacing-2xl: 3rem; /* 48px */
|
||||||
--spacing-3xl: 4rem;
|
--spacing-3xl: 4rem; /* 64px */
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
TYPOGRAPHY
|
TYPOGRAPHY
|
||||||
============================================ */
|
============================================ */
|
||||||
--font-family-base: "Aptos", "Segoe UI Variable Text", "Segoe UI", sans-serif;
|
--font-family-base: Arial, sans-serif;
|
||||||
--font-family-heading: "Aptos Display", "Aptos", "Segoe UI Variable Display", "Segoe UI", sans-serif;
|
--font-family-heading: Arial, sans-serif;
|
||||||
--font-family-mono: "IBM Plex Mono", "Cascadia Code", "Consolas", monospace;
|
--font-family-mono: 'Courier New', monospace;
|
||||||
|
|
||||||
--font-size-xs: 0.75rem;
|
/* Font Sizes */
|
||||||
--font-size-sm: 0.875rem;
|
--font-size-xs: 0.75rem; /* 12px */
|
||||||
--font-size-base: 1rem;
|
--font-size-sm: 0.875rem; /* 14px */
|
||||||
--font-size-lg: 1.125rem;
|
--font-size-base: 1rem; /* 16px */
|
||||||
--font-size-xl: 1.25rem;
|
--font-size-lg: 1.125rem; /* 18px */
|
||||||
--font-size-2xl: 1.5rem;
|
--font-size-xl: 1.25rem; /* 20px */
|
||||||
--font-size-3xl: 2rem;
|
--font-size-2xl: 1.5rem; /* 24px */
|
||||||
--font-size-4xl: 2.75rem;
|
--font-size-3xl: 2rem; /* 32px */
|
||||||
|
|
||||||
|
/* 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-height-tight: 1.15;
|
/* Line Heights */
|
||||||
|
--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;
|
||||||
|
|
||||||
@ -123,27 +119,27 @@
|
|||||||
--border-width-medium: 2px;
|
--border-width-medium: 2px;
|
||||||
--border-width-thick: 4px;
|
--border-width-thick: 4px;
|
||||||
|
|
||||||
--border-radius-sm: 10px;
|
--border-radius-sm: 4px;
|
||||||
--border-radius-md: 14px;
|
--border-radius-md: 6px;
|
||||||
--border-radius-lg: 20px;
|
--border-radius-lg: 8px;
|
||||||
--border-radius-xl: 28px;
|
--border-radius-xl: 12px;
|
||||||
--border-radius-full: 999px;
|
--border-radius-full: 50%;
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
SHADOWS
|
SHADOWS
|
||||||
============================================ */
|
============================================ */
|
||||||
--shadow-sm: 0 8px 20px rgba(36, 33, 28, 0.06);
|
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
--shadow-md: 0 16px 34px rgba(36, 33, 28, 0.1);
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
--shadow-lg: 0 24px 56px rgba(36, 33, 28, 0.14);
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
--shadow-xl: 0 32px 80px rgba(36, 33, 28, 0.18);
|
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||||
--shadow-card: 0 14px 36px rgba(36, 33, 28, 0.09);
|
--shadow-card: 0 0 10px rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
TRANSITIONS
|
TRANSITIONS
|
||||||
============================================ */
|
============================================ */
|
||||||
--transition-fast: 0.15s ease;
|
--transition-fast: 0.15s ease;
|
||||||
--transition-base: 0.24s ease;
|
--transition-base: 0.2s ease;
|
||||||
--transition-slow: 0.35s ease;
|
--transition-slow: 0.3s ease;
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
Z-INDEX LAYERS
|
Z-INDEX LAYERS
|
||||||
@ -158,58 +154,48 @@
|
|||||||
/* ============================================
|
/* ============================================
|
||||||
LAYOUT
|
LAYOUT
|
||||||
============================================ */
|
============================================ */
|
||||||
--container-max-width: 560px;
|
--container-max-width: 480px;
|
||||||
--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);
|
|
||||||
|
|
||||||
--input-padding-y: 0.85rem;
|
/* Buttons */
|
||||||
--input-padding-x: 1rem;
|
--button-padding-y: 0.6rem;
|
||||||
--input-border-color: var(--color-border-light);
|
--button-padding-x: 1.5rem;
|
||||||
--input-border-radius: var(--border-radius-md);
|
--button-border-radius: var(--border-radius-sm);
|
||||||
|
--button-font-weight: var(--font-weight-medium);
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
|
--input-padding-y: 0.6rem;
|
||||||
|
--input-padding-x: 0.75rem;
|
||||||
|
--input-border-color: var(--color-border-medium);
|
||||||
|
--input-border-radius: var(--border-radius-sm);
|
||||||
--input-focus-border-color: var(--color-primary);
|
--input-focus-border-color: var(--color-primary);
|
||||||
--input-focus-shadow: 0 0 0 4px rgba(15, 118, 110, 0.12);
|
--input-focus-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
--card-bg: var(--color-bg-surface);
|
--card-bg: var(--color-bg-surface);
|
||||||
--card-padding: var(--spacing-lg);
|
--card-padding: var(--spacing-md);
|
||||||
--card-border-radius: var(--border-radius-lg);
|
--card-border-radius: var(--border-radius-lg);
|
||||||
--card-shadow: var(--shadow-card);
|
--card-shadow: var(--shadow-card);
|
||||||
|
|
||||||
--button-secondary-bg: rgba(30, 144, 255, 0.12);
|
/* Modals */
|
||||||
--button-secondary-hover-bg: rgba(30, 144, 255, 0.2);
|
--modal-backdrop-bg: rgba(0, 0, 0, 0.5);
|
||||||
--button-secondary-border: rgba(30, 144, 255, 0.26);
|
--modal-bg: var(--color-white);
|
||||||
--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
|
SIMPLIFIED ALIASES (for component convenience)
|
||||||
============================================ */
|
============================================ */
|
||||||
--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);
|
||||||
@ -217,104 +203,108 @@
|
|||||||
--card-hover: var(--color-bg-hover);
|
--card-hover: var(--color-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
DARK MODE
|
||||||
|
============================================ */
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
--color-primary: #5fb2ff;
|
/* Primary Colors */
|
||||||
--color-primary-hover: #83c4ff;
|
--color-primary: #4da3ff;
|
||||||
--color-primary-light: rgba(95, 178, 255, 0.14);
|
--color-primary-hover: #66b3ff;
|
||||||
--color-primary-dark: #2d8ff0;
|
--color-primary-light: #1a3a52;
|
||||||
|
--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: #86efac;
|
--color-success-hover: #5fe88d;
|
||||||
--color-success-light: rgba(74, 222, 128, 0.16);
|
--color-success-light: #1a3a28;
|
||||||
|
|
||||||
--color-danger: #f87171;
|
--color-danger: #f87171;
|
||||||
--color-danger-hover: #fca5a5;
|
--color-danger-hover: #fa8585;
|
||||||
--color-danger-light: rgba(248, 113, 113, 0.16);
|
--color-danger-light: #4a2020;
|
||||||
|
|
||||||
--color-warning: #fbbf24;
|
--color-warning: #fbbf24;
|
||||||
--color-warning-hover: #fcd34d;
|
--color-warning-hover: #fcd34d;
|
||||||
--color-warning-light: rgba(251, 191, 36, 0.16);
|
--color-warning-light: #3a2f0f;
|
||||||
|
|
||||||
--color-info: #38bdf8;
|
--color-info: #38bdf8;
|
||||||
--color-info-hover: #7dd3fc;
|
--color-info-hover: #5dc9fc;
|
||||||
--color-info-light: rgba(56, 189, 248, 0.16);
|
--color-info-light: #1a2f3a;
|
||||||
|
|
||||||
--color-text-primary: #f4f7fb;
|
/* Text Colors */
|
||||||
--color-text-secondary: #b2bccb;
|
--color-text-primary: #f1f5f9;
|
||||||
--color-text-muted: #7f8aa0;
|
--color-text-secondary: #94a3b8;
|
||||||
--color-text-inverse: #0f172a;
|
--color-text-muted: #64748b;
|
||||||
--color-text-disabled: #667085;
|
--color-text-inverse: #1e293b;
|
||||||
|
--color-text-disabled: #475569;
|
||||||
|
|
||||||
--color-bg-body: #0f1722;
|
/* Background Colors */
|
||||||
--color-bg-surface: rgba(15, 23, 34, 0.84);
|
--color-bg-body: #0f172a;
|
||||||
--color-bg-elevated: rgba(20, 29, 42, 0.96);
|
--color-bg-surface: #1e293b;
|
||||||
--color-bg-hover: rgba(30, 41, 59, 0.95);
|
--color-bg-hover: #334155;
|
||||||
--color-bg-disabled: rgba(30, 41, 59, 0.7);
|
--color-bg-disabled: #1e293b;
|
||||||
--color-bg-hero: linear-gradient(135deg, rgba(45, 212, 191, 0.18), rgba(251, 191, 36, 0.16));
|
|
||||||
|
|
||||||
--color-border-light: rgba(148, 163, 184, 0.18);
|
/* Border Colors */
|
||||||
--color-border-medium: rgba(148, 163, 184, 0.32);
|
--color-border-light: #334155;
|
||||||
--color-border-dark: rgba(203, 213, 225, 0.48);
|
--color-border-medium: #475569;
|
||||||
--color-border-disabled: rgba(100, 116, 139, 0.3);
|
--color-border-dark: #64748b;
|
||||||
|
--color-border-disabled: #334155;
|
||||||
|
|
||||||
--color-gray-50: #111827;
|
/* Neutral Colors - Dark adjusted */
|
||||||
--color-gray-100: #172030;
|
--color-gray-50: #1e293b;
|
||||||
--color-gray-200: #1f2937;
|
--color-gray-100: #1e293b;
|
||||||
--color-gray-300: #334155;
|
--color-gray-200: #334155;
|
||||||
--color-gray-400: #475569;
|
--color-gray-300: #475569;
|
||||||
--color-gray-500: #64748b;
|
--color-gray-400: #64748b;
|
||||||
--color-gray-600: #94a3b8;
|
--color-gray-500: #94a3b8;
|
||||||
--color-gray-700: #cbd5e1;
|
--color-gray-600: #cbd5e1;
|
||||||
--color-gray-800: #e2e8f0;
|
--color-gray-700: #e2e8f0;
|
||||||
|
--color-gray-800: #f1f5f9;
|
||||||
--color-gray-900: #f8fafc;
|
--color-gray-900: #f8fafc;
|
||||||
|
|
||||||
--shadow-sm: 0 10px 24px rgba(2, 6, 23, 0.24);
|
/* Shadows - Lighter for dark mode */
|
||||||
--shadow-md: 0 18px 40px rgba(2, 6, 23, 0.34);
|
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
||||||
--shadow-lg: 0 28px 60px rgba(2, 6, 23, 0.42);
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
|
||||||
--shadow-xl: 0 42px 90px rgba(2, 6, 23, 0.5);
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
|
||||||
--shadow-card: 0 18px 44px rgba(2, 6, 23, 0.34);
|
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6);
|
||||||
|
--shadow-card: 0 0 10px rgba(0, 0, 0, 0.5);
|
||||||
|
|
||||||
--modal-backdrop-bg: rgba(2, 6, 23, 0.72);
|
/* Modals */
|
||||||
--modal-bg: var(--color-bg-elevated);
|
--modal-backdrop-bg: rgba(0, 0, 0, 0.8);
|
||||||
--input-focus-shadow: 0 0 0 4px rgba(45, 212, 191, 0.18);
|
--modal-bg: var(--color-bg-surface);
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
|
--input-border-color: var(--color-border-medium);
|
||||||
|
--input-focus-shadow: 0 0 0 2px rgba(77, 163, 255, 0.3);
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
--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: #f4f7fb;
|
--color-text-primary: #f8f9fa;
|
||||||
--color-text-secondary: #b2bccb;
|
--color-text-secondary: #adb5bd;
|
||||||
--color-bg-body: #0f1722;
|
--color-bg-body: #212529;
|
||||||
--color-bg-surface: rgba(15, 23, 34, 0.84);
|
--color-bg-surface: #343a40;
|
||||||
--color-border-light: rgba(148, 163, 184, 0.18);
|
--color-border-light: #495057;
|
||||||
--color-border-medium: rgba(148, 163, 184, 0.32);
|
--color-border-medium: #6c757d;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
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; }
|
||||||
@ -332,6 +322,7 @@
|
|||||||
.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; }
|
||||||
@ -347,11 +338,13 @@
|
|||||||
.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; }
|
||||||
|
|||||||
@ -61,21 +61,17 @@
|
|||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--card-bg);
|
background: var(--color-bg-surface);
|
||||||
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-elevated);
|
background: var(--color-bg-surface);
|
||||||
border: 1px solid var(--color-border-light);
|
border-radius: var(--card-border-radius);
|
||||||
border-radius: var(--border-radius-xl);
|
padding: var(--spacing-lg);
|
||||||
padding: clamp(1.5rem, 3vw, 2.25rem);
|
box-shadow: var(--shadow-lg);
|
||||||
box-shadow: var(--shadow-xl);
|
|
||||||
backdrop-filter: blur(18px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
@ -91,83 +87,74 @@
|
|||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: var(--button-padding-y) var(--button-padding-x);
|
padding: var(--button-padding-y) var(--button-padding-x);
|
||||||
border: 1px solid transparent;
|
border: none;
|
||||||
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: transform var(--transition-base), box-shadow var(--transition-base), background var(--transition-base), color var(--transition-base), border-color var(--transition-base);
|
transition: var(--transition-base);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
display: inline-flex;
|
display: inline-block;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.45rem;
|
|
||||||
min-height: 46px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark));
|
background: var(--color-primary);
|
||||||
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) {
|
||||||
transform: translateY(-2px);
|
background: var(--color-primary-hover);
|
||||||
box-shadow: 0 18px 34px rgba(30, 144, 255, 0.28);
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: var(--button-secondary-bg);
|
background: var(--color-primary);
|
||||||
color: var(--button-secondary-text);
|
color: var(--color-text-inverse);
|
||||||
border-color: var(--button-secondary-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
.btn-secondary:hover:not(:disabled) {
|
||||||
background: var(--button-secondary-hover-bg);
|
background: var(--color-primary-hover);
|
||||||
border-color: var(--button-secondary-border-hover);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background: linear-gradient(135deg, var(--color-danger), var(--color-danger-hover));
|
background: var(--color-danger);
|
||||||
color: var(--color-text-inverse);
|
color: var(--color-text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger:hover:not(:disabled) {
|
.btn-danger:hover:not(:disabled) {
|
||||||
transform: translateY(-1px);
|
background: var(--color-danger-hover);
|
||||||
box-shadow: 0 16px 30px rgba(220, 38, 38, 0.18);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-success {
|
.btn-success {
|
||||||
background: linear-gradient(135deg, var(--color-success), var(--color-success-hover));
|
background: var(--color-success);
|
||||||
color: var(--color-text-inverse);
|
color: var(--color-text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-success:hover:not(:disabled) {
|
.btn-success:hover:not(:disabled) {
|
||||||
transform: translateY(-1px);
|
background: var(--color-success-hover);
|
||||||
box-shadow: 0 16px 30px rgba(21, 128, 61, 0.18);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline {
|
.btn-outline {
|
||||||
background: var(--button-ghost-bg);
|
background: transparent;
|
||||||
color: var(--button-ghost-text);
|
color: var(--color-primary);
|
||||||
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(--button-ghost-hover-bg);
|
background: var(--color-primary);
|
||||||
transform: translateY(-1px);
|
color: var(--color-text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
background: var(--button-ghost-bg);
|
background: var(--color-bg-surface);
|
||||||
color: var(--button-ghost-text);
|
color: var(--color-text-primary);
|
||||||
border: var(--border-width-thin) solid var(--button-ghost-border);
|
border: var(--border-width-thin) solid var(--color-border-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost:hover:not(:disabled) {
|
.btn-ghost:hover:not(:disabled) {
|
||||||
background: var(--button-ghost-hover-bg);
|
background: var(--color-bg-hover);
|
||||||
border-color: var(--button-ghost-border-hover);
|
border-color: var(--color-border-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-sm {
|
.btn-sm {
|
||||||
@ -188,8 +175,6 @@
|
|||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
box-shadow: none;
|
|
||||||
transform: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@ -215,8 +200,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: rgba(255, 255, 255, 0.72);
|
background: var(--color-bg-surface);
|
||||||
transition: border-color var(--transition-base), box-shadow var(--transition-base), background var(--transition-base), transform var(--transition-base);
|
transition: var(--transition-base);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -224,8 +209,6 @@
|
|||||||
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 {
|
||||||
@ -239,7 +222,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: rgba(255, 255, 255, 0.72);
|
background: var(--color-bg-surface);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition-base);
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
@ -248,7 +231,6 @@
|
|||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
|
|||||||
@ -1,59 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
@ -1,320 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
Loading…
Reference in New Issue
Block a user