Compare commits

..

2 Commits

Author SHA1 Message Date
4c8c197e17 Merge pull request 'feature-custom-store-locations' (#4) from feature-custom-store-locations into main
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 37s
Build & Deploy Costco Grocery List / deploy (push) Successful in 6s
Build & Deploy Costco Grocery List / notify (push) Successful in 0s
Reviewed-on: #4
2026-05-31 00:35:29 -09:00
76817fb969 Merge pull request 'chore: harden reliability checks' (#2) from main-new into main
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 30s
Build & Deploy Costco Grocery List / deploy (push) Successful in 5s
Build & Deploy Costco Grocery List / notify (push) Successful in 0s
Reviewed-on: #2
2026-05-25 14:28:32 -09:00
16 changed files with 736 additions and 2139 deletions

View File

@ -162,8 +162,6 @@ For `app/api/**/[param]/route.ts`:
- Touch: long-press affordance for item-level actions when no visible button. - Touch: long-press affordance for item-level actions when no visible button.
- Mouse: hover affordance on interactive rows/cards. - Mouse: hover affordance on interactive rows/cards.
- Tap targets remain >= 40px on mobile. - Tap targets remain >= 40px on mobile.
- Avoid redundant nearby labels. If a tab, section title, count chip, row label, or control state already communicates the meaning, do not repeat it with an eyebrow label, explanatory zero-state sentence, or duplicate card label.
- Prefer compact label/value rows for dense settings controls instead of stacked labels with large vertical gaps.
- Modal overlays must close on outside click/tap. - Modal overlays must close on outside click/tap.
- For every frontend action that manipulates database state, show a toast/bubble notification with basic outcome details (action + target + success/failure). - For every frontend action that manipulates database state, show a toast/bubble notification with basic outcome details (action + target + success/failure).
- Frontend destructive actions should use the shared `ConfirmSlideModal` pattern instead of browser `confirm()` unless there is a documented exception. - Frontend destructive actions should use the shared `ConfirmSlideModal` pattern instead of browser `confirm()` unless there is a documented exception.

View File

@ -134,8 +134,6 @@ exports.getHouseholdStores = async (householdId) => {
sl.address, sl.address,
sl.is_default, sl.is_default,
sl.map_data, sl.map_data,
COALESCE(zone_counts.zone_count, 0)::int AS zone_count,
COALESCE(item_counts.item_count, 0)::int AS item_count,
sl.created_at, sl.created_at,
sl.updated_at, sl.updated_at,
CASE CASE
@ -144,19 +142,6 @@ exports.getHouseholdStores = async (householdId) => {
END AS display_name END AS display_name
FROM store_locations sl FROM store_locations sl
JOIN household_custom_stores hcs ON hcs.id = sl.household_store_id JOIN household_custom_stores hcs ON hcs.id = sl.household_store_id
LEFT JOIN LATERAL (
SELECT COUNT(*)::int AS zone_count
FROM store_location_zones slz
WHERE slz.household_id = sl.household_id
AND slz.store_location_id = sl.id
AND slz.is_active = TRUE
) zone_counts ON TRUE
LEFT JOIN LATERAL (
SELECT COUNT(*)::int AS item_count
FROM household_store_items hsi
WHERE hsi.household_id = sl.household_id
AND hsi.store_location_id = sl.id
) item_counts ON TRUE
WHERE sl.household_id = $1 WHERE sl.household_id = $1
ORDER BY sl.is_default DESC, hcs.name ASC, sl.name ASC`, ORDER BY sl.is_default DESC, hcs.name ASC, sl.name ASC`,
[householdId, DEFAULT_LOCATION_NAME] [householdId, DEFAULT_LOCATION_NAME]

View File

@ -1,48 +0,0 @@
jest.mock("../db/pool", () => ({
query: jest.fn(),
}));
const pool = require("../db/pool");
const Stores = require("../models/store.model");
describe("store.model", () => {
beforeEach(() => {
pool.query.mockReset();
});
test("lists household stores with location manager counts", async () => {
pool.query.mockResolvedValueOnce({
rows: [
{
location_id: 10,
id: 10,
household_id: 1,
household_store_id: 100,
name: "Costco",
location_name: "Default Location",
is_default: true,
zone_count: 2,
item_count: 5,
display_name: "Costco",
},
],
});
const result = await Stores.getHouseholdStores(1);
expect(result).toEqual([
expect.objectContaining({
id: 10,
display_name: "Costco",
zone_count: 2,
item_count: 5,
}),
]);
expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining("AS zone_count"),
[1, "Default Location"]
);
expect(pool.query.mock.calls[0][0]).toContain("FROM store_location_zones slz");
expect(pool.query.mock.calls[0][0]).toContain("FROM household_store_items hsi");
});
});

View File

@ -24,7 +24,6 @@ This directory contains practical project documentation. Root-level rules still
## Guides ## Guides
- `guides/api-documentation.md`: REST API reference. Verify against current code before changing APIs. - `guides/api-documentation.md`: REST API reference. Verify against current code before changing APIs.
- `guides/frontend-readme.md`: frontend development notes. - `guides/frontend-readme.md`: frontend development notes.
- `guides/management-modal-patterns.md`: reusable modal patterns for managing scoped item/list records.
- `guides/MOBILE_RESPONSIVE_AUDIT.md`: mobile design and audit checklist. - `guides/MOBILE_RESPONSIVE_AUDIT.md`: mobile design and audit checklist.
- `guides/setup-checklist.md`: older setup checklist; prefer `DEVELOPMENT.md` for current commands. - `guides/setup-checklist.md`: older setup checklist; prefer `DEVELOPMENT.md` for current commands.

View File

@ -1,47 +0,0 @@
# Management Modal Patterns
Use this guide for modals that manage scoped lists of app-owned records, such as store items, store zones, and store locations.
Current adopters:
- Store item catalog management.
- Store location management.
- Store location zone management.
## Purpose
- Management modals should keep users in the current workflow while they inspect, edit, add, or remove records for a scoped parent.
- The parent scope must be obvious in the title, for example `Costco Items`.
- Modals should avoid repeating table labels inside every row. Use row layout, grouping, and the edit surface for detail.
## Structure
- Header: title, one short description when the scope is not obvious, and a close button.
- Primary toolbar: search input plus the primary create action inline.
- Bulk toolbar: destructive or multi-select actions above the list, separate from search/create controls.
- List: compact rows with the record's primary identity and any essential visual affordance.
- Editor: clicking or tapping a row opens the edit/settings modal for that record.
- Confirmation: destructive actions must use `ConfirmSlideModal`, not browser dialogs.
## Row Behavior
- Normal mode: the entire row opens settings for that record.
- Delete mode: the row toggles selected/unselected state and does not open settings.
- Selection state must be visible on the row and must not rely only on color.
- Avoid per-row action buttons when the same action applies to every row.
## Bulk Delete Pattern
- Show a `Delete Items` button above the list for users with delete permission.
- Clicking `Delete Items` enters delete mode, clears any previous selection, and changes the button to `Confirm Delete (#)`.
- Show a `Cancel` button while delete mode is active.
- Disable confirm while zero items are selected.
- Clicking confirm opens `ConfirmSlideModal`; only the slide confirmation performs the mutation.
- On success, exit delete mode, clear selection, refresh the list, and show a toast.
- On failure, keep the modal open and show a toast with the API error summary.
## Permission Rules
- Keep authorization server-side. Client visibility only improves UX.
- Members can open item settings when the API allows them to manage item details.
- Delete controls should be shown only to owners/admins when deletion is admin-scoped.
## Accessibility
- Modal containers should use dialog semantics when practical.
- Rows that perform actions should be keyboard reachable.
- Delete-mode rows should expose selected state with `aria-pressed` or an equivalent state.
- Buttons must have stable labels that describe the action in the current mode.

View File

@ -71,7 +71,6 @@ export default function ManageHousehold() {
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
const [pendingRoleChange, setPendingRoleChange] = useState(null); const [pendingRoleChange, setPendingRoleChange] = useState(null);
const [pendingMemberRemoval, setPendingMemberRemoval] = useState(null); const [pendingMemberRemoval, setPendingMemberRemoval] = useState(null);
const [selectedMember, setSelectedMember] = useState(null);
const isManager = ["owner", "admin"].includes(activeHousehold?.role); const isManager = ["owner", "admin"].includes(activeHousehold?.role);
const isOwner = activeHousehold?.role === "owner"; const isOwner = activeHousehold?.role === "owner";
@ -86,19 +85,6 @@ export default function ManageHousehold() {
} }
}, [activeHousehold?.id, isManager]); }, [activeHousehold?.id, isManager]);
useEffect(() => {
if (!selectedMember) return undefined;
const handleKeyDown = (event) => {
if (event.key === "Escape") {
setSelectedMember(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedMember]);
const loadMembers = async () => { const loadMembers = async () => {
if (!activeHousehold?.id) return; if (!activeHousehold?.id) return;
setLoading(true); setLoading(true);
@ -374,34 +360,13 @@ export default function ManageHousehold() {
const managerCount = members.filter((member) => ["owner", "admin"].includes(member.role)).length; const managerCount = members.filter((member) => ["owner", "admin"].includes(member.role)).length;
const memberCount = members.filter((member) => member.role === "member").length; const memberCount = members.filter((member) => member.role === "member").length;
const selectedRoleMeta = selectedMember
? ROLE_METADATA[selectedMember.role] || { icon: "👤", label: selectedMember.role }
: null;
const selectedMemberIsSelf = selectedMember?.id === parseInt(userId, 10);
const canManageSelectedMember =
Boolean(selectedMember) &&
isManager &&
!selectedMemberIsSelf &&
selectedMember.role !== "owner";
const selectedMemberNextRole = selectedMember?.role === "admin" ? "member" : "admin";
const openMemberRoleChange = (nextRole) => {
if (!selectedMember) return;
handleUpdateRole(selectedMember.id, nextRole, selectedMember.username);
setSelectedMember(null);
};
const openMemberRemoval = () => {
if (!selectedMember) return;
handleRemoveMember(selectedMember.id, selectedMember.username);
setSelectedMember(null);
};
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"> <div className="manage-section-header">
<div> <div>
<p className="manage-section-eyebrow">Household</p>
<h2>Identity</h2> <h2>Identity</h2>
</div> </div>
</div> </div>
@ -446,6 +411,7 @@ export default function ManageHousehold() {
<section key="join-and-invites" className="manage-section"> <section key="join-and-invites" className="manage-section">
<div className="manage-section-header"> <div className="manage-section-header">
<div> <div>
<p className="manage-section-eyebrow">Entry Rules</p>
<h2>Invite Links</h2> <h2>Invite Links</h2>
</div> </div>
</div> </div>
@ -469,7 +435,9 @@ export default function ManageHousehold() {
{inviteLoading ? ( {inviteLoading ? (
<p>Loading invite settings...</p> <p>Loading invite settings...</p>
) : pendingRequests.length > 0 ? ( ) : pendingRequests.length === 0 ? (
<p className="section-description">No pending join requests right now.</p>
) : (
<div className="pending-requests-list"> <div className="pending-requests-list">
{pendingRequests.map((request) => { {pendingRequests.map((request) => {
const requesterLabel = getRequesterLabel(request); const requesterLabel = getRequesterLabel(request);
@ -505,7 +473,7 @@ export default function ManageHousehold() {
); );
})} })}
</div> </div>
) : null} )}
<div className="invite-controls"> <div className="invite-controls">
<label> <label>
@ -579,6 +547,7 @@ export default function ManageHousehold() {
<section key="members" className="manage-section"> <section key="members" className="manage-section">
<div className="manage-section-header"> <div className="manage-section-header">
<div> <div>
<p className="manage-section-eyebrow">People</p>
<h2>Members ({members.length})</h2> <h2>Members ({members.length})</h2>
</div> </div>
</div> </div>
@ -591,13 +560,7 @@ export default function ManageHousehold() {
const isSelf = member.id === parseInt(userId, 10); const isSelf = member.id === parseInt(userId, 10);
return ( return (
<button <div key={member.id} className="member-card">
key={member.id}
type="button"
className="member-card member-card-button"
onClick={() => setSelectedMember(member)}
aria-label={`Open member actions for ${member.username}`}
>
<div className="member-main"> <div className="member-main">
<div className="member-info"> <div className="member-info">
<span className={`member-role member-role-${member.role}`}> <span className={`member-role member-role-${member.role}`}>
@ -607,76 +570,46 @@ export default function ManageHousehold() {
{isSelf && <span className="member-self-pill">You</span>} {isSelf && <span className="member-self-pill">You</span>}
</div> </div>
</div> </div>
{isManager && !isSelf && member.role !== "owner" && (
<div className="member-actions">
{isOwner && (
<button
onClick={() => handleUpdateRole(member.id, "owner", member.username)}
className="btn-primary btn-small member-owner-action"
>
Make Owner
</button> </button>
)}
<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> </div>
)} )}
</section> </section>
{selectedMember && (
<div className="member-actions-modal-overlay" onClick={() => setSelectedMember(null)}>
<div
className="member-actions-modal"
role="dialog"
aria-modal="true"
aria-labelledby="member-actions-title"
onClick={(event) => event.stopPropagation()}
>
<div className="member-actions-modal-header">
<div className="member-actions-modal-copy">
<h3 id="member-actions-title">{selectedMember.username}</h3>
<span className={`member-role member-role-${selectedMember.role}`}>
{selectedRoleMeta.icon} {selectedRoleMeta.label}
</span>
</div>
<button
type="button"
className="member-actions-modal-close"
onClick={() => setSelectedMember(null)}
aria-label="Close member actions"
>
&times;
</button>
</div>
{canManageSelectedMember ? (
<div className="member-actions-modal-actions">
{isOwner && (
<button
type="button"
onClick={() => openMemberRoleChange("owner")}
className="btn-primary member-owner-action"
>
Make Owner
</button>
)}
<button
type="button"
onClick={() => openMemberRoleChange(selectedMemberNextRole)}
className="btn-secondary member-role-action"
>
{selectedMember.role === "admin" ? "Make Member" : "Make Admin"}
</button>
<button
type="button"
onClick={openMemberRemoval}
className="btn-danger"
>
Remove
</button>
</div>
) : (
<p className="member-actions-modal-empty">No actions available for this member.</p>
)}
</div>
</div>
)}
{(isManager || isMemberOnly) && ( {(isManager || isMemberOnly) && (
<section key="danger-zone" className="manage-section danger-zone"> <section key="danger-zone" className="manage-section danger-zone">
<div className="manage-section-header"> <div className="manage-section-header">
<div> <div>
<p className="manage-section-eyebrow">Final Actions</p>
<h2>Danger Zone</h2> <h2>Danger Zone</h2>
</div> </div>
{isMemberOnly ? ( {isMemberOnly ? (

View File

@ -1,14 +1,21 @@
import { useContext, useMemo, useState } from "react"; import { useContext, useEffect, useMemo, useState } from "react";
import { createHouseholdStore } from "../../api/stores"; import {
addLocationToStore,
createHouseholdStore,
createLocationZone,
deleteLocationZone,
getLocationZones,
removeLocation,
setDefaultLocation,
updateLocationZone,
} from "../../api/stores";
import StoreAvailableItemsManager from "./StoreAvailableItemsManager";
import { HouseholdContext } from "../../context/HouseholdContext"; import { HouseholdContext } from "../../context/HouseholdContext";
import { StoreContext } from "../../context/StoreContext"; import { StoreContext } from "../../context/StoreContext";
import useActionToast from "../../hooks/useActionToast"; import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage"; import getApiErrorMessage from "../../lib/getApiErrorMessage";
import StoreAvailableItemsManager from "./StoreAvailableItemsManager";
import StoreLocationManager from "./StoreLocationManager";
import StoreZoneManager from "./StoreZoneManager";
import "../../styles/components/manage/StoreAvailableItemsManager.css";
import "../../styles/components/manage/ManageStores.css"; import "../../styles/components/manage/ManageStores.css";
import "../../styles/components/manage/StoreAvailableItemsManager.css";
function groupLocationsByStore(locations) { function groupLocationsByStore(locations) {
const grouped = new Map(); const grouped = new Map();
@ -29,8 +36,163 @@ function groupLocationsByStore(locations) {
return Array.from(grouped.values()).sort((a, b) => a.name.localeCompare(b.name)); return Array.from(grouped.values()).sort((a, b) => a.name.localeCompare(b.name));
} }
function locationLabel(location) { function ZoneManager({ householdId, location, canManage, refreshActiveZones }) {
return location.display_name || location.name; const toast = useActionToast();
const [isOpen, setIsOpen] = useState(false);
const [zones, setZones] = useState([]);
const [loading, setLoading] = useState(false);
const [newZoneName, setNewZoneName] = useState("");
const loadZones = async () => {
if (!householdId || !location?.id) return;
setLoading(true);
try {
const response = await getLocationZones(householdId, location.id);
setZones(response.data?.zones || []);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to load zones");
toast.error("Load zones failed", `Load zones failed: ${message}`);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (isOpen) {
loadZones();
}
}, [isOpen, householdId, location?.id]);
const handleCreateZone = async () => {
const name = newZoneName.trim();
if (!name) return;
try {
const nextSortOrder =
zones.length > 0 ? Math.max(...zones.map((zone) => zone.sort_order || 0)) + 10 : 10;
await createLocationZone(householdId, location.id, {
name,
sort_order: nextSortOrder,
});
setNewZoneName("");
await loadZones();
await refreshActiveZones();
toast.success("Added zone", `Added zone ${name}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to add zone");
toast.error("Add zone failed", `Add zone failed: ${message}`);
}
};
const handleMoveZone = async (zone, direction) => {
const currentIndex = zones.findIndex((candidate) => candidate.id === zone.id);
const swapIndex = currentIndex + direction;
if (currentIndex < 0 || swapIndex < 0 || swapIndex >= zones.length) return;
const other = zones[swapIndex];
try {
await Promise.all([
updateLocationZone(householdId, location.id, zone.id, {
sort_order: other.sort_order,
}),
updateLocationZone(householdId, location.id, other.id, {
sort_order: zone.sort_order,
}),
]);
await loadZones();
await refreshActiveZones();
} catch (error) {
const message = getApiErrorMessage(error, "Failed to reorder zones");
toast.error("Reorder zones failed", `Reorder zones failed: ${message}`);
}
};
const handleDeleteZone = async (zone) => {
if (!confirm(`Remove zone "${zone.name}" from ${location.display_name || location.name}?`)) {
return;
}
try {
await deleteLocationZone(householdId, location.id, zone.id);
await loadZones();
await refreshActiveZones();
toast.success("Removed zone", `Removed zone ${zone.name}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to remove zone");
toast.error("Remove zone failed", `Remove zone failed: ${message}`);
}
};
return (
<div className="store-zones-panel">
<button
type="button"
className="btn-secondary btn-small"
onClick={() => setIsOpen((current) => !current)}
>
{isOpen ? "Hide Zones" : "Manage Zones"}
</button>
{isOpen ? (
<div className="store-zones-content">
{canManage ? (
<div className="store-zone-create-row">
<input
value={newZoneName}
onChange={(event) => setNewZoneName(event.target.value)}
placeholder="New zone name"
/>
<button type="button" className="btn-primary btn-small" onClick={handleCreateZone}>
Add Zone
</button>
</div>
) : null}
{loading ? (
<p className="empty-message">Loading zones...</p>
) : zones.length === 0 ? (
<p className="empty-message">No zones for this location.</p>
) : (
<div className="store-zone-list">
{zones.map((zone, index) => (
<div key={zone.id} className="store-zone-row">
<span className="store-zone-order">{index + 1}</span>
<span className="store-zone-name">{zone.name}</span>
{canManage ? (
<div className="store-zone-actions">
<button
type="button"
className="btn-secondary btn-small"
disabled={index === 0}
onClick={() => handleMoveZone(zone, -1)}
>
Up
</button>
<button
type="button"
className="btn-secondary btn-small"
disabled={index === zones.length - 1}
onClick={() => handleMoveZone(zone, 1)}
>
Down
</button>
<button
type="button"
className="btn-danger btn-small"
onClick={() => handleDeleteZone(zone)}
>
Remove
</button>
</div>
) : null}
</div>
))}
</div>
)}
</div>
) : null}
</div>
);
} }
export default function ManageStores() { export default function ManageStores() {
@ -42,7 +204,12 @@ export default function ManageStores() {
refreshZones, refreshZones,
} = useContext(StoreContext); } = useContext(StoreContext);
const toast = useActionToast(); const toast = useActionToast();
const [newStoreName, setNewStoreName] = useState(""); const [createForm, setCreateForm] = useState({
name: "",
location_name: "",
address: "",
});
const [locationDrafts, setLocationDrafts] = useState({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const isAdmin = ["owner", "admin"].includes(activeHousehold?.role); const isAdmin = ["owner", "admin"].includes(activeHousehold?.role);
@ -58,17 +225,18 @@ export default function ManageStores() {
const handleCreateStore = async (event) => { const handleCreateStore = async (event) => {
event.preventDefault(); event.preventDefault();
const storeName = newStoreName.trim(); if (!createForm.name.trim()) return;
if (!storeName) return;
setSaving(true); setSaving(true);
try { try {
await createHouseholdStore(activeHousehold.id, { await createHouseholdStore(activeHousehold.id, {
name: storeName, name: createForm.name.trim(),
location_name: createForm.location_name.trim() || "Default Location",
address: createForm.address.trim() || null,
}); });
setNewStoreName(""); setCreateForm({ name: "", location_name: "", address: "" });
await refreshAfterStoreChange(); await refreshAfterStoreChange();
toast.success("Created store", `Created store ${storeName}`); toast.success("Created store", `Created store ${createForm.name.trim()}`);
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, "Failed to create store"); const message = getApiErrorMessage(error, "Failed to create store");
toast.error("Create store failed", `Create store failed: ${message}`); toast.error("Create store failed", `Create store failed: ${message}`);
@ -77,29 +245,60 @@ export default function ManageStores() {
} }
}; };
const handleAddLocation = async (householdStoreId, storeName) => {
const draft = locationDrafts[householdStoreId] || {};
const name = String(draft.name || "").trim();
if (!name) return;
try {
await addLocationToStore(activeHousehold.id, householdStoreId, {
name,
address: String(draft.address || "").trim() || null,
});
setLocationDrafts((current) => ({
...current,
[householdStoreId]: { name: "", address: "" },
}));
await refreshAfterStoreChange();
toast.success("Added location", `Added ${name} to ${storeName}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to add location");
toast.error("Add location failed", `Add location failed: ${message}`);
}
};
const handleSetDefault = async (location) => {
try {
await setDefaultLocation(activeHousehold.id, location.id);
await refreshAfterStoreChange();
toast.success("Updated default location", `Default location set to ${location.display_name || location.name}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to set default location");
toast.error("Set default failed", `Set default failed: ${message}`);
}
};
const handleRemoveLocation = async (location) => {
const label = location.display_name || location.name;
if (!confirm(`Remove ${label} from this household?`)) return;
try {
await removeLocation(activeHousehold.id, location.id);
await refreshAfterStoreChange();
toast.success("Removed location", `Removed ${label}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to remove location");
toast.error("Remove location failed", `Remove location failed: ${message}`);
}
};
return ( return (
<div className="manage-stores"> <div className="manage-stores">
<section className="manage-section"> <section className="manage-section">
<div className="manage-stores-topline">
<h2>Store Locations ({householdStores.length})</h2> <h2>Store Locations ({householdStores.length})</h2>
{isAdmin ? (
<form className="add-store-inline" onSubmit={handleCreateStore}>
<input
value={newStoreName}
onChange={(event) => setNewStoreName(event.target.value)}
placeholder="Store name"
aria-label="Store name"
required
/>
<button type="submit" className="btn-primary" disabled={saving}>
{saving ? "Adding..." : "Add"}
</button>
</form>
) : null}
</div>
<p className="manage-stores-help"> <p className="manage-stores-help">
Stores are private to this household. Locations define map-specific zones, item Stores and locations are private to this household. Each location has its own zones,
placement, and shopping order. item defaults, and shopping order.
</p> </p>
{householdStores.length === 0 ? ( {householdStores.length === 0 ? (
<p className="empty-message">No store locations added yet.</p> <p className="empty-message">No store locations added yet.</p>
@ -107,64 +306,133 @@ export default function ManageStores() {
<div className="stores-list"> <div className="stores-list">
{groupedStores.map((storeGroup) => ( {groupedStores.map((storeGroup) => (
<div key={storeGroup.household_store_id} className="store-card"> <div key={storeGroup.household_store_id} className="store-card">
<div className="store-card-header">
<div className="store-info"> <div className="store-info">
<h3>{storeGroup.name}</h3> <h3>{storeGroup.name}</h3>
</div> </div>
<StoreLocationManager
householdId={activeHousehold.id}
storeGroup={storeGroup}
allLocationCount={householdStores.length}
canManage={isAdmin}
refreshAfterStoreChange={refreshAfterStoreChange}
/>
</div>
<div className="store-location-list"> <div className="store-location-list">
{storeGroup.locations.map((location) => { {storeGroup.locations.map((location) => (
const label = locationLabel(location);
const showLocationName =
storeGroup.locations.length > 1 || label !== storeGroup.name;
return (
<div key={location.id} className="store-location-row"> <div key={location.id} className="store-location-row">
<div className="store-info store-location-copy"> <div className="store-info">
{showLocationName ? <strong>{label}</strong> : null} <strong>{location.display_name || location.name}</strong>
{location.address ? ( {location.address ? (
<p className="store-location">{location.address}</p> <p className="store-location">{location.address}</p>
) : null} ) : null}
{location.is_default ? (
<p className="store-location">Default shopping location</p>
) : null}
</div> </div>
<div className="store-location-controls"> <div className="store-actions">
<StoreZoneManager {isAdmin && !location.is_default ? (
<button
type="button"
onClick={() => handleSetDefault(location)}
className="btn-secondary btn-small"
>
Set Default
</button>
) : null}
{isAdmin ? (
<button
type="button"
onClick={() => handleRemoveLocation(location)}
className="btn-danger btn-small"
disabled={householdStores.length === 1}
title={householdStores.length === 1 ? "Cannot remove last location" : ""}
>
Remove
</button>
) : null}
</div>
<ZoneManager
householdId={activeHousehold.id} householdId={activeHousehold.id}
location={location} location={location}
canManage={isAdmin} canManage={isAdmin}
refreshActiveZones={refreshZones} refreshActiveZones={refreshZones}
refreshStoreCounts={refreshStores}
zoneCount={Number(location.zone_count ?? location.zoneCount ?? 0)}
/> />
<StoreAvailableItemsManager <StoreAvailableItemsManager
householdId={activeHousehold.id} householdId={activeHousehold.id}
store={location} store={location}
isAdmin={isAdmin} isAdmin={isAdmin}
refreshStoreCounts={refreshStores}
itemCount={Number(location.item_count ?? location.itemCount ?? 0)}
/> />
</div> </div>
))}
</div> </div>
);
})} {isAdmin ? (
<div className="add-location-panel">
<input
value={locationDrafts[storeGroup.household_store_id]?.name || ""}
onChange={(event) =>
setLocationDrafts((current) => ({
...current,
[storeGroup.household_store_id]: {
...(current[storeGroup.household_store_id] || {}),
name: event.target.value,
},
}))
}
placeholder="Location name"
/>
<input
value={locationDrafts[storeGroup.household_store_id]?.address || ""}
onChange={(event) =>
setLocationDrafts((current) => ({
...current,
[storeGroup.household_store_id]: {
...(current[storeGroup.household_store_id] || {}),
address: event.target.value,
},
}))
}
placeholder="Address or notes"
/>
<button
type="button"
className="btn-primary btn-small"
onClick={() => handleAddLocation(storeGroup.household_store_id, storeGroup.name)}
>
Add Location
</button>
</div> </div>
) : null}
</div> </div>
))} ))}
</div> </div>
)} )}
</section> </section>
{!isAdmin && activeStore ? ( {isAdmin ? (
<section className="manage-section">
<h2>Add Store</h2>
<form className="add-store-panel" onSubmit={handleCreateStore}>
<input
value={createForm.name}
onChange={(event) => setCreateForm((current) => ({ ...current, name: event.target.value }))}
placeholder="Store name, e.g. Costco"
required
/>
<input
value={createForm.location_name}
onChange={(event) =>
setCreateForm((current) => ({ ...current, location_name: event.target.value }))
}
placeholder="Location name, e.g. Fontana"
/>
<input
value={createForm.address}
onChange={(event) => setCreateForm((current) => ({ ...current, address: event.target.value }))}
placeholder="Address or notes"
/>
<button type="submit" className="btn-primary" disabled={saving}>
{saving ? "Adding..." : "+ Add Store"}
</button>
</form>
</section>
) : activeStore ? (
<p className="manage-stores-note"> <p className="manage-stores-note">
Household members can manage item defaults. Only owners and admins can manage stores, Household members can manage item defaults. Only owners and admins can manage stores,
locations, zones, and item deletion. locations, zones, and item deletion.

View File

@ -20,17 +20,10 @@ function itemImageSource(item) {
return `data:${mimeType};base64,${item.item_image}`; return `data:${mimeType};base64,${item.item_image}`;
} }
export default function StoreAvailableItemsManager({ export default function StoreAvailableItemsManager({ householdId, store, isAdmin }) {
householdId,
store,
isAdmin,
refreshStoreCounts,
itemCount = 0,
}) {
const toast = useActionToast(); const toast = useActionToast();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [displayItemCount, setDisplayItemCount] = useState(itemCount);
const [zones, setZones] = useState([]); const [zones, setZones] = useState([]);
const [catalogReady, setCatalogReady] = useState(true); const [catalogReady, setCatalogReady] = useState(true);
const [catalogMessage, setCatalogMessage] = useState(""); const [catalogMessage, setCatalogMessage] = useState("");
@ -38,12 +31,7 @@ export default function StoreAvailableItemsManager({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [editorItem, setEditorItem] = useState(null); const [editorItem, setEditorItem] = useState(null);
const [showEditor, setShowEditor] = useState(false); const [showEditor, setShowEditor] = useState(false);
const [deleteMode, setDeleteMode] = useState(false); const [pendingDeleteItem, setPendingDeleteItem] = useState(null);
const [selectedDeleteIds, setSelectedDeleteIds] = useState(() => new Set());
const [pendingDeleteItems, setPendingDeleteItems] = useState([]);
const selectedDeleteItems = items.filter((item) => selectedDeleteIds.has(item.item_id));
const selectedDeleteCount = selectedDeleteItems.length;
const loadItems = useCallback(async (search = query) => { const loadItems = useCallback(async (search = query) => {
if (!householdId || !store?.id) { if (!householdId || !store?.id) {
@ -92,15 +80,9 @@ export default function StoreAvailableItemsManager({
loadZones(); loadZones();
}, [isOpen, query, loadItems, loadZones]); }, [isOpen, query, loadItems, loadZones]);
useEffect(() => {
setDisplayItemCount(itemCount);
}, [itemCount]);
const closeManager = () => { const closeManager = () => {
setIsOpen(false); setIsOpen(false);
setDeleteMode(false); setPendingDeleteItem(null);
setSelectedDeleteIds(new Set());
setPendingDeleteItems([]);
}; };
const handleUpdate = async (payload) => { const handleUpdate = async (payload) => {
@ -126,7 +108,6 @@ export default function StoreAvailableItemsManager({
setShowEditor(false); setShowEditor(false);
setEditorItem(null); setEditorItem(null);
await loadItems(query); await loadItems(query);
await refreshStoreCounts?.();
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, "Failed to update store item"); const message = getApiErrorMessage(error, "Failed to update store item");
toast.error("Update store item failed", `Update store item failed: ${message}`); toast.error("Update store item failed", `Update store item failed: ${message}`);
@ -134,65 +115,19 @@ export default function StoreAvailableItemsManager({
} }
}; };
const openEditor = (item) => {
setEditorItem(item);
setShowEditor(true);
};
const toggleDeleteSelection = (itemId) => {
setSelectedDeleteIds((currentIds) => {
const nextIds = new Set(currentIds);
if (nextIds.has(itemId)) {
nextIds.delete(itemId);
} else {
nextIds.add(itemId);
}
return nextIds;
});
};
const startDeleteMode = () => {
setDeleteMode(true);
setSelectedDeleteIds(new Set());
};
const cancelDeleteMode = () => {
setDeleteMode(false);
setSelectedDeleteIds(new Set());
};
const confirmSelectedDelete = () => {
if (selectedDeleteCount === 0) {
return;
}
setPendingDeleteItems(selectedDeleteItems);
};
const handleDeleteConfirm = async () => { const handleDeleteConfirm = async () => {
if (pendingDeleteItems.length === 0) { if (!pendingDeleteItem) {
return; return;
} }
try { try {
await Promise.all( await deleteAvailableItem(householdId, store.id, pendingDeleteItem.item_id);
pendingDeleteItems.map((item) => deleteAvailableItem(householdId, store.id, item.item_id)) toast.success("Deleted store item", `Deleted ${pendingDeleteItem.item_name} from ${store.display_name || store.name}`);
); setPendingDeleteItem(null);
const count = pendingDeleteItems.length;
toast.success(
count === 1 ? "Deleted store item" : "Deleted store items",
`Deleted ${count} ${count === 1 ? "item" : "items"} from ${store.display_name || store.name}`
);
setPendingDeleteItems([]);
setDeleteMode(false);
setSelectedDeleteIds(new Set());
await loadItems(query); await loadItems(query);
await refreshStoreCounts?.();
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, "Failed to delete store item"); const message = getApiErrorMessage(error, "Failed to delete store item");
toast.error("Delete store items failed", `Delete store items failed: ${message}`); toast.error("Delete store item failed", `Delete store item failed: ${message}`);
} }
}; };
@ -203,7 +138,7 @@ export default function StoreAvailableItemsManager({
className="btn-secondary btn-small store-available-items-trigger" className="btn-secondary btn-small store-available-items-trigger"
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
> >
Manage Items ({displayItemCount}) Manage Items
</button> </button>
{isOpen ? ( {isOpen ? (
@ -251,28 +186,6 @@ export default function StoreAvailableItemsManager({
</button> </button>
</div> </div>
{isAdmin && catalogReady && items.length > 0 ? (
<div className="store-items-bulk-toolbar">
<button
type="button"
className="btn-danger btn-small store-items-delete-toggle"
disabled={deleteMode ? selectedDeleteCount === 0 : loading}
onClick={deleteMode ? confirmSelectedDelete : startDeleteMode}
>
{deleteMode ? `Confirm Delete (${selectedDeleteCount})` : "Delete Items"}
</button>
{deleteMode ? (
<button
type="button"
className="btn-secondary btn-small store-items-delete-cancel"
onClick={cancelDeleteMode}
>
Cancel
</button>
) : null}
</div>
) : null}
<div className="store-items-modal-body"> <div className="store-items-modal-body">
{!catalogReady ? ( {!catalogReady ? (
<p className="empty-message">Run the latest database migrations to enable store item management.</p> <p className="empty-message">Run the latest database migrations to enable store item management.</p>
@ -282,40 +195,26 @@ export default function StoreAvailableItemsManager({
<p className="empty-message">No household items found for this store yet.</p> <p className="empty-message">No household items found for this store yet.</p>
) : ( ) : (
<div className="store-items-table"> <div className="store-items-table">
<div className="store-items-table-head" aria-hidden="true">
<span>Item</span>
<span>Store Defaults</span>
<span>Actions</span>
</div>
<div className="store-items-table-body"> <div className="store-items-table-body">
{items.map((item) => { {items.map((item) => {
const imageSrc = itemImageSource(item); const imageSrc = itemImageSource(item);
const isSelectedForDelete = selectedDeleteIds.has(item.item_id); const details = [item.item_type, item.item_group, item.zone].filter(Boolean);
return ( return (
<button <div key={item.item_id} className="store-items-table-row">
key={item.item_id}
type="button"
className={`store-items-table-row store-items-table-row-button ${deleteMode ? "is-delete-selectable" : ""} ${isSelectedForDelete ? "is-selected" : ""}`}
aria-label={
deleteMode
? `${isSelectedForDelete ? "Deselect" : "Select"} ${item.item_name} for deletion`
: `Edit settings for ${item.item_name}`
}
aria-pressed={deleteMode ? isSelectedForDelete : undefined}
onClick={() => {
if (deleteMode) {
toggleDeleteSelection(item.item_id);
} else {
openEditor(item);
}
}}
>
<div className="store-items-table-cell store-items-table-item"> <div className="store-items-table-cell store-items-table-item">
<span className="store-items-mobile-label">Item</span>
<div className="store-available-items-summary"> <div className="store-available-items-summary">
{imageSrc ? ( {imageSrc ? (
<img src={imageSrc} alt="" className="store-available-items-thumb" /> <img src={imageSrc} alt="" className="store-available-items-thumb" />
) : ( ) : (
<span <span className="store-available-items-thumb store-available-items-thumb-placeholder">
className="store-available-items-thumb store-available-items-thumb-placeholder" {item.item_name?.slice(0, 1).toUpperCase() || "?"}
aria-hidden="true"
>
{"\uD83D\uDCE6"}
</span> </span>
)} )}
<div className="store-available-items-copy"> <div className="store-available-items-copy">
@ -324,12 +223,38 @@ export default function StoreAvailableItemsManager({
</div> </div>
</div> </div>
{deleteMode ? ( <div className="store-items-table-cell">
<span className="store-items-delete-indicator" aria-hidden="true"> <span className="store-items-mobile-label">Store Defaults</span>
{isSelectedForDelete ? "✓" : ""} <span className="store-items-defaults-text">
{details.join(" | ") || "No store defaults set"}
</span> </span>
) : null} </div>
<div className="store-items-table-cell store-items-table-actions">
<span className="store-items-mobile-label">Actions</span>
<div className="store-available-items-actions">
<button
type="button"
className="btn-secondary btn-small"
onClick={() => {
setEditorItem(item);
setShowEditor(true);
}}
>
Edit Settings
</button> </button>
{isAdmin ? (
<button
type="button"
className="btn-danger btn-small"
onClick={() => setPendingDeleteItem(item)}
>
Delete Item
</button>
) : null}
</div>
</div>
</div>
); );
})} })}
</div> </div>
@ -352,19 +277,15 @@ export default function StoreAvailableItemsManager({
/> />
<ConfirmSlideModal <ConfirmSlideModal
isOpen={pendingDeleteItems.length > 0} isOpen={Boolean(pendingDeleteItem)}
title={ title={pendingDeleteItem ? `Delete ${pendingDeleteItem.item_name}?` : "Delete item?"}
pendingDeleteItems.length === 1
? `Delete ${pendingDeleteItems[0].item_name}?`
: `Delete ${pendingDeleteItems.length} items?`
}
description={ description={
pendingDeleteItems.length > 0 pendingDeleteItem
? `Slide to confirm. This permanently deletes ${pendingDeleteItems.length === 1 ? pendingDeleteItems[0].item_name : `${pendingDeleteItems.length} items`} from ${store.display_name || store.name} for this household, including current list entries and history.` ? `Slide to confirm. This permanently deletes ${pendingDeleteItem.item_name} from ${store.display_name || store.name} for this household, including current list entries and history.`
: "" : ""
} }
confirmLabel={pendingDeleteItems.length === 1 ? "Delete Item" : "Delete Items"} confirmLabel="Delete Item"
onClose={() => setPendingDeleteItems([])} onClose={() => setPendingDeleteItem(null)}
onConfirm={handleDeleteConfirm} onConfirm={handleDeleteConfirm}
/> />
</> </>

View File

@ -1,381 +0,0 @@
import { useState } from "react";
import {
addLocationToStore,
removeLocation,
setDefaultLocation,
updateLocation,
} from "../../api/stores";
import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage";
import ConfirmSlideModal from "../modals/ConfirmSlideModal";
function locationLabel(location) {
return location.display_name || location.name;
}
function locationEditName(location) {
return location.location_name || location.name || "";
}
function LocationSettingsModal({
location,
draft,
setDraft,
canManage,
onCancel,
onSave,
onSetDefault,
}) {
if (!location) return null;
return (
<div className="store-items-modal-overlay" onClick={onCancel}>
<div className="store-items-modal store-settings-modal" onClick={(event) => event.stopPropagation()}>
<div className="store-items-modal-header">
<div>
<h3>{locationLabel(location)} Settings</h3>
<p>Update this location name, notes, or default status.</p>
</div>
<button
type="button"
className="store-items-modal-close"
onClick={onCancel}
aria-label="Close location settings"
>
x
</button>
</div>
<div className="store-settings-form">
<label>
<span>Location name</span>
<input
value={draft.name}
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
disabled={!canManage}
/>
</label>
<label>
<span>Address or notes</span>
<input
value={draft.address}
onChange={(event) => setDraft((current) => ({ ...current, address: event.target.value }))}
disabled={!canManage}
/>
</label>
{canManage ? (
<div className="store-settings-actions">
{!location.is_default ? (
<button type="button" className="btn-secondary btn-small" onClick={onSetDefault}>
Set Default
</button>
) : null}
<button type="button" className="btn-primary btn-small" onClick={onSave}>
Save Changes
</button>
</div>
) : null}
</div>
</div>
</div>
);
}
export default function StoreLocationManager({
householdId,
storeGroup,
allLocationCount,
canManage,
refreshAfterStoreChange,
}) {
const toast = useActionToast();
const locationCount = storeGroup.locations.length;
const [isOpen, setIsOpen] = useState(false);
const [locationDraft, setLocationDraft] = useState({ name: "", address: "" });
const [deleteMode, setDeleteMode] = useState(false);
const [selectedDeleteIds, setSelectedDeleteIds] = useState(() => new Set());
const [pendingDeleteLocations, setPendingDeleteLocations] = useState([]);
const [editingLocation, setEditingLocation] = useState(null);
const [editingLocationDraft, setEditingLocationDraft] = useState({ name: "", address: "" });
const selectedDeleteLocations = storeGroup.locations.filter((location) =>
selectedDeleteIds.has(location.id)
);
const selectedDeleteCount = selectedDeleteLocations.length;
const canConfirmDelete = selectedDeleteCount > 0 && allLocationCount - selectedDeleteCount >= 1;
const closeManager = () => {
setIsOpen(false);
setDeleteMode(false);
setSelectedDeleteIds(new Set());
setPendingDeleteLocations([]);
setEditingLocation(null);
};
const handleAddLocation = async () => {
const name = locationDraft.name.trim();
if (!name) return;
try {
await addLocationToStore(householdId, storeGroup.household_store_id, {
name,
address: locationDraft.address.trim() || null,
});
setLocationDraft({ name: "", address: "" });
await refreshAfterStoreChange();
toast.success("Added location", `Added ${name} to ${storeGroup.name}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to add location");
toast.error("Add location failed", `Add location failed: ${message}`);
}
};
const openLocationSettings = (location) => {
setEditingLocation(location);
setEditingLocationDraft({
name: locationEditName(location),
address: location.address || "",
});
};
const handleSaveLocation = async () => {
if (!editingLocation) return;
const name = editingLocationDraft.name.trim();
if (!name) return;
try {
await updateLocation(householdId, editingLocation.id, {
name,
address: editingLocationDraft.address.trim() || null,
});
await refreshAfterStoreChange();
setEditingLocation(null);
toast.success("Updated location", `Updated ${name}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to update location");
toast.error("Update location failed", `Update location failed: ${message}`);
}
};
const handleSetDefault = async () => {
if (!editingLocation) return;
try {
await setDefaultLocation(householdId, editingLocation.id);
await refreshAfterStoreChange();
setEditingLocation(null);
toast.success("Updated default location", `Default location set to ${locationLabel(editingLocation)}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to set default location");
toast.error("Set default failed", `Set default failed: ${message}`);
}
};
const toggleLocationSelection = (locationId) => {
setSelectedDeleteIds((currentIds) => {
const nextIds = new Set(currentIds);
if (nextIds.has(locationId)) {
nextIds.delete(locationId);
} else {
nextIds.add(locationId);
}
return nextIds;
});
};
const startDeleteMode = () => {
setDeleteMode(true);
setSelectedDeleteIds(new Set());
};
const cancelDeleteMode = () => {
setDeleteMode(false);
setSelectedDeleteIds(new Set());
};
const confirmSelectedDelete = () => {
if (!canConfirmDelete) return;
setPendingDeleteLocations(selectedDeleteLocations);
};
const handleDeleteConfirm = async () => {
if (pendingDeleteLocations.length === 0) {
return;
}
try {
await Promise.all(
pendingDeleteLocations.map((location) => removeLocation(householdId, location.id))
);
const count = pendingDeleteLocations.length;
await refreshAfterStoreChange();
setPendingDeleteLocations([]);
setDeleteMode(false);
setSelectedDeleteIds(new Set());
toast.success(
count === 1 ? "Removed location" : "Removed locations",
`Removed ${count} ${count === 1 ? "location" : "locations"} from ${storeGroup.name}`
);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to remove locations");
toast.error("Remove locations failed", `Remove locations failed: ${message}`);
}
};
return (
<>
<button
type="button"
className="btn-secondary btn-small store-location-manager-trigger"
onClick={() => setIsOpen(true)}
>
Manage Locations ({locationCount})
</button>
{isOpen ? (
<div className="store-items-modal-overlay" onClick={closeManager}>
<div className="store-items-modal" onClick={(event) => event.stopPropagation()}>
<div className="store-items-modal-header">
<div>
<h3>{storeGroup.name} Locations</h3>
<p>Manage locations, defaults, and location notes for this store.</p>
</div>
<button
type="button"
className="store-items-modal-close"
onClick={closeManager}
aria-label="Close manage locations modal"
>
x
</button>
</div>
{canManage ? (
<div className="store-items-modal-toolbar store-management-create-row store-location-create-row">
<input
value={locationDraft.name}
onChange={(event) =>
setLocationDraft((current) => ({ ...current, name: event.target.value }))
}
placeholder="Location name"
/>
<input
value={locationDraft.address}
onChange={(event) =>
setLocationDraft((current) => ({ ...current, address: event.target.value }))
}
placeholder="Address or notes"
/>
<button type="button" className="btn-primary btn-small" onClick={handleAddLocation}>
Add Location
</button>
</div>
) : null}
{canManage && storeGroup.locations.length > 0 ? (
<div className="store-items-bulk-toolbar">
<button
type="button"
className="btn-danger btn-small store-items-delete-toggle"
disabled={deleteMode ? !canConfirmDelete : allLocationCount <= 1}
onClick={deleteMode ? confirmSelectedDelete : startDeleteMode}
title={
deleteMode && selectedDeleteCount > 0 && !canConfirmDelete
? "At least one household location must remain"
: ""
}
>
{deleteMode ? `Confirm Delete (${selectedDeleteCount})` : "Delete Locations"}
</button>
{deleteMode ? (
<button
type="button"
className="btn-secondary btn-small store-items-delete-cancel"
onClick={cancelDeleteMode}
>
Cancel
</button>
) : null}
</div>
) : null}
<div className="store-items-modal-body">
<div className="store-items-table">
<div className="store-items-table-body">
{storeGroup.locations.map((location) => {
const isSelectedForDelete = selectedDeleteIds.has(location.id);
return (
<button
key={location.id}
type="button"
className={`store-items-table-row store-items-table-row-button store-management-row ${deleteMode ? "is-delete-selectable" : ""} ${isSelectedForDelete ? "is-selected" : ""}`}
aria-label={
deleteMode
? `${isSelectedForDelete ? "Deselect" : "Select"} ${locationLabel(location)} for deletion`
: `Edit location ${locationLabel(location)}`
}
aria-pressed={deleteMode ? isSelectedForDelete : undefined}
onClick={() => {
if (deleteMode) {
toggleLocationSelection(location.id);
} else {
openLocationSettings(location);
}
}}
>
<span className="store-management-name">
{locationLabel(location)}
{location.is_default ? (
<span className="store-management-badge">Default</span>
) : null}
</span>
{location.address ? (
<span className="store-management-meta">{location.address}</span>
) : null}
{deleteMode ? (
<span className="store-items-delete-indicator" aria-hidden="true">
{isSelectedForDelete ? "\u2713" : ""}
</span>
) : null}
</button>
);
})}
</div>
</div>
</div>
</div>
</div>
) : null}
<LocationSettingsModal
location={editingLocation}
draft={editingLocationDraft}
setDraft={setEditingLocationDraft}
canManage={canManage}
onCancel={() => setEditingLocation(null)}
onSave={handleSaveLocation}
onSetDefault={handleSetDefault}
/>
<ConfirmSlideModal
isOpen={pendingDeleteLocations.length > 0}
title={
pendingDeleteLocations.length === 1
? `Delete ${locationLabel(pendingDeleteLocations[0])}?`
: `Delete ${pendingDeleteLocations.length} locations?`
}
description={
pendingDeleteLocations.length > 0
? `Slide to confirm. This removes ${pendingDeleteLocations.length === 1 ? locationLabel(pendingDeleteLocations[0]) : `${pendingDeleteLocations.length} locations`} from this household.`
: ""
}
confirmLabel={pendingDeleteLocations.length === 1 ? "Delete Location" : "Delete Locations"}
onClose={() => setPendingDeleteLocations([])}
onConfirm={handleDeleteConfirm}
/>
</>
);
}

View File

@ -1,506 +0,0 @@
import { useEffect, useRef, useState } from "react";
import {
createLocationZone,
deleteLocationZone,
getLocationZones,
updateLocationZone,
} from "../../api/stores";
import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage";
import ConfirmSlideModal from "../modals/ConfirmSlideModal";
function locationLabel(location) {
return location.display_name || location.name;
}
function ZoneSettingsModal({
zone,
draft,
setDraft,
canManage,
canMoveUp,
canMoveDown,
onCancel,
onSave,
onMove,
}) {
if (!zone) return null;
return (
<div className="store-items-modal-overlay" onClick={onCancel}>
<div className="store-items-modal store-settings-modal" onClick={(event) => event.stopPropagation()}>
<div className="store-items-modal-header">
<div>
<h3>{zone.name} Settings</h3>
<p>Update this zone name or adjust its shopping order.</p>
</div>
<button
type="button"
className="store-items-modal-close"
onClick={onCancel}
aria-label="Close zone settings"
>
x
</button>
</div>
<div className="store-settings-form">
<label>
<span>Zone name</span>
<input
value={draft.name}
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
disabled={!canManage}
/>
</label>
{canManage ? (
<div className="store-settings-actions">
<button
type="button"
className="btn-secondary btn-small"
disabled={!canMoveUp}
onClick={() => onMove(-1)}
>
Move Up
</button>
<button
type="button"
className="btn-secondary btn-small"
disabled={!canMoveDown}
onClick={() => onMove(1)}
>
Move Down
</button>
<button type="button" className="btn-primary btn-small" onClick={onSave}>
Save Changes
</button>
</div>
) : null}
</div>
</div>
</div>
);
}
export default function StoreZoneManager({
householdId,
location,
canManage,
refreshActiveZones,
refreshStoreCounts,
zoneCount = 0,
}) {
const toast = useActionToast();
const [isOpen, setIsOpen] = useState(false);
const [zones, setZones] = useState([]);
const [displayZoneCount, setDisplayZoneCount] = useState(zoneCount);
const [loading, setLoading] = useState(false);
const [newZoneName, setNewZoneName] = useState("");
const [deleteMode, setDeleteMode] = useState(false);
const [selectedDeleteIds, setSelectedDeleteIds] = useState(() => new Set());
const [pendingDeleteZones, setPendingDeleteZones] = useState([]);
const [editingZone, setEditingZone] = useState(null);
const [editingZoneDraft, setEditingZoneDraft] = useState({ name: "" });
const [draggedZoneId, setDraggedZoneId] = useState(null);
const [dragOverZoneId, setDragOverZoneId] = useState(null);
const [reordering, setReordering] = useState(false);
const draggedZoneIdRef = useRef(null);
const hasDraggedZoneRef = useRef(false);
const selectedDeleteZones = zones.filter((zone) => selectedDeleteIds.has(zone.id));
const selectedDeleteCount = selectedDeleteZones.length;
const canDragReorder = canManage && !deleteMode && !loading && !reordering;
const loadZones = async () => {
if (!householdId || !location?.id) return;
setLoading(true);
try {
const response = await getLocationZones(householdId, location.id);
const nextZones = response.data?.zones || [];
setZones(nextZones);
setDisplayZoneCount(nextZones.length);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to load zones");
toast.error("Load zones failed", `Load zones failed: ${message}`);
} finally {
setLoading(false);
}
};
const closeManager = () => {
setIsOpen(false);
setDeleteMode(false);
setSelectedDeleteIds(new Set());
setPendingDeleteZones([]);
setEditingZone(null);
setDraggedZoneId(null);
setDragOverZoneId(null);
draggedZoneIdRef.current = null;
hasDraggedZoneRef.current = false;
};
const openZoneSettings = (zone) => {
setEditingZone(zone);
setEditingZoneDraft({ name: zone.name || "" });
};
const toggleZoneSelection = (zoneId) => {
setSelectedDeleteIds((currentIds) => {
const nextIds = new Set(currentIds);
if (nextIds.has(zoneId)) {
nextIds.delete(zoneId);
} else {
nextIds.add(zoneId);
}
return nextIds;
});
};
const startDeleteMode = () => {
setDeleteMode(true);
setSelectedDeleteIds(new Set());
};
const cancelDeleteMode = () => {
setDeleteMode(false);
setSelectedDeleteIds(new Set());
};
const confirmSelectedDelete = () => {
if (selectedDeleteCount === 0) return;
setPendingDeleteZones(selectedDeleteZones);
};
useEffect(() => {
if (isOpen) {
loadZones();
}
}, [isOpen, householdId, location?.id]);
useEffect(() => {
setDisplayZoneCount(zoneCount);
}, [zoneCount]);
const handleCreateZone = async () => {
const name = newZoneName.trim();
if (!name) return;
try {
const nextSortOrder =
zones.length > 0 ? Math.max(...zones.map((zone) => zone.sort_order || 0)) + 10 : 10;
await createLocationZone(householdId, location.id, {
name,
sort_order: nextSortOrder,
});
setNewZoneName("");
await loadZones();
await refreshActiveZones();
await refreshStoreCounts?.();
toast.success("Added zone", `Added zone ${name}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to add zone");
toast.error("Add zone failed", `Add zone failed: ${message}`);
}
};
const persistZoneOrder = async (orderedZones) => {
const zonesWithSortOrder = orderedZones.map((zone, index) => ({
...zone,
sort_order: (index + 1) * 10,
}));
const changedZones = zonesWithSortOrder.filter((zone) => {
const currentZone = zones.find((candidate) => candidate.id === zone.id);
return currentZone && currentZone.sort_order !== zone.sort_order;
});
if (changedZones.length === 0) return true;
setReordering(true);
setZones(zonesWithSortOrder);
try {
await Promise.all(
changedZones.map((zone) =>
updateLocationZone(householdId, location.id, zone.id, {
sort_order: zone.sort_order,
})
)
);
await loadZones();
await refreshActiveZones();
toast.success("Reordered zones", "Updated shopping order");
return true;
} catch (error) {
const message = getApiErrorMessage(error, "Failed to reorder zones");
toast.error("Reorder zones failed", `Reorder zones failed: ${message}`);
await loadZones();
return false;
} finally {
setReordering(false);
}
};
const moveZone = async (fromIndex, toIndex) => {
if (fromIndex < 0 || toIndex < 0 || fromIndex >= zones.length || toIndex >= zones.length) {
return false;
}
const orderedZones = [...zones];
const [movedZone] = orderedZones.splice(fromIndex, 1);
orderedZones.splice(toIndex, 0, movedZone);
return persistZoneOrder(orderedZones);
};
const handleMoveZone = async (zone, direction) => {
const currentIndex = zones.findIndex((candidate) => candidate.id === zone.id);
const moved = await moveZone(currentIndex, currentIndex + direction);
if (moved) {
setEditingZone(null);
}
};
const handleZoneDragStart = (event, zoneId) => {
if (!canDragReorder) {
event.preventDefault();
return;
}
draggedZoneIdRef.current = zoneId;
hasDraggedZoneRef.current = true;
setDraggedZoneId(zoneId);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", String(zoneId));
};
const clearZoneDrag = () => {
draggedZoneIdRef.current = null;
setDraggedZoneId(null);
setDragOverZoneId(null);
window.setTimeout(() => {
hasDraggedZoneRef.current = false;
}, 0);
};
const handleZoneDrop = async (event, targetZoneId) => {
event.preventDefault();
const sourceZoneId = draggedZoneIdRef.current || event.dataTransfer.getData("text/plain");
clearZoneDrag();
if (!sourceZoneId || String(sourceZoneId) === String(targetZoneId)) return;
const sourceIndex = zones.findIndex((zone) => String(zone.id) === String(sourceZoneId));
const targetIndex = zones.findIndex((zone) => String(zone.id) === String(targetZoneId));
await moveZone(sourceIndex, targetIndex);
};
const handleSaveZone = async () => {
if (!editingZone) return;
const name = editingZoneDraft.name.trim();
if (!name) return;
try {
await updateLocationZone(householdId, location.id, editingZone.id, { name });
await loadZones();
await refreshActiveZones();
setEditingZone(null);
toast.success("Updated zone", `Updated zone ${name}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to update zone");
toast.error("Update zone failed", `Update zone failed: ${message}`);
}
};
const handleDeleteConfirm = async () => {
if (pendingDeleteZones.length === 0) {
return;
}
try {
await Promise.all(
pendingDeleteZones.map((zone) => deleteLocationZone(householdId, location.id, zone.id))
);
const count = pendingDeleteZones.length;
await loadZones();
await refreshActiveZones();
await refreshStoreCounts?.();
setPendingDeleteZones([]);
setDeleteMode(false);
setSelectedDeleteIds(new Set());
toast.success(count === 1 ? "Removed zone" : "Removed zones", `Removed ${count} ${count === 1 ? "zone" : "zones"}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to remove zones");
toast.error("Remove zones failed", `Remove zones failed: ${message}`);
}
};
const editingZoneIndex = editingZone
? zones.findIndex((zone) => zone.id === editingZone.id)
: -1;
return (
<>
<button
type="button"
className="btn-secondary btn-small store-zone-manager-trigger"
onClick={() => setIsOpen(true)}
>
Manage Zones ({displayZoneCount})
</button>
{isOpen ? (
<div className="store-items-modal-overlay" onClick={closeManager}>
<div className="store-items-modal" onClick={(event) => event.stopPropagation()}>
<div className="store-items-modal-header">
<div>
<h3>{locationLabel(location)} Zones</h3>
<p>Manage shopping order zones for this location.</p>
</div>
<button
type="button"
className="store-items-modal-close"
onClick={closeManager}
aria-label="Close manage zones modal"
>
x
</button>
</div>
{canManage ? (
<div className="store-items-modal-toolbar store-management-create-row">
<input
value={newZoneName}
onChange={(event) => setNewZoneName(event.target.value)}
placeholder="New zone name"
/>
<button type="button" className="btn-primary btn-small" onClick={handleCreateZone}>
Add Zone
</button>
</div>
) : null}
{canManage && zones.length > 0 ? (
<div className="store-items-bulk-toolbar">
<button
type="button"
className="btn-danger btn-small store-items-delete-toggle"
disabled={deleteMode ? selectedDeleteCount === 0 : loading}
onClick={deleteMode ? confirmSelectedDelete : startDeleteMode}
>
{deleteMode ? `Confirm Delete (${selectedDeleteCount})` : "Delete Zones"}
</button>
{deleteMode ? (
<button
type="button"
className="btn-secondary btn-small store-items-delete-cancel"
onClick={cancelDeleteMode}
>
Cancel
</button>
) : null}
</div>
) : null}
<div className="store-items-modal-body">
{loading ? (
<p className="empty-message">Loading zones...</p>
) : zones.length === 0 ? (
<p className="empty-message">No zones for this location.</p>
) : (
<div className="store-items-table">
<div className="store-items-table-body">
{zones.map((zone, index) => {
const isSelectedForDelete = selectedDeleteIds.has(zone.id);
return (
<button
key={zone.id}
type="button"
className={`store-items-table-row store-items-table-row-button store-management-row store-management-row-with-order ${canDragReorder ? "store-management-row-with-drag" : ""} ${draggedZoneId === zone.id ? "is-dragging" : ""} ${dragOverZoneId === zone.id ? "is-drag-target" : ""} ${deleteMode ? "is-delete-selectable" : ""} ${isSelectedForDelete ? "is-selected" : ""}`}
aria-label={
deleteMode
? `${isSelectedForDelete ? "Deselect" : "Select"} ${zone.name} for deletion`
: `Edit zone ${zone.name}`
}
aria-pressed={deleteMode ? isSelectedForDelete : undefined}
onDragOver={(event) => {
if (!canDragReorder || !draggedZoneIdRef.current) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
setDragOverZoneId(zone.id);
}}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setDragOverZoneId(null);
}
}}
onDrop={(event) => handleZoneDrop(event, zone.id)}
onClick={() => {
if (hasDraggedZoneRef.current) return;
if (deleteMode) {
toggleZoneSelection(zone.id);
} else {
openZoneSettings(zone);
}
}}
>
{canDragReorder ? (
<span
className="store-zone-drag-handle"
draggable
aria-hidden="true"
title="Drag to reorder"
onClick={(event) => event.stopPropagation()}
onDragStart={(event) => handleZoneDragStart(event, zone.id)}
onDragEnd={clearZoneDrag}
/>
) : null}
<span className="store-management-order">{index + 1}</span>
<span className="store-management-name">{zone.name}</span>
{deleteMode ? (
<span className="store-items-delete-indicator" aria-hidden="true">
{isSelectedForDelete ? "\u2713" : ""}
</span>
) : null}
</button>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
) : null}
<ZoneSettingsModal
zone={editingZone}
draft={editingZoneDraft}
setDraft={setEditingZoneDraft}
canManage={canManage}
canMoveUp={editingZoneIndex > 0}
canMoveDown={editingZoneIndex >= 0 && editingZoneIndex < zones.length - 1}
onCancel={() => setEditingZone(null)}
onSave={handleSaveZone}
onMove={(direction) => handleMoveZone(editingZone, direction)}
/>
<ConfirmSlideModal
isOpen={pendingDeleteZones.length > 0}
title={
pendingDeleteZones.length === 1
? `Delete ${pendingDeleteZones[0].name}?`
: `Delete ${pendingDeleteZones.length} zones?`
}
description={
pendingDeleteZones.length > 0
? `Slide to confirm. This removes ${pendingDeleteZones.length === 1 ? pendingDeleteZones[0].name : `${pendingDeleteZones.length} zones`} from ${locationLabel(location)}.`
: ""
}
confirmLabel={pendingDeleteZones.length === 1 ? "Delete Zone" : "Delete Zones"}
onClose={() => setPendingDeleteZones([])}
onConfirm={handleDeleteConfirm}
/>
</>
);
}

View File

@ -39,11 +39,20 @@ body.dark-mode .manage-section {
} }
.manage-section-header h2 { .manage-section-header h2 {
margin: 0; margin: 0.15rem 0 0;
font-size: 1.2rem; font-size: 1.2rem;
color: var(--text-primary); color: var(--text-primary);
} }
.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.92rem;
@ -122,7 +131,7 @@ body.dark-mode .edit-name-form input {
} }
.manage-household-join-policy-toggle { .manage-household-join-policy-toggle {
margin-bottom: 0; margin-bottom: 0.2rem;
} }
.pending-requests-summary { .pending-requests-summary {
@ -234,16 +243,15 @@ body.dark-mode .pending-request-card {
.invite-controls { .invite-controls {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: repeat(2, minmax(140px, 180px)) auto;
gap: 0.45rem; gap: 0.8rem;
align-items: stretch; align-items: end;
} }
.invite-controls label { .invite-controls label {
display: grid; display: flex;
grid-template-columns: 4.5rem minmax(0, 1fr); flex-direction: column;
gap: 0.75rem; gap: 0.35rem;
align-items: center;
color: var(--text-primary); color: var(--text-primary);
font-size: 0.9rem; font-size: 0.9rem;
} }
@ -257,19 +265,14 @@ body.dark-mode .pending-request-card {
} }
.invite-controls select { .invite-controls select {
width: 100%; min-width: 120px;
min-width: 0;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);
padding: 0.62rem 0.75rem; padding: 0.7rem 0.75rem;
background: rgba(255, 255, 255, 1); background: rgba(255, 255, 255, 1);
color: var(--text-primary); color: var(--text-primary);
} }
.invite-controls .btn-primary {
margin-top: 0.2rem;
}
[data-theme="dark"] .invite-controls select, [data-theme="dark"] .invite-controls select,
body.dark-mode .invite-controls select { body.dark-mode .invite-controls select {
background: rgba(12, 19, 30, 0.92); background: rgba(12, 19, 30, 0.92);
@ -374,30 +377,20 @@ body.dark-mode .invite-status-badge.is-used {
.members-list { .members-list {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(270px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
gap: 0.4rem; gap: 0.85rem;
} }
.member-card { .member-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.3rem; gap: 0.7rem;
width: 100%; padding: 0.85rem 1rem;
min-height: 44px;
padding: 0.55rem 0.8rem;
background: rgba(255, 255, 255, 1); background: rgba(255, 255, 255, 1);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--border-radius-md); border-radius: var(--border-radius-lg);
color: inherit;
font: inherit;
text-align: left;
transition: all 0.2s; transition: all 0.2s;
} }
.member-card-button {
appearance: none;
cursor: pointer;
}
[data-theme="dark"] .member-card, [data-theme="dark"] .member-card,
body.dark-mode .member-card { body.dark-mode .member-card {
background: rgba(12, 19, 30, 0.9); background: rgba(12, 19, 30, 0.9);
@ -415,11 +408,6 @@ body.dark-mode .member-card:hover {
background: rgba(20, 32, 48, 0.98); background: rgba(20, 32, 48, 0.98);
} }
.member-card-button:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 3px;
}
.member-main { .member-main {
min-width: 0; min-width: 0;
} }
@ -427,7 +415,7 @@ body.dark-mode .member-card:hover {
.member-info { .member-info {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.45rem;
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;
white-space: nowrap; white-space: nowrap;
@ -439,15 +427,15 @@ body.dark-mode .member-card:hover {
text-overflow: ellipsis; text-overflow: ellipsis;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
font-size: 0.95rem; font-size: 1rem;
} }
.member-role { .member-role {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.28rem; gap: 0.35rem;
flex: 0 0 auto; flex: 0 0 auto;
font-size: 0.82rem; font-size: 0.88rem;
text-transform: capitalize; text-transform: capitalize;
font-weight: 700; font-weight: 700;
} }
@ -478,6 +466,16 @@ body.dark-mode .member-card:hover {
font-weight: 700; font-weight: 700;
} }
.member-actions {
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
justify-content: flex-start;
align-items: center;
padding-top: 0.65rem;
border-top: 1px solid color-mix(in srgb, var(--color-border-light) 82%, transparent);
}
.member-owner-action { .member-owner-action {
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
} }
@ -508,6 +506,11 @@ body.dark-mode .member-role-action:hover:not(:disabled) {
color: #f3f9ff; 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: color-mix(in srgb, var(--danger) 30%, transparent);
@ -524,7 +527,8 @@ body.dark-mode .danger-zone {
border-color: color-mix(in srgb, var(--danger) 42%, transparent); border-color: color-mix(in srgb, var(--danger) 42%, transparent);
} }
.danger-zone h2 { .danger-zone h2,
.danger-zone .manage-section-eyebrow {
color: var(--danger); color: var(--danger);
} }
@ -555,88 +559,16 @@ body.dark-mode .danger-zone {
cursor: not-allowed; cursor: not-allowed;
} }
.member-actions-modal-overlay {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md);
background: var(--modal-backdrop-bg);
}
.member-actions-modal {
width: min(420px, calc(100vw - (2 * var(--spacing-md))));
max-height: calc(100vh - (2 * var(--spacing-md)));
overflow: auto;
padding: 1.2rem;
border: 1px solid var(--color-border-light);
border-radius: var(--border-radius-lg);
background: var(--modal-bg);
box-shadow: var(--shadow-xl);
}
.member-actions-modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.member-actions-modal-copy {
min-width: 0;
}
.member-actions-modal-copy h3 {
margin: 0.15rem 0 0.45rem;
color: var(--text-primary);
font-size: 1.25rem;
overflow-wrap: anywhere;
}
.member-actions-modal-close {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 40px;
height: 40px;
border: 1px solid var(--border);
border-radius: var(--border-radius-full);
background: var(--button-ghost-bg);
color: var(--text-primary);
cursor: pointer;
font-size: 1.3rem;
line-height: 1;
}
.member-actions-modal-close:hover,
.member-actions-modal-close:focus-visible {
border-color: var(--primary);
color: var(--primary);
outline: none;
}
.member-actions-modal-actions {
display: flex;
flex-direction: column;
gap: 0.65rem;
margin-top: 1.1rem;
}
.member-actions-modal-actions button {
width: 100%;
}
.member-actions-modal-empty {
margin: 1rem 0 0;
color: var(--text-secondary);
font-size: 0.92rem;
}
/* Responsive */ /* Responsive */
@media (max-width: 900px) { @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 { .invite-link-card {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@ -684,6 +616,12 @@ body.dark-mode .danger-zone {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.member-actions {
width: 100%;
justify-content: flex-start;
}
.member-actions button,
.pending-request-actions button { .pending-request-actions button {
flex: 1 1 100%; flex: 1 1 100%;
} }

View File

@ -22,14 +22,6 @@
font-size: 1.3rem; font-size: 1.3rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
margin: 0;
}
.manage-stores-topline {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(240px, 360px);
gap: 1rem;
align-items: center;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@ -58,11 +50,11 @@
background: var(--background); background: var(--background);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
padding: 1rem; padding: 1.25rem;
transition: all 0.2s; transition: all 0.2s;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 1rem;
} }
.store-card:hover { .store-card:hover {
@ -70,23 +62,16 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
} }
.store-card-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
}
.store-info h3 { .store-info h3 {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
margin: 0; margin: 0 0 0.5rem 0;
} }
.store-location { .store-location {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.85rem; font-size: 0.9rem;
margin: 0; margin: 0;
} }
@ -102,65 +87,44 @@
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.store-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.store-location-list { .store-location-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.65rem; gap: 1rem;
} }
.store-location-row { .store-location-row {
display: flex;
flex-direction: column;
gap: 0.55rem;
padding-top: 0.65rem;
border-top: 1px solid var(--border);
}
.store-location-row:first-child {
padding-top: 0;
border-top: 0;
}
.store-location-copy {
display: flex;
flex-direction: column;
gap: 0.12rem;
}
.store-location-copy strong {
color: var(--text-primary);
font-size: 0.94rem;
}
.store-location-controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
.store-location-manager-trigger,
.store-zone-manager-trigger {
width: 100%;
}
.store-card-header > .store-location-manager-trigger {
width: auto;
}
.store-items-modal-toolbar.store-management-create-row {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem; gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--card-bg);
}
.store-location-row > .store-zones-panel,
.store-location-row > .store-available-items-trigger {
grid-column: 1 / -1;
}
.add-location-panel,
.store-zone-create-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center; align-items: center;
} }
.store-items-modal-toolbar.store-location-create-row { .add-location-panel input,
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; .add-store-panel input,
} .store-zone-create-row input {
.add-store-inline input,
.store-management-create-row input,
.store-settings-form input {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
padding: 0.75rem; padding: 0.75rem;
@ -170,128 +134,99 @@
color: var(--text-primary); color: var(--text-primary);
} }
.store-management-row { .store-zones-panel {
grid-template-columns: minmax(0, 1fr) auto;
}
.store-management-row-with-order {
grid-template-columns: auto minmax(0, 1fr);
}
.store-management-row-with-order.is-delete-selectable {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.store-management-row-with-drag {
grid-template-columns: auto auto minmax(0, 1fr);
}
.store-management-order {
width: 2rem;
color: var(--text-secondary);
font-size: 0.85rem;
text-align: right;
}
.store-management-name {
min-width: 0;
color: var(--text-primary);
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-zone-drag-handle {
width: 2rem;
height: 2rem;
display: inline-flex;
border-radius: var(--border-radius-sm);
color: var(--text-secondary);
cursor: grab;
touch-action: none;
background-image: radial-gradient(currentColor 1.4px, transparent 1.4px);
background-position: center;
background-size: 6px 6px;
background-repeat: repeat;
}
.store-zone-drag-handle:hover,
.store-zone-drag-handle:focus-visible {
color: var(--color-primary);
background-color: var(--color-primary-light);
outline: none;
}
.store-management-row.is-dragging {
opacity: 0.55;
}
.store-management-row.is-drag-target {
border-color: var(--color-primary);
box-shadow: inset 3px 0 0 var(--color-primary);
}
.store-management-meta {
grid-column: 1 / -1;
min-width: 0;
color: var(--text-secondary);
font-size: var(--font-size-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-management-badge {
display: inline-flex;
align-items: center;
margin-left: var(--spacing-xs);
padding: 2px 6px;
border-radius: var(--border-radius-full);
background: var(--color-primary-light);
color: var(--color-primary);
font-size: var(--font-size-xs);
font-weight: 700;
}
.store-settings-modal {
width: min(520px, 100%);
}
.store-settings-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
} }
.store-settings-form label { .store-zones-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.35rem; gap: 0.75rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--background);
} }
.store-settings-form label span { .store-zone-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.store-zone-row {
display: grid;
grid-template-columns: 2rem minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
}
.store-zone-order {
color: var(--text-secondary); color: var(--text-secondary);
font-size: var(--font-size-sm); font-size: 0.85rem;
font-weight: 700; text-align: right;
} }
.store-settings-actions { .store-zone-name {
min-width: 0;
color: var(--text-primary);
}
.store-zone-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
} }
.add-store-inline { /* Add Store Panel */
display: grid; .add-store-panel {
grid-template-columns: minmax(0, 1fr) auto; display: flex;
gap: 0.5rem; flex-direction: column;
align-items: center; gap: 1rem;
margin-top: 1rem;
} }
.add-store-inline .btn-primary { .available-stores {
min-width: 4.5rem; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.available-store-card {
background: var(--background);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.25rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
transition: all 0.2s;
}
.available-store-card:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.available-store-card .store-info {
flex: 1;
}
.available-store-card h3 {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.25rem 0;
}
.available-store-card .store-location {
color: var(--text-secondary);
font-size: 0.85rem;
margin: 0;
} }
/* Empty State */ /* Empty State */
@ -304,49 +239,44 @@
/* Responsive */ /* Responsive */
@media (max-width: 600px) { @media (max-width: 600px) {
.stores-list { .stores-list,
.available-stores {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.store-card-header, .store-actions {
.store-location-controls, width: 100%;
.manage-stores-topline, }
.add-store-inline,
.store-items-modal-toolbar.store-management-create-row, .store-actions button {
.store-items-modal-toolbar.store-location-create-row, flex: 1;
.store-management-row { }
.available-store-card {
flex-direction: column;
align-items: flex-start;
}
.available-store-card button {
width: 100%;
}
.store-location-row,
.add-location-panel,
.store-zone-create-row,
.store-zone-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.store-card-header > .store-location-manager-trigger { .store-zone-order {
width: 100%;
}
.add-store-inline .btn-primary {
width: 100%;
}
.store-management-order {
text-align: left; text-align: left;
} }
.store-management-row-with-order { .store-zone-actions {
grid-template-columns: auto minmax(0, 1fr);
}
.store-management-row-with-order.is-delete-selectable {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.store-management-row-with-drag {
grid-template-columns: auto auto minmax(0, 1fr);
}
.store-settings-actions {
justify-content: stretch; justify-content: stretch;
} }
.store-settings-actions button { .store-zone-actions button {
flex: 1; flex: 1;
} }
} }

View File

@ -60,35 +60,11 @@
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 1; z-index: 1;
display: flex;
align-items: center;
gap: var(--spacing-xs);
background: var(--modal-bg); background: var(--modal-bg);
} }
.store-items-modal-toolbar .btn-small {
flex: 0 0 auto;
min-height: 40px;
white-space: nowrap;
}
.store-items-bulk-toolbar {
display: flex;
align-items: center;
gap: var(--spacing-xs);
justify-content: flex-end;
}
.store-items-delete-toggle,
.store-items-delete-cancel {
min-height: 38px;
min-width: 132px;
}
.store-available-items-search { .store-available-items-search {
flex: 1 1 auto;
width: 100%; width: 100%;
min-width: 0;
padding: var(--input-padding-y) var(--input-padding-x); padding: var(--input-padding-y) var(--input-padding-x);
border: var(--border-width-thin) solid var(--input-border-color); border: var(--border-width-thin) solid var(--input-border-color);
border-radius: var(--input-border-radius); border-radius: var(--input-border-radius);
@ -116,20 +92,24 @@
gap: var(--spacing-sm); gap: var(--spacing-sm);
} }
.store-items-table-head,
.store-items-table-row { .store-items-table-row {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(220px, 2fr) minmax(180px, 2fr) minmax(170px, 1fr);
gap: var(--spacing-md); gap: var(--spacing-md);
align-items: center; align-items: center;
} }
.store-items-table-row-button { .store-items-table-head {
width: 100%; position: sticky;
appearance: none; top: 0;
color: inherit; padding: 0 var(--spacing-sm) var(--spacing-xs);
font: inherit; background: var(--modal-bg);
text-align: left; color: var(--color-text-secondary);
cursor: pointer; font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
} }
.store-items-table-body { .store-items-table-body {
@ -145,22 +125,6 @@
background: var(--color-bg-surface); background: var(--color-bg-surface);
} }
.store-items-table-row-button:hover,
.store-items-table-row-button:focus-visible {
border-color: var(--color-primary);
background: var(--color-bg-hover);
outline: none;
}
.store-items-table-row-button.is-delete-selectable {
border-color: color-mix(in srgb, var(--color-danger) 35%, var(--color-border-light));
}
.store-items-table-row-button.is-selected {
border-color: var(--color-danger);
background: var(--color-danger-light);
}
.store-items-table-cell { .store-items-table-cell {
min-width: 0; min-width: 0;
} }
@ -189,11 +153,8 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: var(--border-width-medium) solid var(--color-border-light); color: var(--color-text-secondary);
background: var(--color-gray-100); font-weight: var(--font-weight-semibold);
color: var(--color-border-medium);
font-size: 1.75rem;
line-height: 1;
} }
.store-available-items-copy { .store-available-items-copy {
@ -210,27 +171,24 @@
white-space: nowrap; white-space: nowrap;
} }
.store-items-defaults-text {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.store-items-table-actions { .store-items-table-actions {
justify-self: end; justify-self: end;
} }
.store-items-delete-indicator { .store-available-items-actions {
width: 28px; display: flex;
height: 28px; gap: var(--spacing-xs);
display: inline-flex; flex-wrap: wrap;
align-items: center; justify-content: flex-end;
justify-content: center;
border: var(--border-width-thin) solid var(--color-border-light);
border-radius: var(--border-radius-full);
color: var(--color-text-inverse);
background: var(--color-bg-surface);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
} }
.store-items-table-row-button.is-selected .store-items-delete-indicator { .store-items-mobile-label {
border-color: var(--color-danger); display: none;
background: var(--color-danger);
} }
@media (max-width: 720px) { @media (max-width: 720px) {
@ -239,21 +197,37 @@
padding: var(--spacing-md); padding: var(--spacing-md);
} }
.store-items-table-head {
display: none;
}
.store-items-table-row { .store-items-table-row {
grid-template-columns: minmax(0, 1fr); display: flex;
flex-direction: column;
align-items: stretch;
gap: var(--spacing-sm); gap: var(--spacing-sm);
} }
.store-items-table-row-button.is-delete-selectable { .store-items-mobile-label {
grid-template-columns: minmax(0, 1fr) auto; display: block;
margin-bottom: 4px;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
} }
.store-items-bulk-toolbar { .store-items-table-actions {
justify-self: stretch;
}
.store-available-items-actions {
width: 100%; width: 100%;
align-items: stretch; justify-content: stretch;
} }
.store-items-bulk-toolbar button { .store-available-items-actions button {
flex: 1 1 0; flex: 1 1 0;
} }
} }

View File

@ -36,24 +36,15 @@ test("manage stores opens a modal to edit and delete household store items", asy
}, },
]; ];
await page.route("**/households/1/stores", async (route) => { await page.route("**/stores", async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify([ body: JSON.stringify([{ id: 10, name: "Costco" }]),
{
id: 10,
household_store_id: 100,
name: "Costco",
is_default: true,
zone_count: 0,
item_count: availableItems.length,
},
]),
}); });
}); });
await page.route("**/households/1/locations/10/available-items*", async (route) => { await page.route("**/households/1/stores/10/available-items*", async (route) => {
const request = route.request(); const request = route.request();
const url = new URL(request.url()); const url = new URL(request.url());
const query = (url.searchParams.get("query") || "").toLowerCase(); const query = (url.searchParams.get("query") || "").toLowerCase();
@ -71,7 +62,7 @@ test("manage stores opens a modal to edit and delete household store items", asy
await route.fulfill({ status: 500 }); await route.fulfill({ status: 500 });
}); });
await page.route("**/households/1/locations/10/available-items/777", async (route) => { await page.route("**/households/1/stores/10/available-items/777", async (route) => {
if (route.request().method() === "PATCH") { if (route.request().method() === "PATCH") {
availableItems = availableItems.map((item) => availableItems = availableItems.map((item) =>
item.item_id === 777 item.item_id === 777
@ -98,7 +89,7 @@ test("manage stores opens a modal to edit and delete household store items", asy
await route.fulfill({ status: 500 }); await route.fulfill({ status: 500 });
}); });
await page.route("**/households/1/locations/10/available-items/501", async (route) => { await page.route("**/households/1/stores/10/available-items/501", async (route) => {
if (route.request().method() === "DELETE") { if (route.request().method() === "DELETE") {
availableItems = availableItems.filter((item) => item.item_id !== 501); availableItems = availableItems.filter((item) => item.item_id !== 501);
await route.fulfill({ await route.fulfill({
@ -114,46 +105,18 @@ test("manage stores opens a modal to edit and delete household store items", asy
await page.goto("/manage?tab=stores"); await page.goto("/manage?tab=stores");
await expect(page.getByRole("heading", { name: "Add Store" })).toHaveCount(0);
await expect(page.getByPlaceholder("Store name")).toBeVisible();
await expect(page.getByPlaceholder("Location name")).toHaveCount(0);
await expect(page.getByPlaceholder("Address or notes")).toHaveCount(0);
const storeCard = page.locator(".store-card").filter({ hasText: "Costco" }); const storeCard = page.locator(".store-card").filter({ hasText: "Costco" });
await expect(storeCard).toBeVisible(); await expect(storeCard).toBeVisible();
await expect(storeCard.getByText("Costco", { exact: true })).toHaveCount(1); await expect(storeCard.getByRole("button", { name: "Manage Items" })).toBeVisible();
await expect(storeCard.getByText("Default location")).toHaveCount(0);
await expect(storeCard.getByText("Default shopping location")).toHaveCount(0);
await expect(storeCard.getByRole("button", { name: "Manage Items (2)" })).toBeVisible();
await storeCard.getByRole("button", { name: "Manage Items (2)" }).click(); await storeCard.getByRole("button", { name: "Manage Items" }).click();
const managerModal = page.locator(".store-items-modal"); const managerModal = page.locator(".store-items-modal");
await expect(managerModal).toBeVisible(); await expect(managerModal).toBeVisible();
await expect(managerModal.getByText("milk", { exact: true })).toBeVisible(); await expect(managerModal.getByText("milk", { exact: true })).toBeVisible();
await expect(managerModal.getByText("apples", { exact: true })).toBeVisible(); await expect(managerModal.getByText("apples", { exact: true })).toBeVisible();
await expect(managerModal.locator(".store-available-items-thumb-placeholder").first()).toHaveText("\uD83D\uDCE6");
await expect(managerModal.getByText("Store Defaults")).toHaveCount(0);
await expect(managerModal.getByText("No store defaults set")).toHaveCount(0);
await expect(managerModal.getByText("Edit Settings", { exact: true })).toHaveCount(0);
await expect(managerModal.getByText("Delete Item", { exact: true })).toHaveCount(0);
await expect(managerModal.locator(".store-available-items-action")).toHaveCount(0);
const searchBox = await managerModal.getByPlaceholder("Search household/store items").boundingBox(); await managerModal.locator(".store-items-table-row").filter({ hasText: "apples" }).getByRole("button", { name: "Edit Settings" }).click();
const addButtonBox = await managerModal.getByRole("button", { name: "Add Item" }).boundingBox();
expect(searchBox).not.toBeNull();
expect(addButtonBox).not.toBeNull();
expect(
Math.abs(
((searchBox?.y ?? 0) + (searchBox?.height ?? 0) / 2) -
((addButtonBox?.y ?? 0) + (addButtonBox?.height ?? 0) / 2)
)
).toBeLessThan(2);
const appleRow = managerModal.getByRole("button", { name: "Edit settings for apples" });
const milkRow = managerModal.locator(".store-items-table-row").filter({ hasText: "milk" });
await appleRow.click();
const editorModal = page.locator(".available-item-editor-modal"); const editorModal = page.locator(".available-item-editor-modal");
await expect(editorModal).toBeVisible(); await expect(editorModal).toBeVisible();
await expect(editorModal.getByLabel("Item Name")).toBeDisabled(); await expect(editorModal.getByLabel("Item Name")).toBeDisabled();
@ -163,22 +126,9 @@ test("manage stores opens a modal to edit and delete household store items", asy
await editorModal.getByRole("button", { name: "Save Changes" }).click(); await editorModal.getByRole("button", { name: "Save Changes" }).click();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated store item"); await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated store item");
await expect(managerModal.getByText("produce | Fruits | Produce & Fresh Vegetables")).toHaveCount(0); await expect(managerModal.getByText("produce | Fruits | Produce & Fresh Vegetables")).toBeVisible();
await managerModal.getByRole("button", { name: "Delete Items" }).click(); await managerModal.locator(".store-items-table-row").filter({ hasText: "milk" }).getByRole("button", { name: "Delete Item" }).click();
await expect(managerModal.getByRole("button", { name: "Confirm Delete (0)" })).toBeDisabled();
await expect(managerModal.getByRole("button", { name: "Cancel" })).toBeVisible();
await milkRow.click();
await expect(managerModal.getByRole("button", { name: "Confirm Delete (1)" })).toBeEnabled();
await expect(milkRow).toHaveClass(/is-selected/);
await milkRow.click();
await expect(managerModal.getByRole("button", { name: "Confirm Delete (0)" })).toBeDisabled();
await expect(milkRow).not.toHaveClass(/is-selected/);
await milkRow.click();
await managerModal.getByRole("button", { name: "Confirm Delete (1)" }).click();
const confirmModal = page.locator(".confirm-slide-modal"); const confirmModal = page.locator(".confirm-slide-modal");
await expect(confirmModal).toBeVisible(); await expect(confirmModal).toBeVisible();
await expect(confirmModal.getByText("Delete milk?")).toBeVisible(); await expect(confirmModal.getByText("Delete milk?")).toBeVisible();
@ -191,290 +141,6 @@ test("manage stores opens a modal to edit and delete household store items", asy
await expect(managerModal.locator(".store-items-table-row").filter({ hasText: "milk" })).toHaveCount(0); await expect(managerModal.locator(".store-items-table-row").filter({ hasText: "milk" })).toHaveCount(0);
}); });
test("manage stores uses modal flows for locations and zones", async ({ page }) => {
await page.setViewportSize({ width: 460, height: 1000 });
await seedAuthStorage(page, { username: "store-manager", role: "owner" });
await mockConfig(page);
await mockHouseholdAndStoreShell(page, {
household: { name: "Modal House", role: "owner" },
});
let locations = [
{
id: 10,
household_store_id: 100,
name: "Costco",
location_name: "Default Location",
display_name: "Costco",
address: "",
is_default: true,
zone_count: 2,
item_count: 3,
},
{
id: 11,
household_store_id: 100,
name: "Costco",
location_name: "Fontana",
display_name: "Costco - Fontana",
address: "Sierra Ave",
is_default: false,
zone_count: 0,
item_count: 0,
},
];
let zones = [
{ id: 901, name: "Bakery", sort_order: 10 },
{ id: 902, name: "Frozen Foods", sort_order: 20 },
];
await page.route("**/households/1/stores", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
locations.map((location) =>
location.id === 10 ? { ...location, zone_count: zones.length } : location
)
),
});
});
await page.route("**/households/1/stores/100/locations", async (route) => {
const request = route.request();
if (request.method() === "POST") {
const body = request.postDataJSON() as { name?: string; address?: string };
const locationName = body.name || "New Location";
locations = [
...locations,
{
id: 12,
household_store_id: 100,
name: "Costco",
location_name: locationName,
display_name: `Costco - ${locationName}`,
address: body.address || "",
is_default: false,
zone_count: 0,
item_count: 0,
},
];
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ store: locations[locations.length - 1] }),
});
return;
}
await route.fulfill({ status: 405 });
});
await page.route("**/households/1/locations/11", async (route) => {
const request = route.request();
if (request.method() === "PATCH") {
const body = request.postDataJSON() as { name?: string; address?: string };
locations = locations.map((location) =>
location.id === 11
? {
...location,
location_name: body.name || location.location_name,
display_name: `Costco - ${body.name || location.location_name}`,
address: body.address || "",
}
: location
);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ store: locations.find((location) => location.id === 11) }),
});
return;
}
if (request.method() === "DELETE") {
locations = locations.filter((location) => location.id !== 11);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ message: "Location removed successfully" }),
});
return;
}
await route.fulfill({ status: 405 });
});
await page.route("**/households/1/locations/11/default", async (route) => {
if (route.request().method() === "PATCH") {
locations = locations.map((location) => ({
...location,
is_default: location.id === 11,
}));
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ message: "Default location updated successfully" }),
});
return;
}
await route.fulfill({ status: 405 });
});
await page.route("**/households/1/locations/10/zones", async (route) => {
const request = route.request();
if (request.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
zones: [...zones].sort((a, b) => a.sort_order - b.sort_order),
}),
});
return;
}
if (request.method() === "POST") {
const body = request.postDataJSON() as { name?: string; sort_order?: number };
zones = [
...zones,
{ id: 903, name: body.name || "New Zone", sort_order: body.sort_order || 30 },
];
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ zone: zones[zones.length - 1] }),
});
return;
}
await route.fulfill({ status: 405 });
});
await page.route("**/households/1/locations/10/zones/*", async (route) => {
const request = route.request();
const zoneId = Number(new URL(request.url()).pathname.split("/").pop());
if (request.method() === "PATCH") {
const body = request.postDataJSON() as { name?: string; sort_order?: number };
zones = zones.map((zone) =>
zone.id === zoneId
? { ...zone, name: body.name || zone.name, sort_order: body.sort_order ?? zone.sort_order }
: zone
);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ zone: zones.find((zone) => zone.id === zoneId) }),
});
return;
}
if (request.method() === "DELETE") {
zones = zones.filter((zone) => zone.id !== zoneId);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ message: "Zone removed successfully" }),
});
return;
}
await route.fulfill({ status: 405 });
});
await page.goto("/manage?tab=stores");
const storeCard = page.locator(".store-card").filter({ hasText: "Costco" });
await expect(storeCard).toBeVisible();
await expect(storeCard.getByRole("button", { name: "Set Default" })).toHaveCount(0);
await expect(storeCard.getByRole("button", { name: "Remove" })).toHaveCount(0);
await expect(storeCard.getByPlaceholder("Location name")).toHaveCount(0);
await storeCard.getByRole("button", { name: "Manage Locations (2)" }).click();
const locationsModal = page.locator(".store-items-modal").filter({
has: page.getByRole("heading", { name: "Costco Locations" }),
});
await expect(locationsModal).toBeVisible();
await expect(locationsModal.getByPlaceholder("Location name")).toBeVisible();
await expect(locationsModal.getByRole("button", { name: "Delete Locations" })).toBeVisible();
await locationsModal.getByRole("button", { name: "Edit location Costco - Fontana" }).click();
const locationSettings = page.locator(".store-items-modal").filter({
has: page.getByRole("heading", { name: "Costco - Fontana Settings" }),
});
await expect(locationSettings).toBeVisible();
await locationSettings.getByLabel("Location name").fill("Upland");
await locationSettings.getByLabel("Address or notes").fill("Mountain Ave");
await locationSettings.getByRole("button", { name: "Save Changes" }).click();
await expect(
page.locator(".action-toast.action-toast-success").filter({ hasText: "Updated location" })
).toContainText("Updated location");
await locationsModal.getByRole("button", { name: "Delete Locations" }).click();
await locationsModal.getByRole("button", { name: "Select Costco - Upland for deletion" }).click();
await expect(locationsModal.getByRole("button", { name: "Confirm Delete (1)" })).toBeEnabled();
await locationsModal.getByRole("button", { name: "Confirm Delete (1)" }).click();
await expect(page.getByRole("heading", { name: "Delete Costco - Upland?" })).toBeVisible();
await confirmSlide(page);
await expect(
page.locator(".action-toast.action-toast-success").filter({ hasText: "Removed location" })
).toContainText("Removed location");
await page.getByLabel("Close manage locations modal").click();
await expect(storeCard.getByRole("button", { name: "Manage Locations (1)" })).toBeVisible();
await storeCard.getByRole("button", { name: "Manage Zones (2)" }).click();
const zonesModal = page.locator(".store-items-modal").filter({
has: page.getByRole("heading", { name: "Costco Zones" }),
});
await expect(zonesModal).toBeVisible();
await expect(zonesModal.getByPlaceholder("New zone name")).toBeVisible();
await expect(zonesModal.getByRole("button", { name: "Up" })).toHaveCount(0);
await expect(zonesModal.getByRole("button", { name: "Down" })).toHaveCount(0);
await expect(zonesModal.getByRole("button", { name: "Remove" })).toHaveCount(0);
const bakeryZoneRow = zonesModal.getByRole("button", { name: "Edit zone Bakery" });
const frozenZoneRow = zonesModal.getByRole("button", { name: "Edit zone Frozen Foods" });
await expect(bakeryZoneRow.locator(".store-zone-drag-handle")).toBeVisible();
const bakeryOrderBox = await bakeryZoneRow.locator(".store-management-order").boundingBox();
const bakeryNameBox = await bakeryZoneRow.locator(".store-management-name").boundingBox();
expect(bakeryOrderBox).not.toBeNull();
expect(bakeryNameBox).not.toBeNull();
expect(
Math.abs(
((bakeryOrderBox?.y ?? 0) + (bakeryOrderBox?.height ?? 0) / 2) -
((bakeryNameBox?.y ?? 0) + (bakeryNameBox?.height ?? 0) / 2)
)
).toBeLessThan(3);
await bakeryZoneRow.locator(".store-zone-drag-handle").dragTo(frozenZoneRow);
await expect(
page.locator(".action-toast.action-toast-success").filter({ hasText: "Reordered zones" })
).toContainText("Reordered zones");
await expect(zonesModal.locator(".store-management-row-with-order").first()).toContainText("Frozen Foods");
await zonesModal.getByRole("button", { name: "Edit zone Bakery" }).click();
const zoneSettings = page.locator(".store-items-modal").filter({
has: page.getByRole("heading", { name: "Bakery Settings" }),
});
await expect(zoneSettings).toBeVisible();
await zoneSettings.getByLabel("Zone name").fill("Bread");
await zoneSettings.getByRole("button", { name: "Save Changes" }).click();
await expect(
page.locator(".action-toast.action-toast-success").filter({ hasText: "Updated zone" })
).toContainText("Updated zone");
await zonesModal.getByRole("button", { name: "Delete Zones" }).click();
await zonesModal.getByRole("button", { name: "Select Bread for deletion" }).click();
await zonesModal.getByRole("button", { name: "Confirm Delete (1)" }).click();
await expect(page.getByRole("heading", { name: "Delete Bread?" })).toBeVisible();
await confirmSlide(page);
await expect(
page.locator(".action-toast.action-toast-success").filter({ hasText: "Removed zone" })
).toContainText("Removed zone");
});
test("grocery page remains unchanged and does not show a store items picker", async ({ page }) => { test("grocery page remains unchanged and does not show a store items picker", async ({ page }) => {
await seedAuthStorage(page); await seedAuthStorage(page);
await mockConfig(page); await mockConfig(page);
@ -490,7 +156,7 @@ test("grocery page remains unchanged and does not show a store items picker", as
}); });
}); });
await page.route("**/households/1/locations/10/list/recent", async (route) => { await page.route("**/households/1/stores/10/list/recent", async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
@ -498,7 +164,7 @@ test("grocery page remains unchanged and does not show a store items picker", as
}); });
}); });
await page.route("**/households/1/locations/10/list/suggestions**", async (route) => { await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
@ -506,7 +172,7 @@ test("grocery page remains unchanged and does not show a store items picker", as
}); });
}); });
await page.route("**/households/1/locations/10/list/item**", async (route) => { await page.route("**/households/1/stores/10/list/item**", async (route) => {
await route.fulfill({ await route.fulfill({
status: 404, status: 404,
contentType: "application/json", contentType: "application/json",
@ -514,7 +180,7 @@ test("grocery page remains unchanged and does not show a store items picker", as
}); });
}); });
await page.route("**/households/1/locations/10/list", async (route) => { await page.route("**/households/1/stores/10/list", async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",

View File

@ -176,9 +176,7 @@ test("household management shows pending invite approvals and can approve them",
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Approved join request"); 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.locator(".action-toast.action-toast-success")).toContainText("Pending Pal");
await expect(page.getByText("No pending join requests right now.")).toHaveCount(0); await expect(page.getByText("No pending join requests right now.")).toBeVisible();
await expect(page.locator(".pending-requests-summary")).toContainText("0");
await expect(page.locator(".manage-section-eyebrow")).toHaveCount(0);
await expect(page.getByText("Members (2)")).toBeVisible(); await expect(page.getByText("Members (2)")).toBeVisible();
}); });
@ -233,11 +231,7 @@ test("household member removal opens slide confirmation instead of browser dialo
await page.goto("/manage?tab=household"); await page.goto("/manage?tab=household");
const memberCard = page.locator(".member-card").filter({ hasText: "remove-me" }); const memberCard = page.locator(".member-card").filter({ hasText: "remove-me" });
await expect(memberCard.getByRole("button", { name: "Remove" })).toHaveCount(0); await memberCard.getByRole("button", { name: "Remove" }).click();
await memberCard.click();
const memberActionsDialog = page.getByRole("dialog", { name: "remove-me" });
await expect(memberActionsDialog).toBeVisible();
await memberActionsDialog.getByRole("button", { name: "Remove" }).click();
await expect(page.getByRole("heading", { name: "Remove remove-me?" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Remove remove-me?" })).toBeVisible();
await expect(page.getByText("Slide to confirm. They will lose access to this household.")).toBeVisible(); await expect(page.getByText("Slide to confirm. They will lose access to this household.")).toBeVisible();
@ -272,21 +266,11 @@ test("household owner can transfer ownership from household settings", async ({
}); });
}); });
await page.route("**/households/1/stores", async (route) => { await page.route("**/stores/household/1", async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify([ body: JSON.stringify([{ id: 10, name: "Costco", location: "Warehouse", is_default: true }]),
{ id: 10, household_store_id: 100, name: "Costco", location: "Warehouse", is_default: true },
]),
});
});
await page.route("**/households/1/locations/10/zones", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ zones: [] }),
}); });
}); });
@ -362,13 +346,9 @@ test("household owner can transfer ownership from household settings", async ({
await page.goto("/manage?tab=household"); await page.goto("/manage?tab=household");
await expect(page.getByRole("button", { name: "Make Owner" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Make Owner" })).toBeVisible();
const memberCard = page.locator(".member-card").filter({ hasText: "nico-admin" }); await page.getByRole("button", { name: "Make Owner" }).click();
await memberCard.click();
const memberActionsDialog = page.getByRole("dialog", { name: "nico-admin" });
await expect(memberActionsDialog).toBeVisible();
await memberActionsDialog.getByRole("button", { name: "Make Owner" }).click();
await expect(page.getByText("Transfer ownership to nico-admin?")).toBeVisible(); await expect(page.getByText("Transfer ownership to nico-admin?")).toBeVisible();
await confirmSlide(page); await confirmSlide(page);

View File

@ -47,15 +47,10 @@ test("manage stores add success shows success toast", async ({ page }) => {
await seedAuthStorage(page); await seedAuthStorage(page);
await mockConfig(page); await mockConfig(page);
let stores = [ let linkedStoreIds = [10];
{ const allStores = [
id: 10, { id: 10, name: "Costco North", location: "North", is_default: true },
household_store_id: 100, { id: 11, name: "Costco South", location: "South", is_default: false },
name: "Costco",
location_name: "Default Location",
display_name: "Costco",
is_default: true,
},
]; ];
await page.route("**/households", async (route) => { await page.route("**/households", async (route) => {
@ -66,63 +61,65 @@ test("manage stores add success shows success toast", async ({ page }) => {
}); });
}); });
await page.route("**/households/1/stores", async (route) => { await page.route("**/stores/household/1", async (route) => {
const request = route.request(); const request = route.request();
if (request.method() === "GET") { if (request.method() === "GET") {
const payload = linkedStoreIds.map((id, index) => {
const store = allStores.find((candidate) => candidate.id === id);
return {
...store,
is_default: index === 0,
};
});
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify(stores), body: JSON.stringify(payload),
}); });
return; return;
} }
if (request.method() === "POST") { if (request.method() === "POST") {
const body = request.postDataJSON() as { name?: string }; const body = request.postDataJSON() as { storeId?: number };
const name = body.name || "New Store"; if (body.storeId && !linkedStoreIds.includes(body.storeId)) {
stores = [ linkedStoreIds = [...linkedStoreIds, body.storeId];
...stores, }
{
id: 11,
household_store_id: 101,
name,
location_name: "Default Location",
display_name: name,
is_default: false,
},
];
await route.fulfill({ await route.fulfill({
status: 201, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ store: stores[stores.length - 1] }), body: JSON.stringify({ ok: true }),
}); });
return; return;
} }
await route.fulfill({ status: 405 }); await route.fallback();
}); });
await page.route("**/households/1/locations/10/zones", async (route) => { await page.route("**/stores", async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ zones: [] }), body: JSON.stringify(allStores),
}); });
}); });
await page.goto("/manage?tab=stores"); await page.goto("/manage?tab=stores");
const addStoreForm = page.locator(".add-store-inline"); await page.getByRole("button", { name: "+ Add Store" }).click();
await addStoreForm.getByLabel("Store name").fill("Stater Bros"); await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click();
await addStoreForm.getByRole("button", { name: "Add" }).click();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Created store"); await expect(page.locator(".action-toast.action-toast-success")).toContainText("Added store");
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Stater Bros"); await expect(page.locator(".action-toast.action-toast-success")).toContainText("Costco South");
}); });
test("manage stores add failure shows normalized error toast", async ({ page }) => { test("manage stores add failure shows normalized error toast", async ({ page }) => {
await seedAuthStorage(page); await seedAuthStorage(page);
await mockConfig(page); await mockConfig(page);
const allStores = [
{ id: 10, name: "Costco North", location: "North", is_default: true },
{ id: 11, name: "Costco South", location: "South", is_default: false },
];
await page.route("**/households", async (route) => { await page.route("**/households", async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
@ -131,22 +128,13 @@ test("manage stores add failure shows normalized error toast", async ({ page })
}); });
}); });
await page.route("**/households/1/stores", async (route) => { await page.route("**/stores/household/1", async (route) => {
const request = route.request(); const request = route.request();
if (request.method() === "GET") { if (request.method() === "GET") {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify([ body: JSON.stringify([{ id: 10, name: "Costco North", location: "North", is_default: true }]),
{
id: 10,
household_store_id: 100,
name: "Costco",
location_name: "Default Location",
display_name: "Costco",
is_default: true,
},
]),
}); });
return; return;
} }
@ -162,23 +150,22 @@ test("manage stores add failure shows normalized error toast", async ({ page })
return; return;
} }
await route.fulfill({ status: 405 }); await route.fallback();
}); });
await page.route("**/households/1/locations/10/zones", async (route) => { await page.route("**/stores", async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ zones: [] }), body: JSON.stringify(allStores),
}); });
}); });
await page.goto("/manage?tab=stores"); await page.goto("/manage?tab=stores");
const addStoreForm = page.locator(".add-store-inline"); await page.getByRole("button", { name: "+ Add Store" }).click();
await addStoreForm.getByLabel("Store name").fill("Costco"); await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click();
await addStoreForm.getByRole("button", { name: "Add" }).click();
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Create store failed"); await expect(page.locator(".action-toast.action-toast-error")).toContainText("Add store failed");
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Store already linked to household"); await expect(page.locator(".action-toast.action-toast-error")).toContainText("Store already linked to household");
}); });