Compare commits

...

26 Commits

Author SHA1 Message Date
5fd9d68fe3 Merge pull request 'Add drag reorder for zones' (#18) from feature/drag-reorder-zones into feature-custom-store-locations 2026-05-31 19:42:57 -09:00
Nico
5e2b2e6e5a feat: add drag reorder for zones 2026-05-31 21:41:33 -07:00
d94a169417 Merge pull request 'Remove default location card label' (#17) from feature/remove-default-location-label into feature-custom-store-locations 2026-05-31 19:30:07 -09:00
Nico
fb35b2b695 fix: remove default location card label 2026-05-31 21:28:41 -07:00
bafbc0fdac Merge pull request 'Show store manager counts' (#16) from feature/show-store-manager-counts into feature-custom-store-locations 2026-05-31 19:22:54 -09:00
Nico
346fb917b7 feat: show store manager counts 2026-05-31 21:21:12 -07:00
27a6a50744 Merge pull request 'Compact add store control' (#15) from feature/compact-add-store-control into feature-custom-store-locations 2026-05-31 19:07:58 -09:00
Nico
f80ea472ce fix: compact add store control 2026-05-31 21:06:41 -07:00
8387e22b4f Merge pull request 'Flatten store location cards' (#14) from feature/flatten-store-location-cards into feature-custom-store-locations 2026-05-31 18:58:19 -09:00
Nico
4b98740a7b fix: flatten store location cards 2026-05-31 20:56:45 -07:00
737aa6d66e Merge pull request 'Reduce manage page redundant labels' (#13) from feature/reduce-manage-page-redundancy into feature-custom-store-locations 2026-05-31 18:49:06 -09:00
Nico
0ef35c3f92 fix: reduce manage page redundant labels 2026-05-31 20:47:41 -07:00
6891fc2235 Merge pull request 'Tighten member list spacing' (#12) from feature/compact-member-list-spacing into feature-custom-store-locations 2026-05-31 18:41:14 -09:00
Nico
a432db93bc Tighten member list spacing 2026-05-31 20:40:05 -07:00
99d42f6dd0 Merge pull request 'Move store zones and locations into modals' (#11) from feature/store-management-modals into feature-custom-store-locations 2026-05-31 17:47:17 -09:00
Nico
31eade066f Move store zones and locations into modals 2026-05-31 19:45:14 -07:00
ce04ac6951 Merge pull request 'Compact store delete count label' (#10) from feature/compact-store-delete-label into feature-custom-store-locations 2026-05-31 17:22:59 -09:00
Nico
3816f255a0 fix: compact store delete count label 2026-05-31 19:21:08 -07:00
fe9f1eeacc Merge pull request 'Add store item bulk delete mode' (#9) from feature/store-item-bulk-delete into feature-custom-store-locations 2026-05-31 17:01:05 -09:00
Nico
7e46e25366 feat: add store item bulk delete mode 2026-05-31 18:59:16 -07:00
f968d304cc Merge pull request 'Simplify store item manager rows' (#8) from feature/store-item-manager-cleanup into feature-custom-store-locations 2026-05-31 16:43:56 -09:00
Nico
6252c0538f fix: simplify store item manager rows 2026-05-31 18:42:25 -07:00
eef2c15e8c Merge pull request 'Use grocery placeholder icon in store items' (#7) from feature/store-item-placeholder-icon into feature-custom-store-locations 2026-05-31 16:17:16 -09:00
Nico
6731ba3d09 fix: use item placeholder icon in store manager 2026-05-31 18:15:52 -07:00
cb38b051b3 Merge pull request 'Move member actions into a modal' (#6) from feature/member-actions-modal into feature-custom-store-locations 2026-05-31 16:07:49 -09:00
Nico
ec7b403546 feat: move member actions into modal 2026-05-31 18:06:06 -07:00
16 changed files with 2132 additions and 729 deletions

View File

@ -162,6 +162,8 @@ For `app/api/**/[param]/route.ts`:
- Touch: long-press affordance for item-level actions when no visible button.
- Mouse: hover affordance on interactive rows/cards.
- 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.
- 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.

View File

@ -134,6 +134,8 @@ exports.getHouseholdStores = async (householdId) => {
sl.address,
sl.is_default,
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.updated_at,
CASE
@ -142,6 +144,19 @@ exports.getHouseholdStores = async (householdId) => {
END AS display_name
FROM store_locations sl
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
ORDER BY sl.is_default DESC, hcs.name ASC, sl.name ASC`,
[householdId, DEFAULT_LOCATION_NAME]

View File

@ -0,0 +1,48 @@
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,6 +24,7 @@ This directory contains practical project documentation. Root-level rules still
## Guides
- `guides/api-documentation.md`: REST API reference. Verify against current code before changing APIs.
- `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/setup-checklist.md`: older setup checklist; prefer `DEVELOPMENT.md` for current commands.

View File

@ -0,0 +1,47 @@
# 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,6 +71,7 @@ export default function ManageHousehold() {
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
const [pendingRoleChange, setPendingRoleChange] = useState(null);
const [pendingMemberRemoval, setPendingMemberRemoval] = useState(null);
const [selectedMember, setSelectedMember] = useState(null);
const isManager = ["owner", "admin"].includes(activeHousehold?.role);
const isOwner = activeHousehold?.role === "owner";
@ -85,6 +86,19 @@ export default function ManageHousehold() {
}
}, [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 () => {
if (!activeHousehold?.id) return;
setLoading(true);
@ -360,13 +374,34 @@ export default function ManageHousehold() {
const managerCount = members.filter((member) => ["owner", "admin"].includes(member.role)).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 (
<div className="manage-household">
<section key="household-name" className="manage-section">
<div className="manage-section-header">
<div>
<p className="manage-section-eyebrow">Household</p>
<h2>Identity</h2>
</div>
</div>
@ -411,7 +446,6 @@ export default function ManageHousehold() {
<section key="join-and-invites" className="manage-section">
<div className="manage-section-header">
<div>
<p className="manage-section-eyebrow">Entry Rules</p>
<h2>Invite Links</h2>
</div>
</div>
@ -435,9 +469,7 @@ export default function ManageHousehold() {
{inviteLoading ? (
<p>Loading invite settings...</p>
) : pendingRequests.length === 0 ? (
<p className="section-description">No pending join requests right now.</p>
) : (
) : pendingRequests.length > 0 ? (
<div className="pending-requests-list">
{pendingRequests.map((request) => {
const requesterLabel = getRequesterLabel(request);
@ -473,7 +505,7 @@ export default function ManageHousehold() {
);
})}
</div>
)}
) : null}
<div className="invite-controls">
<label>
@ -547,7 +579,6 @@ export default function ManageHousehold() {
<section key="members" className="manage-section">
<div className="manage-section-header">
<div>
<p className="manage-section-eyebrow">People</p>
<h2>Members ({members.length})</h2>
</div>
</div>
@ -560,7 +591,13 @@ export default function ManageHousehold() {
const isSelf = member.id === parseInt(userId, 10);
return (
<div key={member.id} className="member-card">
<button
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-info">
<span className={`member-role member-role-${member.role}`}>
@ -570,46 +607,76 @@ export default function ManageHousehold() {
{isSelf && <span className="member-self-pill">You</span>}
</div>
</div>
{isManager && !isSelf && member.role !== "owner" && (
<div className="member-actions">
{isOwner && (
<button
onClick={() => handleUpdateRole(member.id, "owner", member.username)}
className="btn-primary btn-small member-owner-action"
>
Make Owner
</button>
)}
<button
onClick={() => handleUpdateRole(
member.id,
member.role === "admin" ? "member" : "admin",
member.username
)}
className="btn-secondary btn-small member-role-action"
>
{member.role === "admin" ? "Make Member" : "Make Admin"}
</button>
<button
onClick={() => handleRemoveMember(member.id, member.username)}
className="btn-danger btn-small"
>
Remove
</button>
</div>
)}
</div>
);
})}
</div>
)}
</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) && (
<section key="danger-zone" className="manage-section danger-zone">
<div className="manage-section-header">
<div>
<p className="manage-section-eyebrow">Final Actions</p>
<h2>Danger Zone</h2>
</div>
{isMemberOnly ? (

View File

@ -1,21 +1,14 @@
import { useContext, useEffect, useMemo, useState } from "react";
import {
addLocationToStore,
createHouseholdStore,
createLocationZone,
deleteLocationZone,
getLocationZones,
removeLocation,
setDefaultLocation,
updateLocationZone,
} from "../../api/stores";
import StoreAvailableItemsManager from "./StoreAvailableItemsManager";
import { useContext, useMemo, useState } from "react";
import { createHouseholdStore } from "../../api/stores";
import { HouseholdContext } from "../../context/HouseholdContext";
import { StoreContext } from "../../context/StoreContext";
import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage";
import "../../styles/components/manage/ManageStores.css";
import StoreAvailableItemsManager from "./StoreAvailableItemsManager";
import StoreLocationManager from "./StoreLocationManager";
import StoreZoneManager from "./StoreZoneManager";
import "../../styles/components/manage/StoreAvailableItemsManager.css";
import "../../styles/components/manage/ManageStores.css";
function groupLocationsByStore(locations) {
const grouped = new Map();
@ -36,163 +29,8 @@ function groupLocationsByStore(locations) {
return Array.from(grouped.values()).sort((a, b) => a.name.localeCompare(b.name));
}
function ZoneManager({ householdId, location, canManage, refreshActiveZones }) {
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>
);
function locationLabel(location) {
return location.display_name || location.name;
}
export default function ManageStores() {
@ -204,12 +42,7 @@ export default function ManageStores() {
refreshZones,
} = useContext(StoreContext);
const toast = useActionToast();
const [createForm, setCreateForm] = useState({
name: "",
location_name: "",
address: "",
});
const [locationDrafts, setLocationDrafts] = useState({});
const [newStoreName, setNewStoreName] = useState("");
const [saving, setSaving] = useState(false);
const isAdmin = ["owner", "admin"].includes(activeHousehold?.role);
@ -225,18 +58,17 @@ export default function ManageStores() {
const handleCreateStore = async (event) => {
event.preventDefault();
if (!createForm.name.trim()) return;
const storeName = newStoreName.trim();
if (!storeName) return;
setSaving(true);
try {
await createHouseholdStore(activeHousehold.id, {
name: createForm.name.trim(),
location_name: createForm.location_name.trim() || "Default Location",
address: createForm.address.trim() || null,
name: storeName,
});
setCreateForm({ name: "", location_name: "", address: "" });
setNewStoreName("");
await refreshAfterStoreChange();
toast.success("Created store", `Created store ${createForm.name.trim()}`);
toast.success("Created store", `Created store ${storeName}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to create store");
toast.error("Create store failed", `Create store failed: ${message}`);
@ -245,60 +77,29 @@ 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 (
<div className="manage-stores">
<section className="manage-section">
<div className="manage-stores-topline">
<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">
Stores and locations are private to this household. Each location has its own zones,
item defaults, and shopping order.
Stores are private to this household. Locations define map-specific zones, item
placement, and shopping order.
</p>
{householdStores.length === 0 ? (
<p className="empty-message">No store locations added yet.</p>
@ -306,133 +107,64 @@ export default function ManageStores() {
<div className="stores-list">
{groupedStores.map((storeGroup) => (
<div key={storeGroup.household_store_id} className="store-card">
<div className="store-card-header">
<div className="store-info">
<h3>{storeGroup.name}</h3>
</div>
<StoreLocationManager
householdId={activeHousehold.id}
storeGroup={storeGroup}
allLocationCount={householdStores.length}
canManage={isAdmin}
refreshAfterStoreChange={refreshAfterStoreChange}
/>
</div>
<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 className="store-info">
<strong>{location.display_name || location.name}</strong>
<div className="store-info store-location-copy">
{showLocationName ? <strong>{label}</strong> : null}
{location.address ? (
<p className="store-location">{location.address}</p>
) : null}
{location.is_default ? (
<p className="store-location">Default shopping location</p>
) : null}
</div>
<div className="store-actions">
{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
<div className="store-location-controls">
<StoreZoneManager
householdId={activeHousehold.id}
location={location}
canManage={isAdmin}
refreshActiveZones={refreshZones}
refreshStoreCounts={refreshStores}
zoneCount={Number(location.zone_count ?? location.zoneCount ?? 0)}
/>
<StoreAvailableItemsManager
householdId={activeHousehold.id}
store={location}
isAdmin={isAdmin}
refreshStoreCounts={refreshStores}
itemCount={Number(location.item_count ?? location.itemCount ?? 0)}
/>
</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>
) : null}
</div>
))}
</div>
)}
</section>
{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 ? (
{!isAdmin && activeStore ? (
<p className="manage-stores-note">
Household members can manage item defaults. Only owners and admins can manage stores,
locations, zones, and item deletion.

View File

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

View File

@ -0,0 +1,381 @@
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

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

View File

@ -22,6 +22,14 @@
font-size: 1.3rem;
font-weight: 600;
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;
}
@ -50,11 +58,11 @@
background: var(--background);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.25rem;
padding: 1rem;
transition: all 0.2s;
display: flex;
flex-direction: column;
gap: 1rem;
gap: 0.75rem;
}
.store-card:hover {
@ -62,16 +70,23 @@
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 {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.5rem 0;
margin: 0;
}
.store-location {
color: var(--text-secondary);
font-size: 0.9rem;
font-size: 0.85rem;
margin: 0;
}
@ -87,44 +102,65 @@
margin-top: 0.5rem;
}
.store-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.store-location-list {
display: flex;
flex-direction: column;
gap: 1rem;
gap: 0.65rem;
}
.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;
grid-template-columns: minmax(0, 1fr) auto;
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;
}
.add-location-panel input,
.add-store-panel input,
.store-zone-create-row input {
.store-items-modal-toolbar.store-location-create-row {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
}
.add-store-inline input,
.store-management-create-row input,
.store-settings-form input {
width: 100%;
box-sizing: border-box;
padding: 0.75rem;
@ -134,99 +170,128 @@
color: var(--text-primary);
}
.store-zones-panel {
display: flex;
flex-direction: column;
gap: 0.75rem;
.store-management-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.store-zones-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--background);
.store-management-row-with-order {
grid-template-columns: auto minmax(0, 1fr);
}
.store-zone-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
.store-management-row-with-order.is-delete-selectable {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.store-zone-row {
display: grid;
grid-template-columns: 2rem minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
.store-management-row-with-drag {
grid-template-columns: auto auto minmax(0, 1fr);
}
.store-zone-order {
.store-management-order {
width: 2rem;
color: var(--text-secondary);
font-size: 0.85rem;
text-align: right;
}
.store-zone-name {
.store-management-name {
min-width: 0;
color: var(--text-primary);
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-zone-actions {
.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;
flex-direction: column;
gap: 0.75rem;
}
.store-settings-form label {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.store-settings-form label span {
color: var(--text-secondary);
font-size: var(--font-size-sm);
font-weight: 700;
}
.store-settings-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
/* Add Store Panel */
.add-store-panel {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
.available-stores {
.add-store-inline {
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;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.5rem;
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;
.add-store-inline .btn-primary {
min-width: 4.5rem;
}
/* Empty State */
@ -239,44 +304,49 @@
/* Responsive */
@media (max-width: 600px) {
.stores-list,
.available-stores {
.stores-list {
grid-template-columns: 1fr;
}
.store-actions {
width: 100%;
}
.store-actions button {
flex: 1;
}
.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 {
.store-card-header,
.store-location-controls,
.manage-stores-topline,
.add-store-inline,
.store-items-modal-toolbar.store-management-create-row,
.store-items-modal-toolbar.store-location-create-row,
.store-management-row {
grid-template-columns: 1fr;
}
.store-zone-order {
.store-card-header > .store-location-manager-trigger {
width: 100%;
}
.add-store-inline .btn-primary {
width: 100%;
}
.store-management-order {
text-align: left;
}
.store-zone-actions {
.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-settings-actions {
justify-content: stretch;
}
.store-zone-actions button {
.store-settings-actions button {
flex: 1;
}
}

View File

@ -60,11 +60,35 @@
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
gap: var(--spacing-xs);
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 {
flex: 1 1 auto;
width: 100%;
min-width: 0;
padding: var(--input-padding-y) var(--input-padding-x);
border: var(--border-width-thin) solid var(--input-border-color);
border-radius: var(--input-border-radius);
@ -92,24 +116,20 @@
gap: var(--spacing-sm);
}
.store-items-table-head,
.store-items-table-row {
display: grid;
grid-template-columns: minmax(220px, 2fr) minmax(180px, 2fr) minmax(170px, 1fr);
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--spacing-md);
align-items: center;
}
.store-items-table-head {
position: sticky;
top: 0;
padding: 0 var(--spacing-sm) var(--spacing-xs);
background: var(--modal-bg);
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-table-row-button {
width: 100%;
appearance: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.store-items-table-body {
@ -125,6 +145,22 @@
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 {
min-width: 0;
}
@ -153,8 +189,11 @@
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
font-weight: var(--font-weight-semibold);
border: var(--border-width-medium) solid var(--color-border-light);
background: var(--color-gray-100);
color: var(--color-border-medium);
font-size: 1.75rem;
line-height: 1;
}
.store-available-items-copy {
@ -171,24 +210,27 @@
white-space: nowrap;
}
.store-items-defaults-text {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.store-items-table-actions {
justify-self: end;
}
.store-available-items-actions {
display: flex;
gap: var(--spacing-xs);
flex-wrap: wrap;
justify-content: flex-end;
.store-items-delete-indicator {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
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-mobile-label {
display: none;
.store-items-table-row-button.is-selected .store-items-delete-indicator {
border-color: var(--color-danger);
background: var(--color-danger);
}
@media (max-width: 720px) {
@ -197,37 +239,21 @@
padding: var(--spacing-md);
}
.store-items-table-head {
display: none;
}
.store-items-table-row {
display: flex;
flex-direction: column;
align-items: stretch;
grid-template-columns: minmax(0, 1fr);
gap: var(--spacing-sm);
}
.store-items-mobile-label {
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-table-row-button.is-delete-selectable {
grid-template-columns: minmax(0, 1fr) auto;
}
.store-items-table-actions {
justify-self: stretch;
}
.store-available-items-actions {
.store-items-bulk-toolbar {
width: 100%;
justify-content: stretch;
align-items: stretch;
}
.store-available-items-actions button {
.store-items-bulk-toolbar button {
flex: 1 1 0;
}
}

View File

@ -36,15 +36,24 @@ test("manage stores opens a modal to edit and delete household store items", asy
},
];
await page.route("**/stores", async (route) => {
await page.route("**/households/1/stores", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: 10, name: "Costco" }]),
body: JSON.stringify([
{
id: 10,
household_store_id: 100,
name: "Costco",
is_default: true,
zone_count: 0,
item_count: availableItems.length,
},
]),
});
});
await page.route("**/households/1/stores/10/available-items*", async (route) => {
await page.route("**/households/1/locations/10/available-items*", async (route) => {
const request = route.request();
const url = new URL(request.url());
const query = (url.searchParams.get("query") || "").toLowerCase();
@ -62,7 +71,7 @@ test("manage stores opens a modal to edit and delete household store items", asy
await route.fulfill({ status: 500 });
});
await page.route("**/households/1/stores/10/available-items/777", async (route) => {
await page.route("**/households/1/locations/10/available-items/777", async (route) => {
if (route.request().method() === "PATCH") {
availableItems = availableItems.map((item) =>
item.item_id === 777
@ -89,7 +98,7 @@ test("manage stores opens a modal to edit and delete household store items", asy
await route.fulfill({ status: 500 });
});
await page.route("**/households/1/stores/10/available-items/501", async (route) => {
await page.route("**/households/1/locations/10/available-items/501", async (route) => {
if (route.request().method() === "DELETE") {
availableItems = availableItems.filter((item) => item.item_id !== 501);
await route.fulfill({
@ -105,18 +114,46 @@ test("manage stores opens a modal to edit and delete household store items", asy
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" });
await expect(storeCard).toBeVisible();
await expect(storeCard.getByRole("button", { name: "Manage Items" })).toBeVisible();
await expect(storeCard.getByText("Costco", { exact: true })).toHaveCount(1);
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" }).click();
await storeCard.getByRole("button", { name: "Manage Items (2)" }).click();
const managerModal = page.locator(".store-items-modal");
await expect(managerModal).toBeVisible();
await expect(managerModal.getByText("milk", { 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);
await managerModal.locator(".store-items-table-row").filter({ hasText: "apples" }).getByRole("button", { name: "Edit Settings" }).click();
const searchBox = await managerModal.getByPlaceholder("Search household/store items").boundingBox();
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");
await expect(editorModal).toBeVisible();
await expect(editorModal.getByLabel("Item Name")).toBeDisabled();
@ -126,9 +163,22 @@ test("manage stores opens a modal to edit and delete household store items", asy
await editorModal.getByRole("button", { name: "Save Changes" }).click();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated store item");
await expect(managerModal.getByText("produce | Fruits | Produce & Fresh Vegetables")).toBeVisible();
await expect(managerModal.getByText("produce | Fruits | Produce & Fresh Vegetables")).toHaveCount(0);
await managerModal.locator(".store-items-table-row").filter({ hasText: "milk" }).getByRole("button", { name: "Delete Item" }).click();
await managerModal.getByRole("button", { name: "Delete Items" }).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");
await expect(confirmModal).toBeVisible();
await expect(confirmModal.getByText("Delete milk?")).toBeVisible();
@ -141,6 +191,290 @@ 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);
});
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 }) => {
await seedAuthStorage(page);
await mockConfig(page);
@ -156,7 +490,7 @@ test("grocery page remains unchanged and does not show a store items picker", as
});
});
await page.route("**/households/1/stores/10/list/recent", async (route) => {
await page.route("**/households/1/locations/10/list/recent", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
@ -164,7 +498,7 @@ test("grocery page remains unchanged and does not show a store items picker", as
});
});
await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
await page.route("**/households/1/locations/10/list/suggestions**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
@ -172,7 +506,7 @@ test("grocery page remains unchanged and does not show a store items picker", as
});
});
await page.route("**/households/1/stores/10/list/item**", async (route) => {
await page.route("**/households/1/locations/10/list/item**", async (route) => {
await route.fulfill({
status: 404,
contentType: "application/json",
@ -180,7 +514,7 @@ test("grocery page remains unchanged and does not show a store items picker", as
});
});
await page.route("**/households/1/stores/10/list", async (route) => {
await page.route("**/households/1/locations/10/list", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",

View File

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

View File

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