@@ -121,11 +184,14 @@ export default function ConfirmBuyModal({
value={quantity}
readOnly
className="confirm-buy-counter-display"
+ aria-label="Quantity to mark bought"
/>
@@ -133,10 +199,10 @@ export default function ConfirmBuyModal({
-
diff --git a/frontend/src/components/store/StoreTabs.jsx b/frontend/src/components/store/StoreTabs.jsx
index 0884d48..4ea6904 100644
--- a/frontend/src/components/store/StoreTabs.jsx
+++ b/frontend/src/components/store/StoreTabs.jsx
@@ -1,5 +1,5 @@
import { useContext, useMemo, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
+import { useLocation, useNavigate } from 'react-router-dom';
import { StoreContext } from '../../context/StoreContext';
import '../../styles/components/StoreTabs.css';
@@ -55,6 +55,7 @@ function buildStoreOptions(stores) {
export default function StoreTabs() {
const { stores, activeStore, setActiveStore, loading } = useContext(StoreContext);
const navigate = useNavigate();
+ const routeLocation = useLocation();
const [activePicker, setActivePicker] = useState(null);
const storeOptions = useMemo(() => buildStoreOptions(stores || []), [stores]);
const activeStoreKey = getStoreKey(activeStore);
@@ -98,8 +99,12 @@ export default function StoreTabs() {
};
const handleManageMap = () => {
- const locationQuery = selectedLocation?.id ? `&locationId=${selectedLocation.id}` : "";
- navigate(`/manage?tab=stores${locationQuery}`);
+ if (!selectedLocation?.id || !selectedStoreOption?.key) return;
+ navigate(`/stores/${selectedStoreOption.key}/locations/${selectedLocation.id}/map`, {
+ state: {
+ returnTo: `${routeLocation.pathname}${routeLocation.search}${routeLocation.hash}`,
+ },
+ });
};
return (
diff --git a/frontend/src/config.ts b/frontend/src/config.ts
index 0aa5d12..c1d58bd 100644
--- a/frontend/src/config.ts
+++ b/frontend/src/config.ts
@@ -1 +1,41 @@
-export const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:5000";
\ No newline at end of file
+const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
+
+function trimTrailingSlash(value: string) {
+ return value.replace(/\/+$/, "");
+}
+
+function getDefaultApiBaseUrl() {
+ if (typeof window === "undefined") {
+ return "http://localhost:5000";
+ }
+
+ const protocol = window.location.protocol === "https:" ? "https:" : "http:";
+ return `${protocol}//${window.location.hostname}:5000`;
+}
+
+function shouldUseBrowserHostForApi(url: URL) {
+ if (typeof window === "undefined") {
+ return false;
+ }
+
+ return LOOPBACK_HOSTS.has(url.hostname) && !LOOPBACK_HOSTS.has(window.location.hostname);
+}
+
+function resolveApiBaseUrl() {
+ const configuredApiUrl = import.meta.env.VITE_API_URL?.trim();
+ if (!configuredApiUrl) {
+ return getDefaultApiBaseUrl();
+ }
+
+ try {
+ const apiUrl = new URL(configuredApiUrl);
+ if (shouldUseBrowserHostForApi(apiUrl)) {
+ apiUrl.hostname = window.location.hostname;
+ }
+ return trimTrailingSlash(apiUrl.toString());
+ } catch {
+ return trimTrailingSlash(configuredApiUrl);
+ }
+}
+
+export const API_BASE_URL = resolveApiBaseUrl();
diff --git a/frontend/src/context/StoreContext.jsx b/frontend/src/context/StoreContext.jsx
index 7a7f22f..d7b92d5 100644
--- a/frontend/src/context/StoreContext.jsx
+++ b/frontend/src/context/StoreContext.jsx
@@ -84,7 +84,6 @@ export const StoreProvider = ({ children }) => {
useEffect(() => {
if (!activeHousehold || stores.length === 0) return;
- console.log('[StoreContext] Setting active store from:', stores);
const householdStoreStorageKey = `activeHouseholdStoreId_${activeHousehold.id}`;
const legacyLocationStorageKey = `activeStoreId_${activeHousehold.id}`;
const savedHouseholdStoreId = localStorage.getItem(householdStoreStorageKey);
@@ -94,7 +93,6 @@ export const StoreProvider = ({ children }) => {
chooseLocationByStoredIds(stores, activeHousehold.id, savedHouseholdStoreId) ||
chooseStoreByHouseholdStoreId(stores, savedHouseholdStoreId);
if (store) {
- console.log('[StoreContext] Found saved household store:', store);
setActiveStoreState(store);
return;
}
@@ -105,7 +103,6 @@ export const StoreProvider = ({ children }) => {
const savedLocation = stores.find(s => String(s.id) === String(savedLocationId));
const store = savedLocation || chooseStoreByHouseholdStoreId(stores, getHouseholdStoreKey(savedLocation));
if (store) {
- console.log('[StoreContext] Migrated saved location to store:', store);
const householdStoreId = getHouseholdStoreKey(store);
setActiveStoreState(store);
localStorage.setItem(householdStoreStorageKey, householdStoreId);
@@ -122,7 +119,6 @@ export const StoreProvider = ({ children }) => {
stores,
getHouseholdStoreKey(stores.find(s => s.is_default) || stores[0])
);
- console.log('[StoreContext] Using store:', defaultStore);
setActiveStoreState(defaultStore);
const householdStoreId = getHouseholdStoreKey(defaultStore);
localStorage.setItem(householdStoreStorageKey, householdStoreId);
@@ -146,9 +142,7 @@ export const StoreProvider = ({ children }) => {
setLoading(true);
setError(null);
try {
- console.log('[StoreContext] Loading stores for household:', activeHousehold.id);
const response = await getHouseholdStores(activeHousehold.id);
- console.log('[StoreContext] Loaded stores:', response.data);
setStores(response.data);
} catch (err) {
console.error('[StoreContext] Failed to load stores:', err);
diff --git a/frontend/src/hooks/useLocationMapBuying.js b/frontend/src/hooks/useLocationMapBuying.js
new file mode 100644
index 0000000..7f15b85
--- /dev/null
+++ b/frontend/src/hooks/useLocationMapBuying.js
@@ -0,0 +1,145 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { getItemByName, markBought } from "../api/list";
+import getApiErrorMessage from "../lib/getApiErrorMessage";
+
+const EMPTY_MAP_ITEMS = [];
+
+function getNextMapBuyItem(items, currentIndex, excludedItemId) {
+ const remainingItems = items.filter((item) => item.id !== excludedItemId);
+ if (!remainingItems.length) return null;
+ return remainingItems[Math.min(Math.max(currentIndex, 0), remainingItems.length - 1)];
+}
+
+function getLocallyReducedMapItem(item, quantity) {
+ const currentQuantity = Number(item.quantity || 1);
+ const boughtQuantity = Math.max(1, Number(quantity || 1));
+ return {
+ ...item,
+ quantity: Math.max(1, currentQuantity - boughtQuantity),
+ };
+}
+
+function getLocallyBoughtMapItem(item) {
+ return {
+ ...item,
+ bought: true,
+ };
+}
+
+export default function useLocationMapBuying({
+ activeHouseholdId,
+ locationId,
+ selectedObject,
+ selectedZoneItems = EMPTY_MAP_ITEMS,
+ setMapState,
+ toast,
+ visibleUnmappedItems = EMPTY_MAP_ITEMS,
+}) {
+ const [buyModalItem, setBuyModalItem] = useState(null);
+ const mapBuyScopeRef = useRef({ activeHouseholdId: null, locationId: null });
+ const buyModalItems = useMemo(
+ () => (selectedObject ? selectedZoneItems : visibleUnmappedItems).filter((item) => !item.bought),
+ [selectedObject, selectedZoneItems, visibleUnmappedItems]
+ );
+
+ const updateMapItems = useCallback((updater) => {
+ setMapState((currentState) => {
+ if (!currentState) return currentState;
+ const currentItems = currentState.items || EMPTY_MAP_ITEMS;
+ return { ...currentState, items: updater(currentItems) };
+ });
+ }, [setMapState]);
+
+ useEffect(() => {
+ mapBuyScopeRef.current = {
+ activeHouseholdId,
+ locationId,
+ };
+ }, [activeHouseholdId, locationId]);
+
+ useEffect(() => {
+ setBuyModalItem(null);
+ }, [activeHouseholdId, locationId]);
+
+ const isCurrentMapBuyScope = useCallback((scopedHouseholdId, scopedLocationId) => (
+ String(mapBuyScopeRef.current.activeHouseholdId || "") === String(scopedHouseholdId || "") &&
+ String(mapBuyScopeRef.current.locationId || "") === String(scopedLocationId || "")
+ ), []);
+
+ const handleMapBuyCancel = useCallback(() => {
+ setBuyModalItem(null);
+ }, []);
+
+ const handleMapBuyNavigate = useCallback((item) => {
+ setBuyModalItem(item);
+ }, []);
+
+ const handleMapBuyConfirm = useCallback(async (quantity) => {
+ if (!activeHouseholdId || !locationId || !buyModalItem) {
+ setBuyModalItem(null);
+ return;
+ }
+
+ const scopedHouseholdId = activeHouseholdId;
+ const scopedLocationId = locationId;
+ const item = buyModalItems.find((candidate) => candidate.id === buyModalItem.id) || buyModalItem;
+
+ try {
+ const currentIndex = buyModalItems.findIndex((candidate) => candidate.id === item.id);
+ const resolvedIndex = currentIndex >= 0 ? currentIndex : 0;
+
+ await markBought(scopedHouseholdId, scopedLocationId, item.item_name, quantity, true);
+ if (!isCurrentMapBuyScope(scopedHouseholdId, scopedLocationId)) return;
+
+ let nextBuyModalItems = buyModalItems;
+ if (quantity >= item.quantity) {
+ nextBuyModalItems = buyModalItems.filter((candidate) => candidate.id !== item.id);
+ updateMapItems((currentItems) =>
+ currentItems.map((candidate) => (
+ candidate.id === item.id ? getLocallyBoughtMapItem(candidate) : candidate
+ ))
+ );
+ } else {
+ let updatedItem;
+ try {
+ const response = await getItemByName(scopedHouseholdId, scopedLocationId, item.item_name);
+ if (!isCurrentMapBuyScope(scopedHouseholdId, scopedLocationId)) return;
+ updatedItem = response.data || getLocallyReducedMapItem(item, quantity);
+ } catch {
+ if (!isCurrentMapBuyScope(scopedHouseholdId, scopedLocationId)) return;
+ updatedItem = getLocallyReducedMapItem(item, quantity);
+ toast.info("Updated item locally", "Latest quantity will sync when the map reloads.");
+ }
+ nextBuyModalItems = buyModalItems.map((candidate) => (candidate.id === item.id ? updatedItem : candidate));
+
+ updateMapItems((currentItems) =>
+ currentItems.map((candidate) => (candidate.id === item.id ? updatedItem : candidate))
+ );
+ }
+ setBuyModalItem(getNextMapBuyItem(nextBuyModalItems, resolvedIndex, item.id));
+
+ toast.success("Marked item bought", `Marked item ${item.item_name} as bought`);
+ } catch (error) {
+ if (!isCurrentMapBuyScope(scopedHouseholdId, scopedLocationId)) return;
+ const message = getApiErrorMessage(error, "Failed to mark item as bought");
+ toast.error("Mark item bought failed", `Mark item bought failed: ${message}`);
+ }
+ }, [
+ activeHouseholdId,
+ buyModalItem,
+ buyModalItems,
+ isCurrentMapBuyScope,
+ locationId,
+ toast,
+ updateMapItems,
+ ]);
+
+ return {
+ buyModalItem,
+ buyModalItems,
+ handleMapBuyCancel,
+ handleMapBuyConfirm,
+ handleMapBuyNavigate,
+ setBuyModalItem,
+ };
+}
diff --git a/frontend/src/hooks/useLocationMapDerivedState.js b/frontend/src/hooks/useLocationMapDerivedState.js
new file mode 100644
index 0000000..dd912df
--- /dev/null
+++ b/frontend/src/hooks/useLocationMapDerivedState.js
@@ -0,0 +1,83 @@
+import { useMemo } from "react";
+import {
+ DEFAULT_MAP_SIZE,
+ getObjectKey,
+ itemMatchesMapFilters,
+ itemsForZone,
+ mapForMode,
+ unmappedItems,
+} from "../lib/locationMapUtils";
+
+const EMPTY_MAP_ITEMS = [];
+
+export default function useLocationMapDerivedState({
+ filters,
+ mapDraft,
+ mapState,
+ mode,
+ objects,
+ previewDraft,
+ selectedObjectKey,
+ username,
+}) {
+ const activeMap = useMemo(
+ () => mapForMode(mapState, mode, previewDraft),
+ [mapState, mode, previewDraft]
+ );
+ const mapItems = mapState?.items || EMPTY_MAP_ITEMS;
+ const selectedObject = useMemo(
+ () => objects.find((object) => getObjectKey(object) === selectedObjectKey) || null,
+ [objects, selectedObjectKey]
+ );
+ const selectedZoneItems = useMemo(
+ () => selectedObject?.zone_id
+ ? itemsForZone(mapItems, selectedObject.zone_id, filters, username)
+ : EMPTY_MAP_ITEMS,
+ [filters, mapItems, selectedObject?.zone_id, username]
+ );
+ const visibleUnmappedItems = useMemo(
+ () => unmappedItems(mapItems, filters, username),
+ [filters, mapItems, username]
+ );
+ const { visibleAssignedItemCount, unmappedItemCount } = useMemo(() => {
+ let nextVisibleAssignedItemCount = 0;
+ let nextUnmappedItemCount = 0;
+
+ mapItems.forEach((item) => {
+ if (!item.zone_id) {
+ nextUnmappedItemCount += 1;
+ return;
+ }
+ if (itemMatchesMapFilters(item, filters, username)) {
+ nextVisibleAssignedItemCount += 1;
+ }
+ });
+
+ return {
+ visibleAssignedItemCount: nextVisibleAssignedItemCount,
+ unmappedItemCount: nextUnmappedItemCount,
+ };
+ }, [filters, mapItems, username]);
+ const hiddenAreaCount = useMemo(
+ () => objects.filter((object) => object.visible === false).length,
+ [objects]
+ );
+ const mapSize = useMemo(
+ () => ({
+ width: mapDraft.width || activeMap?.width || DEFAULT_MAP_SIZE.width,
+ height: mapDraft.height || activeMap?.height || DEFAULT_MAP_SIZE.height,
+ }),
+ [activeMap?.height, activeMap?.width, mapDraft.height, mapDraft.width]
+ );
+
+ return {
+ hiddenAreaCount,
+ mapItems,
+ mapSize,
+ selectedObject,
+ selectedZoneItems,
+ unmappedItemCount,
+ visibleAssignedItemCount,
+ visibleUnmappedItems,
+ };
+}
diff --git a/frontend/src/hooks/useLocationMapDraftActions.js b/frontend/src/hooks/useLocationMapDraftActions.js
new file mode 100644
index 0000000..8296716
--- /dev/null
+++ b/frontend/src/hooks/useLocationMapDraftActions.js
@@ -0,0 +1,144 @@
+import { useCallback, useEffect, useRef } from "react";
+import {
+ createBlankLocationMap,
+ createLocationMapFromZones,
+ publishLocationMapDraft,
+ saveLocationMapDraft,
+} from "../api/locationMaps";
+import getApiErrorMessage from "../lib/getApiErrorMessage";
+import { getObjectKey, prepareObjectsForSave } from "../lib/locationMapUtils";
+
+export default function useLocationMapDraftActions({
+ activeHouseholdId,
+ beginSaving,
+ endSaving,
+ hasUnsavedChanges,
+ locationId,
+ mapDraft,
+ objects,
+ selectedObjectKey,
+ setMapState,
+ setMode,
+ setPreviewDraft,
+ syncMapDraftFromState,
+ toast,
+}) {
+ const latestScopeRef = useRef({ activeHouseholdId, locationId });
+ const selectedObjectIndex = selectedObjectKey
+ ? objects.findIndex((object) => getObjectKey(object) === selectedObjectKey)
+ : -1;
+
+ useEffect(() => {
+ latestScopeRef.current = { activeHouseholdId, locationId };
+ }, [activeHouseholdId, locationId]);
+
+ const isCurrentScope = useCallback(() => (
+ String(latestScopeRef.current.activeHouseholdId ?? "") === String(activeHouseholdId ?? "") &&
+ String(latestScopeRef.current.locationId ?? "") === String(locationId ?? "")
+ ), [activeHouseholdId, locationId]);
+
+ const applyDraftResponse = useCallback((response, nextMode = "edit", nextPreviewDraft = true, options = {}) => {
+ setMapState(response.data);
+ setMode(nextMode);
+ setPreviewDraft(nextPreviewDraft);
+ syncMapDraftFromState(response.data, nextMode, nextPreviewDraft, options);
+ }, [setMapState, setMode, setPreviewDraft, syncMapDraftFromState]);
+
+ const handleCreateBlank = useCallback(async () => {
+ if (!activeHouseholdId || !locationId) return;
+ beginSaving("create-blank");
+ try {
+ const response = await createBlankLocationMap(activeHouseholdId, locationId, mapDraft);
+ if (!isCurrentScope()) return;
+ applyDraftResponse(response);
+ toast.success("Created map", "Blank draft map created");
+ } catch (error) {
+ if (!isCurrentScope()) return;
+ toast.error("Create map failed", getApiErrorMessage(error, "Failed to create map"));
+ } finally {
+ if (isCurrentScope()) endSaving();
+ }
+ }, [
+ activeHouseholdId, applyDraftResponse, beginSaving, endSaving, isCurrentScope,
+ locationId, mapDraft, toast,
+ ]);
+
+ const handleCreateFromZones = useCallback(async () => {
+ if (!activeHouseholdId || !locationId) return;
+ beginSaving("create-zones");
+ try {
+ const response = await createLocationMapFromZones(activeHouseholdId, locationId, mapDraft);
+ if (!isCurrentScope()) return;
+ applyDraftResponse(response);
+ toast.success("Created starter map", "Zones were added as editable rectangles");
+ } catch (error) {
+ if (!isCurrentScope()) return;
+ toast.error("Create map failed", getApiErrorMessage(error, "Failed to create map from zones"));
+ } finally {
+ if (isCurrentScope()) endSaving();
+ }
+ }, [
+ activeHouseholdId, applyDraftResponse, beginSaving, endSaving, isCurrentScope,
+ locationId, mapDraft, toast,
+ ]);
+
+ const handleSaveDraft = useCallback(async () => {
+ if (!activeHouseholdId || !locationId) return;
+ beginSaving("save");
+ try {
+ const response = await saveLocationMapDraft(activeHouseholdId, locationId, {
+ map: mapDraft,
+ objects: prepareObjectsForSave(objects),
+ });
+ if (!isCurrentScope()) return;
+ applyDraftResponse(response, "edit", true, { preserveSelectedIndex: selectedObjectIndex });
+ toast.success("Saved draft", "Map draft saved");
+ } catch (error) {
+ if (!isCurrentScope()) return;
+ toast.error("Save draft failed", getApiErrorMessage(error, "Failed to save map draft"));
+ } finally {
+ if (isCurrentScope()) endSaving();
+ }
+ }, [
+ activeHouseholdId, applyDraftResponse, beginSaving, endSaving, isCurrentScope, locationId,
+ mapDraft, objects, selectedObjectIndex, toast,
+ ]);
+
+ const handlePublish = useCallback(async () => {
+ if (!activeHouseholdId || !locationId) return;
+ beginSaving("publish");
+ let savedPendingDraft = false;
+ try {
+ if (hasUnsavedChanges) {
+ const saveResponse = await saveLocationMapDraft(activeHouseholdId, locationId, {
+ map: mapDraft,
+ objects: prepareObjectsForSave(objects),
+ });
+ if (!isCurrentScope()) return;
+ savedPendingDraft = true;
+ applyDraftResponse(saveResponse, "edit", true, { preserveSelectedIndex: selectedObjectIndex });
+ }
+
+ const response = await publishLocationMapDraft(activeHouseholdId, locationId);
+ if (!isCurrentScope()) return;
+ applyDraftResponse(response, "view", false, { preserveSelectedIndex: selectedObjectIndex });
+ toast.success(
+ "Published map",
+ hasUnsavedChanges
+ ? "Latest edits were saved and published"
+ : "Map is now visible to household members"
+ );
+ } catch (error) {
+ if (!isCurrentScope()) return;
+ const message = getApiErrorMessage(error, "Failed to publish map");
+ toast.error("Publish failed", savedPendingDraft ? `${message}. Draft changes were saved.` : message);
+ } finally {
+ if (isCurrentScope()) endSaving();
+ }
+ }, [
+ activeHouseholdId, applyDraftResponse, beginSaving, endSaving, hasUnsavedChanges, isCurrentScope,
+ locationId, mapDraft, objects, selectedObjectIndex, toast,
+ ]);
+
+ return { handleCreateBlank, handleCreateFromZones, handlePublish, handleSaveDraft };
+}
diff --git a/frontend/src/hooks/useLocationMapDraftState.js b/frontend/src/hooks/useLocationMapDraftState.js
new file mode 100644
index 0000000..af64193
--- /dev/null
+++ b/frontend/src/hooks/useLocationMapDraftState.js
@@ -0,0 +1,134 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { getLocationMap } from "../api/locationMaps";
+import getApiErrorMessage from "../lib/getApiErrorMessage";
+import { getDefaultMapDraft, getMapDraftSnapshot } from "../lib/locationMapDraftStateUtils";
+import { DEFAULT_MAP_FILTERS } from "../lib/locationMapUtils";
+
+export default function useLocationMapDraftState({
+ activeHousehold,
+ hasLoaded,
+ householdLoading,
+ locationId,
+ toast,
+}) {
+ const [mapState, setMapState] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [loadError, setLoadError] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [savingAction, setSavingAction] = useState(null);
+ const [mode, setMode] = useState("setup");
+ const [previewDraft, setPreviewDraft] = useState(false);
+ const [mapDraft, setMapDraft] = useState(getDefaultMapDraft);
+ const [objects, setObjects] = useState([]);
+ const [selectedObjectKey, setSelectedObjectKey] = useState(null);
+ const [filters, setFilters] = useState(DEFAULT_MAP_FILTERS);
+ const [layersOpen, setLayersOpen] = useState(false);
+ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
+ const [zoom, setZoom] = useState(0.75);
+ const [history, setHistory] = useState([]);
+ const [future, setFuture] = useState([]);
+ const [dragState, setDragState] = useState(null);
+ const toastRef = useRef(toast);
+ const loadRequestRef = useRef(0);
+
+ const resetDraftState = useCallback(() => {
+ setObjects([]);
+ setSelectedObjectKey(null);
+ setHistory([]);
+ setFuture([]);
+ setHasUnsavedChanges(false);
+ setMapDraft(getDefaultMapDraft());
+ }, []);
+
+ const syncMapDraftFromState = useCallback((nextState, nextMode, nextPreviewDraft, options = {}) => {
+ const snapshot = getMapDraftSnapshot(nextState, nextMode, nextPreviewDraft, options);
+ if (!snapshot) return;
+
+ setMapDraft(snapshot.mapDraft);
+ setObjects(snapshot.objects);
+ setSelectedObjectKey(snapshot.selectedObjectKey);
+ setHistory([]);
+ setFuture([]);
+ setHasUnsavedChanges(false);
+ }, []);
+
+ const loadMap = useCallback(async () => {
+ const requestId = loadRequestRef.current + 1;
+ loadRequestRef.current = requestId;
+
+ if (!activeHousehold?.id || !locationId) {
+ setLoading(false);
+ return;
+ }
+
+ setLoading(true);
+ setLoadError(null);
+ setSaving(false);
+ setSavingAction(null);
+ setFilters({ ...DEFAULT_MAP_FILTERS });
+ setLayersOpen(false);
+ try {
+ const response = await getLocationMap(activeHousehold.id, locationId);
+ if (loadRequestRef.current !== requestId) return;
+
+ const nextState = response.data;
+ const nextCanManage = Boolean(
+ nextState.can_manage ?? ["owner", "admin"].includes(activeHousehold?.role)
+ );
+ setMapState(nextState);
+
+ if (!nextState.draft_map && !nextState.published_map) {
+ setMode("setup");
+ setPreviewDraft(false);
+ resetDraftState();
+ return;
+ }
+
+ if (nextState.published_map) {
+ setMode("view");
+ setPreviewDraft(false);
+ syncMapDraftFromState(nextState, "view", false);
+ } else if (nextState.draft_map && nextCanManage) {
+ setMode("setup");
+ setPreviewDraft(true);
+ syncMapDraftFromState(nextState, "edit", true);
+ } else {
+ setMode("setup");
+ setPreviewDraft(false);
+ resetDraftState();
+ }
+ } catch (error) {
+ if (loadRequestRef.current !== requestId) return;
+
+ const message = getApiErrorMessage(error, "Failed to load map");
+ toastRef.current.error("Load map failed", message);
+ setMapState(null);
+ resetDraftState();
+ setLoadError(message);
+ } finally {
+ if (loadRequestRef.current === requestId) {
+ setLoading(false);
+ }
+ }
+ }, [activeHousehold?.id, activeHousehold?.role, locationId, resetDraftState, syncMapDraftFromState]);
+
+ useEffect(() => { toastRef.current = toast; }, [toast]);
+
+ useEffect(() => {
+ if (!householdLoading && hasLoaded) {
+ loadMap();
+ }
+ }, [hasLoaded, householdLoading, loadMap]);
+
+ const beginSaving = useCallback((action) => { setSavingAction(action); setSaving(true); }, []);
+
+ const endSaving = useCallback(() => { setSaving(false); setSavingAction(null); }, []);
+
+ return {
+ beginSaving, dragState, endSaving, filters, future, hasUnsavedChanges, history, layersOpen,
+ loadError, loadMap, loading, mapDraft, mapState, mode, objects, previewDraft, saving,
+ savingAction, selectedObjectKey, setDragState, setFilters, setFuture,
+ setHasUnsavedChanges, setHistory, setLayersOpen, setMapState, setMode, setObjects,
+ setMapDraft, setPreviewDraft, setSelectedObjectKey, setZoom, syncMapDraftFromState, zoom,
+ };
+}
diff --git a/frontend/src/hooks/useLocationMapObjectActions.js b/frontend/src/hooks/useLocationMapObjectActions.js
new file mode 100644
index 0000000..aaed22a
--- /dev/null
+++ b/frontend/src/hooks/useLocationMapObjectActions.js
@@ -0,0 +1,190 @@
+import { useCallback, useState } from "react";
+import {
+ clampObjectToMap,
+ createClientObject,
+ getMapObjectDisplayLabel,
+ getObjectKey,
+ objectZoneId,
+} from "../lib/locationMapUtils";
+
+const EMPTY_ZONES = [];
+const MAP_EDGE_GROW_PADDING = 360;
+
+function createHistorySnapshot(objects, selectedObjectKey) {
+ return {
+ objects: objects.map((object) => ({ ...object })),
+ selectedObjectKey: selectedObjectKey ?? null,
+ };
+}
+
+export default function useLocationMapObjectActions({
+ canManage,
+ hiddenAreaCount,
+ mapSize,
+ mapState,
+ objects,
+ selectedObject,
+ selectedObjectKey,
+ setFuture,
+ setHasUnsavedChanges,
+ setHistory,
+ setMapDraft,
+ setObjects,
+ setSelectedObjectKey,
+ toast,
+}) {
+ const [pendingDeleteObject, setPendingDeleteObject] = useState(null);
+ const zones = mapState?.zones || EMPTY_ZONES;
+
+ const remember = useCallback((currentObjects = objects, currentSelectedObjectKey = selectedObjectKey) => {
+ setHistory((previous) => [
+ ...previous.slice(-19),
+ createHistorySnapshot(currentObjects, currentSelectedObjectKey),
+ ]);
+ setFuture([]);
+ }, [objects, selectedObjectKey, setFuture, setHistory]);
+
+ const updateObjects = useCallback((updater, bounds = mapSize) => {
+ setHasUnsavedChanges(true);
+ setObjects((currentObjects) =>
+ updater(currentObjects).map((object) => clampObjectToMap(object, bounds))
+ );
+ }, [mapSize, setHasUnsavedChanges, setObjects]);
+
+ const expandMapToFitObject = useCallback((object) => {
+ const shiftX = object.x < 0 ? Math.ceil(Math.abs(object.x) + MAP_EDGE_GROW_PADDING) : 0;
+ const shiftY = object.y < 0 ? Math.ceil(Math.abs(object.y) + MAP_EDGE_GROW_PADDING) : 0;
+ const shiftedObject = {
+ ...object,
+ x: Number(object.x || 0) + shiftX,
+ y: Number(object.y || 0) + shiftY,
+ };
+ const nextWidth = Math.max(
+ mapSize.width + shiftX,
+ Math.ceil(shiftedObject.x + Number(object.width || 0) + MAP_EDGE_GROW_PADDING)
+ );
+ const nextHeight = Math.max(
+ mapSize.height + shiftY,
+ Math.ceil(shiftedObject.y + Number(object.height || 0) + MAP_EDGE_GROW_PADDING)
+ );
+ const nextMapSize = { width: nextWidth, height: nextHeight };
+
+ if (nextWidth > mapSize.width || nextHeight > mapSize.height || shiftX > 0 || shiftY > 0) {
+ setMapDraft((currentDraft) => ({
+ ...currentDraft,
+ width: Math.max(Number(currentDraft.width || 0), nextWidth),
+ height: Math.max(Number(currentDraft.height || 0), nextHeight),
+ }));
+ setHasUnsavedChanges(true);
+ }
+
+ return { mapSize: nextMapSize, shiftX, shiftY };
+ }, [mapSize.height, mapSize.width, setHasUnsavedChanges, setMapDraft]);
+
+ const handleAddObject = useCallback(() => {
+ if (!canManage) return;
+ remember();
+ const usedZoneIds = new Set(objects.map((object) => objectZoneId(object)).filter(Boolean));
+ const nextZone = zones.find((zone) => !usedZoneIds.has(String(zone.id))) || zones[0];
+ const nextObject = clampObjectToMap(createClientObject(nextZone, objects.length), mapSize);
+ setObjects((currentObjects) => [...currentObjects, nextObject]);
+ setSelectedObjectKey(getObjectKey(nextObject));
+ setHasUnsavedChanges(true);
+ }, [canManage, mapSize, objects, remember, setHasUnsavedChanges, setObjects, setSelectedObjectKey, zones]);
+
+ const handleDuplicateObject = useCallback(() => {
+ if (!selectedObject) return;
+ remember();
+ const displayLabel = getMapObjectDisplayLabel(selectedObject, "Area");
+ const nextObject = {
+ ...selectedObject,
+ id: undefined,
+ client_id: `copy-${Date.now()}`,
+ label: `${displayLabel} Copy`,
+ x: selectedObject.x + 28,
+ y: selectedObject.y + 28,
+ sort_order: objects.length + 1,
+ };
+ setObjects((currentObjects) => [...currentObjects, clampObjectToMap(nextObject, mapSize)]);
+ setSelectedObjectKey(getObjectKey(nextObject));
+ setHasUnsavedChanges(true);
+ }, [mapSize, objects.length, remember, selectedObject, setHasUnsavedChanges, setObjects, setSelectedObjectKey]);
+
+ const requestDeleteObject = useCallback(() => {
+ if (selectedObject) setPendingDeleteObject(selectedObject);
+ }, [selectedObject]);
+
+ const handleDeleteObject = useCallback(() => {
+ if (!pendingDeleteObject) return;
+ remember();
+ setObjects((currentObjects) =>
+ currentObjects.filter((object) => getObjectKey(object) !== getObjectKey(pendingDeleteObject))
+ );
+ setSelectedObjectKey(null);
+ setHasUnsavedChanges(true);
+ toast.success("Deleted map area", "Save the draft to keep this change.");
+ setPendingDeleteObject(null);
+ }, [pendingDeleteObject, remember, setHasUnsavedChanges, setObjects, setSelectedObjectKey, toast]);
+
+ const handleShowHiddenAreas = useCallback(() => {
+ if (!canManage || hiddenAreaCount === 0) return;
+ remember();
+ updateObjects((currentObjects) =>
+ currentObjects.map((object) => (
+ object.visible === false ? { ...object, visible: true } : object
+ ))
+ );
+ toast.success("Showed hidden areas", "Save the draft to keep this change.");
+ }, [canManage, hiddenAreaCount, remember, toast, updateObjects]);
+
+ const handleObjectField = useCallback((field, value) => {
+ if (!selectedObject) return;
+ if (selectedObject[field] === value) return;
+
+ remember();
+ updateObjects((currentObjects) =>
+ currentObjects.map((object) => (
+ getObjectKey(object) === getObjectKey(selectedObject) ? { ...object, [field]: value } : object
+ ))
+ );
+ }, [remember, selectedObject, updateObjects]);
+
+ const handleZoneLinkChange = useCallback((zoneId) => {
+ if (!selectedObject) return;
+ const zone = zones.find((candidate) => String(candidate.id) === String(zoneId));
+ const nextZoneId = zone?.id || null;
+ const nextZoneName = zone?.name || null;
+ const nextLabel = selectedObject.label ?? zone?.name ?? "";
+ if (
+ (selectedObject.zone_id || null) === nextZoneId &&
+ (selectedObject.zone_name || null) === nextZoneName &&
+ selectedObject.label === nextLabel
+ ) {
+ return;
+ }
+
+ remember();
+ updateObjects((currentObjects) =>
+ currentObjects.map((object) => (
+ getObjectKey(object) === selectedObjectKey
+ ? { ...object, zone_id: nextZoneId, zone_name: nextZoneName, label: object.label ?? zone?.name ?? "" }
+ : object
+ ))
+ );
+ }, [remember, selectedObject, selectedObjectKey, updateObjects, zones]);
+
+ return {
+ handleAddObject,
+ handleDeleteObject,
+ handleDuplicateObject,
+ handleObjectField,
+ handleShowHiddenAreas,
+ handleZoneLinkChange,
+ expandMapToFitObject,
+ pendingDeleteObject,
+ remember,
+ requestDeleteObject,
+ setPendingDeleteObject,
+ updateObjects,
+ };
+}
diff --git a/frontend/src/hooks/useLocationMapViewControls.js b/frontend/src/hooks/useLocationMapViewControls.js
new file mode 100644
index 0000000..0d8c244
--- /dev/null
+++ b/frontend/src/hooks/useLocationMapViewControls.js
@@ -0,0 +1,220 @@
+import { useCallback, useEffect, useRef } from "react";
+import useMobileMapAutoFit from "./useMobileMapAutoFit";
+import { getObjectKey } from "../lib/locationMapUtils";
+
+function cloneMapObjects(objects = []) {
+ return objects.map((object) => ({ ...object }));
+}
+
+function getHistorySnapshot(snapshot, fallbackSelectedObjectKey = null) {
+ if (Array.isArray(snapshot)) {
+ return {
+ objects: cloneMapObjects(snapshot),
+ selectedObjectKey: fallbackSelectedObjectKey,
+ };
+ }
+
+ return {
+ objects: cloneMapObjects(snapshot?.objects || []),
+ selectedObjectKey: snapshot?.selectedObjectKey ?? null,
+ };
+}
+
+function createHistorySnapshot(objects, selectedObjectKey) {
+ return {
+ objects: cloneMapObjects(objects),
+ selectedObjectKey: selectedObjectKey ?? null,
+ };
+}
+
+function isTextEditingTarget(target) {
+ return Boolean(target?.closest?.("input, textarea, select, [contenteditable]"));
+}
+
+export default function useLocationMapViewControls({
+ filters,
+ future = [],
+ hasUnsavedChanges,
+ history = [],
+ mapSize,
+ mapState,
+ mode,
+ objects,
+ previewDraft,
+ saving,
+ selectedObjectKey,
+ setFuture,
+ setHasUnsavedChanges,
+ setHistory,
+ setLayersOpen,
+ setMode,
+ setObjects,
+ setPreviewDraft,
+ setSelectedObjectKey,
+ setZoom,
+ svgRef,
+ syncMapDraftFromState,
+ zoom,
+}) {
+ const shortcutControlsRef = useRef({
+ handleRedo: () => {},
+ handleUndo: () => {},
+ mode: "view",
+ saving: false,
+ });
+
+ useEffect(() => {
+ if (!selectedObjectKey) return;
+
+ const selectedMapObject = objects.find((object) => getObjectKey(object) === selectedObjectKey);
+ if (!selectedMapObject || !filters.showZones || selectedMapObject.visible === false) {
+ setSelectedObjectKey(null);
+ }
+ }, [filters.showZones, objects, selectedObjectKey, setSelectedObjectKey]);
+
+ const fitMapToCanvas = useMobileMapAutoFit({
+ mapSize,
+ mapState,
+ mode,
+ setZoom,
+ svgRef,
+ zoom,
+ });
+
+ const handleUndo = useCallback(() => {
+ if (history.length === 0) return;
+
+ const priorSnapshot = getHistorySnapshot(history[history.length - 1], selectedObjectKey);
+ setFuture([
+ createHistorySnapshot(objects, selectedObjectKey),
+ ...future.slice(0, 19),
+ ]);
+ setObjects(priorSnapshot.objects);
+ setSelectedObjectKey(priorSnapshot.selectedObjectKey);
+ setHasUnsavedChanges(history.length > 1);
+ setHistory(history.slice(0, -1));
+ }, [
+ future, history, objects, selectedObjectKey, setFuture, setHasUnsavedChanges, setHistory,
+ setObjects, setSelectedObjectKey,
+ ]);
+
+ const handleRedo = useCallback(() => {
+ if (future.length === 0) return;
+
+ const nextSnapshot = getHistorySnapshot(future[0], selectedObjectKey);
+ setHistory([
+ ...history.slice(-19),
+ createHistorySnapshot(objects, selectedObjectKey),
+ ]);
+ setObjects(nextSnapshot.objects);
+ setSelectedObjectKey(nextSnapshot.selectedObjectKey);
+ setHasUnsavedChanges(true);
+ setFuture(future.slice(1));
+ }, [
+ future, history, objects, selectedObjectKey, setFuture, setHasUnsavedChanges, setHistory,
+ setObjects, setSelectedObjectKey,
+ ]);
+
+ shortcutControlsRef.current = {
+ handleRedo,
+ handleUndo,
+ mode,
+ saving,
+ };
+
+ useEffect(() => {
+ if (typeof window === "undefined") {
+ return undefined;
+ }
+
+ const handleKeyDown = (event) => {
+ if (
+ event.defaultPrevented ||
+ event.altKey ||
+ !(event.ctrlKey || event.metaKey) ||
+ isTextEditingTarget(event.target)
+ ) {
+ return;
+ }
+
+ const controls = shortcutControlsRef.current;
+ if (controls.mode !== "edit" || controls.saving) {
+ return;
+ }
+
+ const key = event.key.toLowerCase();
+ const wantsUndo = key === "z" && !event.shiftKey;
+ const wantsRedo = key === "y" || (key === "z" && event.shiftKey);
+
+ if (wantsUndo) {
+ event.preventDefault();
+ controls.handleUndo();
+ return;
+ }
+
+ if (wantsRedo) {
+ event.preventDefault();
+ controls.handleRedo();
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown, true);
+ return () => window.removeEventListener("keydown", handleKeyDown, true);
+ }, []);
+
+ const handlePreviewDraft = useCallback(() => {
+ setMode("view");
+ setLayersOpen(false);
+ setPreviewDraft(true);
+ setSelectedObjectKey(null);
+ }, [setLayersOpen, setMode, setPreviewDraft, setSelectedObjectKey]);
+
+ const handleViewMode = useCallback(() => {
+ setMode("view");
+ setLayersOpen(false);
+ if ((mode === "edit" || previewDraft) && (hasUnsavedChanges || mapState?.draft_map)) {
+ setPreviewDraft(true);
+ return;
+ }
+ setPreviewDraft(false);
+ if (mapState) {
+ syncMapDraftFromState(mapState, "view", false);
+ }
+ }, [
+ hasUnsavedChanges,
+ mapState,
+ mode,
+ previewDraft,
+ setLayersOpen,
+ setMode,
+ setPreviewDraft,
+ setSelectedObjectKey,
+ syncMapDraftFromState,
+ ]);
+
+ const handleEditMode = useCallback(() => {
+ setMode("edit");
+ setLayersOpen(false);
+ setPreviewDraft(true);
+ if (!previewDraft && mapState && !hasUnsavedChanges) {
+ syncMapDraftFromState(mapState, "edit", true);
+ }
+ }, [
+ hasUnsavedChanges,
+ mapState,
+ previewDraft,
+ setLayersOpen,
+ setMode,
+ setPreviewDraft,
+ syncMapDraftFromState,
+ ]);
+
+ return {
+ handleEditMode,
+ handleFitMap: fitMapToCanvas,
+ handlePreviewDraft,
+ handleRedo,
+ handleUndo,
+ handleViewMode,
+ };
+}
diff --git a/frontend/src/hooks/useMobileMapAutoFit.js b/frontend/src/hooks/useMobileMapAutoFit.js
new file mode 100644
index 0000000..16aeba4
--- /dev/null
+++ b/frontend/src/hooks/useMobileMapAutoFit.js
@@ -0,0 +1,111 @@
+import { useCallback, useEffect, useMemo, useRef } from "react";
+import { DEFAULT_MAP_SIZE, clampMapZoom } from "../lib/locationMapUtils";
+
+const COMPACT_MAP_LAYOUT_QUERY = "(max-width: 839px)";
+
+function getAutoFitKey(mapState, mapSize) {
+ return [
+ mapState?.location?.id || "",
+ mapState?.draft_map?.id || "",
+ mapState?.published_map?.id || "",
+ mapSize.width,
+ mapSize.height,
+ ].join(":");
+}
+
+function getFitZoom(mapSize, scrollElement) {
+ const fallbackWidth = typeof window === "undefined" ? DEFAULT_MAP_SIZE.width : window.innerWidth;
+ const fallbackHeight = typeof window === "undefined" ? DEFAULT_MAP_SIZE.height : window.innerHeight;
+ const isCompactLayout = typeof window !== "undefined"
+ && window.matchMedia(COMPACT_MAP_LAYOUT_QUERY).matches;
+ const minimumWidth = isCompactLayout ? 160 : 280;
+ const minimumHeight = isCompactLayout ? 120 : 220;
+ const availableWidth = Math.max(minimumWidth, (scrollElement?.clientWidth || fallbackWidth) - 24);
+ const availableHeight = Math.max(minimumHeight, (scrollElement?.clientHeight || fallbackHeight) - 24);
+
+ return Number(clampMapZoom(
+ Math.min(availableWidth / mapSize.width, availableHeight / mapSize.height)
+ ).toFixed(2));
+}
+
+export default function useMobileMapAutoFit({
+ mapSize,
+ mapState,
+ mode,
+ setZoom,
+ svgRef,
+ zoom,
+}) {
+ const lastAutoFitKeyRef = useRef(null);
+ const lastFittedZoomRef = useRef(null);
+ const latestZoomRef = useRef(zoom);
+ const autoFitKey = useMemo(
+ () => getAutoFitKey(mapState, mapSize),
+ [mapSize, mapState]
+ );
+
+ useEffect(() => {
+ latestZoomRef.current = zoom;
+ }, [zoom]);
+
+ const fitMapToCanvas = useCallback(() => {
+ const scrollElement = svgRef.current?.closest(".location-map-scroll");
+ const nextZoom = getFitZoom(mapSize, scrollElement);
+ lastFittedZoomRef.current = nextZoom;
+ setZoom(nextZoom);
+ scrollElement?.scrollTo({ left: 0, top: 0 });
+ }, [mapSize, setZoom, svgRef]);
+
+ useEffect(() => {
+ if (typeof window === "undefined") return undefined;
+ if (!mapState || mode === "setup") return undefined;
+ if (!window.matchMedia(COMPACT_MAP_LAYOUT_QUERY).matches) return undefined;
+ if (lastAutoFitKeyRef.current === autoFitKey) return undefined;
+
+ const frameId = window.requestAnimationFrame(() => {
+ if (!svgRef.current) return;
+ lastAutoFitKeyRef.current = autoFitKey;
+ fitMapToCanvas();
+ });
+
+ return () => window.cancelAnimationFrame(frameId);
+ }, [autoFitKey, fitMapToCanvas, mapState, mode, svgRef]);
+
+ useEffect(() => {
+ if (typeof window === "undefined") return undefined;
+ if (!mapState || mode === "setup") return undefined;
+
+ let frameId = null;
+ const mediaQuery = window.matchMedia(COMPACT_MAP_LAYOUT_QUERY);
+ const shouldPreserveManualZoom = () => {
+ const lastFittedZoom = lastFittedZoomRef.current;
+ return lastFittedZoom !== null && Math.abs(latestZoomRef.current - lastFittedZoom) > 0.01;
+ };
+ const scheduleFit = () => {
+ if (!mediaQuery.matches || shouldPreserveManualZoom()) return;
+ if (frameId !== null) {
+ window.cancelAnimationFrame(frameId);
+ }
+ frameId = window.requestAnimationFrame(() => {
+ frameId = null;
+ fitMapToCanvas();
+ });
+ };
+
+ window.addEventListener("resize", scheduleFit);
+ window.addEventListener("orientationchange", scheduleFit);
+ window.visualViewport?.addEventListener("resize", scheduleFit);
+ scheduleFit();
+
+ return () => {
+ if (frameId !== null) {
+ window.cancelAnimationFrame(frameId);
+ }
+ window.removeEventListener("resize", scheduleFit);
+ window.removeEventListener("orientationchange", scheduleFit);
+ window.visualViewport?.removeEventListener("resize", scheduleFit);
+ };
+ }, [fitMapToCanvas, mapState, mode]);
+
+ return fitMapToCanvas;
+}
diff --git a/frontend/src/hooks/useUnsavedMapLeaveGuard.js b/frontend/src/hooks/useUnsavedMapLeaveGuard.js
new file mode 100644
index 0000000..820dee9
--- /dev/null
+++ b/frontend/src/hooks/useUnsavedMapLeaveGuard.js
@@ -0,0 +1,158 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useBeforeUnload } from "react-router-dom";
+
+export default function useUnsavedMapLeaveGuard({
+ shouldGuardLeave,
+ navigate,
+ performBackNavigation,
+}) {
+ const [pendingLeaveTarget, setPendingLeaveTarget] = useState(null);
+ const guardedClickTargetRef = useRef(null);
+ const allowGuardedClickRef = useRef(false);
+ const confirmedLeaveRef = useRef(false);
+ const historyGuardActiveRef = useRef(false);
+ const guardedUrlRef = useRef("");
+
+ useBeforeUnload(
+ useCallback((event) => {
+ if (!shouldGuardLeave) return;
+ event.preventDefault();
+ event.returnValue = "";
+ }, [shouldGuardLeave]),
+ { capture: true }
+ );
+
+ useEffect(() => {
+ if (!shouldGuardLeave) {
+ if (historyGuardActiveRef.current && guardedUrlRef.current === window.location.href) {
+ window.history.back();
+ }
+ setPendingLeaveTarget(null);
+ guardedClickTargetRef.current = null;
+ confirmedLeaveRef.current = false;
+ historyGuardActiveRef.current = false;
+ return undefined;
+ }
+
+ if (!historyGuardActiveRef.current) {
+ guardedUrlRef.current = window.location.href;
+ window.history.pushState(
+ { ...(window.history.state || {}), unsavedMapGuard: true },
+ "",
+ guardedUrlRef.current
+ );
+ historyGuardActiveRef.current = true;
+ }
+
+ const handleDocumentClick = (event) => {
+ if (allowGuardedClickRef.current) return;
+
+ if (
+ event.defaultPrevented ||
+ event.button !== 0 ||
+ event.altKey ||
+ event.ctrlKey ||
+ event.metaKey ||
+ event.shiftKey
+ ) {
+ return;
+ }
+
+ const target = event.target instanceof Element ? event.target : null;
+ const button = target?.closest("button");
+ const isSwitchingHousehold = Boolean(
+ button?.classList.contains("household-option") &&
+ !button.classList.contains("create-household-btn") &&
+ !button.closest(".household-option-row.active")
+ );
+
+ if (button?.classList.contains("user-dropdown-logout") || isSwitchingHousehold) {
+ event.preventDefault();
+ event.stopPropagation();
+ guardedClickTargetRef.current = button;
+ setPendingLeaveTarget({ type: "click" });
+ return;
+ }
+
+ const anchor = target?.closest("a[href]");
+ if (!anchor || (anchor.target && anchor.target !== "_self") || anchor.hasAttribute("download")) {
+ return;
+ }
+
+ const nextUrl = new URL(anchor.href, window.location.href);
+ if (nextUrl.origin !== window.location.origin) return;
+
+ const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
+ const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
+ if (nextPath === currentPath) return;
+
+ event.preventDefault();
+ setPendingLeaveTarget({ type: "path", path: nextPath });
+ };
+ const handleBrowserHistoryNavigation = () => {
+ if (confirmedLeaveRef.current) return;
+
+ window.history.pushState(
+ { ...(window.history.state || {}), unsavedMapGuard: true },
+ "",
+ guardedUrlRef.current || window.location.href
+ );
+ setPendingLeaveTarget({ type: "history" });
+ };
+
+ document.addEventListener("click", handleDocumentClick, true);
+ window.addEventListener("popstate", handleBrowserHistoryNavigation, true);
+ return () => {
+ document.removeEventListener("click", handleDocumentClick, true);
+ window.removeEventListener("popstate", handleBrowserHistoryNavigation, true);
+ };
+ }, [shouldGuardLeave]);
+
+ const requestBackNavigation = useCallback(() => {
+ if (shouldGuardLeave) {
+ setPendingLeaveTarget({ type: "back" });
+ return;
+ }
+ performBackNavigation();
+ }, [performBackNavigation, shouldGuardLeave]);
+
+ const clearPendingLeave = useCallback(() => {
+ guardedClickTargetRef.current = null;
+ setPendingLeaveTarget(null);
+ }, []);
+
+ const confirmPendingLeave = useCallback(() => {
+ const target = pendingLeaveTarget;
+ setPendingLeaveTarget(null);
+ confirmedLeaveRef.current = true;
+ if (target?.type === "history") {
+ historyGuardActiveRef.current = false;
+ window.history.go(-2);
+ return;
+ }
+ if (target?.type === "click") {
+ const guardedClickTarget = guardedClickTargetRef.current;
+ guardedClickTargetRef.current = null;
+ if (guardedClickTarget) {
+ allowGuardedClickRef.current = true;
+ guardedClickTarget.click();
+ window.setTimeout(() => {
+ allowGuardedClickRef.current = false;
+ }, 0);
+ return;
+ }
+ }
+ if (target?.type === "path" && target.path) {
+ navigate(target.path);
+ return;
+ }
+ performBackNavigation();
+ }, [navigate, pendingLeaveTarget, performBackNavigation]);
+
+ return {
+ pendingLeaveTarget,
+ requestBackNavigation,
+ clearPendingLeave,
+ confirmPendingLeave,
+ };
+}
diff --git a/frontend/src/lib/locationMapDraftStateUtils.js b/frontend/src/lib/locationMapDraftStateUtils.js
new file mode 100644
index 0000000..31ca3a3
--- /dev/null
+++ b/frontend/src/lib/locationMapDraftStateUtils.js
@@ -0,0 +1,39 @@
+import {
+ DEFAULT_MAP_SIZE,
+ getObjectKey,
+ mapForMode,
+ normalizeMapObject,
+ objectsForMode,
+} from "./locationMapUtils";
+
+export function getDefaultMapDraft() {
+ return {
+ name: "Store Map",
+ width: DEFAULT_MAP_SIZE.width,
+ height: DEFAULT_MAP_SIZE.height,
+ };
+}
+
+export function getMapDraftSnapshot(nextState, nextMode, nextPreviewDraft, options = {}) {
+ const nextMap = mapForMode(nextState, nextMode, nextPreviewDraft);
+ const nextObjects = objectsForMode(nextState, nextMode, nextPreviewDraft);
+ if (!nextMap) return null;
+
+ const normalizedObjects = nextObjects.map(normalizeMapObject);
+ const preserveSelectedIndex = Number.isInteger(options.preserveSelectedIndex)
+ ? options.preserveSelectedIndex
+ : -1;
+ const preservedSelectedObject = preserveSelectedIndex >= 0
+ ? normalizedObjects[preserveSelectedIndex]
+ : null;
+
+ return {
+ mapDraft: {
+ name: nextMap.name || "Store Map",
+ width: nextMap.width || DEFAULT_MAP_SIZE.width,
+ height: nextMap.height || DEFAULT_MAP_SIZE.height,
+ },
+ objects: normalizedObjects,
+ selectedObjectKey: preservedSelectedObject ? getObjectKey(preservedSelectedObject) : null,
+ };
+}
diff --git a/frontend/src/lib/locationMapUtils.js b/frontend/src/lib/locationMapUtils.js
new file mode 100644
index 0000000..cb82917
--- /dev/null
+++ b/frontend/src/lib/locationMapUtils.js
@@ -0,0 +1,202 @@
+const DEFAULT_LOCATION_NAME = "Default Location";
+
+export const DEFAULT_MAP_SIZE = {
+ width: 1000,
+ height: 700,
+};
+
+export const MIN_MAP_ZOOM = 0.3;
+export const MAX_MAP_ZOOM = 1.6;
+
+export const DEFAULT_MAP_FILTERS = {
+ showZones: true,
+ showLabels: true,
+ showCounts: true,
+ showPins: false,
+ showMyItems: true,
+ showOtherItems: false,
+ showCompleted: false,
+ showUnmapped: false,
+};
+
+export const MAP_OBJECT_TYPES = [
+ "zone",
+ "aisle",
+ "entrance",
+ "checkout",
+ "shelf",
+ "wall",
+ "department",
+ "anchor",
+ "other",
+];
+
+export function getStoreName(location) {
+ return location?.name || "Store";
+}
+
+export function getLocationName(location) {
+ if (!location) return "Location";
+ if (!location.location_name || location.location_name === DEFAULT_LOCATION_NAME) {
+ return "Default";
+ }
+ return location.location_name;
+}
+
+export function getMapStatus(state, options = {}) {
+ if (options.hasUnsavedChanges) return "Unsaved Draft";
+ if (!state?.draft_map && !state?.published_map) return "No Map";
+ if (options.mode === "edit") return "Draft";
+ if (options.mode === "view" && options.previewDraft && state?.draft_map) return "Viewing Draft";
+ if (state?.published_map) return "Published";
+ if (state?.draft_map) return "Draft";
+ return "No Map";
+}
+
+export function objectZoneId(object) {
+ return object?.zone_id ? String(object.zone_id) : "";
+}
+
+export function mapForMode(mapState, mode, previewDraft) {
+ if (mode === "edit") {
+ return mapState?.draft_map || mapState?.published_map || null;
+ }
+
+ if (previewDraft && mapState?.draft_map) {
+ return mapState.draft_map;
+ }
+
+ return mapState?.published_map || mapState?.draft_map || null;
+}
+
+export function objectsForMode(mapState, mode, previewDraft) {
+ if (mode === "edit") {
+ return mapState?.draft_objects?.length
+ ? mapState.draft_objects
+ : mapState?.published_objects || [];
+ }
+
+ if (previewDraft && mapState?.draft_map) {
+ return mapState.draft_objects || [];
+ }
+
+ return mapState?.published_objects?.length
+ ? mapState.published_objects
+ : mapState?.draft_objects || [];
+}
+
+export function createClientObject(zone, index = 0) {
+ const offset = index * 24;
+ return {
+ client_id: `new-${Date.now()}-${index}`,
+ zone_id: zone?.id || null,
+ zone_name: zone?.name || null,
+ type: "zone",
+ label: zone?.name || "New Area",
+ x: 80 + offset,
+ y: 80 + offset,
+ width: 220,
+ height: 130,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: index + 1,
+ metadata_json: {},
+ };
+}
+
+export function normalizeMapObject(object, index = 0) {
+ const hasExplicitLabel = Object.prototype.hasOwnProperty.call(object, "label");
+
+ return {
+ ...object,
+ client_id: object.client_id || `object-${object.id || Date.now()}-${index}`,
+ zone_id: object.zone_id ?? null,
+ zone_name: object.zone_name || null,
+ type: MAP_OBJECT_TYPES.includes(object.type) ? object.type : "zone",
+ label: hasExplicitLabel
+ ? String(object.label ?? "").trim()
+ : object.zone_name || "Map Area",
+ x: Number(object.x) || 0,
+ y: Number(object.y) || 0,
+ width: Math.max(40, Number(object.width) || 160),
+ height: Math.max(40, Number(object.height) || 100),
+ rotation: Number(object.rotation) || 0,
+ locked: Boolean(object.locked),
+ visible: object.visible !== false,
+ sort_order: Number(object.sort_order ?? object.sortOrder ?? index + 1),
+ metadata_json: object.metadata_json || {},
+ };
+}
+
+export function getObjectKey(object) {
+ return String(object.id || object.client_id);
+}
+
+export function getMapObjectDisplayLabel(object, fallback = "Map Area") {
+ const label = String(object?.label ?? "").trim();
+ const zoneName = String(object?.zone_name ?? "").trim();
+ return label || zoneName || fallback;
+}
+
+export function prepareObjectsForSave(objects) {
+ return objects.map((object, index) => ({
+ zone_id: object.zone_id || null,
+ type: object.type || "zone",
+ label: String(object.label ?? "").trim(),
+ x: Number(object.x) || 0,
+ y: Number(object.y) || 0,
+ width: Math.max(40, Number(object.width) || 160),
+ height: Math.max(40, Number(object.height) || 100),
+ rotation: Number(object.rotation) || 0,
+ locked: Boolean(object.locked),
+ visible: object.visible !== false,
+ sort_order: index + 1,
+ metadata_json: object.metadata_json || {},
+ }));
+}
+
+export function itemMatchesMapFilters(item, filters, username) {
+ if (!filters.showCompleted && item.bought) return false;
+
+ const addedBy = Array.isArray(item.added_by_users) ? item.added_by_users : [];
+ const hasOwnershipData = addedBy.length > 0 && username;
+ const isMine = hasOwnershipData ? addedBy.includes(username) : true;
+ if (isMine && !filters.showMyItems) return false;
+ if (!isMine && !filters.showOtherItems) return false;
+ return true;
+}
+
+export function itemsForZone(items, zoneId, filters, username) {
+ return items.filter((item) => (
+ String(item.zone_id || "") === String(zoneId || "") &&
+ itemMatchesMapFilters(item, filters, username)
+ ));
+}
+
+export function unmappedItems(items, filters, username) {
+ return items.filter((item) => !item.zone_id && itemMatchesMapFilters(item, filters, username));
+}
+
+export function clientPointToMap(event, svgElement, mapSize) {
+ const rect = svgElement.getBoundingClientRect();
+ const x = ((event.clientX - rect.left) / rect.width) * mapSize.width;
+ const y = ((event.clientY - rect.top) / rect.height) * mapSize.height;
+ return { x, y };
+}
+
+export function clampMapZoom(value) {
+ return Math.max(MIN_MAP_ZOOM, Math.min(MAX_MAP_ZOOM, value));
+}
+
+export function clampObjectToMap(object, mapSize) {
+ const width = Math.max(40, Math.min(object.width, mapSize.width));
+ const height = Math.max(40, Math.min(object.height, mapSize.height));
+ return {
+ ...object,
+ width,
+ height,
+ x: Math.max(0, Math.min(object.x, mapSize.width - width)),
+ y: Math.max(0, Math.min(object.y, mapSize.height - height)),
+ };
+}
diff --git a/frontend/src/pages/LocationMapManager.jsx b/frontend/src/pages/LocationMapManager.jsx
new file mode 100644
index 0000000..a98dc13
--- /dev/null
+++ b/frontend/src/pages/LocationMapManager.jsx
@@ -0,0 +1,483 @@
+import { useCallback, useContext, useEffect, useRef } from "react";
+import { useLocation, useNavigate, useParams } from "react-router-dom";
+import { AuthContext } from "../context/AuthContext";
+import { HouseholdContext } from "../context/HouseholdContext";
+import { StoreContext } from "../context/StoreContext";
+import LocationMapBottomSheet from "../components/maps/LocationMapBottomSheet";
+import LocationMapCanvas from "../components/maps/LocationMapCanvas";
+import LocationMapDialogs from "../components/maps/LocationMapDialogs";
+import LocationMapSetupPanel from "../components/maps/LocationMapSetupPanel";
+import {
+ LocationMapLoadErrorState,
+ LocationMapMessageState,
+} from "../components/maps/LocationMapStateViews";
+import LocationMapToolbar from "../components/maps/LocationMapToolbar";
+import LocationMapTopbar from "../components/maps/LocationMapTopbar";
+import useActionToast from "../hooks/useActionToast";
+import useLocationMapDerivedState from "../hooks/useLocationMapDerivedState";
+import useLocationMapDraftActions from "../hooks/useLocationMapDraftActions";
+import useLocationMapDraftState from "../hooks/useLocationMapDraftState";
+import useLocationMapBuying from "../hooks/useLocationMapBuying";
+import useLocationMapObjectActions from "../hooks/useLocationMapObjectActions";
+import useLocationMapViewControls from "../hooks/useLocationMapViewControls";
+import useUnsavedMapLeaveGuard from "../hooks/useUnsavedMapLeaveGuard";
+import { getMapStatus } from "../lib/locationMapUtils";
+import "../styles/pages/LocationMapManager.css";
+
+function isTextEditingTarget(target) {
+ return Boolean(target?.closest?.("input, textarea, select, [contenteditable]"));
+}
+
+export default function LocationMapManager() {
+ const { storeId, locationId } = useParams();
+ const navigate = useNavigate();
+ const routeLocation = useLocation();
+ const returnPath = routeLocation.state?.returnTo;
+ const toast = useActionToast();
+ const { username } = useContext(AuthContext);
+ const { activeHousehold, loading: householdLoading, hasLoaded } = useContext(HouseholdContext);
+ const { stores, activeStore, setActiveStore } = useContext(StoreContext);
+
+ const {
+ beginSaving,
+ dragState,
+ endSaving,
+ filters,
+ future,
+ hasUnsavedChanges,
+ history,
+ layersOpen,
+ loadError,
+ loadMap,
+ loading,
+ mapDraft,
+ mapState,
+ mode,
+ objects,
+ previewDraft,
+ saving,
+ savingAction,
+ selectedObjectKey,
+ setDragState,
+ setFilters,
+ setFuture,
+ setHasUnsavedChanges,
+ setHistory,
+ setLayersOpen,
+ setMapDraft,
+ setMapState,
+ setMode,
+ setObjects,
+ setPreviewDraft,
+ setSelectedObjectKey,
+ setZoom,
+ syncMapDraftFromState,
+ zoom,
+ } = useLocationMapDraftState({
+ activeHousehold,
+ hasLoaded,
+ householdLoading,
+ locationId,
+ toast,
+ });
+ const svgRef = useRef(null);
+
+ const location = mapState?.location || stores.find((store) => String(store.id) === String(locationId));
+ const canManage = Boolean(mapState?.can_manage ?? ["owner", "admin"].includes(activeHousehold?.role));
+ const {
+ hiddenAreaCount,
+ mapItems,
+ mapSize,
+ selectedObject,
+ selectedZoneItems,
+ unmappedItemCount,
+ visibleAssignedItemCount,
+ visibleUnmappedItems,
+ } = useLocationMapDerivedState({
+ filters,
+ mapDraft,
+ mapState,
+ mode,
+ objects,
+ previewDraft,
+ selectedObjectKey,
+ username,
+ });
+ const {
+ buyModalItem,
+ buyModalItems,
+ handleMapBuyCancel,
+ handleMapBuyConfirm,
+ handleMapBuyNavigate,
+ setBuyModalItem,
+ } = useLocationMapBuying({
+ activeHouseholdId: activeHousehold?.id,
+ locationId,
+ selectedObject,
+ selectedZoneItems,
+ setMapState,
+ toast,
+ visibleUnmappedItems,
+ });
+ const shouldGuardLeave = canManage && hasUnsavedChanges;
+
+ useEffect(() => {
+ const matchingLocation = stores.find((store) => String(store.id) === String(locationId));
+ if (matchingLocation && String(activeStore?.id) !== String(matchingLocation.id)) {
+ setActiveStore(matchingLocation);
+ }
+ }, [activeStore?.id, locationId, setActiveStore, stores]);
+
+ const {
+ handleCreateBlank,
+ handleCreateFromZones,
+ handlePublish,
+ handleSaveDraft,
+ } = useLocationMapDraftActions({
+ activeHouseholdId: activeHousehold?.id,
+ beginSaving,
+ endSaving,
+ hasUnsavedChanges,
+ locationId,
+ mapDraft,
+ objects,
+ selectedObjectKey,
+ setMapState,
+ setMode,
+ setPreviewDraft,
+ syncMapDraftFromState,
+ toast,
+ });
+
+ const {
+ handleAddObject,
+ handleDeleteObject,
+ handleDuplicateObject,
+ handleObjectField,
+ handleShowHiddenAreas,
+ handleZoneLinkChange,
+ expandMapToFitObject,
+ pendingDeleteObject,
+ remember,
+ requestDeleteObject,
+ setPendingDeleteObject,
+ updateObjects,
+ } = useLocationMapObjectActions({
+ canManage,
+ hiddenAreaCount,
+ mapSize,
+ mapState,
+ objects,
+ selectedObject,
+ selectedObjectKey,
+ setFuture,
+ setHasUnsavedChanges,
+ setHistory,
+ setMapDraft,
+ setObjects,
+ setSelectedObjectKey,
+ toast,
+ });
+
+ useEffect(() => {
+ setPendingDeleteObject(null);
+ }, [activeHousehold?.id, locationId, setPendingDeleteObject]);
+
+ const {
+ handleEditMode,
+ handleFitMap,
+ handlePreviewDraft,
+ handleRedo,
+ handleUndo,
+ handleViewMode,
+ } = useLocationMapViewControls({
+ filters,
+ future,
+ hasUnsavedChanges,
+ history,
+ mapSize,
+ mapState,
+ mode,
+ objects,
+ previewDraft,
+ saving,
+ selectedObjectKey,
+ setFuture,
+ setHasUnsavedChanges,
+ setHistory,
+ setLayersOpen,
+ setMode,
+ setObjects,
+ setPreviewDraft,
+ setSelectedObjectKey,
+ setZoom,
+ svgRef,
+ syncMapDraftFromState,
+ zoom,
+ });
+
+ const performBackNavigation = useCallback(() => {
+ if (typeof returnPath === "string" && returnPath.startsWith("/")) {
+ navigate(returnPath, { replace: true });
+ return;
+ }
+ navigate("/");
+ }, [navigate, returnPath]);
+
+ const {
+ pendingLeaveTarget,
+ requestBackNavigation,
+ clearPendingLeave,
+ confirmPendingLeave,
+ } = useUnsavedMapLeaveGuard({
+ shouldGuardLeave,
+ navigate,
+ performBackNavigation,
+ });
+
+ useEffect(() => {
+ const hasOpenModal = Boolean(buyModalItem || pendingDeleteObject || pendingLeaveTarget);
+ if (!selectedObjectKey || layersOpen || hasOpenModal || typeof window === "undefined") {
+ return undefined;
+ }
+
+ const handleKeyDown = (event) => {
+ if (
+ event.defaultPrevented ||
+ event.key !== "Escape" ||
+ isTextEditingTarget(event.target)
+ ) {
+ return;
+ }
+
+ setSelectedObjectKey(null);
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [buyModalItem, layersOpen, pendingDeleteObject, pendingLeaveTarget, selectedObjectKey, setSelectedObjectKey]);
+
+ useEffect(() => {
+ const hasOpenModal = Boolean(buyModalItem || pendingDeleteObject || pendingLeaveTarget);
+ if (mode !== "edit" || !canManage || hasOpenModal || typeof window === "undefined") {
+ return undefined;
+ }
+
+ const handleKeyDown = (event) => {
+ const wantsSave =
+ (event.ctrlKey || event.metaKey) &&
+ !event.altKey &&
+ event.key.toLowerCase() === "s";
+
+ if (!wantsSave) return;
+ event.preventDefault();
+ if (saving || !hasUnsavedChanges) return;
+ handleSaveDraft();
+ };
+
+ window.addEventListener("keydown", handleKeyDown, true);
+ return () => window.removeEventListener("keydown", handleKeyDown, true);
+ }, [
+ buyModalItem,
+ canManage,
+ handleSaveDraft,
+ hasUnsavedChanges,
+ mode,
+ pendingDeleteObject,
+ pendingLeaveTarget,
+ saving,
+ ]);
+
+ useEffect(() => {
+ const hasOpenModal = Boolean(buyModalItem || pendingDeleteObject || pendingLeaveTarget);
+ if (
+ !selectedObjectKey ||
+ mode !== "edit" ||
+ !canManage ||
+ saving ||
+ layersOpen ||
+ hasOpenModal ||
+ typeof window === "undefined"
+ ) {
+ return undefined;
+ }
+
+ const handleKeyDown = (event) => {
+ if (
+ event.defaultPrevented ||
+ !["Backspace", "Delete"].includes(event.key) ||
+ isTextEditingTarget(event.target)
+ ) {
+ return;
+ }
+
+ event.preventDefault();
+ requestDeleteObject();
+ };
+
+ window.addEventListener("keydown", handleKeyDown, true);
+ return () => window.removeEventListener("keydown", handleKeyDown, true);
+ }, [
+ buyModalItem,
+ canManage,
+ layersOpen,
+ mode,
+ pendingDeleteObject,
+ pendingLeaveTarget,
+ requestDeleteObject,
+ saving,
+ selectedObjectKey,
+ ]);
+
+ if (!hasLoaded || householdLoading || loading) {
+ return (
+
+ );
+ }
+
+ if (!activeHousehold) {
+ return (
+
+ );
+ }
+
+ if (loadError) {
+ return (
+
+ );
+ }
+
+ const hasAnyMap = Boolean(mapState?.draft_map || mapState?.published_map);
+ const hasVisibleOrManageableMap = Boolean(mapState?.published_map || (mapState?.draft_map && canManage));
+ const status = getMapStatus(mapState, { mode, previewDraft, hasUnsavedChanges });
+
+ return (
+
+
+
+
+ {hasAnyMap && mode !== "setup" ? (
+
+ ) : null}
+
+ {!hasAnyMap || (mode === "setup" && mapState?.draft_map && !mapState?.published_map) ? (
+ {
+ setMode("edit");
+ setPreviewDraft(true);
+ }}
+ onPreview={handlePreviewDraft}
+ onPublish={handlePublish}
+ onCreateFromZones={handleCreateFromZones}
+ onCreateBlank={handleCreateBlank}
+ />
+ ) : (
+ <>
+
+ setFilters((current) => ({
+ ...current,
+ showZones: true,
+ }))
+ }
+ onShowHiddenAreas={handleShowHiddenAreas}
+ />
+ setSelectedObjectKey(null)}
+ onSelectZoneItem={setBuyModalItem}
+ onSelectUnmappedItem={setBuyModalItem}
+ />
+ >
+ )}
+
+
+ setPendingDeleteObject(null)}
+ onDeleteConfirm={handleDeleteObject}
+ pendingLeaveTarget={pendingLeaveTarget}
+ onLeaveClose={clearPendingLeave}
+ onLeaveConfirm={confirmPendingLeave}
+ />
+
+ );
+}
diff --git a/frontend/src/styles/ConfirmBuyModal.css b/frontend/src/styles/ConfirmBuyModal.css
index 6a64977..47a3cc5 100644
--- a/frontend/src/styles/ConfirmBuyModal.css
+++ b/frontend/src/styles/ConfirmBuyModal.css
@@ -52,13 +52,12 @@
}
.confirm-buy-nav-btn {
- width: 35px;
- height: 35px;
+ width: 40px;
+ height: 40px;
border: var(--border-width-medium) solid var(--color-primary);
border-radius: var(--border-radius-full);
background: var(--color-bg-surface);
color: var(--color-primary);
- font-size: 1.8em;
font-weight: bold;
cursor: pointer;
transition: var(--transition-base);
@@ -70,6 +69,18 @@
flex-shrink: 0;
}
+.confirm-buy-nav-icon {
+ width: 22px;
+ height: 22px;
+ display: block;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 2.4;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ pointer-events: none;
+}
+
.confirm-buy-nav-btn:hover:not(:disabled) {
background: var(--color-primary);
color: var(--color-text-inverse);
@@ -100,8 +111,21 @@
}
.confirm-buy-image-placeholder {
- font-size: 4em;
+ width: min(58%, 150px);
+ aspect-ratio: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: var(--border-width-medium) solid var(--color-border-light);
+ border-radius: var(--border-radius-lg);
+ background: var(--color-gray-100);
color: var(--color-border-medium);
+ font-size: 4.75em;
+ line-height: 1;
+}
+
+.confirm-buy-image-placeholder span {
+ transform: translateY(0.02em);
}
.confirm-buy-quantity-section {
@@ -260,9 +284,8 @@
}
.confirm-buy-nav-btn {
- width: 30px;
- height: 30px;
- font-size: 1.6em;
+ width: 40px;
+ height: 40px;
}
.confirm-buy-counter-btn {
@@ -299,8 +322,7 @@
}
.confirm-buy-nav-btn {
- width: 28px;
- height: 28px;
- font-size: 1.4em;
+ width: 40px;
+ height: 40px;
}
}
diff --git a/frontend/src/styles/pages/LocationMapManager.css b/frontend/src/styles/pages/LocationMapManager.css
new file mode 100644
index 0000000..8a793d8
--- /dev/null
+++ b/frontend/src/styles/pages/LocationMapManager.css
@@ -0,0 +1,1271 @@
+.location-map-page {
+ height: calc(100dvh - 56px);
+ min-height: 0;
+ background: #07111d;
+ color: #f8fafc;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.location-map-topbar {
+ position: relative;
+ z-index: 25;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.75rem clamp(0.75rem, 2vw, 1.25rem);
+ border-bottom: 1px solid var(--color-border-light);
+ background: rgba(17, 28, 42, 0.94);
+ backdrop-filter: blur(14px);
+}
+
+.location-map-back,
+.location-map-topbar button {
+ min-height: 40px;
+ border: 1px solid var(--color-border-light);
+ border-radius: 8px;
+ background: #15283b;
+ color: #f8fafc;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.location-map-back {
+ width: 40px;
+ padding: 0;
+ font-size: 1.15rem;
+ line-height: 1;
+}
+
+.location-map-title {
+ min-width: 0;
+}
+
+.location-map-title h1 {
+ font-size: clamp(1rem, 3vw, 1.35rem);
+ color: #f8fafc;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.location-map-title span {
+ display: block;
+ margin-top: 0.15rem;
+ color: #9fb3c8;
+ font-size: 0.86rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.location-map-status {
+ min-width: 78px;
+ padding: 0.35rem 0.6rem;
+ border: 1px solid var(--color-border-light);
+ border-radius: 999px;
+ color: #f8fafc;
+ font-size: 0.78rem;
+ font-weight: 800;
+ text-align: center;
+}
+
+.location-map-status-no-map {
+ background: rgba(148, 163, 184, 0.16);
+}
+
+.location-map-status-loading {
+ background: rgba(148, 163, 184, 0.16);
+}
+
+.location-map-status-draft {
+ background: rgba(245, 158, 11, 0.2);
+ border-color: rgba(245, 158, 11, 0.36);
+}
+
+.location-map-status-viewing-draft {
+ background: rgba(14, 165, 233, 0.2);
+ border-color: rgba(56, 189, 248, 0.4);
+}
+
+.location-map-status-unsaved-draft {
+ background: rgba(244, 114, 182, 0.2);
+ border-color: rgba(244, 114, 182, 0.42);
+}
+
+.location-map-status-load-error {
+ background: rgba(248, 113, 113, 0.2);
+ border-color: rgba(248, 113, 113, 0.42);
+}
+
+.location-map-status-published {
+ background: rgba(20, 184, 166, 0.18);
+ border-color: rgba(20, 184, 166, 0.34);
+}
+
+.location-map-workspace {
+ flex: 1;
+ min-height: 0;
+ min-width: 0;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ overflow: hidden;
+}
+
+.location-map-toolbar {
+ display: flex;
+ align-items: flex-start;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ padding: 0.75rem clamp(0.75rem, 2vw, 1.25rem);
+ border-bottom: 1px solid var(--color-border-light);
+ background: rgba(11, 19, 31, 0.98);
+}
+
+.location-map-mode-group {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+}
+
+.location-map-mode-buttons,
+.location-map-zoom-controls,
+.location-map-history-buttons,
+.location-map-editor-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+}
+
+.location-map-mode-buttons button,
+.location-map-zoom-controls button,
+.location-map-history-buttons button,
+.location-map-editor-actions button,
+.location-map-setup-actions button {
+ min-height: 40px;
+ padding: 0.55rem 0.8rem;
+ border: 1px solid var(--color-border-light);
+ border-radius: 8px;
+ background: #15283b;
+ color: #f8fafc;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.location-map-mode-buttons {
+ gap: 0.25rem;
+ padding: 0.25rem;
+ border: 1px solid var(--color-border-light);
+ border-radius: 999px;
+ background: rgba(15, 23, 34, 0.68);
+}
+
+.location-map-mode-buttons button {
+ min-height: 36px;
+ border-color: transparent;
+ border-radius: 999px;
+ background: transparent;
+}
+
+.location-map-mode-buttons button.active,
+.location-map-editor-actions button.primary,
+.location-map-setup-actions .btn-primary {
+ border-color: var(--color-primary);
+ background: var(--color-primary);
+ color: #06111f;
+}
+
+.location-map-history-buttons {
+ gap: 0.3rem;
+}
+
+.location-map-history-buttons button {
+ min-width: 40px;
+ padding-inline: 0.55rem;
+ font-size: 1.12rem;
+ line-height: 1;
+}
+
+.location-map-history-buttons button.location-map-icon-button,
+.location-map-zoom-controls button.location-map-icon-button {
+ width: 40px;
+ min-width: 40px;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.location-map-toolbar-icon {
+ width: 18px;
+ height: 18px;
+ display: block;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 2.2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ pointer-events: none;
+}
+
+.location-map-editor-actions button.danger {
+ border-color: rgba(248, 113, 113, 0.5);
+ background: rgba(248, 113, 113, 0.24);
+}
+
+.location-map-mode-buttons button:disabled,
+.location-map-zoom-controls button:disabled,
+.location-map-history-buttons button:disabled,
+.location-map-editor-actions button:disabled,
+.location-map-hidden-areas button:disabled,
+.location-map-setup-actions button:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+
+.location-map-zoom-controls span {
+ min-width: 54px;
+ color: #cbd5e1;
+ font-size: 0.9rem;
+ font-weight: 700;
+ text-align: center;
+}
+
+.location-map-zoom-controls {
+ margin-left: auto;
+}
+
+.location-map-label-short {
+ display: none;
+}
+
+.location-map-canvas-shell {
+ min-height: 0;
+ min-width: 0;
+ max-width: 100vw;
+ overflow: hidden;
+ padding: 0.75rem;
+}
+
+.location-map-scroll {
+ position: relative;
+ width: 100%;
+ max-width: 100%;
+ height: 100%;
+ min-height: 0;
+ overflow: auto;
+ border: 1px solid var(--color-border-light);
+ border-radius: 8px;
+ background:
+ linear-gradient(135deg, rgba(20, 184, 166, 0.08), transparent 40%),
+ linear-gradient(180deg, rgba(15, 23, 42, 0.2), rgba(15, 23, 42, 0.04));
+ background-color: #101b2a;
+ touch-action: pan-x pan-y;
+}
+
+.location-map-canvas-shell.is-pan-tool .location-map-scroll {
+ cursor: grab;
+}
+
+.location-map-canvas-shell.is-panning .location-map-scroll {
+ cursor: grabbing;
+ user-select: none;
+}
+
+.location-map-svg {
+ min-width: 320px;
+ min-height: 260px;
+ display: block;
+}
+
+.location-map-svg.is-editable {
+ touch-action: none;
+}
+
+.location-map-background {
+ fill: rgba(15, 23, 34, 0.92);
+ stroke: rgba(148, 163, 184, 0.28);
+ stroke-width: 2;
+}
+
+.location-map-grid {
+ fill: url("#location-map-grid");
+ stroke: rgba(148, 163, 184, 0.18);
+ stroke-width: 1;
+}
+
+.location-map-object rect:first-child {
+ fill: rgba(30, 83, 124, 0.78);
+ stroke: rgba(96, 165, 250, 0.72);
+ stroke-width: 2;
+ cursor: pointer;
+ transition: fill 120ms ease, stroke 120ms ease, stroke-width 120ms ease;
+}
+
+.location-map-object.is-editable rect:first-child {
+ cursor: grab;
+}
+
+.location-map-object.is-editable.is-locked rect:first-child {
+ cursor: not-allowed;
+}
+
+.location-map-object.is-editable rect:first-child:active {
+ cursor: grabbing;
+}
+
+.location-map-object.is-editable.is-locked rect:first-child:active {
+ cursor: not-allowed;
+}
+
+.location-map-object:not(.is-selected):hover rect:first-child {
+ fill: rgba(37, 99, 141, 0.88);
+ stroke: rgba(147, 197, 253, 0.9);
+ stroke-width: 3;
+}
+
+.location-map-object rect:first-child:focus-visible {
+ outline: none;
+ stroke: #bfdbfe;
+ stroke-width: 4;
+}
+
+.location-map-object.is-selected rect:first-child {
+ fill: rgba(20, 184, 166, 0.34);
+ stroke: rgb(45, 212, 191);
+ stroke-width: 4;
+}
+
+.location-map-object.is-selected rect:first-child:focus-visible {
+ stroke: rgb(45, 212, 191);
+}
+
+.location-map-label {
+ fill: #f8fafc;
+ font-size: 24px;
+ font-weight: 800;
+ pointer-events: none;
+}
+
+.location-map-count {
+ fill: rgba(226, 232, 240, 0.82);
+ font-size: 18px;
+ font-weight: 700;
+ pointer-events: none;
+}
+
+.location-map-pin {
+ fill: rgb(245, 158, 11);
+ stroke: rgba(15, 23, 42, 0.9);
+ stroke-width: 2;
+ pointer-events: none;
+}
+
+.location-map-resize-handle {
+ fill: rgb(245, 158, 11);
+ stroke: rgba(15, 23, 42, 0.9);
+ stroke-width: 2;
+ cursor: nwse-resize;
+ touch-action: none;
+}
+
+.location-map-lock-badge {
+ pointer-events: none;
+}
+
+.location-map-lock-badge rect:first-child {
+ fill: rgba(15, 23, 42, 0.88);
+ stroke: rgba(251, 191, 36, 0.88);
+ stroke-width: 2;
+}
+
+.location-map-lock-badge path {
+ fill: none;
+ stroke: #fde68a;
+ stroke-width: 3;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.location-map-lock-badge .location-map-lock-body {
+ fill: #fde68a;
+ stroke: none;
+}
+
+.location-map-empty-canvas {
+ position: sticky;
+ left: 50%;
+ bottom: 1rem;
+ width: fit-content;
+ max-width: calc(100% - 2rem);
+ transform: translateX(-50%);
+ padding: 0.55rem 0.75rem;
+ border: 1px solid var(--color-border-light);
+ border-radius: 999px;
+ background: rgba(17, 28, 42, 0.9);
+ color: #cbd5e1;
+ font-weight: 800;
+ pointer-events: none;
+}
+
+.location-map-empty-canvas.has-action {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.55rem;
+ padding: 0.4rem 0.45rem 0.4rem 0.7rem;
+ pointer-events: auto;
+}
+
+.location-map-empty-canvas button {
+ min-height: 40px;
+ padding: 0 0.75rem;
+ border: 1px solid rgba(96, 165, 250, 0.52);
+ border-radius: 999px;
+ background: rgba(59, 130, 246, 0.24);
+ color: #f8fafc;
+ font-weight: 900;
+ cursor: pointer;
+}
+
+.location-map-empty-canvas button:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+.location-map-bottom-sheet {
+ position: relative;
+ max-height: 43vh;
+ overflow-y: auto;
+ padding: 0.8rem clamp(0.75rem, 2vw, 1.25rem) 1rem;
+ border-top: 1px solid var(--color-border-light);
+ background: rgba(17, 28, 42, 0.96);
+ box-shadow: 0 -18px 40px rgba(0, 0, 0, 0.28);
+}
+
+.location-map-bottom-sheet-handle {
+ display: none;
+}
+
+.location-map-sheet-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 0.75rem;
+ margin-bottom: 0.65rem;
+}
+
+.location-map-sheet-header > div {
+ min-width: 0;
+}
+
+.location-map-sheet-heading {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ min-width: 0;
+}
+
+.location-map-sheet-header strong {
+ display: block;
+ min-width: 0;
+ color: #f8fafc;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.location-map-sheet-header > div span {
+ display: block;
+ margin-top: 0.2rem;
+ color: #9fb3c8;
+ font-size: 0.82rem;
+}
+
+.location-map-sheet-header span,
+.location-map-muted {
+ color: #9fb3c8;
+}
+
+.location-map-sheet-header .location-map-sheet-count {
+ flex: 0 0 auto;
+ min-width: 58px;
+ margin-top: 0;
+ padding: 0.2rem 0.45rem;
+ border-radius: 999px;
+ background: rgba(96, 165, 250, 0.18);
+ color: #f8fafc;
+ font-size: 0.82rem;
+ font-weight: 900;
+ text-align: center;
+}
+
+.location-map-layer-actions {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.location-map-sheet-close {
+ width: 40px;
+ min-width: 40px;
+ height: 40px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--color-border-light);
+ border-radius: 999px;
+ background: rgba(15, 23, 34, 0.72);
+ color: #f8fafc;
+ font-size: 1.25rem;
+ font-weight: 900;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.location-map-sheet-close:hover,
+.location-map-sheet-close:focus-visible {
+ border-color: rgba(96, 165, 250, 0.76);
+ background: rgba(59, 130, 246, 0.22);
+}
+
+.location-map-layer-toggle {
+ min-height: 40px;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ padding: 0.45rem 0.7rem;
+ border: 1px solid var(--color-border-light);
+ border-radius: 999px;
+ background: rgba(15, 23, 34, 0.72);
+ color: #f8fafc;
+ font-weight: 800;
+ cursor: pointer;
+}
+
+.location-map-layer-count {
+ min-width: 1.35rem;
+ height: 1.35rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 999px;
+ background: var(--color-primary);
+ color: #06111f;
+ font-size: 0.72rem;
+ font-weight: 900;
+}
+
+.location-map-layer-toggle.active {
+ border-color: var(--color-primary);
+ background: rgba(59, 130, 246, 0.22);
+}
+
+.location-map-display-panel {
+ display: grid;
+ gap: 0.45rem;
+ margin-bottom: 0.7rem;
+}
+
+.location-map-layer-reset {
+ width: 40px;
+ min-width: 40px;
+ min-height: 40px;
+ padding: 0;
+ border: 1px solid var(--color-border-light);
+ border-radius: 999px;
+ background: rgba(15, 23, 34, 0.72);
+ color: #dbeafe;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+}
+
+.location-map-layer-reset-icon {
+ width: 18px;
+ height: 18px;
+ display: block;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 2.2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ pointer-events: none;
+}
+
+.location-map-display-controls {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 0.4rem;
+}
+
+.location-map-display-controls button {
+ min-height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 0.35rem;
+ padding: 0.35rem 0.45rem;
+ border: 1px solid var(--color-border-light);
+ border-radius: 999px;
+ background: rgba(15, 23, 34, 0.58);
+ color: #f8fafc;
+ font-size: 0.82rem;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.location-map-layer-state {
+ flex: 0 0 auto;
+ width: 0.85rem;
+ height: 0.85rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid rgba(148, 163, 184, 0.68);
+ border-radius: 999px;
+ background: rgba(2, 6, 23, 0.3);
+}
+
+.location-map-layer-state.is-on {
+ border-color: rgba(96, 165, 250, 0.95);
+ background: var(--color-primary);
+ box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.16);
+}
+
+.location-map-layer-label {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.location-map-display-controls button.active {
+ border-color: rgba(96, 165, 250, 0.76);
+ background: rgba(59, 130, 246, 0.32);
+ color: #f8fafc;
+ box-shadow: inset 0 0 0 1px rgba(96, 165, 250, 0.18);
+}
+
+.location-map-editor-actions {
+ flex-wrap: wrap;
+}
+
+.location-map-editor-action-stack {
+ display: grid;
+ gap: 0.45rem;
+ margin-bottom: 0.75rem;
+}
+
+.location-map-selected-primary-actions {
+ margin-bottom: 0.55rem;
+}
+
+.location-map-selected-secondary-actions {
+ margin-top: 0.55rem;
+ margin-bottom: 0;
+}
+
+.location-map-primary-actions button {
+ flex: 1 1 140px;
+}
+
+.location-map-secondary-actions button {
+ flex: 1 1 82px;
+ min-height: 40px;
+ padding: 0.45rem 0.58rem;
+ background: rgba(21, 40, 59, 0.78);
+ color: #dbeafe;
+ font-size: 0.86rem;
+}
+
+.location-map-hidden-areas {
+ margin-bottom: 0.75rem;
+}
+
+.location-map-hidden-areas button {
+ min-height: 40px;
+ width: 100%;
+ padding: 0.5rem 0.7rem;
+ border: 1px dashed rgba(96, 165, 250, 0.5);
+ border-radius: 8px;
+ background: rgba(59, 130, 246, 0.14);
+ color: #dbeafe;
+ font-weight: 900;
+ cursor: pointer;
+}
+
+.location-map-overview {
+ display: grid;
+ gap: 0.35rem;
+}
+
+.location-map-overview-row {
+ min-height: 38px;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.35rem 0.55rem;
+ border: 1px solid var(--color-border-light);
+ border-radius: 8px;
+ background: rgba(15, 23, 34, 0.56);
+}
+
+.location-map-overview-row span {
+ color: #9fb3c8;
+ font-size: 0.82rem;
+ font-weight: 800;
+}
+
+.location-map-overview-row strong {
+ color: #f8fafc;
+ font-weight: 900;
+ white-space: nowrap;
+}
+
+.location-map-object-form {
+ display: grid;
+ gap: 0.45rem;
+}
+
+.location-map-field-row {
+ min-height: 42px;
+ display: grid;
+ grid-template-columns: minmax(56px, 0.35fr) minmax(0, 1fr);
+ align-items: center;
+ gap: 0.55rem;
+ padding: 0.1rem 0.4rem;
+ border: 1px solid var(--color-border-light);
+ border-radius: 8px;
+ background: rgba(15, 23, 34, 0.58);
+}
+
+.location-map-field-row > span {
+ color: #9fb3c8;
+ font-size: 0.78rem;
+ font-weight: 800;
+}
+
+.location-map-object-form input,
+.location-map-object-form select {
+ width: 100%;
+ min-width: 0;
+ min-height: 40px;
+ padding: 0.4rem 0.6rem;
+ border: 1px solid var(--color-border-light);
+ border-radius: 8px;
+ background: #15283b;
+ color: #f8fafc;
+}
+
+.location-map-object-flags {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.45rem;
+}
+
+.location-map-object-flags button {
+ min-height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.4rem;
+ padding: 0.35rem 0.6rem;
+ border: 1px solid var(--color-border-light);
+ border-radius: 999px;
+ background: rgba(15, 23, 34, 0.58);
+ color: #f8fafc;
+ font-weight: 800;
+ cursor: pointer;
+}
+
+.location-map-object-flag-state {
+ flex: 0 0 auto;
+ width: 0.85rem;
+ height: 0.85rem;
+ border: 1px solid rgba(148, 163, 184, 0.68);
+ border-radius: 999px;
+ background: rgba(2, 6, 23, 0.3);
+}
+
+.location-map-object-flag-state.is-on {
+ border-color: rgba(96, 165, 250, 0.95);
+ background: var(--color-primary);
+ box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.16);
+}
+
+.location-map-object-flag-label {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.location-map-object-flags button.active {
+ border-color: rgba(96, 165, 250, 0.76);
+ background: rgba(59, 130, 246, 0.32);
+ box-shadow: inset 0 0 0 1px rgba(96, 165, 250, 0.18);
+}
+
+.location-map-object-flags button:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+
+.location-map-zone-empty {
+ display: grid;
+ gap: 0.35rem;
+}
+
+.location-map-zone-hidden-note {
+ margin-top: 0.55rem;
+}
+
+.location-map-inline-state {
+ min-height: 42px;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto auto;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.25rem 0.35rem 0.25rem 0.55rem;
+ border: 1px solid var(--color-border-light);
+ border-radius: 8px;
+ background: rgba(15, 23, 34, 0.56);
+ color: #f8fafc;
+}
+
+.location-map-inline-state span {
+ min-width: 0;
+ color: #9fb3c8;
+ font-size: 0.82rem;
+ font-weight: 800;
+}
+
+.location-map-inline-state strong {
+ color: #f8fafc;
+ font-weight: 900;
+ white-space: nowrap;
+}
+
+.location-map-inline-state button {
+ min-height: 40px;
+ min-width: 64px;
+ padding: 0.35rem 0.6rem;
+ border: 1px solid var(--color-border-light);
+ border-radius: 999px;
+ background: #15283b;
+ color: #f8fafc;
+ font-weight: 800;
+ cursor: pointer;
+}
+
+.location-map-zone-items ul,
+.location-map-unmapped-list ul {
+ display: grid;
+ gap: 0.35rem;
+}
+
+.location-map-zone-items li,
+.location-map-unmapped-list li {
+ min-height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.35rem 0.55rem;
+ border: 1px solid var(--color-border-light);
+ border-radius: 8px;
+ background: rgba(15, 23, 34, 0.56);
+ color: #f8fafc;
+}
+
+.location-map-zone-items li.location-map-item-row,
+.location-map-unmapped-list li.location-map-item-row {
+ padding: 0;
+ overflow: hidden;
+}
+
+.location-map-item-button {
+ width: 100%;
+ min-height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.35rem 0.55rem;
+ border: 0;
+ background: transparent;
+ color: #f8fafc;
+ font: inherit;
+ font-weight: 800;
+ text-align: left;
+ cursor: pointer;
+}
+
+.location-map-item-button span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.location-map-item-button:hover,
+.location-map-item-button:focus-visible {
+ outline: none;
+ background: rgba(96, 165, 250, 0.16);
+}
+
+.location-map-zone-items li.is-bought,
+.location-map-unmapped-list li.is-bought {
+ background: rgba(20, 184, 166, 0.12);
+ border-color: rgba(20, 184, 166, 0.3);
+}
+
+.location-map-zone-items li.is-bought .location-map-item-button,
+.location-map-unmapped-list li.is-bought .location-map-item-button {
+ color: #cbd5e1;
+ cursor: default;
+}
+
+.location-map-zone-items li.is-bought .location-map-item-button span,
+.location-map-unmapped-list li.is-bought .location-map-item-button span {
+ text-decoration: line-through;
+ text-decoration-thickness: 2px;
+ text-decoration-color: rgba(20, 184, 166, 0.85);
+}
+
+.location-map-item-button:disabled {
+ opacity: 1;
+}
+
+.location-map-zone-items small,
+.location-map-unmapped-list small {
+ color: #cbd5e1;
+ font-weight: 800;
+ white-space: nowrap;
+}
+
+.location-map-unmapped-list {
+ margin-top: 0.75rem;
+}
+
+.location-map-list-more {
+ padding: 0;
+ overflow: hidden;
+ border-style: dashed;
+ color: #bfdbfe;
+ font-weight: 800;
+}
+
+.location-map-list-more-button {
+ width: 100%;
+ min-height: 40px;
+ padding: 0.35rem 0.55rem;
+ border: 0;
+ background: transparent;
+ color: #bfdbfe;
+ font: inherit;
+ font-weight: 900;
+ cursor: pointer;
+}
+
+.location-map-list-more-button:hover,
+.location-map-list-more-button:focus-visible {
+ outline: none;
+ background: rgba(96, 165, 250, 0.14);
+}
+
+.location-map-setup {
+ align-self: center;
+ width: min(100% - 1.5rem, 560px);
+ margin: 3rem auto;
+ padding: 1.15rem;
+ border: 1px solid var(--color-border-light);
+ border-radius: 8px;
+ background: #111c2a;
+}
+
+.location-map-setup h2 {
+ margin-bottom: 0.45rem;
+ font-size: 1.35rem;
+}
+
+.location-map-state-message {
+ margin: 0.35rem 0 0.9rem;
+ color: #cbd5e1;
+ font-size: 0.95rem;
+ font-weight: 700;
+ line-height: 1.35;
+}
+
+.location-map-message-card .location-map-state-message {
+ margin-bottom: 0;
+}
+
+.location-map-setup-rows {
+ display: grid;
+ gap: 0.4rem;
+ margin-bottom: 0.9rem;
+}
+
+.location-map-setup-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ min-height: 40px;
+ padding: 0.5rem 0.7rem;
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ border-radius: 8px;
+ background: rgba(8, 15, 25, 0.42);
+}
+
+.location-map-setup-row span {
+ color: #9fb3c8;
+ font-size: 0.86rem;
+ font-weight: 700;
+}
+
+.location-map-setup-row strong {
+ min-width: 0;
+ color: #f8fafc;
+ font-weight: 900;
+ text-align: right;
+ overflow-wrap: anywhere;
+}
+
+.location-map-setup-actions {
+ display: grid;
+ gap: 0.6rem;
+}
+
+.location-map-message-workspace {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+@media (min-width: 840px) {
+ .location-map-workspace {
+ grid-template-columns: minmax(0, 1fr) 360px;
+ grid-template-rows: auto minmax(0, 1fr);
+ }
+
+ .location-map-toolbar {
+ grid-column: 1 / -1;
+ }
+
+ .location-map-bottom-sheet {
+ max-height: none;
+ border-top: none;
+ border-left: 1px solid var(--color-border-light);
+ }
+}
+
+@media (max-width: 839px) {
+ .location-map-toolbar {
+ --location-map-mode-slot: clamp(168px, 24vw, 188px);
+ --location-map-history-slot: 84px;
+ display: grid;
+ grid-template-columns: var(--location-map-mode-slot) minmax(0, 1fr);
+ align-items: center;
+ justify-content: stretch;
+ column-gap: 0.35rem;
+ row-gap: 0.4rem;
+ padding: 0.55rem 0.65rem;
+ }
+
+ .location-map-toolbar.has-history-slot {
+ grid-template-columns: var(--location-map-mode-slot) var(--location-map-history-slot) minmax(0, 1fr);
+ }
+
+ .location-map-mode-group {
+ grid-column: 1;
+ width: var(--location-map-mode-slot);
+ max-width: var(--location-map-mode-slot);
+ min-width: 0;
+ }
+
+ .location-map-mode-buttons {
+ width: var(--location-map-mode-slot);
+ max-width: var(--location-map-mode-slot);
+ min-width: 0;
+ padding: 0.18rem;
+ box-sizing: border-box;
+ }
+
+ .location-map-mode-buttons button {
+ flex: 1 1 0;
+ min-width: 0;
+ min-height: 40px;
+ padding: 0.35rem 0.42rem;
+ }
+
+ .location-map-history-buttons {
+ grid-column: 2;
+ gap: 0;
+ width: var(--location-map-history-slot);
+ justify-self: center;
+ }
+
+ .location-map-history-buttons button {
+ min-width: 40px;
+ min-height: 40px;
+ padding-inline: 0.35rem;
+ }
+
+ .location-map-zoom-controls {
+ grid-column: 2;
+ gap: 0.05rem;
+ margin-left: 0;
+ justify-self: end;
+ }
+
+ .location-map-toolbar.has-history-slot .location-map-zoom-controls {
+ grid-column: 3;
+ }
+
+ .location-map-zoom-controls button {
+ min-width: 40px;
+ min-height: 40px;
+ padding: 0.3rem;
+ }
+
+ .location-map-zoom-controls span {
+ min-width: 34px;
+ font-size: 0.82rem;
+ }
+
+ .location-map-bottom-sheet {
+ max-height: 34dvh;
+ }
+
+ .location-map-svg {
+ min-width: 0;
+ min-height: 0;
+ }
+}
+
+@media (max-width: 520px) {
+ .location-map-canvas-shell {
+ padding: 0.5rem;
+ }
+
+ .location-map-bottom-sheet {
+ max-height: 34dvh;
+ padding-top: 0.55rem;
+ }
+
+ .location-map-bottom-sheet-handle {
+ display: block;
+ width: 42px;
+ height: 4px;
+ margin: 0 auto 0.55rem;
+ border-radius: 999px;
+ background: rgba(148, 163, 184, 0.5);
+ }
+
+ .location-map-topbar {
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ gap: 0.45rem;
+ padding: 0.42rem 0.55rem;
+ }
+
+ .location-map-title {
+ display: flex;
+ align-items: baseline;
+ gap: 0.35rem;
+ }
+
+ .location-map-title h1 {
+ flex: 0 1 auto;
+ min-width: 0;
+ font-size: 0.98rem;
+ }
+
+ .location-map-title span {
+ display: inline;
+ flex: 1 1 auto;
+ min-width: 0;
+ margin-top: 0;
+ font-size: 0.78rem;
+ }
+
+ .location-map-back {
+ width: 40px;
+ }
+
+ .location-map-status {
+ min-width: 0;
+ max-width: 90px;
+ justify-self: end;
+ padding: 0.22rem 0.38rem;
+ font-size: 0.68rem;
+ line-height: 1.05;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .location-map-toolbar {
+ --location-map-mode-slot: 132px;
+ --location-map-history-slot: 84px;
+ column-gap: 0.15rem;
+ row-gap: 0.35rem;
+ padding: 0.35rem 0.25rem;
+ }
+
+ .location-map-label-full {
+ display: none;
+ }
+
+ .location-map-label-short {
+ display: inline;
+ }
+
+ .location-map-zoom-controls span {
+ min-width: 28px;
+ }
+
+ .location-map-display-controls {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 380px) {
+ .location-map-toolbar {
+ grid-template-columns: var(--location-map-mode-slot, 124px) var(--location-map-history-slot, 84px);
+ }
+
+ .location-map-toolbar.has-history-slot {
+ grid-template-columns: var(--location-map-mode-slot, 124px) var(--location-map-history-slot, 84px);
+ }
+
+ .location-map-history-buttons {
+ width: var(--location-map-history-slot, 84px);
+ }
+
+ .location-map-history-buttons button,
+ .location-map-zoom-controls button {
+ min-width: 40px;
+ min-height: 40px;
+ padding: 0.3rem;
+ }
+
+ .location-map-zoom-controls,
+ .location-map-toolbar.has-history-slot .location-map-zoom-controls {
+ grid-column: 1 / -1;
+ width: 100%;
+ justify-self: stretch;
+ justify-content: space-between;
+ }
+
+ .location-map-zoom-controls span {
+ min-width: 34px;
+ font-size: 0.78rem;
+ }
+}
diff --git a/frontend/tests/buy-modal-auto-advance.spec.ts b/frontend/tests/buy-modal-auto-advance.spec.ts
index 7bfb7bc..d16a149 100644
--- a/frontend/tests/buy-modal-auto-advance.spec.ts
+++ b/frontend/tests/buy-modal-auto-advance.spec.ts
@@ -64,7 +64,19 @@ async function setupBuyModalRoutes(
});
});
- await page.route("**/households/1/stores/10/list/recent", async (route) => {
+ await page.route("**/households/1/locations/10/zones", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ zones: [
+ { id: 501, name: "Produce", sort_order: 10 },
+ ],
+ }),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/list/recent", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
@@ -72,7 +84,7 @@ async function setupBuyModalRoutes(
});
});
- 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",
@@ -80,7 +92,7 @@ async function setupBuyModalRoutes(
});
});
- await page.route("**/households/1/stores/10/list/classification**", async (route) => {
+ await page.route("**/households/1/locations/10/list/classification**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
@@ -88,7 +100,7 @@ async function setupBuyModalRoutes(
});
});
- await page.route("**/households/1/stores/10/list/item**", async (route) => {
+ await page.route("**/households/1/locations/10/list/item**", async (route) => {
const request = route.request();
if (request.method() === "PATCH") {
@@ -156,7 +168,7 @@ async function setupBuyModalRoutes(
});
});
- 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",
@@ -183,12 +195,11 @@ test("buying an item advances to the next one in the current sort order", async
]);
await page.goto("/");
- await page.locator(".glist-sort").selectOption("qty-high");
await openBuyModal(page, "bread");
await page.getByRole("button", { name: "Mark as Bought" }).click();
- await expect(page.locator(".confirm-buy-item-name")).toHaveText("apples");
+ await expect(page.locator(".confirm-buy-item-name")).toHaveText("milk");
});
test("buying the last item in the current order wraps to the first remaining item", async ({ page }) => {
@@ -201,7 +212,6 @@ test("buying the last item in the current order wraps to the first remaining ite
]);
await page.goto("/");
- await page.locator(".glist-sort").selectOption("az");
await openBuyModal(page, "milk");
await page.getByRole("button", { name: "Mark as Bought" }).click();
@@ -219,7 +229,6 @@ test("partial buy keeps the item on the list and advances past it", async ({ pag
]);
await page.goto("/");
- await page.locator(".glist-sort").selectOption("qty-low");
await openBuyModal(page, "bravo");
await page.locator(".confirm-buy-counter-btn").nth(0).click();
diff --git a/frontend/tests/helpers/e2e.ts b/frontend/tests/helpers/e2e.ts
index 1f4cbc2..77074fc 100644
--- a/frontend/tests/helpers/e2e.ts
+++ b/frontend/tests/helpers/e2e.ts
@@ -16,8 +16,12 @@ type HouseholdSeed = {
type StoreSeed = {
id?: number;
+ household_store_id?: number;
+ household_id?: number;
name?: string;
location?: string;
+ location_name?: string;
+ display_name?: string;
is_default?: boolean;
};
@@ -57,9 +61,21 @@ export async function mockHouseholdAndStoreShell(
invite_code: "ABCD1234",
...options.household,
};
- const stores = options.stores || [
- { id: 10, name: "Costco", location: "Warehouse", is_default: true },
- ];
+ const stores = (options.stores || [
+ { id: 10, household_store_id: 100, name: "Costco", location_name: "Warehouse", is_default: true },
+ ]).map((store, index) => {
+ const locationName = store.location_name || store.location || "Default Location";
+ return {
+ household_id: household.id,
+ household_store_id: store.household_store_id ?? store.id ?? index + 1,
+ id: store.id ?? index + 10,
+ name: store.name || "Costco",
+ location_name: locationName,
+ display_name: store.display_name || `${store.name || "Costco"} - ${locationName}`,
+ is_default: store.is_default ?? index === 0,
+ ...store,
+ };
+ });
await page.route("**/households", async (route) => {
await route.fulfill({
@@ -76,6 +92,14 @@ export async function mockHouseholdAndStoreShell(
body: JSON.stringify(stores),
});
});
+
+ await page.route(`**/households/${household.id}/stores`, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(stores),
+ });
+ });
}
export async function confirmSlide(page: Page) {
diff --git a/frontend/tests/location-map-manager.spec.ts b/frontend/tests/location-map-manager.spec.ts
new file mode 100644
index 0000000..02253e2
--- /dev/null
+++ b/frontend/tests/location-map-manager.spec.ts
@@ -0,0 +1,5410 @@
+import { expect, test, type Page } from "@playwright/test";
+import { confirmSlide, mockConfig, seedAuthStorage } from "./helpers/e2e";
+
+const adminHousehold = { id: 1, name: "Map House", role: "admin", invite_code: "ABCD1234" };
+const memberHousehold = { ...adminHousehold, role: "member" };
+const storeLocation = {
+ id: 10,
+ household_store_id: 100,
+ household_id: 1,
+ name: "Costco",
+ location_name: "Eastvale",
+ display_name: "Costco - Eastvale",
+ is_default: true,
+};
+const secondStoreLocation = {
+ ...storeLocation,
+ id: 11,
+ location_name: "Ontario",
+ display_name: "Costco - Ontario",
+};
+const zones = [
+ { id: 501, name: "Bakery", sort_order: 10, item_count: 2 },
+ { id: 502, name: "Frozen Foods", sort_order: 20, item_count: 1 },
+];
+const items = [
+ {
+ id: 1,
+ item_name: "french bread",
+ quantity: 1,
+ bought: false,
+ zone_id: 501,
+ zone: "Bakery",
+ added_by_users: ["map-user"],
+ },
+ {
+ id: 2,
+ item_name: "sourdough",
+ quantity: 1,
+ bought: false,
+ zone_id: 501,
+ zone: "Bakery",
+ added_by_users: ["map-user"],
+ },
+ {
+ id: 3,
+ item_name: "frozen salmon",
+ quantity: 1,
+ bought: false,
+ zone_id: 502,
+ zone: "Frozen Foods",
+ added_by_users: ["other-user"],
+ },
+ {
+ id: 4,
+ item_name: "loose batteries",
+ quantity: 1,
+ bought: false,
+ zone_id: null,
+ zone: null,
+ added_by_users: ["map-user"],
+ },
+];
+
+function noMapState(canManage = true) {
+ return {
+ location: storeLocation,
+ map: null,
+ draft_map: null,
+ published_map: null,
+ objects: [],
+ draft_objects: [],
+ published_objects: [],
+ zones,
+ items,
+ unmapped_count: 1,
+ can_manage: canManage,
+ };
+}
+
+function draftMapState(objects: Array
>, canManage = true) {
+ const draft = {
+ id: 900,
+ household_id: 1,
+ store_location_id: 10,
+ name: "Store Map",
+ width: 1000,
+ height: 700,
+ status: "draft",
+ version: 1,
+ };
+
+ return {
+ ...noMapState(canManage),
+ map: draft,
+ draft_map: draft,
+ objects,
+ draft_objects: objects,
+ };
+}
+
+function publishedMapState(objects: Array>, canManage = true) {
+ const published = {
+ id: 901,
+ household_id: 1,
+ store_location_id: 10,
+ name: "Store Map",
+ width: 1000,
+ height: 700,
+ status: "published",
+ version: 2,
+ };
+
+ return {
+ ...noMapState(canManage),
+ map: published,
+ published_map: published,
+ objects,
+ published_objects: objects,
+ };
+}
+
+function draftAndPublishedMapState(
+ draftObjects: Array>,
+ publishedObjects: Array>,
+ canManage = true
+) {
+ const draft = {
+ id: 900,
+ household_id: 1,
+ store_location_id: 10,
+ name: "Store Map",
+ width: 1000,
+ height: 700,
+ status: "draft",
+ version: 3,
+ };
+ const published = {
+ ...draft,
+ id: 901,
+ status: "published",
+ version: 2,
+ };
+
+ return {
+ ...noMapState(canManage),
+ map: draft,
+ draft_map: draft,
+ published_map: published,
+ objects: draftObjects,
+ draft_objects: draftObjects,
+ published_objects: publishedObjects,
+ };
+}
+
+async function mockMapShell(
+ page: Page,
+ household: typeof adminHousehold | Array = adminHousehold,
+ locations: Array = [storeLocation]
+) {
+ const households = Array.isArray(household) ? household : [household];
+ const primaryHousehold = households[0] || adminHousehold;
+ await seedAuthStorage(page, { username: "map-user", role: primaryHousehold.role });
+ await mockConfig(page);
+
+ await page.route("**/households", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(households),
+ });
+ });
+
+ await page.route("**/households/1/stores", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(locations),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/zones", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ zones }),
+ });
+ });
+}
+
+async function expectMapFitsCanvas(page: Page, maxZoomPercent: number, context: string) {
+ const scroll = page.locator(".location-map-scroll");
+ const zoomText = page.locator(".location-map-zoom-controls span");
+ await expect.poll(async () => {
+ const value = await zoomText.textContent();
+ return Number(value?.replace("%", "") || 0);
+ }).toBeLessThan(maxZoomPercent);
+
+ const scrollBox = await scroll.boundingBox();
+ const svgBox = await page.locator(".location-map-svg").boundingBox();
+ expect(scrollBox).not.toBeNull();
+ expect(svgBox).not.toBeNull();
+ if (!scrollBox || !svgBox) throw new Error(`${context} fitted map was not measurable`);
+ expect(svgBox.width).toBeLessThanOrEqual(scrollBox.width + 1);
+ expect(svgBox.height).toBeLessThanOrEqual(scrollBox.height + 1);
+
+ return { scroll };
+}
+
+async function readMapZoomPercent(page: Page) {
+ const value = await page.locator(".location-map-zoom-controls span").textContent();
+ return Number(value?.replace("%", "") || 0);
+}
+
+async function expectResizeHandleWithinMapScroll(page: Page) {
+ const scroll = page.locator(".location-map-scroll");
+ const resizeHandle = page.locator(".location-map-resize-handle");
+ await expect(resizeHandle).toBeVisible();
+ await expect.poll(async () => {
+ const scrollBox = await scroll.boundingBox();
+ const handleBox = await resizeHandle.boundingBox();
+ if (!scrollBox || !handleBox) return false;
+
+ return (
+ handleBox.x >= scrollBox.x &&
+ handleBox.y >= scrollBox.y &&
+ handleBox.x + handleBox.width <= scrollBox.x + scrollBox.width + 1 &&
+ handleBox.y + handleBox.height <= scrollBox.y + scrollBox.height + 1
+ );
+ }).toBe(true);
+}
+
+test("admin creates a map from zones, saves a draft, and publishes it", async ({ page }) => {
+ await mockMapShell(page);
+
+ let mapState = noMapState(true);
+ let savedPayload: Record | null = null;
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/map/from-zones", async (route) => {
+ const objects = [
+ {
+ id: 1001,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 28,
+ y: 28,
+ width: 300,
+ height: 180,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ {
+ id: 1002,
+ location_map_id: 900,
+ zone_id: 502,
+ zone_name: "Frozen Foods",
+ type: "zone",
+ label: "Frozen Foods",
+ x: 356,
+ y: 28,
+ width: 300,
+ height: 180,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 2,
+ },
+ ];
+ mapState = draftMapState(objects, true);
+ await route.fulfill({
+ status: 201,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/map/draft", async (route) => {
+ savedPayload = await route.request().postDataJSON();
+ const savedObjects = (savedPayload.objects as Array>).map((object, index) => ({
+ id: 1100 + index,
+ location_map_id: 900,
+ zone_name: object.zone_id === 501 ? "Bakery" : "Frozen Foods",
+ ...object,
+ }));
+ mapState = draftMapState(savedObjects, true);
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/map/publish", async (route) => {
+ mapState = publishedMapState(mapState.draft_objects, true);
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await expect(page.getByRole("heading", { name: "Costco" })).toBeVisible();
+ await expect(page.getByText("Eastvale")).toBeVisible();
+ await expect(page.getByRole("heading", { name: "No Map" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Create From 2 Zones" })).toBeVisible();
+ await expect(page.getByLabel("Map controls")).toHaveCount(0);
+
+ await page.getByRole("button", { name: "Create From 2 Zones" }).click();
+ await expect(page.getByText("Bakery")).toBeVisible();
+ await expect(page.getByText("Frozen Foods")).toBeVisible();
+ await expect(page.getByRole("button", { name: "Move Map" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Edit Areas" })).toHaveCount(0);
+ await expect(page.locator(".location-map-tool-buttons")).toHaveCount(0);
+ await expect(page.locator(".location-map-pin")).toHaveCount(0);
+
+ const bakeryObject = page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first();
+ await bakeryObject.click();
+ await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Bakery");
+ await expect(page.locator(".location-map-object").last()).toHaveAttribute("data-object-key", "1001");
+ await expect(page.getByLabel("Object type")).toHaveCount(0);
+ const moveBox = await bakeryObject.boundingBox();
+ expect(moveBox).not.toBeNull();
+ if (!moveBox) throw new Error("Bakery map object could not be moved");
+ await page.mouse.move(moveBox.x + moveBox.width / 2, moveBox.y + moveBox.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(moveBox.x + moveBox.width / 2 + 60, moveBox.y + moveBox.height / 2 + 40, {
+ steps: 8,
+ });
+ await page.mouse.up();
+
+ const resizeHandle = page.locator(".location-map-resize-handle");
+ await expect(resizeHandle).toBeVisible();
+ await expect(resizeHandle).toHaveAttribute("width", "64");
+ await expect(resizeHandle).toHaveAttribute("height", "64");
+ const resizeBox = await resizeHandle.boundingBox();
+ expect(resizeBox).not.toBeNull();
+ if (!resizeBox) throw new Error("Resize handle was not measurable");
+ await page.mouse.move(resizeBox.x + resizeBox.width / 2, resizeBox.y + resizeBox.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(resizeBox.x + resizeBox.width / 2 + 45, resizeBox.y + resizeBox.height / 2 + 35, {
+ steps: 8,
+ });
+ await page.mouse.up();
+
+ await page.getByRole("textbox", { name: "Label" }).fill("Bread Wall");
+ await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Bread Wall");
+ await page.getByRole("button", { name: "Duplicate area Bread Wall" }).click();
+ await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Bread Wall Copy");
+ await page.getByLabel("Linked zone").selectOption("502");
+ await page.getByRole("textbox", { name: "Label" }).fill("Temporary Freezer");
+ await page.getByRole("button", { name: "Delete area Temporary Freezer" }).click();
+ await expect(page.getByText("This removes the area from the draft map.")).toHaveCount(0);
+ await confirmSlide(page);
+ await expect(page.locator(".location-map-object", { hasText: "Temporary Freezer" })).toHaveCount(0);
+
+ await page.getByRole("button", { name: "Back to grocery list" }).click();
+ await expect(page.getByRole("heading", { name: "Leave with unsaved changes?" })).toBeVisible();
+ await expect(page.getByText("Your draft edits are only on this screen right now.")).toHaveCount(0);
+ await page.getByRole("button", { name: "Cancel" }).click();
+ await expect(page).toHaveURL(/\/stores\/100\/locations\/10\/map$/);
+
+ const mapStatus = page.locator(".location-map-status");
+
+ await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
+ await expect(mapStatus).toHaveAccessibleName("Map status: Unsaved Draft");
+ await page.getByRole("button", { name: "View", exact: true }).click();
+ await expect(page.locator(".location-map-object", { hasText: "Bread Wall" })).toBeVisible();
+ await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
+
+ await page.getByRole("button", { name: "Edit Draft" }).click();
+ await page.locator(".location-map-object", { hasText: "Bread Wall" }).locator("rect").first().click();
+ await page.getByRole("button", { name: "Save Draft" }).click();
+ await expect(page.locator(".location-map-status")).toHaveText("Draft");
+ await expect(mapStatus).toHaveAccessibleName("Map status: Draft");
+
+ expect(savedPayload).not.toBeNull();
+ const savedObjects = savedPayload?.objects as Array>;
+ const savedBreadWall = savedObjects.find((object) => object.label === "Bread Wall");
+ expect(savedBreadWall).toEqual(expect.objectContaining({ zone_id: 501 }));
+ expect(Number(savedBreadWall?.x)).toBeGreaterThan(28);
+ expect(Number(savedBreadWall?.y)).toBeGreaterThan(28);
+ expect(Number(savedBreadWall?.width)).toBeGreaterThan(300);
+ expect(Number(savedBreadWall?.height)).toBeGreaterThan(180);
+ expect(savedObjects.some((object) => object.label === "Temporary Freezer")).toBe(false);
+
+ await page.goto("/login");
+ await expect(page.getByRole("heading", { name: "Login" })).toBeVisible();
+
+ await page.goto("/stores/100/locations/10/map");
+ await expect(page.locator(".location-map-status")).toHaveText("Draft");
+ await expect(page.getByRole("heading", { name: "Draft Map" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "View Draft" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Publish", exact: true })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Preview Map" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Publish Map" })).toHaveCount(0);
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+ await expect(page.locator(".location-map-object", { hasText: "Bread Wall" })).toBeVisible();
+ await expect(page.locator(".location-map-object", { hasText: "Temporary Freezer" })).toHaveCount(0);
+
+ await page.getByRole("button", { name: "Publish", exact: true }).click();
+ await expect(page.locator(".location-map-status")).toHaveText("Published");
+ await expect(mapStatus).toHaveAccessibleName("Map status: Published");
+ await expect(page.getByRole("button", { name: "Edit Draft" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Edit Map" })).toHaveCount(0);
+ await expect(page.getByText("loose batteries")).toHaveCount(0);
+
+ await page.locator(".location-map-object", { hasText: "Bread Wall" }).locator("rect").first().click();
+ await expect(page.locator(".location-map-sheet-count")).toHaveText("2 items");
+ await expect(page.getByText("french bread")).toBeVisible();
+ await expect(page.getByText("sourdough")).toBeVisible();
+ await expect(page.getByText("frozen salmon")).toHaveCount(0);
+
+ await page.getByRole("button", { name: "Layers" }).click();
+ await page.getByRole("button", { name: "Unmapped" }).click();
+ await expect(page.getByText("loose batteries")).toHaveCount(0);
+ await page.locator(".location-map-background").click({ position: { x: 8, y: 8 } });
+ await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Map Details");
+ await expect(page.getByText("loose batteries")).toBeVisible();
+});
+
+test("admin setup uses compact status rows when no zones exist yet", async ({ page }) => {
+ await mockMapShell(page);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ ...noMapState(true),
+ zones: [],
+ items: [],
+ unmapped_count: 0,
+ }),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await expect(page.getByRole("heading", { name: "No Map" })).toBeVisible();
+ const setupDetails = page.getByRole("group", { name: "Map setup details" });
+ await expect(setupDetails.locator(".location-map-setup-row")).toHaveCount(1);
+ await expect(setupDetails.locator(".location-map-setup-row", { hasText: "Zones" })).toContainText("0");
+ await expect(setupDetails.getByText("Status")).toHaveCount(0);
+ await expect(page.getByText("Not started")).toHaveCount(0);
+ await expect(page.getByText("No zones are available yet.")).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Create From Zones" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Create Blank Map" })).toBeEnabled();
+ const blankButtonBox = await page.getByRole("button", { name: "Create Blank Map" }).boundingBox();
+ expect(blankButtonBox).not.toBeNull();
+ if (!blankButtonBox) throw new Error("No-zone setup action was not measurable");
+ expect(blankButtonBox.height).toBeGreaterThanOrEqual(40);
+ await expect(page.getByLabel("Map controls")).toHaveCount(0);
+});
+
+test("map message prompts for a household with compact direct text", async ({ page }) => {
+ await seedAuthStorage(page, { username: "map-user", role: "member" });
+ await mockConfig(page);
+ await page.route("**/households", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify([]),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await expect(page.getByRole("heading", { name: "Select Household" })).toBeVisible();
+ const messageCard = page.locator(".location-map-message-card");
+ await expect(messageCard.getByText("Select a household to manage maps.")).toHaveCount(0);
+ await expect(messageCard.locator(".location-map-state-message")).toHaveCount(0);
+ await expect(messageCard.getByRole("group", { name: "Map message details" })).toHaveCount(0);
+ await expect(messageCard.locator(".location-map-setup-row", { hasText: "Message" })).toHaveCount(0);
+ await expect(messageCard.locator(".location-map-setup-row", { hasText: "Status" })).toHaveCount(0);
+ await expect(page.getByLabel("Map controls")).toHaveCount(0);
+});
+
+test("load failure shows retryable error instead of no-map setup", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = publishedMapState([
+ {
+ id: 391,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+ let attempts = 0;
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ attempts += 1;
+ if (attempts === 1) {
+ await route.fulfill({
+ status: 503,
+ contentType: "application/json",
+ body: JSON.stringify({ error: { message: "Map service unavailable" } }),
+ });
+ return;
+ }
+
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ const errorCard = page.locator(".location-map-load-error");
+ await expect(errorCard.getByRole("heading", { name: "Map Unavailable" })).toBeVisible();
+ await expect(errorCard.locator(".location-map-state-message")).toHaveText("Map service unavailable");
+ await expect(errorCard.getByRole("group", { name: "Map load details" })).toHaveCount(0);
+ await expect(errorCard.locator(".location-map-setup-row", { hasText: "Error" })).toHaveCount(0);
+ await expect(errorCard.locator(".location-map-setup-row", { hasText: "Status" })).toHaveCount(0);
+ await expect(page.getByText("Load Error")).toBeVisible();
+ await expect(page.getByRole("heading", { name: "No Map" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: /Create From/ })).toHaveCount(0);
+
+ await page.getByRole("button", { name: "Retry" }).click();
+
+ await expect(page.getByRole("heading", { name: "Map Unavailable" })).toHaveCount(0);
+ await expect(page.getByText("Published")).toBeVisible();
+ await expect(page.getByRole("button", { name: "Map area Bakery" })).toBeVisible();
+ expect(attempts).toBe(2);
+});
+
+test("loading map keeps location context visible", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = publishedMapState([
+ {
+ id: 461,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+ let resolveMapRequestStarted: () => void = () => {};
+ let releaseMapResponse: () => void = () => {};
+ let resolveMapResponseSent: () => void = () => {};
+ const mapRequestStarted = new Promise((resolve) => {
+ resolveMapRequestStarted = resolve;
+ });
+ const mapResponseGate = new Promise((resolve) => {
+ releaseMapResponse = resolve;
+ });
+ const mapResponseSent = new Promise((resolve) => {
+ resolveMapResponseSent = resolve;
+ });
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ resolveMapRequestStarted();
+ await mapResponseGate;
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ resolveMapResponseSent();
+ });
+
+ const navigation = page.goto("/stores/100/locations/10/map");
+ await mapRequestStarted;
+
+ await expect(page.getByRole("heading", { name: "Costco" })).toBeVisible();
+ await expect(page.getByText("Eastvale")).toBeVisible();
+ await expect(page.getByRole("button", { name: "Back to grocery list" })).toBeVisible();
+ await expect(page.locator(".location-map-status")).toHaveText("Loading");
+ await expect(page.getByRole("status")).toHaveAccessibleName("Map status: Loading");
+ const messageCard = page.locator(".location-map-message-card");
+ await expect(messageCard.locator(".location-map-state-message")).toHaveText("Loading map...");
+ await expect(messageCard.getByRole("group", { name: "Map message details" })).toHaveCount(0);
+ await expect(messageCard.locator(".location-map-setup-row", { hasText: "Message" })).toHaveCount(0);
+ await expect(messageCard.locator(".location-map-setup-row", { hasText: "Status" })).toHaveCount(0);
+
+ releaseMapResponse();
+ await mapResponseSent;
+ await navigation;
+
+ await expect(page.locator(".location-map-status")).toHaveText("Published");
+ await expect(page.getByRole("button", { name: "Map area Bakery" })).toBeVisible();
+});
+
+test("ignores stale map responses after switching locations", async ({ page }) => {
+ await mockMapShell(page, adminHousehold, [storeLocation, secondStoreLocation]);
+
+ const oldLocationState = publishedMapState([
+ {
+ id: 471,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Old Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+ const nextLocationBaseState = publishedMapState([
+ {
+ id: 472,
+ location_map_id: 902,
+ zone_id: 502,
+ zone_name: "Produce",
+ type: "zone",
+ label: "Ontario Produce",
+ x: 80,
+ y: 80,
+ width: 280,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+ const nextPublishedMap = {
+ ...nextLocationBaseState.published_map,
+ id: 902,
+ store_location_id: secondStoreLocation.id,
+ };
+ const nextLocationState = {
+ ...nextLocationBaseState,
+ location: secondStoreLocation,
+ map: nextPublishedMap,
+ published_map: nextPublishedMap,
+ };
+
+ let resolveFirstRequestStarted: () => void = () => {};
+ let releaseFirstResponse: () => void = () => {};
+ let resolveFirstResponseSent: () => void = () => {};
+ const firstRequestStarted = new Promise((resolve) => {
+ resolveFirstRequestStarted = resolve;
+ });
+ const firstResponseGate = new Promise((resolve) => {
+ releaseFirstResponse = resolve;
+ });
+ const firstResponseSent = new Promise((resolve) => {
+ resolveFirstResponseSent = resolve;
+ });
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ resolveFirstRequestStarted();
+ await firstResponseGate;
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(oldLocationState),
+ });
+ resolveFirstResponseSent();
+ });
+ await page.route("**/households/1/locations/11/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(nextLocationState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await firstRequestStarted;
+
+ await page.evaluate(() => {
+ window.history.pushState({}, "", "/stores/100/locations/11/map");
+ window.dispatchEvent(new PopStateEvent("popstate"));
+ });
+
+ await expect(page.getByRole("button", { name: "Map area Ontario Produce" })).toBeVisible();
+ await expect(page.locator(".location-map-title span")).toHaveText("Ontario");
+
+ releaseFirstResponse();
+ await firstResponseSent;
+
+ await expect(page.getByRole("button", { name: "Map area Ontario Produce" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Map area Old Bakery" })).toHaveCount(0);
+ await expect(page.locator(".location-map-title span")).toHaveText("Ontario");
+});
+
+test("ignores stale map save responses after switching locations", async ({ page }) => {
+ await mockMapShell(page, adminHousehold, [storeLocation, secondStoreLocation]);
+
+ const firstLocationState = draftMapState([
+ {
+ id: 483,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Eastvale Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+ const nextLocationBaseState = publishedMapState([
+ {
+ id: 484,
+ location_map_id: 902,
+ zone_id: 502,
+ zone_name: "Produce",
+ type: "zone",
+ label: "Ontario Produce",
+ x: 80,
+ y: 80,
+ width: 280,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+ const nextPublishedMap = {
+ ...nextLocationBaseState.published_map,
+ id: 902,
+ store_location_id: secondStoreLocation.id,
+ };
+ const nextLocationState = {
+ ...nextLocationBaseState,
+ location: secondStoreLocation,
+ map: nextPublishedMap,
+ published_map: nextPublishedMap,
+ };
+
+ let releaseSaveResponse: () => void = () => {};
+ let resolveSaveStarted: () => void = () => {};
+ let resolveSaveResponded: () => void = () => {};
+ const saveStarted = new Promise((resolve) => {
+ resolveSaveStarted = resolve;
+ });
+ const saveGate = new Promise((resolve) => {
+ releaseSaveResponse = resolve;
+ });
+ const saveResponded = new Promise((resolve) => {
+ resolveSaveResponded = resolve;
+ });
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(firstLocationState),
+ });
+ });
+ await page.route("**/households/1/locations/10/map/draft", async (route) => {
+ resolveSaveStarted();
+ await saveGate;
+ const payload = await route.request().postDataJSON();
+ const savedObjects = (payload.objects as Array>).map((object, index) => ({
+ id: 1480 + index,
+ location_map_id: 900,
+ zone_name: object.zone_id === 501 ? "Bakery" : null,
+ ...object,
+ label: "Saved Eastvale Bakery",
+ }));
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(draftMapState(savedObjects, true)),
+ });
+ resolveSaveResponded();
+ });
+ await page.route("**/households/1/locations/11/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(nextLocationState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+ await page.getByRole("button", { name: "Map area Eastvale Bakery" }).click();
+ await page.getByRole("textbox", { name: "Label" }).fill("Saving Eastvale Bakery");
+ await page.getByRole("button", { name: "Save Draft" }).click();
+ await saveStarted;
+
+ await page.evaluate(() => {
+ window.history.pushState({}, "", "/stores/100/locations/11/map");
+ window.dispatchEvent(new PopStateEvent("popstate"));
+ });
+
+ await expect(page.getByRole("button", { name: "Map area Ontario Produce" })).toBeVisible();
+ await expect(page.locator(".location-map-title span")).toHaveText("Ontario");
+ await expect(page.getByRole("button", { name: "Edit Draft" })).toBeEnabled();
+
+ releaseSaveResponse();
+ await saveResponded;
+
+ await expect(page.getByRole("button", { name: "Map area Ontario Produce" })).toBeVisible();
+ await expect(page.locator(".location-map-title span")).toHaveText("Ontario");
+ await expect(page.getByRole("button", { name: "Edit Draft" })).toBeEnabled();
+ await expect(page.getByRole("button", { name: "Map area Saved Eastvale Bakery" })).toHaveCount(0);
+ await expect(page.getByText("Saved draft")).toHaveCount(0);
+});
+
+test("resets map layer filters after switching locations", async ({ page }) => {
+ await mockMapShell(page, adminHousehold, [storeLocation, secondStoreLocation]);
+
+ const firstLocationState = publishedMapState([
+ {
+ id: 481,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Eastvale Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+ const nextLocationBaseState = publishedMapState([
+ {
+ id: 482,
+ location_map_id: 902,
+ zone_id: 502,
+ zone_name: "Produce",
+ type: "zone",
+ label: "Ontario Produce",
+ x: 80,
+ y: 80,
+ width: 280,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+ const nextPublishedMap = {
+ ...nextLocationBaseState.published_map,
+ id: 902,
+ store_location_id: secondStoreLocation.id,
+ };
+ const nextLocationState = {
+ ...nextLocationBaseState,
+ location: secondStoreLocation,
+ map: nextPublishedMap,
+ published_map: nextPublishedMap,
+ };
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(firstLocationState),
+ });
+ });
+ await page.route("**/households/1/locations/11/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(nextLocationState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await expect(page.getByRole("button", { name: "Map area Eastvale Bakery" })).toBeVisible();
+ await page.getByRole("button", { name: "Layers" }).click();
+ await page.getByRole("button", { name: "Zones" }).click();
+ await expect(page.getByText("Zones hidden in Layers")).toBeVisible();
+ await expect(page.getByRole("button", { name: "Map area Eastvale Bakery" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Layers, 1 changed" })).toBeVisible();
+
+ await page.goto("/stores/100/locations/11/map");
+
+ await expect(page.getByRole("button", { name: "Map area Ontario Produce" })).toBeVisible();
+ await expect(page.getByText("Zones hidden in Layers")).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Layers" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Layers, 1 changed" })).toHaveCount(0);
+});
+
+test("closes stale map buy modal after switching locations", async ({ page }) => {
+ await mockMapShell(page, adminHousehold, [storeLocation, secondStoreLocation]);
+
+ const firstLocationState = publishedMapState([
+ {
+ id: 491,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Eastvale Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+ const nextLocationBaseState = publishedMapState([
+ {
+ id: 492,
+ location_map_id: 902,
+ zone_id: 502,
+ zone_name: "Produce",
+ type: "zone",
+ label: "Ontario Produce",
+ x: 80,
+ y: 80,
+ width: 280,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+ const nextPublishedMap = {
+ ...nextLocationBaseState.published_map,
+ id: 902,
+ store_location_id: secondStoreLocation.id,
+ };
+ const nextLocationState = {
+ ...nextLocationBaseState,
+ location: secondStoreLocation,
+ map: nextPublishedMap,
+ published_map: nextPublishedMap,
+ items: [
+ {
+ id: 1,
+ item_name: "ontario oranges",
+ quantity: 1,
+ bought: false,
+ zone_id: 502,
+ zone: "Produce",
+ added_by_users: ["map-user"],
+ },
+ ],
+ };
+ let resolveBuyRequestStarted: () => void = () => {};
+ let releaseBuyResponse: () => void = () => {};
+ let resolveBuyResponseSent: () => void = () => {};
+ const buyRequestStarted = new Promise((resolve) => {
+ resolveBuyRequestStarted = resolve;
+ });
+ const buyResponseGate = new Promise((resolve) => {
+ releaseBuyResponse = resolve;
+ });
+ const buyResponseSent = new Promise((resolve) => {
+ resolveBuyResponseSent = resolve;
+ });
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(firstLocationState),
+ });
+ });
+ await page.route("**/households/1/locations/11/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(nextLocationState),
+ });
+ });
+ await page.route("**/households/1/locations/10/list/item", async (route) => {
+ if (route.request().method() !== "PATCH") {
+ await route.fulfill({ status: 404, contentType: "application/json", body: "{}" });
+ return;
+ }
+
+ resolveBuyRequestStarted();
+ await buyResponseGate;
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ success: true }),
+ });
+ resolveBuyResponseSent();
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.getByRole("button", { name: "Map area Eastvale Bakery" }).click();
+ await page.getByRole("button", { name: "Mark french bread bought" }).click();
+ await expect(page.locator(".confirm-buy-item-name")).toHaveText("french bread");
+ await page.getByRole("button", { name: "Mark as Bought" }).click();
+ await buyRequestStarted;
+
+ await page.evaluate(() => {
+ window.history.pushState({}, "", "/stores/100/locations/11/map");
+ window.dispatchEvent(new PopStateEvent("popstate"));
+ });
+
+ await expect(page.getByRole("button", { name: "Map area Ontario Produce" })).toBeVisible();
+ await expect(page.locator(".location-map-title span")).toHaveText("Ontario");
+ await expect(page.locator(".confirm-buy-modal")).toHaveCount(0);
+ await expect(page.getByText("french bread")).toHaveCount(0);
+ await page.getByRole("button", { name: "Map area Ontario Produce" }).click();
+ await expect(page.getByRole("button", { name: "Mark ontario oranges bought" })).toBeVisible();
+
+ releaseBuyResponse();
+ await buyResponseSent;
+
+ await expect(page.getByRole("button", { name: "Mark ontario oranges bought" })).toBeVisible();
+ await expect(page.getByText("Marked item bought")).toHaveCount(0);
+});
+
+test("closes stale map delete confirmation after switching locations", async ({ page }) => {
+ await mockMapShell(page, adminHousehold, [storeLocation, secondStoreLocation]);
+
+ const firstLocationState = draftMapState([
+ {
+ id: 493,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Eastvale Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+ const nextLocationBaseState = publishedMapState([
+ {
+ id: 494,
+ location_map_id: 902,
+ zone_id: 502,
+ zone_name: "Produce",
+ type: "zone",
+ label: "Ontario Produce",
+ x: 80,
+ y: 80,
+ width: 280,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+ const nextPublishedMap = {
+ ...nextLocationBaseState.published_map,
+ id: 902,
+ store_location_id: secondStoreLocation.id,
+ };
+ const nextLocationState = {
+ ...nextLocationBaseState,
+ location: secondStoreLocation,
+ map: nextPublishedMap,
+ published_map: nextPublishedMap,
+ };
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(firstLocationState),
+ });
+ });
+ await page.route("**/households/1/locations/11/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(nextLocationState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+ await page.getByRole("button", { name: "Map area Eastvale Bakery" }).click();
+ await page.getByRole("button", { name: "Delete area Eastvale Bakery" }).click();
+ await expect(page.getByRole("heading", { name: "Delete Eastvale Bakery?" })).toBeVisible();
+
+ await page.evaluate(() => {
+ window.history.pushState({}, "", "/stores/100/locations/11/map");
+ window.dispatchEvent(new PopStateEvent("popstate"));
+ });
+
+ await expect(page.getByRole("button", { name: "Map area Ontario Produce" })).toBeVisible();
+ await expect(page.locator(".location-map-title span")).toHaveText("Ontario");
+ await expect(page.getByRole("heading", { name: "Delete Eastvale Bakery?" })).toHaveCount(0);
+ await expect(page.locator(".confirm-slide-modal")).toHaveCount(0);
+});
+
+test("admin can add the first area from a blank map canvas", async ({ page }) => {
+ await mockMapShell(page);
+
+ let mapState = {
+ ...noMapState(true),
+ zones: [],
+ items: [],
+ unmapped_count: 0,
+ };
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/map/blank", async (route) => {
+ mapState = {
+ ...draftMapState([], true),
+ zones: [],
+ items: [],
+ unmapped_count: 0,
+ };
+ await route.fulfill({
+ status: 201,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.getByRole("button", { name: "Create Blank Map" }).click();
+
+ await expect(page.locator(".location-map-status")).toHaveText("Draft");
+ await expect(page.getByText("No map areas yet")).toBeVisible();
+ await expect(
+ page.locator(".location-map-empty-canvas").getByRole("button", { name: "Add Area" })
+ ).toBeVisible();
+
+ await page.locator(".location-map-empty-canvas").getByRole("button", { name: "Add Area" }).click();
+
+ await expect(page.getByRole("button", { name: "Map area New Area" })).toBeVisible();
+ await expect(page.locator(".location-map-empty-canvas")).toHaveCount(0);
+ await expect(page.locator(".location-map-sheet-header strong")).toHaveText("New Area");
+ await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("New Area");
+ await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
+ await expect(page.getByRole("button", { name: "Save Draft" })).toBeEnabled();
+});
+
+test("admin added map areas stay inside the canvas", async ({ page }) => {
+ await mockMapShell(page);
+
+ const existingObjects = Array.from({ length: 31 }, (_, index) => ({
+ id: 5000 + index,
+ location_map_id: 900,
+ zone_id: null,
+ zone_name: null,
+ type: "zone",
+ label: `Existing Area ${index + 1}`,
+ x: 24 + (index % 5) * 128,
+ y: 24 + Math.floor(index / 5) * 88,
+ width: 120,
+ height: 72,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: index + 1,
+ }));
+ const mapState = {
+ ...draftMapState(existingObjects, true),
+ zones: [],
+ };
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+ await page.getByRole("button", { name: "Add Area" }).click();
+
+ const newArea = page.getByRole("button", { name: "Map area New Area" });
+ await expect(newArea).toHaveAttribute("aria-pressed", "true");
+ await expect(newArea).toHaveAttribute("x", "780");
+ await expect(newArea).toHaveAttribute("y", "570");
+ await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
+});
+
+test("admin view mode previews the edited draft when a saved draft exists", async ({ page }) => {
+ await mockMapShell(page);
+
+ const publishedObjects = [
+ {
+ id: 1201,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Live Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ];
+ const draftObjects = [
+ {
+ ...publishedObjects[0],
+ id: 1301,
+ location_map_id: 900,
+ label: "Draft Bakery",
+ x: 80,
+ y: 80,
+ },
+ ];
+
+ const mapState = draftAndPublishedMapState(draftObjects, publishedObjects, true);
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await expect(page.locator(".location-map-status")).toHaveText("Published");
+ await expect(page.locator(".location-map-object", { hasText: "Live Bakery" })).toBeVisible();
+ await expect(page.locator(".location-map-object", { hasText: "Draft Bakery" })).toHaveCount(0);
+
+ await page.getByRole("button", { name: "Edit Draft" }).click();
+ await expect(page.locator(".location-map-status")).toHaveText("Draft");
+ await expect(page.locator(".location-map-object", { hasText: "Draft Bakery" })).toBeVisible();
+
+ await page.getByRole("button", { name: "View", exact: true }).click();
+ await expect(page.locator(".location-map-status")).toHaveText("Viewing Draft");
+ await expect(page.locator(".location-map-object", { hasText: "Draft Bakery" })).toBeVisible();
+
+ await page.getByRole("button", { name: "View" }).click();
+ await expect(page.locator(".location-map-status")).toHaveText("Viewing Draft");
+ await expect(page.locator(".location-map-object", { hasText: "Draft Bakery" })).toBeVisible();
+});
+
+test("admin keeps the selected draft area when switching to view mode", async ({ page }) => {
+ await mockMapShell(page);
+
+ const publishedObjects = [
+ {
+ id: 1211,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Live Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ];
+ const draftObjects = [
+ {
+ ...publishedObjects[0],
+ id: 1311,
+ location_map_id: 900,
+ label: "Draft Bakery",
+ x: 80,
+ y: 80,
+ },
+ ];
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(draftAndPublishedMapState(draftObjects, publishedObjects, true)),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await page.getByRole("button", { name: "Edit Draft" }).click();
+ const draftBakeryArea = page.getByRole("button", { name: "Map area Draft Bakery" });
+ await draftBakeryArea.click();
+ await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Draft Bakery");
+ await expect(page.locator(".location-map-object.is-selected")).toHaveAttribute("data-object-key", "1311");
+
+ await page.getByRole("button", { name: "View", exact: true }).click();
+
+ await expect(page.locator(".location-map-status")).toHaveText("Viewing Draft");
+ await expect(page.locator(".location-map-object.is-selected")).toHaveAttribute("data-object-key", "1311");
+ await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Draft Bakery");
+ await expect(page.getByRole("button", { name: "Close selected map area" })).toBeVisible();
+});
+
+test("admin edit mode hides unavailable draft actions until changes exist", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = publishedMapState([
+ {
+ id: 1201,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Live Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.getByRole("button", { name: "Edit Draft" }).click();
+
+ const secondaryActions = page.locator(".location-map-secondary-actions");
+ await expect(page.locator(".location-map-primary-actions")).toHaveCount(0);
+ await expect(secondaryActions.getByRole("button", { name: "Add Area" })).toBeVisible();
+ await expect(secondaryActions.getByRole("button", { name: "View Draft" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Publish", exact: true })).toHaveCount(0);
+
+ await secondaryActions.getByRole("button", { name: "Add Area" }).click();
+
+ await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
+ await expect(page.getByRole("button", { name: "Save Draft" })).toBeEnabled();
+ await expect(page.getByRole("button", { name: "Publish", exact: true })).toBeEnabled();
+});
+
+test("mode switches close open map layers without resetting filters", async ({ page }) => {
+ await mockMapShell(page);
+
+ const publishedObjects = [
+ {
+ id: 1241,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Live Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ];
+ const draftObjects = [
+ {
+ ...publishedObjects[0],
+ id: 1242,
+ location_map_id: 900,
+ label: "Draft Bakery",
+ x: 80,
+ y: 80,
+ },
+ ];
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(draftAndPublishedMapState(draftObjects, publishedObjects, true)),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await page.getByRole("button", { name: "Layers" }).click();
+ await page.getByRole("button", { name: "Pins" }).click();
+ await expect(page.getByRole("button", { name: "Layers, 1 changed" })).toHaveAttribute("aria-expanded", "true");
+ await expect(page.getByRole("group", { name: "Layer filters" })).toBeVisible();
+
+ await page.keyboard.press("Escape");
+
+ await expect(page.getByRole("button", { name: "Layers, 1 changed" })).toHaveAttribute("aria-expanded", "false");
+ await expect(page.getByRole("group", { name: "Layer filters" })).toHaveCount(0);
+
+ await page.getByRole("button", { name: "Layers, 1 changed" }).click();
+ await expect(page.getByRole("group", { name: "Layer filters" })).toBeVisible();
+
+ await page.getByRole("button", { name: "Edit Draft" }).click();
+
+ await expect(page.getByRole("button", { name: "Layers, 1 changed" })).toHaveAttribute("aria-expanded", "false");
+ await expect(page.getByRole("group", { name: "Layer filters" })).toHaveCount(0);
+
+ await page.getByRole("button", { name: "Layers, 1 changed" }).click();
+ await expect(page.getByRole("group", { name: "Layer filters" })).toBeVisible();
+ await page.getByRole("button", { name: "View", exact: true }).click();
+
+ await expect(page.getByRole("button", { name: "Layers, 1 changed" })).toHaveAttribute("aria-expanded", "false");
+ await expect(page.getByRole("group", { name: "Layer filters" })).toHaveCount(0);
+
+ await page.getByRole("button", { name: "Edit Draft" }).click();
+ await page.getByRole("button", { name: "Layers, 1 changed" }).click();
+ await expect(page.getByRole("group", { name: "Layer filters" })).toBeVisible();
+ await page.getByRole("button", { name: "View", exact: true }).click();
+
+ await expect(page.getByRole("button", { name: "Layers, 1 changed" })).toHaveAttribute("aria-expanded", "false");
+ await expect(page.getByRole("group", { name: "Layer filters" })).toHaveCount(0);
+});
+
+test("mobile keeps viewing draft status compact in the topbar", async ({ page }) => {
+ await page.setViewportSize({ width: 409, height: 838 });
+ await mockMapShell(page);
+
+ const publishedObjects = [
+ {
+ id: 1251,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Live Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ];
+ const draftObjects = [
+ {
+ ...publishedObjects[0],
+ id: 1252,
+ location_map_id: 900,
+ label: "Draft Bakery",
+ x: 80,
+ y: 80,
+ },
+ ];
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(draftAndPublishedMapState(draftObjects, publishedObjects, true)),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.getByRole("button", { name: "Edit Draft" }).click();
+ await page.getByRole("button", { name: "View", exact: true }).click();
+
+ const topbar = page.locator(".location-map-topbar");
+ const backButton = page.getByRole("button", { name: "Back to grocery list" });
+ const status = page.locator(".location-map-status");
+ const toolbar = page.locator(".location-map-toolbar");
+ const modeGroup = page.locator(".location-map-mode-group");
+ const modeButtons = page.locator(".location-map-mode-buttons");
+ const historyActions = page.locator(".location-map-history-buttons");
+ const zoomControls = page.locator(".location-map-zoom-controls");
+ await expect(status).toHaveText("Viewing Draft");
+
+ const topbarBox = await topbar.boundingBox();
+ const backButtonBox = await backButton.boundingBox();
+ const titleBox = await page.locator(".location-map-title").boundingBox();
+ const statusBox = await status.boundingBox();
+ const toolbarBox = await toolbar.boundingBox();
+ const modeGroupBox = await modeGroup.boundingBox();
+ const modeBox = await modeButtons.boundingBox();
+ const zoomBox = await zoomControls.boundingBox();
+ expect(topbarBox).not.toBeNull();
+ expect(backButtonBox).not.toBeNull();
+ expect(titleBox).not.toBeNull();
+ expect(statusBox).not.toBeNull();
+ expect(toolbarBox).not.toBeNull();
+ expect(modeGroupBox).not.toBeNull();
+ expect(modeBox).not.toBeNull();
+ expect(zoomBox).not.toBeNull();
+ if (
+ !topbarBox ||
+ !backButtonBox ||
+ !titleBox ||
+ !statusBox ||
+ !toolbarBox ||
+ !modeGroupBox ||
+ !modeBox ||
+ !zoomBox
+ ) {
+ throw new Error("Mobile map controls layout was not measurable");
+ }
+ expect(topbarBox.height).toBeLessThanOrEqual(56);
+ expect(backButtonBox.width).toBeLessThanOrEqual(42);
+ expect(titleBox.height).toBeLessThanOrEqual(24);
+ expect(statusBox.height).toBeLessThanOrEqual(24);
+ expect(statusBox.y).toBeLessThan(titleBox.y + titleBox.height);
+ expect(statusBox.x).toBeGreaterThan(titleBox.x);
+ expect(toolbarBox.height).toBeLessThanOrEqual(64);
+ expect(modeBox.width).toBeGreaterThanOrEqual(130);
+ expect(modeBox.width).toBeLessThanOrEqual(136);
+ await expect(historyActions).toHaveCount(0);
+ expect(zoomBox.y).toBeLessThan(modeBox.y + modeBox.height);
+ expect(zoomBox.x).toBeGreaterThan(modeBox.x + modeBox.width - 1);
+ expect(zoomBox.x + zoomBox.width).toBeLessThanOrEqual(toolbarBox.x + toolbarBox.width + 1);
+
+ await page.getByRole("button", { name: "Edit Draft" }).click();
+ await expect(historyActions).toHaveCount(1);
+ await expect(historyActions.getByRole("button", { name: "Undo" })).toBeVisible();
+ await expect(historyActions.getByRole("button", { name: "Redo" })).toBeVisible();
+ const editToolbarBox = await toolbar.boundingBox();
+ const editModeGroupBox = await modeGroup.boundingBox();
+ const editModeBox = await modeButtons.boundingBox();
+ const editHistoryBox = await historyActions.boundingBox();
+ const editZoomBox = await zoomControls.boundingBox();
+ expect(editToolbarBox).not.toBeNull();
+ expect(editModeGroupBox).not.toBeNull();
+ expect(editModeBox).not.toBeNull();
+ expect(editHistoryBox).not.toBeNull();
+ expect(editZoomBox).not.toBeNull();
+ if (!editToolbarBox || !editModeGroupBox || !editModeBox || !editHistoryBox || !editZoomBox) {
+ throw new Error("Mobile edit controls layout was not measurable");
+ }
+ expect(Math.abs(editModeBox.x - modeBox.x)).toBeLessThanOrEqual(1);
+ expect(Math.abs(editModeGroupBox.width - modeGroupBox.width)).toBeLessThanOrEqual(1);
+ expect(Math.abs(editModeBox.width - modeBox.width)).toBeLessThanOrEqual(1);
+ expect(editToolbarBox.height).toBeLessThanOrEqual(64);
+ expect(editModeBox.width).toBeGreaterThanOrEqual(130);
+ expect(editModeBox.width).toBeLessThanOrEqual(136);
+ expect(editHistoryBox.width).toBeGreaterThanOrEqual(82);
+ expect(editHistoryBox.x).toBeGreaterThan(editModeBox.x + editModeBox.width);
+ expect(editHistoryBox.x + editHistoryBox.width).toBeLessThanOrEqual(editToolbarBox.x + editToolbarBox.width + 1);
+ expect(editZoomBox.y).toBeLessThan(editModeBox.y + editModeBox.height);
+ expect(editZoomBox.x).toBeGreaterThan(editHistoryBox.x + editHistoryBox.width - 1);
+ expect(editZoomBox.x + editZoomBox.width).toBeLessThanOrEqual(editToolbarBox.x + editToolbarBox.width + 1);
+});
+
+test("mobile map refits after compact viewport resize until manually zoomed", async ({ page }) => {
+ await page.setViewportSize({ width: 700, height: 844 });
+ await mockMapShell(page);
+
+ const mapState = publishedMapState([
+ {
+ id: 1258,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await expect.poll(async () => readMapZoomPercent(page)).toBeGreaterThan(0);
+ const initialZoom = await readMapZoomPercent(page);
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ await expect.poll(async () => readMapZoomPercent(page)).toBeLessThan(initialZoom - 8);
+ const refitZoom = await readMapZoomPercent(page);
+
+ await page.getByRole("button", { name: "Zoom in" }).click();
+ await page.getByRole("button", { name: "Zoom in" }).click();
+ const manualZoom = await readMapZoomPercent(page);
+ expect(manualZoom).toBeGreaterThan(refitZoom);
+
+ await page.setViewportSize({ width: 700, height: 844 });
+ await page.waitForTimeout(150);
+ await expect.poll(async () => readMapZoomPercent(page)).toBe(manualZoom);
+});
+
+test("narrow mobile toolbar keeps map controls tappable without overflow", async ({ page }) => {
+ await page.setViewportSize({ width: 360, height: 844 });
+ await mockMapShell(page);
+
+ const mapState = draftMapState([
+ {
+ id: 1261,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+
+ const toolbar = page.locator(".location-map-toolbar");
+ const modeButtons = page.locator(".location-map-mode-buttons");
+ const historyActions = page.locator(".location-map-history-buttons");
+ const zoomControls = page.locator(".location-map-zoom-controls");
+ const toolbarBox = await toolbar.boundingBox();
+ const modeBox = await modeButtons.boundingBox();
+ const historyBox = await historyActions.boundingBox();
+ const zoomBox = await zoomControls.boundingBox();
+ expect(toolbarBox).not.toBeNull();
+ expect(modeBox).not.toBeNull();
+ expect(historyBox).not.toBeNull();
+ expect(zoomBox).not.toBeNull();
+ if (!toolbarBox || !modeBox || !historyBox || !zoomBox) {
+ throw new Error("Narrow mobile toolbar layout was not measurable");
+ }
+
+ expect(toolbarBox.x).toBeGreaterThanOrEqual(0);
+ expect(toolbarBox.x + toolbarBox.width).toBeLessThanOrEqual(361);
+ expect(modeBox.width).toBeGreaterThanOrEqual(130);
+ expect(modeBox.width).toBeLessThanOrEqual(136);
+ expect(modeBox.x + modeBox.width).toBeLessThanOrEqual(historyBox.x);
+ expect(zoomBox.y).toBeGreaterThan(modeBox.y + modeBox.height - 1);
+ expect(zoomBox.x).toBeGreaterThanOrEqual(toolbarBox.x);
+ expect(zoomBox.x + zoomBox.width).toBeLessThanOrEqual(toolbarBox.x + toolbarBox.width + 1);
+
+ const toolbarButtons = toolbar.getByRole("button");
+ const buttonCount = await toolbarButtons.count();
+ expect(buttonCount).toBe(7);
+ await expect(toolbar.getByRole("button", { name: "Undo" })).toBeVisible();
+ await expect(toolbar.getByRole("button", { name: "Redo" })).toBeVisible();
+ await expect(toolbar.getByRole("button", { name: "Zoom out" })).toBeVisible();
+ await expect(toolbar.getByRole("button", { name: "Zoom in" })).toBeVisible();
+ await expect(toolbar.getByRole("button", { name: "Fit" })).toBeVisible();
+ for (let index = 0; index < buttonCount; index += 1) {
+ const buttonBox = await toolbarButtons.nth(index).boundingBox();
+ expect(buttonBox).not.toBeNull();
+ if (!buttonBox) throw new Error("Toolbar button was not measurable");
+ expect(buttonBox.width).toBeGreaterThanOrEqual(40);
+ expect(buttonBox.height).toBeGreaterThanOrEqual(40);
+ }
+
+ const iconButtons = toolbar.locator(".location-map-icon-button");
+ await expect(iconButtons).toHaveCount(5);
+ for (let index = 0; index < await iconButtons.count(); index += 1) {
+ const iconButtonBox = await iconButtons.nth(index).boundingBox();
+ expect(iconButtonBox).not.toBeNull();
+ if (!iconButtonBox) throw new Error("Toolbar icon button was not measurable");
+ expect(iconButtonBox.width).toBeLessThanOrEqual(44);
+ expect(iconButtonBox.height).toBeLessThanOrEqual(44);
+ await expect(iconButtons.nth(index).locator(".location-map-toolbar-icon")).toHaveCount(1);
+ }
+});
+
+test("admin selecting an object does not mark a draft dirty until it changes", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = draftMapState([
+ {
+ id: 1351,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+ const summary = page.getByRole("group", { name: "Draft map summary" });
+ await expect(summary.locator(".location-map-overview-row")).toHaveCount(1);
+ await expect(summary.locator(".location-map-overview-row", { hasText: "Areas" }).locator("strong")).toHaveText("1");
+ await expect(summary.locator(".location-map-overview-row", { hasText: "Hidden" })).toHaveCount(0);
+ await expect(summary.locator(".location-map-overview-row", { hasText: "Unlinked" })).toHaveCount(0);
+
+ const bakeryObject = page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first();
+ await bakeryObject.click();
+
+ await expect(page.getByRole("textbox", { name: "Label" })).toBeVisible();
+ await expect(page.locator(".location-map-status")).toHaveText("Draft");
+ await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Undo" })).toBeDisabled();
+
+ await page.getByRole("textbox", { name: "Label" }).fill("Bakery");
+ await page.getByLabel("Linked zone").selectOption("501");
+ await expect(page.locator(".location-map-status")).toHaveText("Draft");
+ await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Undo" })).toBeDisabled();
+
+ const objectBox = await bakeryObject.boundingBox();
+ expect(objectBox).not.toBeNull();
+ if (!objectBox) throw new Error("Bakery map object was not measurable");
+ await page.mouse.move(objectBox.x + objectBox.width / 2, objectBox.y + objectBox.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(objectBox.x + objectBox.width / 2 + 50, objectBox.y + objectBox.height / 2 + 30, {
+ steps: 8,
+ });
+ await page.mouse.up();
+
+ await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
+ await expect(page.getByRole("button", { name: "Save Draft" })).toBeEnabled();
+ await expect(page.getByRole("button", { name: "Undo" })).toBeEnabled();
+});
+
+test("admin undoing the only draft change clears unsaved state", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = draftMapState([
+ {
+ id: 1356,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+ await page.getByRole("button", { name: "Map area Bakery" }).click();
+
+ await page.getByLabel("Linked zone").selectOption("");
+ await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
+ await expect(page.getByRole("button", { name: "Save Draft" })).toBeEnabled();
+ await expect(page.getByRole("button", { name: "Undo" })).toBeEnabled();
+
+ await page.getByRole("button", { name: "Undo" }).click();
+ await expect(page.locator(".location-map-status")).toHaveText("Draft");
+ await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0);
+ await expect(page.getByLabel("Linked zone")).toHaveValue("501");
+ await expect(page.getByRole("button", { name: "Redo" })).toBeEnabled();
+
+ await page.getByRole("button", { name: "Redo" }).click();
+ await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
+ await expect(page.getByRole("button", { name: "Save Draft" })).toBeEnabled();
+ await expect(page.getByLabel("Linked zone")).toHaveValue("");
+
+ await page.getByRole("button", { name: "Undo" }).focus();
+ await page.keyboard.down("Control");
+ await page.keyboard.press("KeyZ");
+ await page.keyboard.up("Control");
+ await expect(page.locator(".location-map-status")).toHaveText("Draft");
+ await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0);
+ await expect(page.getByLabel("Linked zone")).toHaveValue("501");
+ await expect(page.getByRole("button", { name: "Redo" })).toBeEnabled();
+
+ await page.getByRole("button", { name: "Redo" }).focus();
+ await page.keyboard.down("Control");
+ await page.keyboard.press("KeyY");
+ await page.keyboard.up("Control");
+ await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
+ await expect(page.getByRole("button", { name: "Save Draft" })).toBeEnabled();
+ await expect(page.getByLabel("Linked zone")).toHaveValue("");
+});
+
+test("admin can recover hidden map areas from the empty canvas", async ({ page }) => {
+ await mockMapShell(page);
+
+ let savedPayload: Record | null = null;
+ let mapState = draftMapState([
+ {
+ id: 1354,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/map/draft", async (route) => {
+ savedPayload = await route.request().postDataJSON();
+ const savedObjects = (savedPayload.objects as Array>).map((object, index) => ({
+ id: 1370 + index,
+ location_map_id: 900,
+ zone_name: object.zone_id === 501 ? "Bakery" : null,
+ ...object,
+ }));
+ mapState = draftMapState(savedObjects, true);
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+ await page.getByRole("button", { name: "Map area Bakery" }).click();
+ const visibleToggle = page.getByRole("button", { name: "Visible" });
+ const lockedToggle = page.getByRole("button", { name: "Locked" });
+ await expect(visibleToggle).toHaveAttribute("aria-pressed", "true");
+ await expect(lockedToggle).toHaveAttribute("aria-pressed", "false");
+
+ await visibleToggle.click();
+ await expect(page.getByRole("button", { name: "Map area Bakery" })).toHaveCount(0);
+ await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Edit Areas");
+ await expect(page.getByText("No visible map areas")).toBeVisible();
+ await expect(page.locator(".location-map-empty-canvas").getByRole("button", { name: "Show Hidden" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Show Hidden Areas (1)" })).toHaveCount(0);
+ await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
+
+ await page.locator(".location-map-empty-canvas").getByRole("button", { name: "Show Hidden" }).click();
+ await expect(page.getByRole("button", { name: "Map area Bakery" })).toBeVisible();
+ await expect(page.locator(".location-map-empty-canvas").getByRole("button", { name: "Show Hidden" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Show Hidden Areas (1)" })).toHaveCount(0);
+
+ await page.getByRole("button", { name: "Save Draft" }).click();
+ await expect(page.locator(".location-map-status")).toHaveText("Draft");
+ expect(savedPayload).not.toBeNull();
+ expect((savedPayload?.objects as Array>)[0]).toEqual(
+ expect.objectContaining({ visible: true, zone_id: 501 })
+ );
+});
+
+test("admin edit mode summarizes draft areas before selection", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = draftMapState([
+ {
+ id: 1381,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ {
+ id: 1382,
+ location_map_id: 900,
+ zone_id: null,
+ zone_name: null,
+ type: "zone",
+ label: "Seasonal Endcap",
+ x: 330,
+ y: 40,
+ width: 220,
+ height: 130,
+ rotation: 0,
+ locked: false,
+ visible: false,
+ sort_order: 2,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+
+ const summary = page.getByRole("group", { name: "Draft map summary" });
+ const areasRow = summary.locator(".location-map-overview-row", { hasText: "Areas" });
+ const hiddenRow = summary.locator(".location-map-overview-row", { hasText: "Hidden" });
+ const unlinkedRow = summary.locator(".location-map-overview-row", { hasText: "Unlinked" });
+ await expect(summary).toBeVisible();
+ await expect(areasRow.locator("strong")).toHaveText("2");
+ await expect(hiddenRow.locator("strong")).toHaveText("1");
+ await expect(unlinkedRow.locator("strong")).toHaveText("1");
+ await expect(page.getByRole("button", { name: "Show Hidden Areas (1)" })).toBeVisible();
+
+ await page.getByRole("button", { name: "Map area Bakery" }).click();
+
+ await expect(page.getByRole("group", { name: "Draft map summary" })).toHaveCount(0);
+ await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Bakery");
+});
+
+test("admin locked map areas hide resize affordances", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = draftMapState([
+ {
+ id: 1355,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+ const bakeryArea = page.getByRole("button", { name: "Map area Bakery" });
+ const bakeryObject = page.locator(".location-map-object", { hasText: "Bakery" });
+ await bakeryArea.click();
+
+ await expect(page.locator(".location-map-resize-handle")).toBeVisible();
+ await expect(page.locator(".location-map-lock-badge")).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Map area Bakery, locked", exact: true })).toHaveCount(0);
+ await expect(page.getByRole("spinbutton", { name: "X" })).toHaveCount(0);
+ const lockedToggle = page.getByRole("button", { name: "Locked", exact: true });
+ const visibleToggle = page.getByRole("button", { name: "Visible", exact: true });
+ await expect(lockedToggle).toHaveAttribute("aria-pressed", "false");
+
+ await lockedToggle.click();
+ await expect(lockedToggle).toHaveAttribute("aria-pressed", "true");
+ await expect(page.getByRole("textbox", { name: "Label" })).toBeEnabled();
+ await expect(visibleToggle).toBeEnabled();
+ await expect(page.getByRole("spinbutton", { name: "X" })).toHaveCount(0);
+ await expect(page.getByRole("spinbutton", { name: "Y" })).toHaveCount(0);
+ await expect(page.getByRole("spinbutton", { name: "W" })).toHaveCount(0);
+ await expect(page.getByRole("spinbutton", { name: "H" })).toHaveCount(0);
+ await expect(page.locator(".location-map-resize-handle")).toHaveCount(0);
+ await expect(page.locator(".location-map-lock-badge")).toBeVisible();
+ await expect(bakeryObject).toHaveClass(/is-locked/);
+ await expect(page.getByRole("button", { name: "Map area Bakery, locked", exact: true })).toBeVisible();
+ await expect(page.getByText("Unlock this area before moving or resizing it.")).toHaveCount(0);
+ await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
+
+ await bakeryArea.focus();
+ await page.keyboard.press("ArrowRight");
+ await expect(bakeryArea).toHaveAttribute("x", "40");
+
+ await lockedToggle.click();
+ await expect(lockedToggle).toHaveAttribute("aria-pressed", "false");
+ await expect(page.locator(".location-map-resize-handle")).toBeVisible();
+ await expect(page.locator(".location-map-lock-badge")).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Map area Bakery, locked", exact: true })).toHaveCount(0);
+});
+
+test("admin can drag map areas beyond the current right edge", async ({ page }) => {
+ await page.setViewportSize({ width: 398, height: 617 });
+ await mockMapShell(page);
+
+ const baseState = draftMapState([
+ {
+ id: 1321,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 420,
+ y: 80,
+ width: 220,
+ height: 140,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+ const initialMapWidth = 1400;
+ const mapState = {
+ ...baseState,
+ map: { ...baseState.map, width: initialMapWidth, height: 420 },
+ draft_map: { ...baseState.draft_map, width: initialMapWidth, height: 420 },
+ };
+ let savedPayload: { map?: { width?: number; height?: number }; objects?: Array> } | null = null;
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/map/draft", async (route) => {
+ savedPayload = await route.request().postDataJSON();
+ const savedState = draftMapState(savedPayload?.objects || [], true);
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ ...savedState,
+ map: { ...savedState.map, width: savedPayload?.map?.width, height: savedPayload?.map?.height },
+ draft_map: { ...savedState.draft_map, width: savedPayload?.map?.width, height: savedPayload?.map?.height },
+ }),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+ const scroll = page.locator(".location-map-scroll");
+ const bakeryArea = page.getByRole("button", { name: "Map area Bakery" });
+ await bakeryArea.click();
+
+ const areaBox = await bakeryArea.boundingBox();
+ expect(areaBox).not.toBeNull();
+ if (!areaBox) throw new Error("Bakery map area was not measurable");
+ await page.mouse.move(areaBox.x + areaBox.width / 2, areaBox.y + areaBox.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(areaBox.x + areaBox.width + 180, areaBox.y + areaBox.height / 2, { steps: 12 });
+ await page.mouse.up();
+
+ await expect.poll(async () => scroll.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0);
+ await page.getByRole("button", { name: "Save Draft" }).click();
+
+ await expect.poll(() => savedPayload?.map?.width || 0).toBeGreaterThan(initialMapWidth);
+ expect(savedPayload?.objects?.[0]).toEqual(
+ expect.objectContaining({
+ label: "Bakery",
+ x: expect.any(Number),
+ })
+ );
+ expect(Number(savedPayload?.objects?.[0]?.x)).toBeGreaterThan(420);
+});
+
+test("admin can drag map areas beyond the current left edge", async ({ page }) => {
+ await page.setViewportSize({ width: 398, height: 617 });
+ await mockMapShell(page);
+
+ const baseState = draftMapState([
+ {
+ id: 1322,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 80,
+ y: 80,
+ width: 180,
+ height: 140,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ {
+ id: 1323,
+ location_map_id: 900,
+ zone_id: 502,
+ zone_name: "Frozen Foods",
+ type: "zone",
+ label: "Frozen Foods",
+ x: 320,
+ y: 80,
+ width: 180,
+ height: 140,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 2,
+ },
+ ], true);
+ const leftEdgeInitialMapWidth = 1400;
+ const mapState = {
+ ...baseState,
+ map: { ...baseState.map, width: leftEdgeInitialMapWidth, height: 420 },
+ draft_map: { ...baseState.draft_map, width: leftEdgeInitialMapWidth, height: 420 },
+ };
+ let savedPayload: { map?: { width?: number; height?: number }; objects?: Array> } | null = null;
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/map/draft", async (route) => {
+ savedPayload = await route.request().postDataJSON();
+ const savedState = draftMapState(savedPayload?.objects || [], true);
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ ...savedState,
+ map: { ...savedState.map, width: savedPayload?.map?.width, height: savedPayload?.map?.height },
+ draft_map: { ...savedState.draft_map, width: savedPayload?.map?.width, height: savedPayload?.map?.height },
+ }),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+ const scroll = page.locator(".location-map-scroll");
+ const bakeryArea = page.getByRole("button", { name: "Map area Bakery" });
+ await bakeryArea.click();
+
+ const areaBox = await bakeryArea.boundingBox();
+ expect(areaBox).not.toBeNull();
+ if (!areaBox) throw new Error("Bakery map area was not measurable");
+ await page.mouse.move(areaBox.x + 8, areaBox.y + 8);
+ await page.mouse.down();
+ await page.mouse.move(2, areaBox.y + 8, { steps: 12 });
+ await page.mouse.up();
+
+ await expect.poll(async () => scroll.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0);
+ await page.getByRole("button", { name: "Save Draft" }).click();
+
+ await expect.poll(() => savedPayload?.map?.width || 0).toBeGreaterThan(leftEdgeInitialMapWidth);
+ const savedFrozenArea = savedPayload?.objects?.find((object) => object.label === "Frozen Foods");
+ expect(savedFrozenArea).toEqual(expect.objectContaining({ label: "Frozen Foods" }));
+ expect(Number(savedFrozenArea?.x)).toBeGreaterThan(320);
+ expect(savedPayload?.objects?.every((object) => Number(object.x) >= 0)).toBe(true);
+});
+
+test("admin selected overlapping map area renders above other areas", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = draftMapState([
+ {
+ id: 1311,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 180,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ {
+ id: 1312,
+ location_map_id: 900,
+ zone_id: 502,
+ zone_name: "Frozen Foods",
+ type: "zone",
+ label: "Frozen Foods",
+ x: 120,
+ y: 90,
+ width: 260,
+ height: 180,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 2,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+
+ const bakeryArea = page.getByRole("button", { name: "Map area Bakery" });
+ await bakeryArea.focus();
+ await page.keyboard.press("Enter");
+ await expect(bakeryArea).toHaveAttribute("aria-pressed", "true");
+
+ await expect.poll(async () =>
+ page.locator(".location-map-object").evaluateAll((nodes) =>
+ nodes.map((node) => node.getAttribute("data-object-key"))
+ )
+ ).toEqual(["1312", "1311"]);
+ await expect(page.locator('[data-object-key="1311"] .location-map-resize-handle')).toBeVisible();
+});
+
+test("admin cleared map area labels keep linked zone action names", async ({ page }) => {
+ await mockMapShell(page);
+
+ let savedPayload: Record | null = null;
+ let mapState = draftMapState([
+ {
+ id: 1353,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/map/draft", async (route) => {
+ savedPayload = await route.request().postDataJSON();
+ const savedObjects = (savedPayload.objects as Array>).map((object, index) => ({
+ id: 1360 + index,
+ location_map_id: 900,
+ zone_name: object.zone_id === 501 ? "Bakery" : null,
+ ...object,
+ }));
+ mapState = draftMapState(savedObjects, true);
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+ await page.getByRole("button", { name: "Map area Bakery" }).click();
+ await page.getByRole("textbox", { name: "Label" }).fill("");
+
+ await expect(page.getByRole("button", { name: "Map area Bakery", exact: true })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Map area 1", exact: true })).toHaveCount(0);
+
+ await page.getByRole("button", { name: "Save Draft" }).click();
+ await expect(page.locator(".location-map-status")).toHaveText("Draft");
+ expect(savedPayload).not.toBeNull();
+ expect((savedPayload?.objects as Array>)[0]).toEqual(
+ expect.objectContaining({ label: "", zone_id: 501 })
+ );
+ await expect(page.getByRole("button", { name: "Map area Bakery", exact: true })).toBeVisible();
+ await page.getByRole("button", { name: "Map area Bakery", exact: true }).click();
+ await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("");
+
+ await page.getByRole("button", { name: "Delete area Bakery" }).click();
+ await expect(page.getByRole("heading", { name: "Delete Bakery?" })).toBeVisible();
+ await page.getByRole("button", { name: "Cancel" }).click();
+
+ await page.getByRole("button", { name: "Duplicate area Bakery" }).click();
+ await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Bakery Copy");
+ await expect(page.getByRole("button", { name: "Map area Bakery Copy" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Map area Area Copy" })).toHaveCount(0);
+});
+
+test("admin restores selected map area through duplicate undo and redo", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = draftMapState([
+ {
+ id: 1355,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+ await page.getByRole("button", { name: "Map area Bakery" }).click();
+ await expect(page.locator(".location-map-object.is-selected")).toHaveAttribute("data-object-key", "1355");
+
+ await page.getByRole("button", { name: "Duplicate area Bakery" }).click();
+ await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Bakery Copy");
+ await expect(page.locator(".location-map-object.is-selected")).toContainText("Bakery Copy");
+
+ await page.getByRole("button", { name: "Undo" }).click();
+ await expect(page.getByRole("button", { name: "Map area Bakery Copy" })).toHaveCount(0);
+ await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Bakery");
+ await expect(page.locator(".location-map-object.is-selected")).toHaveAttribute("data-object-key", "1355");
+
+ await page.getByRole("button", { name: "Redo" }).click();
+ await expect(page.getByRole("button", { name: "Map area Bakery Copy" })).toBeVisible();
+ await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Bakery Copy");
+ await expect(page.locator(".location-map-object.is-selected")).toContainText("Bakery Copy");
+});
+
+test("admin nudges selected map areas with arrow keys", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = draftMapState([
+ {
+ id: 1352,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+ const bakeryArea = page.getByRole("button", { name: "Map area Bakery" });
+ await bakeryArea.focus();
+ await expect(bakeryArea).toBeFocused();
+ await page.keyboard.press("Enter");
+ await expect(page.getByText("Move, resize, link, rename, or nudge with arrow keys.")).toHaveCount(0);
+ await expect(page.getByRole("spinbutton", { name: "X" })).toHaveCount(0);
+ await expect(page.getByRole("spinbutton", { name: "Y" })).toHaveCount(0);
+ await expect(bakeryArea).toHaveAttribute("x", "40");
+ await expect(bakeryArea).toHaveAttribute("y", "40");
+ await expect(page.locator(".location-map-status")).toHaveText("Draft");
+
+ await page.keyboard.press("ArrowRight");
+ await expect(bakeryArea).toHaveAttribute("x", "50");
+ await expect(bakeryArea).toHaveAttribute("y", "40");
+ await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
+ await expect(page.getByRole("button", { name: "Save Draft" })).toBeEnabled();
+ await expect(page.getByRole("button", { name: "Undo" })).toBeEnabled();
+
+ await page.keyboard.press("Shift+ArrowDown");
+ await expect(bakeryArea).toHaveAttribute("x", "50");
+ await expect(bakeryArea).toHaveAttribute("y", "65");
+});
+
+test("admin can nudge map areas beyond the current canvas", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = draftMapState([
+ {
+ id: 1353,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 740,
+ y: 540,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+ const bakeryArea = page.getByRole("button", { name: "Map area Bakery" });
+ await bakeryArea.focus();
+ await page.keyboard.press("Enter");
+ await expect(bakeryArea).toHaveAttribute("x", "740");
+ await expect(bakeryArea).toHaveAttribute("y", "540");
+
+ await page.keyboard.press("Shift+ArrowRight");
+ await page.keyboard.press("Shift+ArrowDown");
+
+ await expect(bakeryArea).toHaveAttribute("x", "765");
+ await expect(bakeryArea).toHaveAttribute("y", "565");
+ await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
+ await expect(page.getByRole("button", { name: "Save Draft" })).toBeEnabled();
+ await expect(page.getByRole("button", { name: "Undo" })).toBeEnabled();
+});
+
+test("admin can resize map areas beyond the current canvas", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = draftMapState([
+ {
+ id: 1354,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 820,
+ y: 580,
+ width: 180,
+ height: 120,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+ const bakeryArea = page.getByRole("button", { name: "Map area Bakery" });
+ await bakeryArea.click();
+ await expect(bakeryArea).toHaveAttribute("x", "820");
+ await expect(bakeryArea).toHaveAttribute("y", "580");
+ await expect(bakeryArea).toHaveAttribute("width", "180");
+ await expect(bakeryArea).toHaveAttribute("height", "120");
+
+ const resizeHandle = page.locator(".location-map-resize-handle");
+ await expect(resizeHandle).toBeVisible();
+ const resizeBox = await resizeHandle.boundingBox();
+ expect(resizeBox).not.toBeNull();
+ if (!resizeBox) throw new Error("Resize handle was not measurable");
+
+ await page.mouse.move(resizeBox.x + resizeBox.width / 2, resizeBox.y + resizeBox.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(resizeBox.x + resizeBox.width / 2 + 160, resizeBox.y + resizeBox.height / 2 + 160, {
+ steps: 8,
+ });
+ await page.mouse.up();
+
+ await expect(bakeryArea).toHaveAttribute("x", "820");
+ await expect(bakeryArea).toHaveAttribute("y", "580");
+ await expect.poll(async () => Number(await bakeryArea.getAttribute("width"))).toBeGreaterThan(180);
+ await expect.poll(async () => Number(await bakeryArea.getAttribute("height"))).toBeGreaterThan(120);
+ await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
+ await expect(page.getByRole("button", { name: "Save Draft" })).toBeEnabled();
+ await expect(page.getByRole("button", { name: "Undo" })).toBeEnabled();
+});
+
+test("admin selected map area uses compact editable fields", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = draftMapState([
+ {
+ id: 1352,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+ await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click();
+
+ await expect(page.locator(".location-map-field-row")).toHaveCount(2);
+ await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Bakery");
+ await expect(page.getByLabel("Linked zone")).toHaveValue("501");
+ await expect(page.getByLabel("Object type")).toHaveCount(0);
+ await expect(page.getByRole("spinbutton", { name: "X" })).toHaveCount(0);
+ await expect(page.getByRole("spinbutton", { name: "Y" })).toHaveCount(0);
+ await expect(page.getByRole("spinbutton", { name: "W" })).toHaveCount(0);
+ await expect(page.getByRole("spinbutton", { name: "H" })).toHaveCount(0);
+
+ await page.getByRole("textbox", { name: "Label" }).focus();
+ await page.keyboard.press("Delete");
+ await expect(page.getByRole("heading", { name: "Delete Bakery?" })).toHaveCount(0);
+ await expect(page.locator(".location-map-object.is-selected")).toHaveCount(1);
+
+ await page.getByRole("button", { name: "Visible" }).focus();
+ await page.keyboard.press("Delete");
+ await expect(page.getByRole("heading", { name: "Delete Bakery?" })).toBeVisible();
+ await page.getByRole("button", { name: "Cancel" }).click();
+ await expect(page.getByRole("heading", { name: "Delete Bakery?" })).toHaveCount(0);
+ await expect(page.locator(".location-map-object.is-selected")).toHaveCount(1);
+
+ await page.getByRole("textbox", { name: "Label" }).focus();
+ await page.keyboard.press("Escape");
+ await expect(page.getByRole("textbox", { name: "Label" })).toBeVisible();
+ await expect(page.locator(".location-map-object.is-selected")).toHaveCount(1);
+
+ await page.getByRole("button", { name: "Visible" }).focus();
+ await page.keyboard.press("Escape");
+ await expect(page.getByRole("textbox", { name: "Label" })).toHaveCount(0);
+ await expect(page.locator(".location-map-object.is-selected")).toHaveCount(0);
+ await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Edit Areas");
+});
+
+test("admin save shortcut locks draft controls", async ({ page }) => {
+ await mockMapShell(page);
+
+ let mapState = draftMapState([
+ {
+ id: 1361,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+ let releaseSave: () => void = () => {};
+ let saveStarted = false;
+ const saveGate = new Promise((resolve) => {
+ releaseSave = resolve;
+ });
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/map/draft", async (route) => {
+ saveStarted = true;
+ const payload = await route.request().postDataJSON();
+ await saveGate;
+ const savedObjects = (payload.objects as Array>).map((object, index) => ({
+ ...(mapState.draft_objects[index] || {}),
+ ...object,
+ }));
+ mapState = draftMapState(savedObjects, true);
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+ await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click();
+ const labelInput = page.getByRole("textbox", { name: "Label" });
+ await labelInput.fill("Saving Bakery");
+
+ await labelInput.focus();
+ await page.keyboard.down("Control");
+ await page.keyboard.press("KeyS");
+ await page.keyboard.up("Control");
+ await expect.poll(() => saveStarted).toBe(true);
+ await expect(page.getByRole("button", { name: "Saving..." })).toBeDisabled();
+ await expect(page.getByRole("button", { name: "Publish" })).toBeDisabled();
+ await expect(page.getByRole("button", { name: "Add Area" })).toBeDisabled();
+ await expect(page.getByRole("button", { name: "View Draft" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Undo" })).toBeDisabled();
+ await expect(page.getByRole("button", { name: "Duplicate area Saving Bakery" })).toBeDisabled();
+ await expect(page.getByRole("button", { name: "Delete area Saving Bakery" })).toBeDisabled();
+ await expect(labelInput).toBeDisabled();
+
+ releaseSave();
+ await expect(page.locator(".location-map-status")).toHaveText("Draft");
+ await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0);
+ await expect(labelInput).toBeEnabled();
+ await expect(labelInput).toHaveValue("Saving Bakery");
+ await expect(page.getByRole("button", { name: "Map area Saving Bakery" })).toHaveAttribute("aria-pressed", "true");
+});
+
+test("admin save progress locks empty-canvas recovery actions", async ({ page }) => {
+ await mockMapShell(page);
+
+ let mapState = draftMapState([
+ {
+ id: 1362,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+ let releaseSave: () => void = () => {};
+ let saveStarted = false;
+ const saveGate = new Promise((resolve) => {
+ releaseSave = resolve;
+ });
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/map/draft", async (route) => {
+ saveStarted = true;
+ const payload = await route.request().postDataJSON();
+ await saveGate;
+ const savedObjects = (payload.objects as Array>).map((object, index) => ({
+ ...(mapState.draft_objects[index] || {}),
+ ...object,
+ }));
+ mapState = draftMapState(savedObjects, true);
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+ await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click();
+ await page.getByRole("button", { name: "Visible" }).click();
+
+ await expect(page.getByText("No visible map areas")).toBeVisible();
+ await expect(
+ page.locator(".location-map-empty-canvas").getByRole("button", { name: "Show Hidden" })
+ ).toBeEnabled();
+ await expect(page.getByRole("button", { name: "Show Hidden Areas (1)" })).toHaveCount(0);
+
+ await page.getByRole("button", { name: "Save Draft" }).click();
+ await expect.poll(() => saveStarted).toBe(true);
+ await expect(
+ page.locator(".location-map-empty-canvas").getByRole("button", { name: "Show Hidden" })
+ ).toBeDisabled();
+ await expect(page.getByRole("button", { name: "Show Hidden Areas (1)" })).toHaveCount(0);
+
+ releaseSave();
+ await expect(page.locator(".location-map-status")).toHaveText("Draft");
+ await expect(
+ page.locator(".location-map-empty-canvas").getByRole("button", { name: "Show Hidden" })
+ ).toBeEnabled();
+});
+
+test("admin hard navigation warns when draft edits are unsaved", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = draftMapState([
+ {
+ id: 1371,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ const cleanBeforeUnload = await page.evaluate(() => {
+ const event = new Event("beforeunload", { cancelable: true });
+ const dispatchResult = window.dispatchEvent(event);
+ return {
+ defaultPrevented: event.defaultPrevented,
+ dispatchResult,
+ };
+ });
+ expect(cleanBeforeUnload).toEqual({
+ defaultPrevented: false,
+ dispatchResult: true,
+ });
+
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+ await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click();
+ await page.getByRole("textbox", { name: "Label" }).fill("Hard Navigation Bakery");
+
+ const dirtyBeforeUnload = await page.evaluate(() => {
+ const event = new Event("beforeunload", { cancelable: true });
+ const dispatchResult = window.dispatchEvent(event);
+ return {
+ defaultPrevented: event.defaultPrevented,
+ dispatchResult,
+ };
+ });
+ expect(dirtyBeforeUnload).toEqual({
+ defaultPrevented: true,
+ dispatchResult: false,
+ });
+});
+
+test("admin app navigation warns when draft edits are unsaved", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = draftMapState([
+ {
+ id: 1372,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+ await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click();
+ await page.getByRole("textbox", { name: "Label" }).fill("Unsaved Navbar Bakery");
+
+ await page.getByRole("link", { name: "Home" }).click();
+
+ await expect(page.getByRole("heading", { name: "Leave with unsaved changes?" })).toBeVisible();
+ await page.getByRole("button", { name: "Cancel" }).click();
+ await expect(page).toHaveURL(/\/stores\/100\/locations\/10\/map$/);
+ await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Unsaved Navbar Bakery");
+
+ await page.getByRole("link", { name: "Home" }).click();
+ await confirmSlide(page);
+ await expect(page).toHaveURL(/\/$/);
+});
+
+test("admin browser back warns when draft edits are unsaved", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = draftMapState([
+ {
+ id: 1375,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.evaluate(() => {
+ window.history.replaceState(window.history.state, "", "/");
+ window.history.pushState({}, "", "/stores/100/locations/10/map");
+ });
+ await expect(page).toHaveURL(/\/stores\/100\/locations\/10\/map$/);
+
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+ await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click();
+ await page.getByRole("textbox", { name: "Label" }).fill("Unsaved Back Bakery");
+
+ await page.evaluate(() => window.history.back());
+ await expect(page.getByRole("heading", { name: "Leave with unsaved changes?" })).toBeVisible();
+ await expect(page).toHaveURL(/\/stores\/100\/locations\/10\/map$/);
+ await page.getByRole("button", { name: "Cancel" }).click();
+ await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Unsaved Back Bakery");
+
+ await page.evaluate(() => window.history.back());
+ await expect(page.getByRole("heading", { name: "Leave with unsaved changes?" })).toBeVisible();
+ await confirmSlide(page);
+ await expect(page).toHaveURL(/\/$/);
+});
+
+test("admin browser back leaves after draft edits are undone clean", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = draftMapState([
+ {
+ id: 1376,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.evaluate(() => {
+ window.history.replaceState(window.history.state, "", "/");
+ window.history.pushState({}, "", "/stores/100/locations/10/map");
+ });
+ await expect(page).toHaveURL(/\/stores\/100\/locations\/10\/map$/);
+
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+ await page.getByRole("button", { name: "Map area Bakery" }).click();
+ await page.getByLabel("Linked zone").selectOption("");
+ await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
+
+ await page.getByRole("button", { name: "Undo" }).click();
+ await expect(page.locator(".location-map-status")).toHaveText("Draft");
+ await expect(page.getByRole("heading", { name: "Leave with unsaved changes?" })).toHaveCount(0);
+
+ await page.evaluate(() => window.history.back());
+ await expect(page).toHaveURL(/\/$/);
+ await expect(page.getByRole("heading", { name: "Leave with unsaved changes?" })).toHaveCount(0);
+});
+
+test("admin logout warns when draft edits are unsaved", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = draftMapState([
+ {
+ id: 1373,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ let logoutCalls = 0;
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+ await page.route("**/auth/logout", async (route) => {
+ logoutCalls += 1;
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ message: "Logged out" }),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+ await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click();
+ await page.getByRole("textbox", { name: "Label" }).fill("Unsaved Logout Bakery");
+
+ await page.getByRole("button", { name: "User menu" }).click();
+ await page.getByRole("button", { name: "Logout", exact: true }).click();
+
+ await expect(page.getByRole("heading", { name: "Leave with unsaved changes?" })).toBeVisible();
+ expect(logoutCalls).toBe(0);
+ await page.getByRole("button", { name: "Cancel" }).click();
+ await expect(page).toHaveURL(/\/stores\/100\/locations\/10\/map$/);
+ await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Unsaved Logout Bakery");
+ expect(logoutCalls).toBe(0);
+
+ await page.getByRole("button", { name: "Logout", exact: true }).click();
+ await confirmSlide(page);
+
+ await expect(page).toHaveURL(/\/login$/);
+ expect(logoutCalls).toBe(1);
+});
+
+test("admin household switch warns when draft edits are unsaved", async ({ page }) => {
+ const secondHousehold = {
+ id: 2,
+ name: "Second House",
+ role: "admin",
+ invite_code: "EFGH5678",
+ };
+ await mockMapShell(page, [adminHousehold, secondHousehold]);
+
+ const mapState = draftMapState([
+ {
+ id: 1374,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+ const secondHouseholdMapState = {
+ ...noMapState(true),
+ location: {
+ ...storeLocation,
+ household_id: 2,
+ name: "Second Costco",
+ display_name: "Second Costco - Eastvale",
+ },
+ };
+
+ let secondHouseholdMapRequests = 0;
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+ await page.route("**/households/2/stores", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify([{ ...storeLocation, household_id: 2 }]),
+ });
+ });
+ await page.route("**/households/2/locations/10/zones", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ zones }),
+ });
+ });
+ await page.route("**/households/2/locations/10/map", async (route) => {
+ secondHouseholdMapRequests += 1;
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(secondHouseholdMapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+ await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click();
+ await page.getByRole("textbox", { name: "Label" }).fill("Unsaved Household Bakery");
+
+ await page.locator(".household-switcher-toggle").click();
+ await page.getByRole("button", { name: "Second House", exact: true }).click();
+
+ await expect(page.getByRole("heading", { name: "Leave with unsaved changes?" })).toBeVisible();
+ expect(secondHouseholdMapRequests).toBe(0);
+ await page.getByRole("button", { name: "Cancel" }).click();
+ await expect(page.locator(".household-switcher-toggle .household-name")).toHaveText("Map House");
+ await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Unsaved Household Bakery");
+ expect(secondHouseholdMapRequests).toBe(0);
+
+ await page.getByRole("button", { name: "Second House", exact: true }).click();
+ await confirmSlide(page);
+
+ await expect(page.locator(".household-switcher-toggle .household-name")).toHaveText("Second House");
+ await expect(page.getByRole("heading", { name: "No Map" })).toBeVisible();
+ expect(secondHouseholdMapRequests).toBe(1);
+});
+
+test("admin publish saves pending map edits before publishing", async ({ page }) => {
+ await mockMapShell(page);
+
+ let mapState = publishedMapState([
+ {
+ id: 1401,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Live Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+ let savedPayload: Record | null = null;
+ let publishCalled = false;
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/map/draft", async (route) => {
+ savedPayload = await route.request().postDataJSON();
+ const savedObjects = (savedPayload.objects as Array>).map((object, index) => ({
+ id: 1450 + index,
+ location_map_id: 900,
+ zone_name: object.zone_id === 501 ? "Bakery" : null,
+ ...object,
+ }));
+ mapState = draftAndPublishedMapState(savedObjects, mapState.published_objects, true);
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/map/publish", async (route) => {
+ publishCalled = true;
+ mapState = publishedMapState(mapState.draft_objects, true);
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await page.getByRole("button", { name: "Edit Draft" }).click();
+ await page.locator(".location-map-object", { hasText: "Live Bakery" }).locator("rect").first().click();
+ await page.getByRole("textbox", { name: "Label" }).fill("Quick Publish Bakery");
+ await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
+
+ await page.getByRole("button", { name: "Publish", exact: true }).click();
+
+ await expect.poll(() => publishCalled).toBe(true);
+ expect(savedPayload).not.toBeNull();
+ expect(savedPayload?.objects).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ label: "Quick Publish Bakery",
+ zone_id: 501,
+ }),
+ ])
+ );
+ await expect(page.locator(".location-map-status")).toHaveText("Published");
+ await expect(page.getByRole("button", { name: "Map area Quick Publish Bakery" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Map area Live Bakery" })).toHaveCount(0);
+ await expect(page.locator(".location-map-object.is-selected")).toHaveAttribute("data-object-key", "1450");
+ await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Quick Publish Bakery");
+ await expect(page.getByRole("button", { name: "Close selected map area" })).toBeVisible();
+});
+
+test("admin publish failure keeps successfully saved pending edits", async ({ page }) => {
+ await mockMapShell(page);
+
+ let mapState = publishedMapState([
+ {
+ id: 1461,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Live Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+ let savedPayload: Record | null = null;
+ let publishCalled = false;
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/map/draft", async (route) => {
+ savedPayload = await route.request().postDataJSON();
+ const savedObjects = (savedPayload.objects as Array>).map((object, index) => ({
+ id: 1465 + index,
+ location_map_id: 900,
+ zone_name: object.zone_id === 501 ? "Bakery" : null,
+ ...object,
+ }));
+ mapState = draftAndPublishedMapState(savedObjects, mapState.published_objects, true);
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/map/publish", async (route) => {
+ publishCalled = true;
+ await route.fulfill({
+ status: 503,
+ contentType: "application/json",
+ body: JSON.stringify({ error: { message: "Publish service unavailable" } }),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await page.getByRole("button", { name: "Edit Draft" }).click();
+ await page.locator(".location-map-object", { hasText: "Live Bakery" }).locator("rect").first().click();
+ await page.getByRole("textbox", { name: "Label" }).fill("Saved Draft Bakery");
+ await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
+
+ await page.getByRole("button", { name: "Publish", exact: true }).click();
+
+ await expect.poll(() => publishCalled).toBe(true);
+ expect(savedPayload).not.toBeNull();
+ expect(savedPayload?.objects).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ label: "Saved Draft Bakery",
+ zone_id: 501,
+ }),
+ ])
+ );
+ await expect(page.locator(".location-map-status")).toHaveText("Draft");
+ await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Map area Saved Draft Bakery" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Map area Live Bakery" })).toHaveCount(0);
+});
+
+test("viewer wraps long zone labels and keeps item counts visible", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = publishedMapState([
+ {
+ id: 1501,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Produce & Fresh Vegetables",
+ type: "zone",
+ label: "Produce & Fresh Vegetables",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 120,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ const produceArea = page.locator('[data-object-key="1501"]');
+ await expect(produceArea.locator(".location-map-label tspan")).toHaveCount(2);
+ await expect(produceArea.locator(".location-map-label tspan").nth(0)).toHaveText("Produce & Fresh");
+ await expect(produceArea.locator(".location-map-label tspan").nth(1)).toHaveText("Vegetables");
+ await expect(produceArea.locator(".location-map-count")).toHaveText("2 items");
+ await expect(produceArea.locator(".location-map-count")).toHaveAttribute("y", "116");
+ await expect(page.locator(".location-map-pin")).toHaveCount(0);
+
+ await page.getByRole("button", { name: "Layers" }).click();
+ const layerFilters = page.getByRole("group", { name: "Layer filters" });
+ const labelsToggle = layerFilters.getByRole("button", { name: "Labels" });
+ await expect(layerFilters.locator(".location-map-layer-state")).toHaveCount(8);
+ await expect(labelsToggle).toHaveAttribute("aria-pressed", "true");
+ await expect(labelsToggle.locator(".location-map-layer-state.is-on")).toHaveCount(1);
+ await expect(page.getByRole("button", { name: "Bought" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Done" })).toHaveCount(0);
+ await labelsToggle.click();
+ await expect(labelsToggle).toHaveAttribute("aria-pressed", "false");
+ await expect(labelsToggle.locator(".location-map-layer-state.is-on")).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Layers, 1 changed" })).toBeVisible();
+ await expect(produceArea.locator(".location-map-label")).toHaveCount(0);
+ await expect(produceArea.locator(".location-map-count")).toHaveText("2 items");
+ await expect(produceArea.locator(".location-map-count")).toHaveAttribute("y", "92");
+
+ await page.getByRole("button", { name: "Layers, 1 changed" }).click();
+ await expect(page.getByRole("button", { name: "Reset layer filters" })).toBeVisible();
+ await page.getByRole("button", { name: "Reset layer filters" }).click();
+ await expect(page.getByRole("button", { name: "Layers" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Reset layer filters" })).toHaveCount(0);
+ await expect(produceArea.locator(".location-map-label tspan")).toHaveCount(2);
+ await expect(produceArea.locator(".location-map-count")).toHaveAttribute("y", "116");
+});
+
+test("viewer explains when the zones layer hides the map", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = publishedMapState([
+ {
+ id: 1521,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await expect(page.getByRole("button", { name: "Map area Bakery" })).toBeVisible();
+ await page.getByRole("button", { name: "Map area Bakery" }).click();
+ await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Bakery");
+ await expect(page.locator(".location-map-sheet-count")).toHaveText("2 items");
+
+ await page.getByRole("button", { name: "Layers" }).click();
+ await page.getByRole("button", { name: "Zones" }).click();
+
+ await expect(page.getByRole("button", { name: "Map area Bakery" })).toHaveCount(0);
+ await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Map Details");
+ await expect(page.locator(".location-map-sheet-count")).toHaveCount(0);
+ await expect(page.getByText("Zones hidden in Layers")).toBeVisible();
+ await expect(page.getByRole("button", { name: "Show Zones" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Layers, 1 changed" })).toBeVisible();
+
+ await page.getByRole("button", { name: "Show Zones" }).click();
+ await expect(page.getByRole("button", { name: "Map area Bakery" })).toBeVisible();
+ await expect(page.getByText("Zones hidden in Layers")).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Layers" })).toBeVisible();
+});
+
+test("viewer shows a compact map overview before selecting an area", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = publishedMapState([
+ {
+ id: 1531,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ const overview = page.getByRole("group", { name: "Map summary" });
+ const areasRow = overview.locator(".location-map-overview-row", { hasText: "Areas" });
+ const mappedRow = overview.locator(".location-map-overview-row", { hasText: "Mapped items" });
+ const unmappedRow = overview.locator(".location-map-overview-row", { hasText: "Unmapped" });
+ await expect(overview).toBeVisible();
+ await expect(areasRow.locator("strong")).toHaveText("1 shown");
+ await expect(mappedRow.locator("strong")).toHaveText("2 shown");
+ await expect(unmappedRow.locator("strong")).toHaveText("1 hidden");
+
+ await page.getByRole("button", { name: "Layers" }).click();
+ await page.getByRole("button", { name: "Unmapped" }).click();
+ await expect(unmappedRow.locator("strong")).toHaveText("1");
+
+ await page.getByRole("button", { name: "Map area Bakery" }).click();
+ await expect(page.getByRole("group", { name: "Map summary" })).toHaveCount(0);
+ await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Bakery");
+});
+
+test("viewer omits zero item rows from the map overview", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = {
+ ...publishedMapState([
+ {
+ id: 1531,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true),
+ items: [],
+ unmapped_count: 0,
+ };
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ const overview = page.getByRole("group", { name: "Map summary" });
+ await expect(overview.locator(".location-map-overview-row")).toHaveCount(1);
+ await expect(overview.locator(".location-map-overview-row", { hasText: "Areas" }).locator("strong")).toHaveText("1 shown");
+ await expect(overview.locator(".location-map-overview-row", { hasText: "Mapped items" })).toHaveCount(0);
+ await expect(overview.locator(".location-map-overview-row", { hasText: "Unmapped" })).toHaveCount(0);
+});
+
+test("viewer shows a compact overflow cue for long unmapped lists", async ({ page }) => {
+ await mockMapShell(page);
+
+ const unmappedItems = Array.from({ length: 11 }, (_, index) => ({
+ id: 4000 + index,
+ item_name: `unmapped item ${index + 1}`,
+ quantity: 1,
+ bought: false,
+ zone_id: null,
+ zone: null,
+ added_by_users: ["map-user"],
+ }));
+ const mapState = {
+ ...publishedMapState([], true),
+ items: unmappedItems,
+ unmapped_count: unmappedItems.length,
+ };
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.getByRole("button", { name: "Layers" }).click();
+ const unmappedToggle = page.getByRole("group", { name: "Layer filters" }).getByRole("button", { name: "Unmapped" });
+ await expect(unmappedToggle).toHaveAttribute("aria-pressed", "false");
+ await unmappedToggle.click();
+ await expect(unmappedToggle).toHaveAttribute("aria-pressed", "true");
+
+ await expect(page.getByText("unmapped item 1")).toBeVisible();
+ await expect(page.getByText("unmapped item 8")).toBeVisible();
+ await expect(page.getByText("unmapped item 9")).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Show 3 more unmapped items" })).toHaveText("+3 more");
+ const firstUnmappedButtonBox = await page.getByRole("button", { name: "Mark unmapped item 1 bought" }).boundingBox();
+ const moreUnmappedButtonBox = await page.getByRole("button", { name: "Show 3 more unmapped items" }).boundingBox();
+ expect(firstUnmappedButtonBox).not.toBeNull();
+ expect(moreUnmappedButtonBox).not.toBeNull();
+ if (!firstUnmappedButtonBox || !moreUnmappedButtonBox) {
+ throw new Error("Unmapped item controls were not measurable");
+ }
+ expect(firstUnmappedButtonBox.height).toBeGreaterThanOrEqual(40);
+ expect(moreUnmappedButtonBox.height).toBeGreaterThanOrEqual(40);
+
+ await page.getByRole("button", { name: "Show 3 more unmapped items" }).click();
+
+ await expect(page.getByText("unmapped item 9")).toBeVisible();
+ await expect(page.getByText("unmapped item 11")).toBeVisible();
+ await expect(page.getByRole("button", { name: "Show fewer unmapped items" })).toHaveText("Show fewer");
+
+ await page.getByRole("button", { name: "Show fewer unmapped items" }).click();
+
+ await expect(page.getByText("unmapped item 9")).toHaveCount(0);
+});
+
+test("viewer can buy unmapped map items in place", async ({ page }) => {
+ await mockMapShell(page);
+
+ const unmappedMapItems = [
+ {
+ id: 4201,
+ item_name: "loose batteries",
+ quantity: 1,
+ bought: false,
+ zone_id: null,
+ zone: null,
+ added_by_users: ["map-user"],
+ },
+ {
+ id: 4202,
+ item_name: "paper plates",
+ quantity: 2,
+ bought: false,
+ zone_id: null,
+ zone: null,
+ added_by_users: ["map-user"],
+ },
+ ];
+ const mapState = {
+ ...publishedMapState([], true),
+ items: unmappedMapItems,
+ unmapped_count: unmappedMapItems.length,
+ };
+ const boughtPayloads: Array> = [];
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/list/item", async (route) => {
+ boughtPayloads.push(await route.request().postDataJSON());
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ success: true }),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.getByRole("button", { name: "Layers" }).click();
+ await page.getByRole("button", { name: "Unmapped" }).click();
+
+ await page.getByRole("button", { name: "Mark loose batteries bought" }).click();
+ await expect(page.locator(".confirm-buy-item-name")).toHaveText("loose batteries");
+ await page.getByRole("button", { name: "Mark as Bought" }).click();
+
+ await expect.poll(() => boughtPayloads.length).toBe(1);
+ expect(boughtPayloads[0]).toMatchObject({
+ item_name: "loose batteries",
+ bought: true,
+ quantity_bought: 1,
+ });
+ await expect(page.locator(".confirm-buy-item-name")).toHaveText("paper plates");
+ await expect(page.getByText("loose batteries")).toHaveCount(0);
+ await expect(page.getByRole("group", { name: "Map summary" }).locator(".location-map-overview-row", { hasText: "Unmapped" })).toContainText("1 shown");
+
+ await page.getByRole("button", { name: "Mark as Bought" }).click();
+
+ await expect.poll(() => boughtPayloads.length).toBe(2);
+ expect(boughtPayloads[1]).toMatchObject({
+ item_name: "paper plates",
+ bought: true,
+ quantity_bought: 2,
+ });
+ await expect(page.locator(".confirm-buy-modal")).toHaveCount(0);
+ await expect(page.getByText("paper plates")).toHaveCount(0);
+ await expect(page.getByRole("group", { name: "Map summary" }).locator(".location-map-overview-row", { hasText: "Unmapped" })).toContainText("0 shown");
+
+ await page.getByRole("button", { name: "Bought" }).click();
+ await expect(page.getByRole("group", { name: "Map summary" }).locator(".location-map-overview-row", { hasText: "Unmapped" })).toContainText("2");
+ const looseBatteriesRow = page.getByRole("button", { name: "loose batteries bought" });
+ const paperPlatesRow = page.getByRole("button", { name: "paper plates bought" });
+ await expect(looseBatteriesRow).toBeVisible();
+ await expect(looseBatteriesRow).toBeDisabled();
+ await expect(paperPlatesRow).toBeVisible();
+ await expect(paperPlatesRow).toBeDisabled();
+});
+
+test("viewer hides unrelated unmapped items while a zone is selected", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = publishedMapState([
+ {
+ id: 1531,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.getByRole("button", { name: "Layers" }).click();
+ await page.getByRole("button", { name: "Unmapped" }).click();
+ await expect(page.getByRole("group", { name: "Layer filters" })).toBeVisible();
+ await expect(page.getByRole("group", { name: "Unmapped items" })).toBeVisible();
+ await expect(page.getByText("Unmapped Items")).toHaveCount(0);
+ await expect(page.getByText("loose batteries")).toBeVisible();
+
+ await page.locator('[data-object-key="1531"]').locator("rect").first().click();
+ await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Bakery");
+ await expect(page.getByRole("button", { name: "Layers, 1 changed" })).toHaveAttribute("aria-expanded", "false");
+ await expect(page.getByRole("group", { name: "Layer filters" })).toHaveCount(0);
+ await expect(page.getByText("french bread")).toBeVisible();
+ await expect(page.getByRole("group", { name: "Unmapped items" })).toHaveCount(0);
+ await expect(page.getByText("loose batteries")).toHaveCount(0);
+
+ await page.locator(".location-map-background").click({ position: { x: 8, y: 8 } });
+ await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Map Details");
+ await expect(page.getByRole("group", { name: "Unmapped items" })).toBeVisible();
+ await expect(page.getByText("Unmapped Items")).toHaveCount(0);
+ await expect(page.getByText("loose batteries")).toBeVisible();
+});
+
+test("viewer can buy selected zone items from the map", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = publishedMapState([
+ {
+ id: 1532,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+ const boughtPayloads: Array> = [];
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/list/item", async (route) => {
+ if (route.request().method() !== "PATCH") {
+ await route.fulfill({ status: 404, contentType: "application/json", body: "{}" });
+ return;
+ }
+
+ boughtPayloads.push(await route.request().postDataJSON());
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ success: true }),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ const bakeryArea = page.locator('[data-object-key="1532"]');
+ await bakeryArea.locator("rect").first().click();
+
+ await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Bakery");
+ await expect(page.locator(".location-map-sheet-count")).toHaveText("2 items");
+ await expect(bakeryArea.locator(".location-map-count")).toHaveText("2 items");
+
+ await page.getByRole("button", { name: "Mark french bread bought" }).click();
+ await expect(page.locator(".confirm-buy-modal")).toBeVisible();
+ await expect(page.getByRole("dialog", { name: "french bread" })).toBeVisible();
+ await expect(page.locator(".confirm-buy-item-name")).toHaveText("french bread");
+ await expect(page.getByRole("img", { name: "No item photo" })).toHaveText("\uD83D\uDCE6");
+ const nextItemButton = page.getByRole("button", { name: "Next item" });
+ const nextItemButtonBox = await nextItemButton.boundingBox();
+ expect(nextItemButtonBox).not.toBeNull();
+ if (!nextItemButtonBox) throw new Error("Map buy next item button was not measurable");
+ expect(nextItemButtonBox.width).toBeGreaterThanOrEqual(40);
+ expect(nextItemButtonBox.height).toBeGreaterThanOrEqual(40);
+ await nextItemButton.click();
+ await expect(page.locator(".confirm-buy-item-name")).toHaveText("sourdough");
+ await page.keyboard.press("ArrowLeft");
+ await expect(page.locator(".confirm-buy-item-name")).toHaveText("french bread");
+ await page.keyboard.press("ArrowRight");
+ await expect(page.locator(".confirm-buy-item-name")).toHaveText("sourdough");
+ const previousItemButton = page.getByRole("button", { name: "Previous item" });
+ const previousItemButtonBox = await previousItemButton.boundingBox();
+ expect(previousItemButtonBox).not.toBeNull();
+ if (!previousItemButtonBox) throw new Error("Map buy previous item button was not measurable");
+ expect(previousItemButtonBox.width).toBeGreaterThanOrEqual(40);
+ expect(previousItemButtonBox.height).toBeGreaterThanOrEqual(40);
+ await previousItemButton.click();
+ await expect(page.locator(".confirm-buy-item-name")).toHaveText("french bread");
+ await page.getByRole("button", { name: "Mark as Bought" }).click();
+
+ await expect.poll(() => boughtPayloads.length).toBe(1);
+ expect(boughtPayloads[0]).toMatchObject({
+ item_name: "french bread",
+ bought: true,
+ quantity_bought: 1,
+ });
+ await expect(page.locator(".confirm-buy-item-name")).toHaveText("sourdough");
+ await expect(page.locator(".location-map-sheet-count")).toHaveText("1 shown");
+ await expect(bakeryArea.locator(".location-map-count")).toHaveText("1 shown");
+ await expect(page.getByRole("group", { name: "Zone items" })).not.toContainText("french bread");
+
+ await page.getByRole("button", { name: "Mark as Bought" }).click();
+
+ await expect.poll(() => boughtPayloads.length).toBe(2);
+ expect(boughtPayloads[1]).toMatchObject({
+ item_name: "sourdough",
+ bought: true,
+ quantity_bought: 1,
+ });
+ await expect(page.locator(".confirm-buy-modal")).toHaveCount(0);
+ await expect(page.locator(".location-map-sheet-count")).toHaveText("0 shown");
+ await expect(bakeryArea.locator(".location-map-count")).toHaveText("0 shown");
+ const hiddenZoneState = page.getByRole("group", { name: "Zone items" }).locator(".location-map-inline-state", { hasText: "Hidden" });
+ await expect(hiddenZoneState).toContainText("2");
+ await expect(hiddenZoneState.getByRole("button", { name: "Show Zone Items" })).toHaveText("Show");
+ await expect(page.getByText("Items are assigned here, but hidden by layer filters.")).toHaveCount(0);
+
+ await page.getByRole("button", { name: "Layers" }).click();
+ await page.getByRole("button", { name: "Bought" }).click();
+ await expect(page.locator(".location-map-sheet-count")).toHaveText("2 items");
+ await expect(page.getByRole("button", { name: "french bread bought" })).toBeDisabled();
+ await expect(page.getByRole("button", { name: "sourdough bought" })).toBeDisabled();
+});
+
+test("viewer can close map buy modal with Escape", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = publishedMapState([
+ {
+ id: 1533,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+ const boughtPayloads: Array> = [];
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/list/item", async (route) => {
+ if (route.request().method() === "PATCH") {
+ boughtPayloads.push(await route.request().postDataJSON());
+ }
+ await route.fulfill({ status: 404, contentType: "application/json", body: "{}" });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.locator('[data-object-key="1533"]').locator("rect").first().click();
+ await page.getByRole("button", { name: "Mark french bread bought" }).click();
+ await expect(page.locator(".confirm-buy-modal")).toBeVisible();
+
+ await page.keyboard.press("Escape");
+
+ await expect(page.locator(".confirm-buy-modal")).toHaveCount(0);
+ expect(boughtPayloads).toEqual([]);
+ await expect(page.getByRole("button", { name: "Mark french bread bought" })).toBeVisible();
+});
+
+test("viewer advances map buy modal after partial quantity buys", async ({ page }) => {
+ await mockMapShell(page);
+
+ const partialItems = [
+ {
+ id: 4301,
+ item_name: "flour",
+ quantity: 3,
+ bought: false,
+ zone_id: 501,
+ zone: "Bakery",
+ added_by_users: ["map-user"],
+ },
+ {
+ id: 4302,
+ item_name: "yeast",
+ quantity: 1,
+ bought: false,
+ zone_id: 501,
+ zone: "Bakery",
+ added_by_users: ["map-user"],
+ },
+ ];
+ const updatedFlour = { ...partialItems[0], quantity: 2 };
+ const mapState = {
+ ...publishedMapState([
+ {
+ id: 1534,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true),
+ items: partialItems,
+ };
+ const boughtPayloads: Array> = [];
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/list/item**", async (route) => {
+ if (route.request().method() === "GET") {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(updatedFlour),
+ });
+ return;
+ }
+
+ boughtPayloads.push(await route.request().postDataJSON());
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ success: true }),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.locator('[data-object-key="1534"]').locator("rect").first().click();
+ await page.getByRole("button", { name: "Mark flour bought" }).click();
+ await expect(page.locator(".confirm-buy-item-name")).toHaveText("flour");
+
+ const quantityInput = page.getByRole("spinbutton", { name: "Quantity to mark bought" });
+ await page.keyboard.press("ArrowDown");
+ await page.keyboard.press("ArrowDown");
+ await expect(quantityInput).toHaveValue("1");
+ await page.keyboard.press("ArrowUp");
+ await expect(quantityInput).toHaveValue("2");
+ await page.keyboard.press("ArrowDown");
+ await expect(quantityInput).toHaveValue("1");
+ await page.getByRole("button", { name: "Mark as Bought" }).click();
+
+ await expect.poll(() => boughtPayloads.length).toBe(1);
+ expect(boughtPayloads[0]).toMatchObject({
+ item_name: "flour",
+ bought: true,
+ quantity_bought: 1,
+ });
+ await expect(page.locator(".confirm-buy-item-name")).toHaveText("yeast");
+ await expect(page.getByRole("button", { name: "Mark flour bought" }).locator("small")).toHaveText("x2");
+});
+
+test("viewer keeps map buy flow moving when quantity refresh fails", async ({ page }) => {
+ await mockMapShell(page);
+
+ const partialItems = [
+ {
+ id: 4311,
+ item_name: "flour",
+ quantity: 3,
+ bought: false,
+ zone_id: 501,
+ zone: "Bakery",
+ added_by_users: ["map-user"],
+ },
+ {
+ id: 4312,
+ item_name: "yeast",
+ quantity: 1,
+ bought: false,
+ zone_id: 501,
+ zone: "Bakery",
+ added_by_users: ["map-user"],
+ },
+ ];
+ const mapState = {
+ ...publishedMapState([
+ {
+ id: 1535,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true),
+ items: partialItems,
+ };
+ const boughtPayloads: Array> = [];
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/list/item**", async (route) => {
+ if (route.request().method() === "GET") {
+ await route.fulfill({
+ status: 503,
+ contentType: "application/json",
+ body: JSON.stringify({ error: "refresh unavailable" }),
+ });
+ return;
+ }
+
+ boughtPayloads.push(await route.request().postDataJSON());
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ success: true }),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.locator('[data-object-key="1535"]').locator("rect").first().click();
+ await page.getByRole("button", { name: "Mark flour bought" }).click();
+ await expect(page.locator(".confirm-buy-item-name")).toHaveText("flour");
+
+ await page.locator(".confirm-buy-counter-btn").first().click();
+ await page.locator(".confirm-buy-counter-btn").first().click();
+ await expect(page.locator(".confirm-buy-counter-display")).toHaveValue("1");
+ await page.getByRole("button", { name: "Mark as Bought" }).click();
+
+ await expect.poll(() => boughtPayloads.length).toBe(1);
+ expect(boughtPayloads[0]).toMatchObject({
+ item_name: "flour",
+ bought: true,
+ quantity_bought: 1,
+ });
+ await expect(page.getByText("Updated item locally")).toBeVisible();
+ await expect(page.locator(".confirm-buy-item-name")).toHaveText("yeast");
+ await expect(page.getByRole("button", { name: "Mark flour bought" }).locator("small")).toHaveText("x2");
+ await expect(page.getByText("Mark item bought failed")).toHaveCount(0);
+});
+
+test("viewer shows completed zone items without rebuy actions", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = {
+ ...publishedMapState([
+ {
+ id: 1533,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true),
+ items: [
+ {
+ ...items[0],
+ bought: true,
+ },
+ items[1],
+ ],
+ };
+ const boughtPayloads: Array> = [];
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/list/item", async (route) => {
+ if (route.request().method() !== "PATCH") {
+ await route.fulfill({ status: 404, contentType: "application/json", body: "{}" });
+ return;
+ }
+
+ boughtPayloads.push(await route.request().postDataJSON());
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ success: true }),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.getByRole("button", { name: "Layers" }).click();
+ await page.getByRole("button", { name: "Bought" }).click();
+ await page.locator('[data-object-key="1533"]').locator("rect").first().click();
+
+ const boughtRow = page.getByRole("button", { name: "french bread bought" });
+ await expect(boughtRow).toBeVisible();
+ await expect(boughtRow).toBeDisabled();
+ await expect(boughtRow.locator("small")).toHaveText("Bought");
+
+ await boughtRow.click({ force: true });
+
+ await expect(page.locator(".confirm-buy-modal")).toHaveCount(0);
+ expect(boughtPayloads).toHaveLength(0);
+
+ await page.getByRole("button", { name: "Mark sourdough bought" }).click();
+ await expect(page.locator(".confirm-buy-item-name")).toHaveText("sourdough");
+ await page.getByRole("button", { name: "Mark as Bought" }).click();
+
+ await expect.poll(() => boughtPayloads.length).toBe(1);
+ expect(boughtPayloads[0]).toMatchObject({
+ item_name: "sourdough",
+ bought: true,
+ quantity_bought: 1,
+ });
+ await expect(page.locator(".confirm-buy-modal")).toHaveCount(0);
+ const newlyBoughtRow = page.getByRole("button", { name: "sourdough bought" });
+ await expect(newlyBoughtRow).toBeVisible();
+ await expect(newlyBoughtRow).toBeDisabled();
+ await expect(newlyBoughtRow.locator("small")).toHaveText("Bought");
+});
+
+test("viewer shows compact state when unmapped items are hidden by filters", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = {
+ ...publishedMapState([], true),
+ items: [
+ {
+ id: 4100,
+ item_name: "other batteries",
+ quantity: 1,
+ bought: false,
+ zone_id: null,
+ zone: null,
+ added_by_users: ["other-user"],
+ },
+ ],
+ unmapped_count: 1,
+ };
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.getByRole("button", { name: "Layers" }).click();
+ await page.getByRole("button", { name: "Unmapped" }).click();
+
+ await expect(page.getByText("other batteries")).toHaveCount(0);
+ const hiddenUnmappedState = page.getByRole("group", { name: "Unmapped items" }).locator(".location-map-inline-state", { hasText: "Hidden" });
+ await expect(hiddenUnmappedState).toContainText("1");
+ await expect(hiddenUnmappedState.getByRole("button", { name: "Show Unmapped Items" })).toHaveText("Show");
+ await expect(page.getByText("Unmapped items are hidden by layer filters.")).toHaveCount(0);
+
+ await page.getByRole("button", { name: "Show Unmapped Items" }).click();
+
+ await expect(page.getByText("other batteries")).toBeVisible();
+ await expect(hiddenUnmappedState).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Layers, 3 changed" })).toBeVisible();
+});
+
+test("viewer marks partial unmapped counts when filters hide some items", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = {
+ ...publishedMapState([], true),
+ items: [
+ {
+ id: 4110,
+ item_name: "visible batteries",
+ quantity: 1,
+ bought: false,
+ zone_id: null,
+ zone: null,
+ added_by_users: ["map-user"],
+ },
+ {
+ id: 4111,
+ item_name: "hidden batteries",
+ quantity: 1,
+ bought: false,
+ zone_id: null,
+ zone: null,
+ added_by_users: ["other-user"],
+ },
+ ],
+ unmapped_count: 2,
+ };
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.getByRole("button", { name: "Layers" }).click();
+ await page.getByRole("button", { name: "Unmapped" }).click();
+
+ await expect(page.getByText("visible batteries")).toBeVisible();
+ await expect(page.getByText("hidden batteries")).toHaveCount(0);
+ const hiddenUnmappedState = page.getByRole("group", { name: "Unmapped items" }).locator(".location-map-inline-state", { hasText: "Hidden" });
+ await expect(hiddenUnmappedState).toContainText("1");
+ await expect(hiddenUnmappedState.getByRole("button", { name: "Show All Unmapped Items" })).toHaveText("Show All");
+ await expect(page.getByText("1 more hidden by layer filters.")).toHaveCount(0);
+
+ await page.getByRole("button", { name: "Show All Unmapped Items" }).click();
+
+ await expect(page.getByText("hidden batteries")).toBeVisible();
+ await expect(hiddenUnmappedState).toHaveCount(0);
+});
+
+test("viewer shows compact state when selected zone items are hidden by filters", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = publishedMapState([
+ {
+ id: 1551,
+ location_map_id: 901,
+ zone_id: 502,
+ zone_name: "Frozen Foods",
+ type: "zone",
+ label: "Frozen Foods",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ const frozenArea = page.locator('[data-object-key="1551"]');
+ await expect(frozenArea.locator(".location-map-count")).toHaveText("0 shown");
+ await expect(page.locator(".location-map-pin")).toHaveCount(0);
+
+ await frozenArea.locator("rect").first().click();
+ await expect(page.locator(".location-map-sheet-count")).toHaveText("0 shown");
+ const hiddenZoneState = page.getByRole("group", { name: "Zone items" }).locator(".location-map-inline-state", { hasText: "Hidden" });
+ await expect(hiddenZoneState).toContainText("1");
+ await expect(hiddenZoneState.getByRole("button", { name: "Show Zone Items" })).toHaveText("Show");
+ await expect(page.getByText("Items are assigned here, but hidden by layer filters.")).toHaveCount(0);
+ await expect(page.getByText("frozen salmon")).toHaveCount(0);
+
+ await page.getByRole("button", { name: "Show Zone Items" }).click();
+ await expect(page.locator(".location-map-sheet-count")).toHaveText("1 item");
+ await expect(page.getByText("frozen salmon")).toBeVisible();
+ await expect(frozenArea.locator(".location-map-count")).toHaveText("1 item");
+ await expect(page.locator(".location-map-pin")).toHaveCount(0);
+});
+
+test("viewer marks partial zone counts when filters hide some items", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = {
+ ...publishedMapState([
+ {
+ id: 1552,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true),
+ items: [
+ {
+ id: 5001,
+ item_name: "visible bread",
+ quantity: 1,
+ bought: false,
+ zone_id: 501,
+ zone: "Bakery",
+ added_by_users: ["map-user"],
+ },
+ {
+ id: 5002,
+ item_name: "hidden baguette",
+ quantity: 1,
+ bought: false,
+ zone_id: 501,
+ zone: "Bakery",
+ added_by_users: ["other-user"],
+ },
+ ],
+ };
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ const bakeryArea = page.locator('[data-object-key="1552"]');
+ await expect(bakeryArea.locator(".location-map-count")).toHaveText("1 shown");
+
+ await bakeryArea.locator("rect").first().click();
+ await expect(page.locator(".location-map-sheet-count")).toHaveText("1 shown");
+ await expect(page.getByText("visible bread")).toBeVisible();
+ await expect(page.getByText("hidden baguette")).toHaveCount(0);
+ const hiddenZoneState = page.getByRole("group", { name: "Zone items" }).locator(".location-map-inline-state", { hasText: "Hidden" });
+ await expect(hiddenZoneState).toContainText("1");
+ await expect(hiddenZoneState.getByRole("button", { name: "Show All Zone Items" })).toHaveText("Show All");
+ await expect(page.getByText("1 more hidden by layer filters.")).toHaveCount(0);
+
+ await page.getByRole("button", { name: "Show All Zone Items" }).click();
+
+ await expect(page.locator(".location-map-sheet-count")).toHaveText("2 items");
+ await expect(page.getByText("hidden baguette")).toBeVisible();
+ await expect(bakeryArea.locator(".location-map-count")).toHaveText("2 items");
+});
+
+test("viewer handles empty and many-item zone panels without default pins", async ({ page }) => {
+ await mockMapShell(page);
+
+ const manyItems = Array.from({ length: 14 }, (_, index) => ({
+ id: 3000 + index,
+ item_name: `bulk item ${index + 1}`,
+ quantity: index + 1,
+ bought: false,
+ zone_id: 501,
+ zone: "Bakery",
+ added_by_users: ["map-user"],
+ }));
+ const mapState = {
+ ...publishedMapState([
+ {
+ id: 1561,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ {
+ id: 1562,
+ location_map_id: 901,
+ zone_id: 502,
+ zone_name: "Frozen Foods",
+ type: "zone",
+ label: "Frozen Foods",
+ x: 340,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 2,
+ },
+ ], true),
+ items: manyItems,
+ unmapped_count: 0,
+ };
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await expect(page.locator(".location-map-pin")).toHaveCount(0);
+
+ await page.locator('[data-object-key="1562"]').locator("rect").first().click();
+ await expect(page.locator(".location-map-sheet-count")).toHaveText("0 items");
+ await expect(page.getByRole("group", { name: "Zone items" })).toHaveCount(0);
+ await expect(page.locator(".location-map-inline-state", { hasText: "Items" })).toHaveCount(0);
+ await expect(page.getByText("No items assigned to this zone yet.")).toHaveCount(0);
+
+ await page.locator('[data-object-key="1561"]').locator("rect").first().click();
+ await expect(page.locator(".location-map-sheet-count")).toHaveText("14 items");
+ const zoneItems = page.getByRole("group", { name: "Zone items" });
+ await expect(zoneItems.locator(".location-map-item-row")).toHaveCount(8);
+ await expect(zoneItems.locator(".location-map-item-row").first()).toContainText("bulk item 1");
+ await expect(page.getByText("bulk item 9")).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Show 6 more zone items" })).toHaveText("+6 more");
+ const firstZoneItemButtonBox = await page.getByRole("button", { name: "Mark bulk item 1 bought" }).boundingBox();
+ const moreZoneItemsButtonBox = await page.getByRole("button", { name: "Show 6 more zone items" }).boundingBox();
+ expect(firstZoneItemButtonBox).not.toBeNull();
+ expect(moreZoneItemsButtonBox).not.toBeNull();
+ if (!firstZoneItemButtonBox || !moreZoneItemsButtonBox) {
+ throw new Error("Zone item controls were not measurable");
+ }
+ expect(firstZoneItemButtonBox.height).toBeGreaterThanOrEqual(40);
+ expect(moreZoneItemsButtonBox.height).toBeGreaterThanOrEqual(40);
+
+ await page.getByRole("button", { name: "Show 6 more zone items" }).click();
+
+ await expect(zoneItems.locator(".location-map-item-row")).toHaveCount(14);
+ await expect(zoneItems.locator(".location-map-item-row").last()).toContainText("bulk item 14");
+ await expect(page.getByRole("button", { name: "Show fewer zone items" })).toHaveText("Show fewer");
+ await expect(page.locator(".location-map-pin")).toHaveCount(0);
+});
+
+test("viewer collapses expanded zone items after selecting another zone", async ({ page }) => {
+ await mockMapShell(page);
+
+ const bakeryItems = Array.from({ length: 11 }, (_, index) => ({
+ id: 5100 + index,
+ item_name: `bakery item ${index + 1}`,
+ quantity: 1,
+ bought: false,
+ zone_id: 501,
+ zone: "Bakery",
+ added_by_users: ["map-user"],
+ }));
+ const frozenItems = Array.from({ length: 10 }, (_, index) => ({
+ id: 5200 + index,
+ item_name: `frozen item ${index + 1}`,
+ quantity: 1,
+ bought: false,
+ zone_id: 502,
+ zone: "Frozen Foods",
+ added_by_users: ["map-user"],
+ }));
+ const mapState = {
+ ...publishedMapState([
+ {
+ id: 1563,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ {
+ id: 1564,
+ location_map_id: 901,
+ zone_id: 502,
+ zone_name: "Frozen Foods",
+ type: "zone",
+ label: "Frozen Foods",
+ x: 340,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 2,
+ },
+ ], true),
+ items: [...bakeryItems, ...frozenItems],
+ unmapped_count: 0,
+ };
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await page.locator('[data-object-key="1563"]').locator("rect").first().click();
+ const zoneItems = page.getByRole("group", { name: "Zone items" });
+ await expect(zoneItems.locator(".location-map-item-row")).toHaveCount(8);
+ await page.getByRole("button", { name: "Show 3 more zone items" }).click();
+ await expect(zoneItems.locator(".location-map-item-row")).toHaveCount(11);
+ await expect(page.getByText("bakery item 11")).toBeVisible();
+
+ await page.locator('[data-object-key="1564"]').locator("rect").first().click();
+
+ await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Frozen Foods");
+ await expect(zoneItems.locator(".location-map-item-row")).toHaveCount(8);
+ await expect(page.getByText("frozen item 8")).toBeVisible();
+ await expect(page.getByText("frozen item 9")).toHaveCount(0);
+ await expect(page.getByText("bakery item 11")).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Show 2 more zone items" })).toHaveText("+2 more");
+ await expect(page.getByRole("button", { name: "Show fewer zone items" })).toHaveCount(0);
+});
+
+test("viewer map areas are keyboard selectable", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = publishedMapState([
+ {
+ id: 1571,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ {
+ id: 1572,
+ location_map_id: 901,
+ zone_id: 502,
+ zone_name: "Frozen Foods",
+ type: "zone",
+ label: "Frozen Foods",
+ x: 340,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 2,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ const bakeryArea = page.getByRole("button", { name: "Map area Bakery" });
+ const frozenArea = page.getByRole("button", { name: "Map area Frozen Foods" });
+ const bakeryRect = page.locator('[data-object-key="1571"]').locator("rect").first();
+ await expect(page.getByRole("button", { name: "Map area Bakery, 2 items", exact: true })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Map area Frozen Foods, 0 shown", exact: true })).toBeVisible();
+ await expect(bakeryArea).toHaveAttribute("aria-pressed", "false");
+
+ const initialFill = await bakeryRect.evaluate((element) => getComputedStyle(element).fill);
+ await bakeryRect.hover();
+ await expect.poll(async () => (
+ bakeryRect.evaluate((element) => getComputedStyle(element).fill)
+ )).not.toBe(initialFill);
+
+ await bakeryArea.focus();
+ await expect(bakeryArea).toBeFocused();
+ await page.keyboard.press("Enter");
+ await expect(bakeryArea).toHaveAttribute("aria-pressed", "true");
+ await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Bakery");
+ await expect(page.locator(".location-map-sheet-count")).toHaveText("2 items");
+ await expect(page.getByText("french bread")).toBeVisible();
+
+ await frozenArea.focus();
+ await expect(frozenArea).toBeFocused();
+ await page.keyboard.press("Space");
+ await expect(frozenArea).toHaveAttribute("aria-pressed", "true");
+ await expect(bakeryArea).toHaveAttribute("aria-pressed", "false");
+ await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Frozen Foods");
+ await expect(page.locator(".location-map-sheet-count")).toHaveText("0 shown");
+
+ await page.keyboard.press("Escape");
+ await expect(frozenArea).toBeFocused();
+ await expect(frozenArea).toHaveAttribute("aria-pressed", "false");
+ await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Map Details");
+ await expect(page.getByRole("group", { name: "Map summary" })).toBeVisible();
+});
+
+test("mobile starts fitted and fit returns panned maps into the canvas", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await mockMapShell(page);
+
+ const mapState = publishedMapState([
+ {
+ id: 1581,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ const { scroll } = await expectMapFitsCanvas(page, 45, "Initial mobile");
+
+ const zoomIn = page.getByRole("button", { name: "Zoom in" });
+ await zoomIn.click();
+ await zoomIn.click();
+ await zoomIn.click();
+ await scroll.evaluate((element) => {
+ element.scrollLeft = 320;
+ element.scrollTop = 240;
+ });
+ const pannedScroll = await scroll.evaluate((element) => ({
+ left: element.scrollLeft,
+ top: element.scrollTop,
+ }));
+ expect(pannedScroll.left).toBeGreaterThan(0);
+ expect(pannedScroll.top).toBeGreaterThan(0);
+
+ await page.getByRole("button", { name: "Fit" }).click();
+
+ await expectMapFitsCanvas(page, 45, "Mobile");
+ await expect.poll(async () => scroll.evaluate((element) => ({
+ left: element.scrollLeft,
+ top: element.scrollTop,
+ }))).toEqual({ left: 0, top: 0 });
+});
+
+test("compact tablet map starts fitted on first load", async ({ page }) => {
+ await page.setViewportSize({ width: 768, height: 720 });
+ await mockMapShell(page);
+
+ const mapState = publishedMapState([
+ {
+ id: 1586,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ const toolbar = page.locator(".location-map-toolbar");
+ const modeButtons = page.locator(".location-map-mode-buttons");
+ const historyActions = page.locator(".location-map-history-buttons");
+ const zoomControls = page.locator(".location-map-zoom-controls");
+ const viewModeBox = await modeButtons.boundingBox();
+ const viewZoomBox = await zoomControls.boundingBox();
+ expect(viewModeBox).not.toBeNull();
+ expect(viewZoomBox).not.toBeNull();
+ if (!viewModeBox || !viewZoomBox) {
+ throw new Error("Compact tablet toolbar layout was not measurable");
+ }
+ expect(viewModeBox.width).toBeGreaterThanOrEqual(180);
+ expect(viewModeBox.width).toBeLessThanOrEqual(190);
+ await expect(historyActions).toHaveCount(0);
+ expect(viewZoomBox.x).toBeGreaterThan(viewModeBox.x + viewModeBox.width - 1);
+
+ await page.getByRole("button", { name: "Edit Draft" }).click();
+ await expect(historyActions).toHaveCount(1);
+ const toolbarBox = await toolbar.boundingBox();
+ const editModeBox = await modeButtons.boundingBox();
+ const editHistoryBox = await historyActions.boundingBox();
+ const editZoomBox = await zoomControls.boundingBox();
+ expect(toolbarBox).not.toBeNull();
+ expect(editModeBox).not.toBeNull();
+ expect(editHistoryBox).not.toBeNull();
+ expect(editZoomBox).not.toBeNull();
+ if (!toolbarBox || !editModeBox || !editHistoryBox || !editZoomBox) {
+ throw new Error("Compact tablet edit toolbar layout was not measurable");
+ }
+ expect(Math.abs(editModeBox.x - viewModeBox.x)).toBeLessThanOrEqual(1);
+ expect(Math.abs(editModeBox.width - viewModeBox.width)).toBeLessThanOrEqual(1);
+ expect(editHistoryBox.x).toBeGreaterThan(editModeBox.x + editModeBox.width - 1);
+ expect(editZoomBox.x).toBeGreaterThan(editHistoryBox.x + editHistoryBox.width - 1);
+ expect(editZoomBox.x + editZoomBox.width).toBeLessThanOrEqual(toolbarBox.x + toolbarBox.width + 1);
+
+ await expectMapFitsCanvas(page, 75, "Compact tablet");
+});
+
+test("zoom controls disable at map zoom limits", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = publishedMapState([
+ {
+ id: 1591,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ const zoomText = page.locator(".location-map-zoom-controls span");
+ const zoomIn = page.getByRole("button", { name: "Zoom in" });
+ const zoomOut = page.getByRole("button", { name: "Zoom out" });
+ await expect(zoomIn).toBeEnabled();
+ await expect(zoomOut).toBeEnabled();
+
+ for (let index = 0; index < 6; index += 1) {
+ await zoomIn.click();
+ }
+
+ await expect(zoomText).toHaveText("160%");
+ await expect(zoomIn).toBeDisabled();
+ await expect(zoomOut).toBeEnabled();
+
+ for (let index = 0; index < 9; index += 1) {
+ await zoomOut.click();
+ }
+
+ await expect(zoomText).toHaveText("30%");
+ await expect(zoomOut).toBeDisabled();
+ await expect(zoomIn).toBeEnabled();
+});
+
+test("mobile editor uses bottom sheet controls instead of desktop object toolbar", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await mockMapShell(page);
+
+ const mapState = draftMapState([
+ {
+ id: 1601,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await expect(page.getByRole("heading", { name: "Costco" })).toBeVisible();
+ await expect(page.getByText("Eastvale")).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Draft Map" })).toBeVisible();
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+
+ await expect(page.getByLabel("Map controls")).toBeVisible();
+ await expect(page.locator(".location-map-tool-buttons")).toHaveCount(0);
+ await expect(page.locator(".location-map-mobile-tool-switch")).toHaveCount(0);
+ const historyActions = page.locator(".location-map-history-buttons");
+ await expect(historyActions.getByRole("button", { name: "Undo" })).toBeVisible();
+ await expect(historyActions.getByRole("button", { name: "Redo" })).toBeVisible();
+ await expect(historyActions.getByRole("button", { name: "Undo" })).toBeDisabled();
+ await expect(historyActions.getByRole("button", { name: "Redo" })).toBeDisabled();
+ const primaryActions = page.locator(".location-map-primary-actions");
+ const secondaryActions = page.locator(".location-map-secondary-actions");
+ await expect(primaryActions.getByRole("button", { name: "Save Draft" })).toHaveCount(0);
+ await expect(primaryActions.getByRole("button", { name: "Publish" })).toBeVisible();
+ await expect(secondaryActions.getByRole("button", { name: "Add Area" })).toBeVisible();
+ await expect(secondaryActions.getByRole("button", { name: "View Draft" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "View", exact: true })).toBeVisible();
+ const primaryActionBox = await primaryActions.boundingBox();
+ const secondaryActionBox = await secondaryActions.boundingBox();
+ expect(primaryActionBox).not.toBeNull();
+ expect(secondaryActionBox).not.toBeNull();
+ if (!primaryActionBox || !secondaryActionBox) throw new Error("Mobile action groups were not measurable");
+ expect(primaryActionBox.y).toBeLessThan(secondaryActionBox.y);
+ const secondaryActionButtons = secondaryActions.getByRole("button");
+ const secondaryActionButtonCount = await secondaryActionButtons.count();
+ for (let index = 0; index < secondaryActionButtonCount; index += 1) {
+ const actionButtonBox = await secondaryActionButtons.nth(index).boundingBox();
+ expect(actionButtonBox).not.toBeNull();
+ if (!actionButtonBox) throw new Error("Mobile secondary action was not measurable");
+ expect(actionButtonBox.height).toBeGreaterThanOrEqual(40);
+ }
+ await page.getByRole("button", { name: "Layers" }).click();
+ await expect(page.getByRole("group", { name: "Layer filters" })).toBeVisible();
+ await page.getByRole("button", { name: "Pins" }).click();
+ const resetLayersButtonBox = await page.getByRole("button", { name: "Reset layer filters" }).boundingBox();
+ expect(resetLayersButtonBox).not.toBeNull();
+ if (!resetLayersButtonBox) throw new Error("Mobile layer reset button was not measurable");
+ expect(resetLayersButtonBox.height).toBeGreaterThanOrEqual(40);
+ expect(resetLayersButtonBox.width).toBeLessThanOrEqual(44);
+ await expect(page.locator(".location-map-layer-reset-icon")).toHaveCount(1);
+ await page.getByRole("button", { name: "Reset layer filters" }).click();
+ await page.getByRole("button", { name: "Layers" }).click();
+ await expect(page.locator(".location-map-pin")).toHaveCount(0);
+
+ const canvasBox = await page.locator(".location-map-canvas-shell").boundingBox();
+ const sheetBox = await page.locator(".location-map-bottom-sheet").boundingBox();
+ const sheetHandleBox = await page.locator(".location-map-bottom-sheet-handle").boundingBox();
+ expect(canvasBox).not.toBeNull();
+ expect(sheetBox).not.toBeNull();
+ expect(sheetHandleBox).not.toBeNull();
+ if (!canvasBox || !sheetBox || !sheetHandleBox) throw new Error("Mobile map layout was not measurable");
+ expect(canvasBox.height).toBeGreaterThan(260);
+ expect(sheetBox.height).toBeLessThanOrEqual(360);
+ expect(sheetHandleBox.width).toBeGreaterThanOrEqual(36);
+ expect(sheetHandleBox.height).toBeLessThanOrEqual(6);
+
+ await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click();
+ const labelInput = page.getByRole("textbox", { name: "Label" });
+ await expect(labelInput).toBeVisible();
+ await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Bakery");
+ await expect(page.locator(".location-map-object").last()).toHaveAttribute("data-object-key", "1601");
+ await expect(page.getByLabel("Object type")).toHaveCount(0);
+ await expect(page.locator(".location-map-geometry-controls")).toHaveCount(0);
+ await expect(page.getByLabel("X position")).toHaveCount(0);
+ await expect(page.getByLabel("Y position")).toHaveCount(0);
+ await expect(page.getByLabel("Width")).toHaveCount(0);
+ await expect(page.getByLabel("Height")).toHaveCount(0);
+ const fieldRows = page.locator(".location-map-field-row");
+ await expect(fieldRows).toHaveCount(2);
+ const objectFlagButtons = page.locator(".location-map-object-flags").getByRole("button");
+ await expect(objectFlagButtons).toHaveCount(2);
+ await expect(page.locator(".location-map-object-flags input[type='checkbox']")).toHaveCount(0);
+ const visibleFlag = page.getByRole("button", { name: "Visible" });
+ const lockedFlag = page.getByRole("button", { name: "Locked" });
+ await expect(page.locator(".location-map-object-flag-state")).toHaveCount(2);
+ await expect(visibleFlag).toHaveAttribute("aria-pressed", "true");
+ await expect(visibleFlag.locator(".location-map-object-flag-state.is-on")).toHaveCount(1);
+ await expect(lockedFlag).toHaveAttribute("aria-pressed", "false");
+ await expect(lockedFlag.locator(".location-map-object-flag-state.is-on")).toHaveCount(0);
+ const selectedPrimaryActions = page.locator(".location-map-selected-primary-actions");
+ await expect(selectedPrimaryActions.getByRole("button", { name: "Save Draft" })).toHaveCount(0);
+ await expect(selectedPrimaryActions.getByRole("button", { name: "Publish" })).toBeVisible();
+ await expect(page.locator(".location-map-selected-secondary-actions")).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Bought" })).toHaveCount(0);
+ const selectedPrimaryBox = await selectedPrimaryActions.boundingBox();
+ const labelBox = await labelInput.boundingBox();
+ const firstFieldRowBox = await fieldRows.first().boundingBox();
+ const firstFlagButtonBox = await objectFlagButtons.first().boundingBox();
+ expect(selectedPrimaryBox).not.toBeNull();
+ expect(labelBox).not.toBeNull();
+ expect(firstFieldRowBox).not.toBeNull();
+ expect(firstFlagButtonBox).not.toBeNull();
+ if (!selectedPrimaryBox || !labelBox || !firstFieldRowBox || !firstFlagButtonBox) {
+ throw new Error("Selected object draft actions were not measurable");
+ }
+ expect(selectedPrimaryBox.y).toBeLessThan(labelBox.y);
+ expect(firstFieldRowBox.height).toBeLessThanOrEqual(54);
+ expect(firstFlagButtonBox.height).toBeGreaterThanOrEqual(40);
+ const closeSelectedButton = page.getByRole("button", { name: "Close selected map area" });
+ await expect(closeSelectedButton).toBeVisible();
+ const closeSelectedButtonBox = await closeSelectedButton.boundingBox();
+ expect(closeSelectedButtonBox).not.toBeNull();
+ if (!closeSelectedButtonBox) throw new Error("Selected area close button was not measurable");
+ expect(closeSelectedButtonBox.width).toBeGreaterThanOrEqual(40);
+ expect(closeSelectedButtonBox.height).toBeGreaterThanOrEqual(40);
+ await closeSelectedButton.click();
+ await expect(page.getByRole("textbox", { name: "Label" })).toHaveCount(0);
+ await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Edit Areas");
+ await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0);
+ await expect(page.locator(".location-map-secondary-actions").getByRole("button", { name: "Add Area" })).toBeVisible();
+ await page.getByRole("button", { name: "View", exact: true }).click();
+ await expect(page.getByRole("textbox", { name: "Label" })).toHaveCount(0);
+});
+
+test("mobile edit selection keeps resize handle in reach", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await mockMapShell(page);
+
+ const mapState = draftMapState([
+ {
+ id: 1606,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 720,
+ y: 480,
+ width: 240,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+
+ const scroll = page.locator(".location-map-scroll");
+ const bakeryArea = page.getByRole("button", { name: "Map area Bakery" });
+ const zoomIn = page.getByRole("button", { name: "Zoom in" });
+ await zoomIn.click();
+ await zoomIn.click();
+ await zoomIn.click();
+
+ await bakeryArea.focus();
+ await scroll.evaluate((element) => {
+ element.scrollLeft = 0;
+ element.scrollTop = 0;
+ });
+ await expect.poll(async () => scroll.evaluate((element) => element.scrollLeft)).toBe(0);
+ await expect.poll(async () => scroll.evaluate((element) => element.scrollTop)).toBe(0);
+
+ await page.keyboard.press("Enter");
+ await expect(bakeryArea).toHaveAttribute("aria-pressed", "true");
+ await expect.poll(async () => scroll.evaluate((element) => element.scrollLeft)).toBeGreaterThan(200);
+ await expect.poll(async () => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(80);
+ await expectResizeHandleWithinMapScroll(page);
+});
+
+test("mobile edit zoom keeps selected resize handle in reach", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await mockMapShell(page);
+
+ const mapState = draftMapState([
+ {
+ id: 1608,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 720,
+ y: 480,
+ width: 240,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+
+ const scroll = page.locator(".location-map-scroll");
+ const bakeryArea = page.getByRole("button", { name: "Map area Bakery" });
+ await bakeryArea.focus();
+ await page.keyboard.press("Enter");
+ await expect(bakeryArea).toHaveAttribute("aria-pressed", "true");
+ await expectResizeHandleWithinMapScroll(page);
+
+ await scroll.evaluate((element) => {
+ element.scrollLeft = 0;
+ element.scrollTop = 0;
+ });
+ await expect.poll(async () => scroll.evaluate((element) => element.scrollLeft)).toBe(0);
+ await expect.poll(async () => scroll.evaluate((element) => element.scrollTop)).toBe(0);
+
+ const zoomIn = page.getByRole("button", { name: "Zoom in" });
+ await zoomIn.click();
+ await zoomIn.click();
+ await zoomIn.click();
+
+ await expect.poll(async () => scroll.evaluate((element) => element.scrollLeft)).toBeGreaterThan(200);
+ await expect.poll(async () => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(80);
+ await expectResizeHandleWithinMapScroll(page);
+});
+
+test("mobile editor pans from empty map space without moving areas", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await mockMapShell(page);
+
+ const mapState = draftMapState([
+ {
+ id: 1611,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ await page.getByRole("button", { name: "Continue Editing" }).click();
+
+ const scroll = page.locator(".location-map-scroll");
+ const scrollBox = await scroll.boundingBox();
+ expect(scrollBox).not.toBeNull();
+ if (!scrollBox) throw new Error("Mobile edit map scroll area was not measurable");
+
+ const bakeryArea = page.locator('[data-object-key="1611"]').locator("rect").first();
+ await expect(bakeryArea).toHaveAttribute("x", "40");
+ await expect(bakeryArea).toHaveAttribute("y", "40");
+
+ const zoomIn = page.getByRole("button", { name: "Zoom in" });
+ await zoomIn.click();
+ await zoomIn.click();
+ await zoomIn.click();
+
+ const beforePan = await scroll.evaluate((element) => ({
+ left: element.scrollLeft,
+ top: element.scrollTop,
+ }));
+ expect(beforePan).toEqual({ left: 0, top: 0 });
+
+ const startX = scrollBox.x + scrollBox.width - 48;
+ const startY = scrollBox.y + scrollBox.height - 48;
+ await page.mouse.move(startX, startY);
+ await page.mouse.down();
+ await page.mouse.move(startX - 140, startY - 100, { steps: 8 });
+ await page.mouse.up();
+
+ await expect.poll(async () =>
+ scroll.evaluate((element) => element.scrollLeft)
+ ).toBeGreaterThan(80);
+ await expect.poll(async () =>
+ scroll.evaluate((element) => element.scrollTop)
+ ).toBeGreaterThan(50);
+ await expect(bakeryArea).toHaveAttribute("x", "40");
+ await expect(bakeryArea).toHaveAttribute("y", "40");
+ await expect(page.locator(".location-map-status")).toHaveText("Draft");
+});
+
+test("members can view a published map but cannot edit it", async ({ page }) => {
+ await mockMapShell(page, memberHousehold);
+
+ const mapState = publishedMapState([
+ {
+ id: 1201,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], false);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await expect(page.getByText("Published")).toBeVisible();
+ await expect(page.getByText("Bakery")).toBeVisible();
+ await expect(page.getByRole("button", { name: "Edit Map" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Publish" })).toHaveCount(0);
+});
+
+test("members can buy items from a published map", async ({ page }) => {
+ await mockMapShell(page, memberHousehold);
+
+ const memberItems = [
+ {
+ id: 1721,
+ item_name: "bagels",
+ quantity: 1,
+ bought: false,
+ zone_id: 501,
+ zone: "Bakery",
+ added_by_users: ["map-user"],
+ },
+ ];
+ const mapState = {
+ ...publishedMapState([
+ {
+ id: 1720,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], false),
+ items: memberItems,
+ };
+ const boughtPayloads: Array> = [];
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/list/item**", async (route) => {
+ boughtPayloads.push(await route.request().postDataJSON());
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ success: true }),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await expect(page.getByRole("button", { name: "Edit Draft" })).toHaveCount(0);
+ await page.getByRole("button", { name: "Map area Bakery, 1 item" }).click();
+ await page.getByRole("button", { name: "Mark bagels bought" }).click();
+ await expect(page.locator(".confirm-buy-item-name")).toHaveText("bagels");
+ await page.getByRole("button", { name: "Mark as Bought" }).click();
+
+ await expect.poll(() => boughtPayloads.length).toBe(1);
+ expect(boughtPayloads[0]).toMatchObject({
+ item_name: "bagels",
+ bought: true,
+ quantity_bought: 1,
+ });
+ await expect(page.locator(".confirm-buy-modal")).toHaveCount(0);
+
+ const hiddenZoneState = page.getByRole("group", { name: "Zone items" }).locator(".location-map-inline-state", { hasText: "Hidden" });
+ await expect(hiddenZoneState).toContainText("1");
+ await expect(page.getByRole("button", { name: "bagels bought" })).toHaveCount(0);
+ await hiddenZoneState.getByRole("button", { name: "Show Zone Items" }).click();
+ await expect(page.getByRole("button", { name: "bagels bought" })).toBeDisabled();
+});
+
+test("members see a clean empty state when no map is published", async ({ page }) => {
+ await mockMapShell(page, memberHousehold);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(noMapState(false)),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await expect(page.getByRole("heading", { name: "No Published Map" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "No Map" })).toHaveCount(0);
+ await expect(page.getByRole("group", { name: "Map setup details" })).toHaveCount(0);
+ await expect(page.getByText("Not published")).toHaveCount(0);
+ await expect(page.getByText("A household admin has not published a map yet.")).toHaveCount(0);
+ await expect(page.getByText("Not started")).toHaveCount(0);
+ await expect(page.getByRole("button", { name: /Create From/ })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Create Blank Map" })).toHaveCount(0);
+ await expect(page.getByLabel("Map controls")).toHaveCount(0);
+});
+
+test("members do not see unpublished draft maps", async ({ page }) => {
+ await mockMapShell(page, memberHousehold);
+
+ const mapState = draftMapState([
+ {
+ id: 1701,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Draft Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], false);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await expect(page.getByRole("heading", { name: "No Published Map" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "No Map" })).toHaveCount(0);
+ await expect(page.getByRole("group", { name: "Map setup details" })).toHaveCount(0);
+ await expect(page.getByText("Not published")).toHaveCount(0);
+ await expect(page.getByText("A household admin has not published a map yet.")).toHaveCount(0);
+ await expect(page.getByRole("heading", { name: "Draft Map" })).toHaveCount(0);
+ await expect(page.getByText("A map draft exists for this location")).toHaveCount(0);
+ await expect(page.getByText("Draft Bakery")).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Continue Editing" })).toHaveCount(0);
+ await expect(page.getByLabel("Map controls")).toHaveCount(0);
+});
diff --git a/frontend/tests/store-selector.spec.ts b/frontend/tests/store-selector.spec.ts
index f5102fb..9802db0 100644
--- a/frontend/tests/store-selector.spec.ts
+++ b/frontend/tests/store-selector.spec.ts
@@ -40,6 +40,26 @@ async function mockGroceryShell(page: Page, stores: Array {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ location: stores.find((store) => String(store.id) === route.request().url().match(/locations\/(\d+)\/map/)?.[1]) || stores[0],
+ map: null,
+ draft_map: null,
+ published_map: null,
+ objects: [],
+ draft_objects: [],
+ published_objects: [],
+ zones: [],
+ items: [],
+ unmapped_count: 0,
+ can_manage: true,
+ }),
+ });
+ });
+
await page.route("**/households/1/locations/*/list/recent", async (route) => {
await route.fulfill({
status: 200,
@@ -75,6 +95,13 @@ async function mockGroceryShell(page: Page, stores: Array {
await page.setViewportSize({ width: 473, height: 1000 });
+ const storeContextMessages: string[] = [];
+ page.on("console", (message) => {
+ const text = message.text();
+ if (text.includes("[StoreContext]")) {
+ storeContextMessages.push(text);
+ }
+ });
await mockGroceryShell(page, [
{
id: 10,
@@ -161,7 +188,17 @@ test("grocery store selector shows one selected store and picks from a modal", a
await expect.poll(() => page.evaluate(() => localStorage.getItem("activeStoreLocationId_1_100"))).toBe("13");
await page.getByRole("button", { name: "Manage Map" }).click();
- await expect(page).toHaveURL(/\/manage\?tab=stores&locationId=13$/);
+ await expect(page).toHaveURL(/\/stores\/100\/locations\/13\/map$/);
+ await expect(page.getByRole("heading", { name: "Costco" })).toBeVisible();
+ await expect(page.getByText("Fontana")).toBeVisible();
+ await expect(page.getByRole("heading", { name: "No Map" })).toBeVisible();
+
+ await page.getByRole("button", { name: "Back" }).click();
+ await expect(page).toHaveURL(/\/$/);
+ await expect(storeSelector).toContainText("Costco");
+ await expect(locationSelector).toContainText("Fontana");
+ await expect(page.getByRole("button", { name: "Manage Map" })).toBeVisible();
+ expect(storeContextMessages).toEqual([]);
});
test("single grocery store selector click does not open picker modal", async ({ page }) => {
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 8f1ab7f..69f425b 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -2,9 +2,12 @@ import react from '@vitejs/plugin-react';
import { defineConfig, loadEnv } from 'vite';
const env = loadEnv('', process.cwd());
-const allowedHosts = env.VITE_ALLOWED_HOSTS
+const configuredAllowedHosts = env.VITE_ALLOWED_HOSTS
? env.VITE_ALLOWED_HOSTS.split(',').map((host) => host.trim())
: [];
+const allowedHosts = Array.from(
+ new Set(['localhost', '127.0.0.1', '192.168.7.45', ...configuredAllowedHosts].filter(Boolean))
+);
export default defineConfig({
plugins: [react()],
diff --git a/packages/db/migrations/20260602_010000_location_maps.sql b/packages/db/migrations/20260602_010000_location_maps.sql
new file mode 100644
index 0000000..1611888
--- /dev/null
+++ b/packages/db/migrations/20260602_010000_location_maps.sql
@@ -0,0 +1,66 @@
+-- Location-specific store maps for the mobile map manager.
+-- Maps are scoped to a household-owned physical store location.
+
+CREATE TABLE IF NOT EXISTS location_maps (
+ id BIGSERIAL PRIMARY KEY,
+ household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
+ store_location_id INTEGER NOT NULL REFERENCES store_locations(id) ON DELETE CASCADE,
+ name VARCHAR(120) NOT NULL DEFAULT 'Store Map',
+ width INTEGER NOT NULL DEFAULT 1000 CHECK (width > 0),
+ height INTEGER NOT NULL DEFAULT 700 CHECK (height > 0),
+ status VARCHAR(20) NOT NULL DEFAULT 'draft'
+ CHECK (status IN ('draft', 'published', 'archived')),
+ version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0),
+ created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ published_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_location_maps_location_status
+ ON location_maps(household_id, store_location_id, status, updated_at DESC);
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_location_maps_draft_location
+ ON location_maps(store_location_id)
+ WHERE status = 'draft';
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_location_maps_published_location
+ ON location_maps(store_location_id)
+ WHERE status = 'published';
+
+CREATE TABLE IF NOT EXISTS location_map_objects (
+ id BIGSERIAL PRIMARY KEY,
+ location_map_id BIGINT NOT NULL REFERENCES location_maps(id) ON DELETE CASCADE,
+ zone_id INTEGER REFERENCES store_location_zones(id) ON DELETE SET NULL,
+ type VARCHAR(30) NOT NULL DEFAULT 'zone'
+ CHECK (type IN (
+ 'zone',
+ 'aisle',
+ 'entrance',
+ 'checkout',
+ 'shelf',
+ 'wall',
+ 'department',
+ 'anchor',
+ 'other'
+ )),
+ label VARCHAR(160) NOT NULL DEFAULT '',
+ x NUMERIC(10, 2) NOT NULL DEFAULT 0,
+ y NUMERIC(10, 2) NOT NULL DEFAULT 0,
+ width NUMERIC(10, 2) NOT NULL DEFAULT 160 CHECK (width > 0),
+ height NUMERIC(10, 2) NOT NULL DEFAULT 100 CHECK (height > 0),
+ rotation NUMERIC(7, 2) NOT NULL DEFAULT 0,
+ locked BOOLEAN NOT NULL DEFAULT FALSE,
+ visible BOOLEAN NOT NULL DEFAULT TRUE,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_location_map_objects_map_order
+ ON location_map_objects(location_map_id, sort_order, id);
+
+CREATE INDEX IF NOT EXISTS idx_location_map_objects_zone
+ ON location_map_objects(zone_id);