import React, { useContext, useEffect, useState } from "react"; import { createGroupInviteLink, decideGroupJoinRequest, deleteGroupInviteLink, deleteHousehold, getGroupInviteLinks, getGroupJoinPolicy, getHouseholdMembers, getPendingGroupJoinRequests, removeMember, revokeGroupInviteLink, reviveGroupInviteLink, setGroupJoinPolicy, updateHousehold, updateMemberRole, } from "../../api/households"; import { ToggleButtonGroup } from "../common"; import ConfirmSlideModal from "../modals/ConfirmSlideModal"; import { AuthContext } from "../../context/AuthContext"; import { HouseholdContext } from "../../context/HouseholdContext"; import useActionToast from "../../hooks/useActionToast"; import getApiErrorMessage from "../../lib/getApiErrorMessage"; import "../../styles/components/manage/ManageHousehold.css"; const JOIN_POLICY_OPTIONS = [ { label: "Disabled", value: "NOT_ACCEPTING" }, { label: "Auto", value: "AUTO_ACCEPT" }, { label: "Manual", value: "APPROVAL_REQUIRED" }, ]; const ROLE_METADATA = { owner: { icon: "👑", label: "Owner" }, admin: { icon: "🛠️", label: "Admin" }, member: { icon: "🙂", label: "Member" }, viewer: { icon: "👀", label: "Viewer" }, }; const STATUS_METADATA = { Active: { tone: "active", icon: "🟢" }, Used: { tone: "used", icon: "⚪" }, Revoked: { tone: "revoked", icon: "🔴" }, Expired: { tone: "expired", icon: "🟠" }, }; function getRequesterLabel(request) { return ( request.display_name?.trim() || request.name?.trim() || request.username?.trim() || `User #${request.user_id}` ); } export default function ManageHousehold() { const { userId } = useContext(AuthContext); const { activeHousehold, refreshHouseholds } = useContext(HouseholdContext); const toast = useActionToast(); const [members, setMembers] = useState([]); const [loading, setLoading] = useState(true); const [editingName, setEditingName] = useState(false); const [newName, setNewName] = useState(""); const [joinPolicy, setJoinPolicyValue] = useState("NOT_ACCEPTING"); const [inviteLinks, setInviteLinks] = useState([]); const [pendingRequests, setPendingRequests] = useState([]); const [inviteLoading, setInviteLoading] = useState(false); const [inviteError, setInviteError] = useState(""); const [ttlDays, setTtlDays] = useState(7); const [singleUseMode, setSingleUseMode] = useState("UNLIMITED"); const [pendingDecisionId, setPendingDecisionId] = useState(null); const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); const [pendingRoleChange, setPendingRoleChange] = useState(null); const isManager = ["owner", "admin"].includes(activeHousehold?.role); const isOwner = activeHousehold?.role === "owner"; const isMemberOnly = activeHousehold?.role === "member"; useEffect(() => { loadMembers(); if (isManager) { loadJoinAndInvites(); } else { setPendingRequests([]); } }, [activeHousehold?.id, isManager]); const loadMembers = async () => { if (!activeHousehold?.id) return; setLoading(true); try { const response = await getHouseholdMembers(activeHousehold.id); setMembers(response.data); } catch (error) { console.error("Failed to load members:", error); } finally { setLoading(false); } }; const loadJoinAndInvites = async () => { if (!activeHousehold?.id || !isManager) return; setInviteLoading(true); setInviteError(""); try { const [policyResponse, linksResponse, requestsResponse] = await Promise.all([ getGroupJoinPolicy(activeHousehold.id), getGroupInviteLinks(activeHousehold.id), getPendingGroupJoinRequests(activeHousehold.id), ]); setJoinPolicyValue(policyResponse.data.joinPolicy || "NOT_ACCEPTING"); setInviteLinks(linksResponse.data.links || []); setPendingRequests(requestsResponse.data.requests || []); } catch (error) { setInviteError(error.response?.data?.error?.message || "Failed to load invite links"); } finally { setInviteLoading(false); } }; const getLinkStatus = (link) => { const now = Date.now(); if (link.single_use && link.used_at) return "Used"; if (link.revoked_at) return "Revoked"; if (new Date(link.expires_at).getTime() <= now) return "Expired"; return "Active"; }; const copyTextToClipboard = async (text) => { if (!text) return false; if (navigator?.clipboard?.writeText) { try { await navigator.clipboard.writeText(text); return true; } catch { // Fall through to legacy copy fallback. } } try { const textArea = document.createElement("textarea"); textArea.value = text; textArea.setAttribute("readonly", ""); textArea.style.position = "fixed"; textArea.style.top = "-9999px"; textArea.style.left = "-9999px"; document.body.appendChild(textArea); textArea.focus(); textArea.select(); const copied = document.execCommand("copy"); document.body.removeChild(textArea); return copied; } catch { return false; } }; const copyInviteLink = async (token) => { const inviteUrl = `${window.location.origin}/invite/${encodeURIComponent(token)}`; const copied = await copyTextToClipboard(inviteUrl); if (copied) { const tokenLast4 = String(token || "").slice(-4); toast.info("Copied invite link", `Copied invite link ending in ${tokenLast4}`); return; } toast.error( "Copy invite link failed", "Copy invite link failed: unable to access clipboard. Copy manually." ); }; const handleCreateInviteLink = async () => { try { setInviteError(""); await createGroupInviteLink(activeHousehold.id, { policy: joinPolicy, singleUse: singleUseMode === "ONE_TIME", ttlDays, }); await loadJoinAndInvites(); toast.success("Created invite link", `Created invite link (${ttlDays} day TTL)`); } catch (error) { const message = getApiErrorMessage(error, "Failed to create invite link"); setInviteError(message); toast.error("Create invite link failed", `Create invite link failed: ${message}`); } }; const handleUpdateJoinPolicy = async (value) => { try { setInviteError(""); await setGroupJoinPolicy(activeHousehold.id, value); setJoinPolicyValue(value); toast.success("Updated join policy", `Join policy set to ${value}`); } catch (error) { const message = getApiErrorMessage(error, "Failed to update join policy"); setInviteError(message); toast.error("Update join policy failed", `Update join policy failed: ${message}`); } }; const handleJoinRequestDecision = async (request, decision) => { const requesterName = getRequesterLabel(request); setPendingDecisionId(request.id); try { setInviteError(""); await decideGroupJoinRequest(activeHousehold.id, request.id, decision); await Promise.all([loadJoinAndInvites(), loadMembers()]); if (decision === "APPROVE") { toast.success("Approved join request", `Approved ${requesterName}`); } else { toast.info("Denied join request", `Denied ${requesterName}`); } } catch (error) { const message = getApiErrorMessage(error, "Failed to update join request"); setInviteError(message); toast.error("Join request update failed", `Join request update failed: ${message}`); } finally { setPendingDecisionId(null); } }; const handleRevokeInvite = async (linkId) => { try { setInviteError(""); await revokeGroupInviteLink(activeHousehold.id, linkId); await loadJoinAndInvites(); toast.success("Revoked invite link", "Revoked invite link"); } catch (error) { const message = getApiErrorMessage(error, "Failed to revoke invite link"); setInviteError(message); toast.error("Revoke invite link failed", `Revoke invite link failed: ${message}`); } }; const handleReviveInvite = async (linkId) => { try { setInviteError(""); await reviveGroupInviteLink(activeHousehold.id, linkId, ttlDays); await loadJoinAndInvites(); toast.success("Revived invite link", `Revived invite link for ${ttlDays} days`); } catch (error) { const message = getApiErrorMessage(error, "Failed to revive invite link"); setInviteError(message); toast.error("Revive invite link failed", `Revive invite link failed: ${message}`); } }; const handleDeleteInvite = async (linkId) => { if (!confirm("Delete this invite link permanently?")) return; try { setInviteError(""); await deleteGroupInviteLink(activeHousehold.id, linkId); await loadJoinAndInvites(); toast.success("Deleted invite link", "Deleted invite link"); } catch (error) { const message = getApiErrorMessage(error, "Failed to delete invite link"); setInviteError(message); toast.error("Delete invite link failed", `Delete invite link failed: ${message}`); } }; const handleUpdateName = async () => { if (!newName.trim() || newName === activeHousehold.name) { setEditingName(false); return; } try { await updateHousehold(activeHousehold.id, newName); await refreshHouseholds(); toast.success("Updated household name", `Updated household name to ${newName.trim()}`); setEditingName(false); } catch (error) { const message = getApiErrorMessage(error, "Failed to update household name"); toast.error("Update household name failed", `Update household name failed: ${message}`); } }; const handleConfirmRoleChange = async () => { if (!pendingRoleChange) return; const { memberId, nextRole, memberName } = pendingRoleChange; try { await updateMemberRole(activeHousehold.id, memberId, nextRole); await Promise.all([ loadMembers(), nextRole === "owner" ? refreshHouseholds() : Promise.resolve(), ]); if (nextRole === "owner") { toast.success("Transferred household ownership", `Transferred ownership to ${memberName}`); } else { toast.success("Updated member role", `Updated role for ${memberName} to ${nextRole}`); } setPendingRoleChange(null); } catch (error) { const message = getApiErrorMessage(error, "Failed to update member role"); toast.error("Update member role failed", `Update member role failed: ${message}`); } }; const handleUpdateRole = (memberId, nextRole, memberName) => { if (!nextRole) return; setPendingRoleChange({ memberId, nextRole, memberName }); }; const handleRemoveMember = async (memberId, username) => { if (!confirm(`Remove ${username} from this household?`)) return; try { await removeMember(activeHousehold.id, memberId); await loadMembers(); toast.success("Removed member", `Removed member ${username}`); } catch (error) { const message = getApiErrorMessage(error, "Failed to remove member"); toast.error("Remove member failed", `Remove member failed: ${message}`); } }; const handleDeleteHousehold = async () => { if (!confirm(`Delete "${activeHousehold.name}"? This will delete all lists and data. This cannot be undone.`)) return; if (!confirm("Are you absolutely sure? Type DELETE to confirm.")) return; try { const householdName = activeHousehold.name; await deleteHousehold(activeHousehold.id); await refreshHouseholds(); toast.success("Deleted household", `Deleted household ${householdName}`); } catch (error) { const message = getApiErrorMessage(error, "Failed to delete household"); toast.error("Delete household failed", `Delete household failed: ${message}`); } }; const handleLeaveHousehold = async () => { if (!activeHousehold?.id) return; try { const householdName = activeHousehold.name; await removeMember(activeHousehold.id, parseInt(userId, 10)); setIsLeaveModalOpen(false); await refreshHouseholds(); toast.success("Left household", `Left household ${householdName}`); } catch (error) { const message = getApiErrorMessage(error, "Failed to leave household"); toast.error("Leave household failed", `Leave household failed: ${message}`); } }; const managerCount = members.filter((member) => ["owner", "admin"].includes(member.role)).length; const memberCount = members.filter((member) => member.role === "member").length; return (

Household

Identity

Keep the household name crisp and easy to recognize across invites and shared lists.

{editingName ? (
setNewName(e.target.value)} placeholder="Household name" autoFocus />
) : (

{activeHousehold.name}

🏠 {members.length} people 🛡️ {managerCount} managers 🛒 {memberCount} shoppers
{isManager && ( )}
)}
{isManager && (

Entry Rules

Invite Links

Decide how new people can enter, review manual approvals, then create invite links for the flow you want.

{inviteError &&

{inviteError}

} ({ ...option, disabled: inviteLoading, }))} onChange={handleUpdateJoinPolicy} />
Pending approvals {pendingRequests.length}
{inviteLoading ? (

Loading invite settings...

) : pendingRequests.length === 0 ? (

No pending join requests right now.

) : (
{pendingRequests.map((request) => { const requesterLabel = getRequesterLabel(request); const isBusy = pendingDecisionId === request.id; return (

{requesterLabel}

🕒 Pending

@{request.username} • Requested {new Date(request.created_at).toLocaleString()}

); })}
)}
{inviteLoading ? (

Loading invite links...

) : inviteLinks.length === 0 ? (

No invite links yet.

) : (
{inviteLinks.map((link) => { const status = getLinkStatus(link); const isActive = status === "Active"; const statusMeta = STATUS_METADATA[status] || STATUS_METADATA.Active; return (

Invite ending in {String(link.token).slice(-4)}

{statusMeta.icon} {status}

Policy: {link.policy} • Expires {new Date(link.expires_at).toLocaleString()}

{isActive ? ( ) : ( )}
); })}
)}
)}

People

Members ({members.length})

Role badges and compact actions make it easier to see who runs the household and who just shops.

{loading ? (

Loading members...

) : (
{members.map((member) => { const roleMeta = ROLE_METADATA[member.role] || { icon: "👤", label: member.role }; const isSelf = member.id === parseInt(userId, 10); return (
{roleMeta.icon} {roleMeta.label} {isSelf && ✨ You}
{member.username} ID #{member.id}
{isManager && !isSelf && member.role !== "owner" && (
{isOwner && ( )}
)}
); })}
)}
{(isManager || isMemberOnly) && (

Final Actions

Danger Zone

{isMemberOnly ? "Leaving removes your access to this household." : "Deleting a household is permanent and will delete all lists, items, and history."}

{isMemberOnly ? ( ) : ( )}
)} setIsLeaveModalOpen(false)} onConfirm={handleLeaveHousehold} /> setPendingRoleChange(null)} onConfirm={handleConfirmRoleChange} />
); }