diff --git a/PROJECT_INSTRUCTIONS.md b/PROJECT_INSTRUCTIONS.md index a19eb2f..ceb010b 100644 --- a/PROJECT_INSTRUCTIONS.md +++ b/PROJECT_INSTRUCTIONS.md @@ -24,7 +24,7 @@ If anything conflicts, follow **this** doc. - `docker compose -f docker-compose.dev.yml up -d --build backend` - After backend env/CORS changes, recreate the backend service so `backend/.env` is reloaded: - `docker compose -f docker-compose.dev.yml up -d --force-recreate --no-deps backend` -- For the Docker frontend on port `3010`, `ALLOWED_ORIGINS` must include the exact browser origin, for example `http://localhost:3010` and `http://127.0.0.1:3010`. +- For the Docker frontend on port `3010`, `ALLOWED_ORIGINS` must include exact loopback browser origins such as `http://localhost:3010` and `http://127.0.0.1:3010`; private LAN origins such as `http://192.168.7.45:3010` are allowed by the backend CORS helper for local device testing. - Verify the restarted API with `GET http://127.0.0.1:5000/` and `GET http://127.0.0.1:5000/config`. - Do not print or commit real `.env` values while checking or updating local Docker env. diff --git a/backend/.env.example b/backend/.env.example index acb7711..bb95189 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,6 +6,6 @@ DB_PORT=5432 DB_NAME= PORT=5000 JWT_SECRET=change-me -ALLOWED_ORIGINS=http://localhost:3000 +ALLOWED_ORIGINS=http://localhost:3010,http://127.0.0.1:3010,http://192.168.7.45:3010 SESSION_COOKIE_NAME=sid SESSION_TTL_DAYS=30 diff --git a/backend/app.js b/backend/app.js index c6a97f1..cea98ba 100644 --- a/backend/app.js +++ b/backend/app.js @@ -4,6 +4,7 @@ const path = require("path"); const User = require("./models/user.model"); const requestIdMiddleware = require("./middleware/request-id"); const { sendError } = require("./utils/http"); +const { isAllowedCorsOrigin, parseAllowedOrigins } = require("./utils/cors-origins"); const app = express(); app.use(requestIdMiddleware); @@ -14,17 +15,11 @@ if (process.env.NODE_ENV !== "production") { app.use("/test", express.static(path.join(__dirname, "public"))); } -const allowedOrigins = (process.env.ALLOWED_ORIGINS || "") - .split(",") - .map((origin) => origin.trim()) - .filter(Boolean); +const allowedOrigins = parseAllowedOrigins(process.env.ALLOWED_ORIGINS || ""); app.use( cors({ origin: function (origin, callback) { - if (!origin) return callback(null, true); - if (allowedOrigins.includes(origin)) return callback(null, true); - if (/^http:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true); - if (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true); + if (isAllowedCorsOrigin(origin, allowedOrigins)) return callback(null, true); console.error(`CORS blocked origin: ${origin}`); callback(new Error(`CORS blocked: ${origin}. Add this origin to ALLOWED_ORIGINS environment variable.`)); }, diff --git a/backend/controllers/location-maps.controller.js b/backend/controllers/location-maps.controller.js new file mode 100644 index 0000000..829ea4a --- /dev/null +++ b/backend/controllers/location-maps.controller.js @@ -0,0 +1,131 @@ +const locationMapModel = require("../models/location-map.model"); +const { sendError } = require("../utils/http"); +const { logError } = require("../utils/logger"); + +function parsePositiveInteger(value) { + const parsed = Number.parseInt(String(value), 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + +function canManageMaps(req) { + return ["owner", "admin"].includes(req.household?.role); +} + +function getScope(req) { + return { + householdId: parsePositiveInteger(req.params.householdId), + locationId: parsePositiveInteger(req.params.locationId), + }; +} + +function validateScope(res, scope) { + if (!scope.householdId || !scope.locationId) { + sendError(res, 400, "Household ID and location ID must be positive integers"); + return false; + } + return true; +} + +function sendMapState(res, state, req) { + if (!state) { + return sendError(res, 404, "Store location not found"); + } + + return res.json({ + ...state, + can_manage: canManageMaps(req), + }); +} + +exports.getLocationMap = async (req, res) => { + const scope = getScope(req); + if (!validateScope(res, scope)) return; + + try { + const state = await locationMapModel.getMapState( + scope.householdId, + scope.locationId, + canManageMaps(req) + ); + sendMapState(res, state, req); + } catch (error) { + logError(req, "locationMaps.getLocationMap", error); + sendError(res, 500, "Failed to load location map"); + } +}; + +exports.createBlankMap = async (req, res) => { + const scope = getScope(req); + if (!validateScope(res, scope)) return; + + try { + const state = await locationMapModel.saveBlankDraft( + scope.householdId, + scope.locationId, + req.user.id, + req.body?.map || req.body || {} + ); + res.status(201); + sendMapState(res, state, req); + } catch (error) { + logError(req, "locationMaps.createBlankMap", error); + sendError(res, 500, "Failed to create blank map"); + } +}; + +exports.createMapFromZones = async (req, res) => { + const scope = getScope(req); + if (!validateScope(res, scope)) return; + + try { + const state = await locationMapModel.createDraftFromZones( + scope.householdId, + scope.locationId, + req.user.id, + req.body?.map || req.body || {} + ); + res.status(201); + sendMapState(res, state, req); + } catch (error) { + logError(req, "locationMaps.createMapFromZones", error); + sendError(res, 500, "Failed to create map from zones"); + } +}; + +exports.saveDraft = async (req, res) => { + const scope = getScope(req); + if (!validateScope(res, scope)) return; + + try { + const state = await locationMapModel.saveDraft( + scope.householdId, + scope.locationId, + req.user.id, + req.body || {} + ); + sendMapState(res, state, req); + } catch (error) { + logError(req, "locationMaps.saveDraft", error); + sendError(res, 500, "Failed to save map draft"); + } +}; + +exports.publishDraft = async (req, res) => { + const scope = getScope(req); + if (!validateScope(res, scope)) return; + + try { + const state = await locationMapModel.publishDraft( + scope.householdId, + scope.locationId, + req.user.id + ); + if (!state) { + return sendError(res, 404, "No draft map to publish"); + } + sendMapState(res, state, req); + } catch (error) { + logError(req, "locationMaps.publishDraft", error); + sendError(res, 500, "Failed to publish map"); + } +}; diff --git a/backend/models/location-map.model.js b/backend/models/location-map.model.js new file mode 100644 index 0000000..d9172b9 --- /dev/null +++ b/backend/models/location-map.model.js @@ -0,0 +1,536 @@ +const pool = require("../db/pool"); +const storeModel = require("./store.model"); +const { + buildStarterObjectsFromZones, + getStarterMapSize, +} = require("../services/location-map-layout.service"); + +const DEFAULT_MAP_WIDTH = 1000; +const DEFAULT_MAP_HEIGHT = 700; +const DEFAULT_MAP_NAME = "Store Map"; +const MAP_OBJECT_TYPES = new Set([ + "zone", + "aisle", + "entrance", + "checkout", + "shelf", + "wall", + "department", + "anchor", + "other", +]); + +function toNumber(value, fallback = 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function toPositiveNumber(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function toPositiveInteger(value, fallback) { + const parsed = Number.parseInt(String(value), 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function normalizeMapRow(row) { + if (!row) return null; + return { + ...row, + width: toPositiveInteger(row.width, DEFAULT_MAP_WIDTH), + height: toPositiveInteger(row.height, DEFAULT_MAP_HEIGHT), + version: toPositiveInteger(row.version, 1), + }; +} + +function normalizeObjectRow(row) { + if (!row) return null; + return { + ...row, + x: toNumber(row.x), + y: toNumber(row.y), + width: toPositiveNumber(row.width, 160), + height: toPositiveNumber(row.height, 100), + rotation: toNumber(row.rotation), + locked: Boolean(row.locked), + visible: row.visible !== false, + }; +} + +function sanitizeMapPayload(payload = {}) { + return { + name: + typeof payload.name === "string" && payload.name.trim() + ? payload.name.trim().slice(0, 120) + : DEFAULT_MAP_NAME, + width: toPositiveInteger(payload.width, DEFAULT_MAP_WIDTH), + height: toPositiveInteger(payload.height, DEFAULT_MAP_HEIGHT), + }; +} + +function sanitizeObjectPayload(object = {}, index = 0) { + const type = MAP_OBJECT_TYPES.has(object.type) ? object.type : "zone"; + const rawZoneId = object.zone_id ?? object.zoneId; + const zoneId = rawZoneId === null || rawZoneId === "" || rawZoneId === undefined + ? null + : toPositiveInteger(rawZoneId, null); + + return { + zone_id: zoneId, + type, + label: + typeof object.label === "string" && object.label.trim() + ? object.label.trim().slice(0, 160) + : "", + x: toNumber(object.x), + y: toNumber(object.y), + width: toPositiveNumber(object.width, 160), + height: toPositiveNumber(object.height, 100), + rotation: toNumber(object.rotation), + locked: Boolean(object.locked), + visible: object.visible !== false, + sort_order: toPositiveInteger(object.sort_order ?? object.sortOrder, index + 1), + metadata_json: + object.metadata_json && typeof object.metadata_json === "object" && !Array.isArray(object.metadata_json) + ? object.metadata_json + : {}, + }; +} + +async function getMaps(db, householdId, locationId, includeDraft) { + const statuses = includeDraft ? ["draft", "published"] : ["published"]; + const result = await db.query( + `SELECT + id, + household_id, + store_location_id, + name, + width, + height, + status, + version, + created_by, + updated_by, + published_at, + created_at, + updated_at + FROM location_maps + WHERE household_id = $1 + AND store_location_id = $2 + AND status = ANY($3::text[]) + ORDER BY + CASE status WHEN 'draft' THEN 0 WHEN 'published' THEN 1 ELSE 2 END, + updated_at DESC`, + [householdId, locationId, statuses] + ); + + return result.rows.map(normalizeMapRow); +} + +async function getObjects(db, mapId) { + if (!mapId) return []; + const result = await db.query( + `SELECT + lmo.id, + lmo.location_map_id, + lmo.zone_id, + lmo.type, + lmo.label, + lmo.x, + lmo.y, + lmo.width, + lmo.height, + lmo.rotation, + lmo.locked, + lmo.visible, + lmo.sort_order, + lmo.metadata_json, + slz.name AS zone_name, + lmo.created_at, + lmo.updated_at + FROM location_map_objects lmo + LEFT JOIN store_location_zones slz ON slz.id = lmo.zone_id + WHERE lmo.location_map_id = $1 + ORDER BY lmo.sort_order ASC, lmo.id ASC`, + [mapId] + ); + + return result.rows.map(normalizeObjectRow); +} + +async function getZoneSummaries(db, householdId, locationId) { + const result = await db.query( + `WITH map_items AS ( + SELECT + hl.id, + resolved_zone.id AS zone_id + FROM household_lists hl + JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id + LEFT JOIN household_item_classifications hic + ON hic.household_id = hl.household_id + AND hic.store_location_id = hl.store_location_id + AND hic.household_store_item_id = hl.household_store_item_id + LEFT JOIN store_location_zones resolved_zone + ON resolved_zone.household_id = hl.household_id + AND resolved_zone.store_location_id = hl.store_location_id + AND resolved_zone.is_active = TRUE + AND ( + resolved_zone.id = hic.zone_id + OR ( + hic.zone_id IS NULL + AND hic.zone IS NOT NULL + AND resolved_zone.normalized_name = LOWER(BTRIM(hic.zone)) + ) + ) + WHERE hl.household_id = $1 + AND hl.store_location_id = $2 + AND hl.bought = FALSE + ), + item_counts AS ( + SELECT zone_id, COUNT(*)::int AS item_count + FROM map_items + WHERE zone_id IS NOT NULL + GROUP BY zone_id + ) + SELECT + slz.id, + slz.name, + slz.sort_order, + slz.color, + slz.map_metadata, + slz.is_active, + COALESCE(item_counts.item_count, 0)::int AS item_count + FROM store_location_zones slz + LEFT JOIN item_counts ON item_counts.zone_id = slz.id + WHERE slz.household_id = $1 + AND slz.store_location_id = $2 + AND slz.is_active = TRUE + ORDER BY slz.sort_order ASC, slz.name ASC`, + [householdId, locationId] + ); + + return result.rows; +} + +async function getMapItems(db, householdId, locationId) { + const result = await db.query( + `SELECT + hl.id, + hl.household_store_item_id AS item_id, + hsi.name AS item_name, + hl.quantity, + hl.bought, + resolved_zone.id AS zone_id, + COALESCE(resolved_zone.name, hic.zone) AS zone, + ( + SELECT ARRAY_AGG(active_added_by_users.user_label ORDER BY active_added_by_users.last_added_on DESC, active_added_by_users.user_label) + FROM ( + SELECT + COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label, + MAX(active_history.added_on) AS last_added_on + FROM ( + SELECT + hlh.*, + COALESCE( + SUM(hlh.quantity) OVER ( + PARTITION BY hlh.household_list_id + ORDER BY hlh.added_on DESC, hlh.id DESC + ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING + ), + 0 + ) AS newer_quantity + FROM household_list_history hlh + WHERE hlh.household_list_id = hl.id + ) active_history + JOIN users u ON active_history.added_by = u.id + WHERE active_history.newer_quantity < GREATEST(hl.quantity, 0) + GROUP BY user_label + ) active_added_by_users + ) AS added_by_users + FROM household_lists hl + JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id + LEFT JOIN household_item_classifications hic + ON hic.household_id = hl.household_id + AND hic.store_location_id = hl.store_location_id + AND hic.household_store_item_id = hl.household_store_item_id + LEFT JOIN store_location_zones resolved_zone + ON resolved_zone.household_id = hl.household_id + AND resolved_zone.store_location_id = hl.store_location_id + AND resolved_zone.is_active = TRUE + AND ( + resolved_zone.id = hic.zone_id + OR ( + hic.zone_id IS NULL + AND hic.zone IS NOT NULL + AND resolved_zone.normalized_name = LOWER(BTRIM(hic.zone)) + ) + ) + WHERE hl.household_id = $1 + AND hl.store_location_id = $2 + AND ( + hl.bought = FALSE + OR hl.modified_on >= NOW() - INTERVAL '24 hours' + ) + ORDER BY resolved_zone.sort_order ASC NULLS LAST, hsi.name ASC`, + [householdId, locationId] + ); + + return result.rows.map((row) => ({ + ...row, + added_by_users: Array.isArray(row.added_by_users) ? row.added_by_users : [], + })); +} + +async function getActiveZoneIds(db, householdId, locationId) { + const result = await db.query( + `SELECT id + FROM store_location_zones + WHERE household_id = $1 + AND store_location_id = $2 + AND is_active = TRUE`, + [householdId, locationId] + ); + + return new Set(result.rows.map((row) => Number(row.id))); +} + +async function insertObjects(db, mapId, objects, validZoneIds = null) { + for (let index = 0; index < objects.length; index += 1) { + const object = sanitizeObjectPayload(objects[index], index); + const scopedZoneId = + object.zone_id && validZoneIds && !validZoneIds.has(Number(object.zone_id)) + ? null + : object.zone_id; + await db.query( + `INSERT INTO location_map_objects + ( + location_map_id, + zone_id, + type, + label, + x, + y, + width, + height, + rotation, + locked, + visible, + sort_order, + metadata_json + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)`, + [ + mapId, + scopedZoneId, + object.type, + object.label, + object.x, + object.y, + object.width, + object.height, + object.rotation, + object.locked, + object.visible, + object.sort_order, + JSON.stringify(object.metadata_json), + ] + ); + } +} + +async function upsertDraftMap(db, householdId, locationId, userId, mapPayload) { + const map = sanitizeMapPayload(mapPayload); + const result = await db.query( + `INSERT INTO location_maps + (household_id, store_location_id, name, width, height, status, version, created_by, updated_by, updated_at) + VALUES ($1, $2, $3, $4, $5, 'draft', 1, $6, $6, NOW()) + ON CONFLICT (store_location_id) WHERE status = 'draft' + DO UPDATE SET + name = EXCLUDED.name, + width = EXCLUDED.width, + height = EXCLUDED.height, + updated_by = EXCLUDED.updated_by, + updated_at = NOW() + RETURNING + id, + household_id, + store_location_id, + name, + width, + height, + status, + version, + created_by, + updated_by, + published_at, + created_at, + updated_at`, + [householdId, locationId, map.name, map.width, map.height, userId] + ); + + return normalizeMapRow(result.rows[0]); +} + +exports.getMapState = async (householdId, locationId, includeDraft = false) => { + const location = await storeModel.getLocationById(householdId, locationId); + if (!location) return null; + + const maps = await getMaps(pool, householdId, locationId, includeDraft); + const draftMap = includeDraft ? maps.find((map) => map.status === "draft") || null : null; + const publishedMap = maps.find((map) => map.status === "published") || null; + const activeMap = draftMap || publishedMap; + const [draftObjects, publishedObjects, zones, items] = await Promise.all([ + getObjects(pool, draftMap?.id), + getObjects(pool, publishedMap?.id), + getZoneSummaries(pool, householdId, locationId), + getMapItems(pool, householdId, locationId), + ]); + const objects = draftMap ? draftObjects : publishedObjects; + + return { + location, + map: activeMap || null, + draft_map: draftMap, + published_map: publishedMap, + objects, + draft_objects: draftObjects, + published_objects: publishedObjects, + zones, + items, + unmapped_count: items.filter((item) => !item.zone_id).length, + }; +}; + +exports.saveBlankDraft = async (householdId, locationId, userId, mapPayload = {}) => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const map = await upsertDraftMap(client, householdId, locationId, userId, mapPayload); + await client.query("DELETE FROM location_map_objects WHERE location_map_id = $1", [map.id]); + await client.query("COMMIT"); + return exports.getMapState(householdId, locationId, true); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +}; + +exports.createDraftFromZones = async (householdId, locationId, userId, mapPayload = {}) => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const zones = await getZoneSummaries(client, householdId, locationId); + const map = await upsertDraftMap(client, householdId, locationId, userId, mapPayload); + await client.query("DELETE FROM location_map_objects WHERE location_map_id = $1", [map.id]); + + const starterSize = getStarterMapSize(zones, map); + if (starterSize.width !== map.width || starterSize.height !== map.height) { + await client.query( + `UPDATE location_maps + SET width = $1, + height = $2, + updated_at = NOW(), + updated_by = $3 + WHERE id = $4`, + [starterSize.width, starterSize.height, userId, map.id] + ); + map.width = starterSize.width; + map.height = starterSize.height; + } + + await insertObjects( + client, + map.id, + buildStarterObjectsFromZones(zones, map.width, map.height) + ); + await client.query("COMMIT"); + return exports.getMapState(householdId, locationId, true); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +}; + +exports.saveDraft = async (householdId, locationId, userId, payload = {}) => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const map = await upsertDraftMap(client, householdId, locationId, userId, payload.map || {}); + const validZoneIds = await getActiveZoneIds(client, householdId, locationId); + await client.query("DELETE FROM location_map_objects WHERE location_map_id = $1", [map.id]); + await insertObjects(client, map.id, Array.isArray(payload.objects) ? payload.objects : [], validZoneIds); + await client.query("COMMIT"); + return exports.getMapState(householdId, locationId, true); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +}; + +exports.publishDraft = async (householdId, locationId, userId) => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const draftResult = await client.query( + `SELECT id + FROM location_maps + WHERE household_id = $1 + AND store_location_id = $2 + AND status = 'draft' + FOR UPDATE`, + [householdId, locationId] + ); + + if (draftResult.rowCount === 0) { + await client.query("ROLLBACK"); + return null; + } + + const versionResult = await client.query( + `SELECT COALESCE(MAX(version), 0)::int AS version + FROM location_maps + WHERE household_id = $1 + AND store_location_id = $2`, + [householdId, locationId] + ); + const nextVersion = (versionResult.rows[0]?.version || 0) + 1; + + await client.query( + `UPDATE location_maps + SET status = 'archived', updated_by = $3, updated_at = NOW() + WHERE household_id = $1 + AND store_location_id = $2 + AND status = 'published'`, + [householdId, locationId, userId] + ); + + await client.query( + `UPDATE location_maps + SET status = 'published', + version = $3, + published_at = NOW(), + updated_by = $4, + updated_at = NOW() + WHERE household_id = $1 + AND store_location_id = $2 + AND id = $5`, + [householdId, locationId, nextVersion, userId, draftResult.rows[0].id] + ); + + await client.query("COMMIT"); + return exports.getMapState(householdId, locationId, true); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +}; diff --git a/backend/routes/households.routes.js b/backend/routes/households.routes.js index 970d647..f6c5fc5 100644 --- a/backend/routes/households.routes.js +++ b/backend/routes/households.routes.js @@ -3,6 +3,7 @@ const router = express.Router(); const controller = require("../controllers/households.controller"); const listsController = require("../controllers/lists.controller.v2"); const availableItemsController = require("../controllers/available-items.controller"); +const locationMapsController = require("../controllers/location-maps.controller"); const storesController = require("../controllers/stores.controller"); const auth = require("../middleware/auth"); const { @@ -134,6 +135,46 @@ router.delete( storesController.deleteZone ); +router.get( + "/:householdId/locations/:locationId/map", + auth, + householdAccess, + locationAccess, + locationMapsController.getLocationMap +); +router.post( + "/:householdId/locations/:locationId/map/blank", + auth, + householdAccess, + locationAccess, + requireHouseholdAdmin, + locationMapsController.createBlankMap +); +router.post( + "/:householdId/locations/:locationId/map/from-zones", + auth, + householdAccess, + locationAccess, + requireHouseholdAdmin, + locationMapsController.createMapFromZones +); +router.put( + "/:householdId/locations/:locationId/map/draft", + auth, + householdAccess, + locationAccess, + requireHouseholdAdmin, + locationMapsController.saveDraft +); +router.post( + "/:householdId/locations/:locationId/map/publish", + auth, + householdAccess, + locationAccess, + requireHouseholdAdmin, + locationMapsController.publishDraft +); + router.get( "/:householdId/locations/:locationId/available-items", auth, diff --git a/backend/services/location-map-layout.service.js b/backend/services/location-map-layout.service.js new file mode 100644 index 0000000..d51e4f4 --- /dev/null +++ b/backend/services/location-map-layout.service.js @@ -0,0 +1,71 @@ +const DEFAULT_MAP_WIDTH = 1000; +const DEFAULT_MAP_HEIGHT = 700; +const STARTER_GAP = 28; +const STARTER_MIN_OBJECT_WIDTH = 120; +const STARTER_MIN_OBJECT_HEIGHT = 80; +const STARTER_TARGET_ROW_HEIGHT = 210; + +function toPositiveInteger(value, fallback) { + const parsed = Number.parseInt(String(value), 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function roundToTwo(value) { + return Math.round(value * 100) / 100; +} + +function getStarterGrid(zones) { + const zoneCount = Array.isArray(zones) ? zones.length : 0; + const columnCount = zoneCount <= 4 ? 2 : 3; + const rowCount = Math.ceil(Math.max(zoneCount, 1) / columnCount); + return { columnCount, rowCount }; +} + +function getStarterMapSize(zones, mapSize = {}) { + const { columnCount, rowCount } = getStarterGrid(zones); + const currentWidth = toPositiveInteger(mapSize.width, DEFAULT_MAP_WIDTH); + const currentHeight = toPositiveInteger(mapSize.height, DEFAULT_MAP_HEIGHT); + const requiredWidth = + columnCount * STARTER_MIN_OBJECT_WIDTH + STARTER_GAP * (columnCount + 1); + const minimumGridHeight = + rowCount * STARTER_MIN_OBJECT_HEIGHT + STARTER_GAP * (rowCount + 1); + const preferredGridHeight = rowCount * STARTER_TARGET_ROW_HEIGHT; + + return { + width: Math.max(currentWidth, requiredWidth), + height: Math.max(currentHeight, minimumGridHeight, preferredGridHeight), + }; +} + +function buildStarterObjectsFromZones(zones, width, height) { + if (!Array.isArray(zones) || !zones.length) return []; + + const mapSize = getStarterMapSize(zones, { width, height }); + const { columnCount, rowCount } = getStarterGrid(zones); + const cellWidth = (mapSize.width - STARTER_GAP * (columnCount + 1)) / columnCount; + const cellHeight = (mapSize.height - STARTER_GAP * (rowCount + 1)) / rowCount; + + return zones.map((zone, index) => { + const column = index % columnCount; + const row = Math.floor(index / columnCount); + return { + zone_id: zone.id, + type: "zone", + label: zone.name, + x: roundToTwo(STARTER_GAP + column * (cellWidth + STARTER_GAP)), + y: roundToTwo(STARTER_GAP + row * (cellHeight + STARTER_GAP)), + width: Math.max(STARTER_MIN_OBJECT_WIDTH, roundToTwo(cellWidth)), + height: Math.max(STARTER_MIN_OBJECT_HEIGHT, roundToTwo(cellHeight)), + rotation: 0, + locked: false, + visible: true, + sort_order: index + 1, + metadata_json: { starter: true }, + }; + }); +} + +module.exports = { + buildStarterObjectsFromZones, + getStarterMapSize, +}; diff --git a/backend/tests/cors-origins.test.js b/backend/tests/cors-origins.test.js new file mode 100644 index 0000000..9ad4bbc --- /dev/null +++ b/backend/tests/cors-origins.test.js @@ -0,0 +1,37 @@ +const { + isAllowedCorsOrigin, + isPrivateLanHostname, + parseAllowedOrigins, +} = require("../utils/cors-origins"); + +describe("CORS origin helpers", () => { + test("parses configured allowed origins", () => { + expect(parseAllowedOrigins(" http://localhost:3010, http://127.0.0.1:3010 ,,")).toEqual([ + "http://localhost:3010", + "http://127.0.0.1:3010", + ]); + }); + + test("allows exact configured origins", () => { + const allowedOrigins = ["http://localhost:3010"]; + + expect(isAllowedCorsOrigin("http://localhost:3010", allowedOrigins)).toBe(true); + }); + + test("allows browser access from the local private network", () => { + expect(isAllowedCorsOrigin("http://192.168.7.45:3010", [])).toBe(true); + }); + + test("recognizes RFC1918 private IPv4 hostnames", () => { + expect(isPrivateLanHostname("10.1.2.3")).toBe(true); + expect(isPrivateLanHostname("172.16.0.1")).toBe(true); + expect(isPrivateLanHostname("172.31.255.254")).toBe(true); + expect(isPrivateLanHostname("192.168.7.45")).toBe(true); + }); + + test("rejects public and lookalike hostnames", () => { + expect(isAllowedCorsOrigin("http://192.168.7.45.example.test:3010", [])).toBe(false); + expect(isAllowedCorsOrigin("http://203.0.113.10:3010", [])).toBe(false); + expect(isAllowedCorsOrigin("ftp://192.168.7.45:3010", [])).toBe(false); + }); +}); diff --git a/backend/tests/location-map-layout.service.test.js b/backend/tests/location-map-layout.service.test.js new file mode 100644 index 0000000..e840fa2 --- /dev/null +++ b/backend/tests/location-map-layout.service.test.js @@ -0,0 +1,78 @@ +const { + buildStarterObjectsFromZones, + getStarterMapSize, +} = require("../services/location-map-layout.service"); + +function makeZones(count) { + return Array.from({ length: count }, (_, index) => ({ + id: index + 1, + name: `Zone ${index + 1}`, + })); +} + +function overlaps(first, second) { + return !( + first.x + first.width <= second.x || + second.x + second.width <= first.x || + first.y + first.height <= second.y || + second.y + second.height <= first.y + ); +} + +describe("location map starter layout", () => { + test("creates bounded starter rectangles for many zones", () => { + const zones = makeZones(13); + const mapSize = getStarterMapSize(zones, { width: 1000, height: 700 }); + const objects = buildStarterObjectsFromZones(zones, mapSize.width, mapSize.height); + + expect(mapSize).toEqual({ width: 1000, height: 1050 }); + expect(objects).toHaveLength(13); + + objects.forEach((object, index) => { + expect(object).toEqual( + expect.objectContaining({ + zone_id: zones[index].id, + type: "zone", + label: zones[index].name, + locked: false, + visible: true, + sort_order: index + 1, + metadata_json: { starter: true }, + }) + ); + expect(object.x).toBeGreaterThanOrEqual(0); + expect(object.y).toBeGreaterThanOrEqual(0); + expect(object.width).toBeGreaterThanOrEqual(120); + expect(object.height).toBeGreaterThanOrEqual(80); + expect(object.x + object.width).toBeLessThanOrEqual(mapSize.width); + expect(object.y + object.height).toBeLessThanOrEqual(mapSize.height); + }); + + for (let firstIndex = 0; firstIndex < objects.length; firstIndex += 1) { + for (let secondIndex = firstIndex + 1; secondIndex < objects.length; secondIndex += 1) { + expect(overlaps(objects[firstIndex], objects[secondIndex])).toBe(false); + } + } + }); + + test("expands cramped map dimensions before placing starter rectangles", () => { + const zones = makeZones(5); + const mapSize = getStarterMapSize(zones, { width: 300, height: 180 }); + const objects = buildStarterObjectsFromZones(zones, 300, 180); + + expect(mapSize).toEqual({ width: 472, height: 420 }); + expect(objects).toHaveLength(5); + objects.forEach((object) => { + expect(object.x + object.width).toBeLessThanOrEqual(mapSize.width); + expect(object.y + object.height).toBeLessThanOrEqual(mapSize.height); + }); + }); + + test("returns no objects for locations with no zones", () => { + expect(buildStarterObjectsFromZones([], 1000, 700)).toEqual([]); + expect(getStarterMapSize([], { width: 1000, height: 700 })).toEqual({ + width: 1000, + height: 700, + }); + }); +}); diff --git a/backend/tests/location-map.model.test.js b/backend/tests/location-map.model.test.js new file mode 100644 index 0000000..04b2bfe --- /dev/null +++ b/backend/tests/location-map.model.test.js @@ -0,0 +1,235 @@ +jest.mock("../db/pool", () => ({ + query: jest.fn(), + connect: jest.fn(), +})); + +jest.mock("../models/store.model", () => ({ + getLocationById: jest.fn(), +})); + +const pool = require("../db/pool"); +const storeModel = require("../models/store.model"); +const LocationMap = require("../models/location-map.model"); + +function createClient() { + return { + query: jest.fn(), + release: jest.fn(), + }; +} + +describe("location-map.model", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + function mockMapStateQueries() { + const location = { + id: 10, + household_id: 1, + name: "Costco", + location_name: "Eastvale", + }; + const draftMap = { + id: 900, + household_id: 1, + store_location_id: 10, + name: "Draft Map", + width: 1000, + height: 700, + status: "draft", + version: 3, + }; + const publishedMap = { + id: 901, + household_id: 1, + store_location_id: 10, + name: "Published Map", + width: 1000, + height: 700, + status: "published", + version: 2, + }; + const draftObject = { + id: 1100, + location_map_id: 900, + zone_id: 501, + type: "zone", + label: "Draft Bakery", + x: 10, + y: 20, + width: 200, + height: 120, + rotation: 0, + locked: false, + visible: true, + sort_order: 1, + }; + const publishedObject = { + ...draftObject, + id: 1200, + location_map_id: 901, + label: "Published Bakery", + }; + const zones = [{ id: 501, name: "Bakery", sort_order: 10, item_count: 1 }]; + const items = [ + { + id: 3001, + item_id: 2001, + item_name: "sourdough", + quantity: 1, + bought: false, + zone_id: 501, + zone: "Bakery", + added_by_users: ["Owner"], + }, + { + id: 3002, + item_id: 2002, + item_name: "bagels", + quantity: 1, + bought: true, + zone_id: 501, + zone: "Bakery", + added_by_users: ["Owner"], + }, + ]; + + storeModel.getLocationById.mockResolvedValue(location); + pool.query.mockImplementation(async (sql, params = []) => { + const query = String(sql); + if (query.includes("FROM location_maps") && query.includes("status = ANY")) { + const statuses = params[2] || []; + return { + rows: [draftMap, publishedMap].filter((map) => statuses.includes(map.status)), + }; + } + if (query.includes("FROM location_map_objects")) { + const mapId = params[0]; + return { + rows: mapId === 900 ? [draftObject] : mapId === 901 ? [publishedObject] : [], + }; + } + if (query.includes("FROM store_location_zones")) { + return { rows: zones }; + } + if (query.includes("FROM household_lists hl")) { + return { rows: items }; + } + return { rows: [], rowCount: 0 }; + }); + + return { draftMap, publishedMap, draftObject, publishedObject, location, zones, items }; + } + + test("hides draft map state from non-managers", async () => { + const { publishedMap, publishedObject, location, zones, items } = mockMapStateQueries(); + + const state = await LocationMap.getMapState(1, 10, false); + + expect(storeModel.getLocationById).toHaveBeenCalledWith(1, 10); + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining("FROM location_maps"), + [1, 10, ["published"]] + ); + expect(state).toEqual(expect.objectContaining({ + location, + map: expect.objectContaining({ id: publishedMap.id, status: "published" }), + draft_map: null, + published_map: expect.objectContaining({ id: publishedMap.id, status: "published" }), + objects: [expect.objectContaining({ id: publishedObject.id, label: "Published Bakery" })], + draft_objects: [], + published_objects: [expect.objectContaining({ id: publishedObject.id })], + zones, + items, + unmapped_count: 0, + })); + }); + + test("includes draft and published map state for managers", async () => { + const { draftMap, publishedMap, draftObject, publishedObject } = mockMapStateQueries(); + + const state = await LocationMap.getMapState(1, 10, true); + + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining("FROM location_maps"), + [1, 10, ["draft", "published"]] + ); + expect(state.map).toEqual(expect.objectContaining({ id: draftMap.id, status: "draft" })); + expect(state.draft_map).toEqual(expect.objectContaining({ id: draftMap.id, status: "draft" })); + expect(state.published_map).toEqual(expect.objectContaining({ id: publishedMap.id, status: "published" })); + expect(state.objects).toEqual([expect.objectContaining({ id: draftObject.id, label: "Draft Bakery" })]); + expect(state.draft_objects).toEqual([expect.objectContaining({ id: draftObject.id })]); + expect(state.published_objects).toEqual([expect.objectContaining({ id: publishedObject.id })]); + }); + + test("loads active and recent bought rows for map item layers", async () => { + const { items } = mockMapStateQueries(); + + const state = await LocationMap.getMapState(1, 10, true); + + expect(state.items).toEqual(items); + expect(state.items.some((item) => item.bought)).toBe(true); + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining("OR hl.modified_on >= NOW() - INTERVAL '24 hours'"), + [1, 10] + ); + }); + + test("unlinks saved map objects from zones outside the current location", async () => { + const client = createClient(); + pool.connect.mockResolvedValue(client); + const getMapStateSpy = jest.spyOn(LocationMap, "getMapState").mockResolvedValue({ + draft_map: { id: 900 }, + draft_objects: [], + }); + + client.query.mockImplementation(async (sql) => { + if (sql === "BEGIN" || sql === "COMMIT") { + return { rows: [], rowCount: 0 }; + } + if (String(sql).includes("INSERT INTO location_maps")) { + return { + rows: [ + { + id: 900, + household_id: 1, + store_location_id: 10, + name: "Store Map", + width: 1000, + height: 700, + status: "draft", + version: 1, + }, + ], + }; + } + if (String(sql).includes("FROM store_location_zones")) { + return { rows: [{ id: 501 }] }; + } + return { rows: [], rowCount: 0 }; + }); + + await LocationMap.saveDraft(1, 10, 42, { + objects: [ + { zone_id: 501, label: "Valid zone" }, + { zone_id: 999, label: "Foreign zone" }, + ], + }); + + const insertCalls = client.query.mock.calls.filter(([sql]) => + String(sql).includes("INSERT INTO location_map_objects") + ); + + expect(insertCalls).toHaveLength(2); + expect(insertCalls[0][1][1]).toBe(501); + expect(insertCalls[1][1][1]).toBeNull(); + expect(client.query).toHaveBeenCalledWith( + expect.stringContaining("FROM store_location_zones"), + [1, 10] + ); + expect(getMapStateSpy).toHaveBeenCalledWith(1, 10, true); + expect(client.query).toHaveBeenCalledWith("COMMIT"); + expect(client.release).toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/store-locations.routes.test.js b/backend/tests/store-locations.routes.test.js index b331eae..665f7ac 100644 --- a/backend/tests/store-locations.routes.test.js +++ b/backend/tests/store-locations.routes.test.js @@ -70,6 +70,14 @@ jest.mock("../controllers/available-items.controller", () => ({ updateAvailableItem: jest.fn(), })); +jest.mock("../controllers/location-maps.controller", () => ({ + createBlankMap: jest.fn((req, res) => res.status(201).json({ message: "blank map" })), + createMapFromZones: jest.fn((req, res) => res.status(201).json({ message: "zone map" })), + getLocationMap: jest.fn((req, res) => res.json({ map: null, objects: [] })), + publishDraft: jest.fn((req, res) => res.json({ message: "published map" })), + saveDraft: jest.fn((req, res) => res.json({ message: "saved draft" })), +})); + jest.mock("../controllers/stores.controller", () => ({ addLocationToStore: jest.fn((req, res) => res.status(201).json({ message: "location" })), createHouseholdStore: jest.fn((req, res) => res.status(201).json({ message: "store" })), @@ -89,6 +97,7 @@ const express = require("express"); const request = require("supertest"); const router = require("../routes/households.routes"); const householdsController = require("../controllers/households.controller"); +const locationMapsController = require("../controllers/location-maps.controller"); const storesController = require("../controllers/stores.controller"); describe("store location routes", () => { @@ -161,4 +170,51 @@ describe("store location routes", () => { expect(response.status).toBe(200); expect(storesController.updateZone).toHaveBeenCalled(); }); + + test("members can view a location map", async () => { + const response = await request(app) + .get("/households/1/locations/2/map") + .set("x-household-role", "member"); + + expect(response.status).toBe(200); + expect(locationMapsController.getLocationMap).toHaveBeenCalled(); + }); + + test("members cannot create or publish location maps", async () => { + const createResponse = await request(app) + .post("/households/1/locations/2/map/from-zones") + .set("x-household-role", "member") + .send({}); + const publishResponse = await request(app) + .post("/households/1/locations/2/map/publish") + .set("x-household-role", "member") + .send({}); + + expect(createResponse.status).toBe(403); + expect(publishResponse.status).toBe(403); + expect(locationMapsController.createMapFromZones).not.toHaveBeenCalled(); + expect(locationMapsController.publishDraft).not.toHaveBeenCalled(); + }); + + test("admins can create, save, and publish location maps", async () => { + const blankResponse = await request(app) + .post("/households/1/locations/2/map/blank") + .set("x-household-role", "admin") + .send({ map: { name: "Costco Map" } }); + const saveResponse = await request(app) + .put("/households/1/locations/2/map/draft") + .set("x-household-role", "admin") + .send({ objects: [] }); + const publishResponse = await request(app) + .post("/households/1/locations/2/map/publish") + .set("x-household-role", "admin") + .send({}); + + expect(blankResponse.status).toBe(201); + expect(saveResponse.status).toBe(200); + expect(publishResponse.status).toBe(200); + expect(locationMapsController.createBlankMap).toHaveBeenCalled(); + expect(locationMapsController.saveDraft).toHaveBeenCalled(); + expect(locationMapsController.publishDraft).toHaveBeenCalled(); + }); }); diff --git a/backend/utils/cors-origins.js b/backend/utils/cors-origins.js new file mode 100644 index 0000000..42c4a74 --- /dev/null +++ b/backend/utils/cors-origins.js @@ -0,0 +1,54 @@ +function parseAllowedOrigins(value = "") { + return String(value) + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); +} + +function parseIpv4(hostname) { + const parts = hostname.split("."); + if (parts.length !== 4) return null; + + const octets = parts.map((part) => { + if (!/^\d{1,3}$/.test(part)) return null; + + const value = Number(part); + return value >= 0 && value <= 255 ? value : null; + }); + + return octets.some((octet) => octet === null) ? null : octets; +} + +function isPrivateLanHostname(hostname) { + const octets = parseIpv4(hostname); + if (!octets) return false; + + const [first, second] = octets; + return ( + first === 10 || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168) + ); +} + +function isAllowedCorsOrigin(origin, allowedOrigins = []) { + if (!origin) return true; + if (allowedOrigins.includes(origin)) return true; + + try { + const url = new URL(origin); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return false; + } + + return isPrivateLanHostname(url.hostname); + } catch { + return false; + } +} + +module.exports = { + isAllowedCorsOrigin, + isPrivateLanHostname, + parseAllowedOrigins, +}; diff --git a/frontend/.env.example b/frontend/.env.example index 610beb1..3545d04 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,2 +1,3 @@ -VITE_API_URL=http://localhost:5000 -VITE_ALLOWED_HOSTS=localhost,127.0.0.1 +# Leave blank to infer the API host from the browser URL, e.g. http://192.168.7.45:5000. +VITE_API_URL= +VITE_ALLOWED_HOSTS=localhost,127.0.0.1,192.168.7.45 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 012284b..a5af8d1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -9,7 +9,8 @@ import { SettingsProvider } from "./context/SettingsContext.jsx"; import { StoreProvider } from "./context/StoreContext.jsx"; import AdminPanel from "./pages/AdminPanel.jsx"; -import GroceryList from "./pages/GroceryList.jsx"; +import GroceryList from "./pages/GroceryList.jsx"; +import LocationMapManager from "./pages/LocationMapManager.jsx"; import Login from "./pages/Login.jsx"; import Manage from "./pages/Manage.jsx"; import Register from "./pages/Register.jsx"; @@ -48,6 +49,7 @@ function App() { } /> } /> } /> + } /> + api.get(`/households/${householdId}/locations/${locationId}/map`); + +export const createBlankLocationMap = (householdId, locationId, map = {}) => + api.post(`/households/${householdId}/locations/${locationId}/map/blank`, { map }); + +export const createLocationMapFromZones = (householdId, locationId, map = {}) => + api.post(`/households/${householdId}/locations/${locationId}/map/from-zones`, { map }); + +export const saveLocationMapDraft = (householdId, locationId, payload) => + api.put(`/households/${householdId}/locations/${locationId}/map/draft`, payload); + +export const publishLocationMapDraft = (householdId, locationId) => + api.post(`/households/${householdId}/locations/${locationId}/map/publish`); diff --git a/frontend/src/components/maps/LocationMapBottomSheet.jsx b/frontend/src/components/maps/LocationMapBottomSheet.jsx new file mode 100644 index 0000000..1f2602c --- /dev/null +++ b/frontend/src/components/maps/LocationMapBottomSheet.jsx @@ -0,0 +1,323 @@ +import { useEffect, useMemo } from "react"; +import { + DEFAULT_MAP_FILTERS, + getMapObjectDisplayLabel, + objectZoneId, +} from "../../lib/locationMapUtils"; +import { + LocationMapLayerPanel, + LocationMapOverview, + MAP_DISPLAY_CONTROLS, + SelectedMapObjectForm, + SelectedZoneItemsPanel, + UnmappedItemsPanel, +} from "./LocationMapBottomSheetSections"; + +const EMPTY_ITEMS = []; + +export default function LocationMapBottomSheet({ + mode, + canManage, + filters, + setFilters, + layersOpen, + setLayersOpen, + selectedObject, + mapObjects = [], + selectedZoneItems = EMPTY_ITEMS, + visibleAssignedItemCount = 0, + visibleUnmappedItems = EMPTY_ITEMS, + unmappedItemCount = 0, + hiddenAreaCount, + saving, + savingAction, + hasUnsavedChanges, + mapState, + onAddObject, + onSaveDraft, + onPublish, + onObjectField, + onZoneLinkChange, + onDuplicateObject, + onDeleteObject, + onShowHiddenAreas, + onClearSelection, + onSelectZoneItem, + onSelectUnmappedItem, +}) { + const selectedTitle = getMapObjectDisplayLabel(selectedObject); + const selectedZoneId = objectZoneId(selectedObject); + const mapItems = mapState?.items || EMPTY_ITEMS; + const { assignedItemCount, selectedAssignedItems } = useMemo(() => { + const nextSelectedAssignedItems = []; + let nextAssignedItemCount = 0; + + mapItems.forEach((item) => { + if (!item.zone_id) return; + nextAssignedItemCount += 1; + if (selectedZoneId && String(item.zone_id) === selectedZoneId) { + nextSelectedAssignedItems.push(item); + } + }); + + return { + assignedItemCount: nextAssignedItemCount, + selectedAssignedItems: nextSelectedAssignedItems, + }; + }, [mapItems, selectedZoneId]); + const hiddenSelectedItemCount = Math.max(0, selectedAssignedItems.length - selectedZoneItems.length); + const hasHiddenSelectedItems = hiddenSelectedItemCount > 0; + const selectedItemCountLabel = hasHiddenSelectedItems + ? `${selectedZoneItems.length} shown` + : `${selectedZoneItems.length} item${selectedZoneItems.length === 1 ? "" : "s"}`; + const sheetTitle = mode === "edit" + ? selectedObject + ? selectedTitle + : "Edit Areas" + : selectedObject + ? selectedTitle + : "Map Details"; + const showHeaderItemCount = mode !== "edit" && Boolean(selectedObject); + const showEditorControls = mode === "edit" && canManage; + const showSelectedObjectForm = Boolean(mode === "edit" && selectedObject); + const showUnmappedPanel = filters.showUnmapped && !selectedObject; + + useEffect(() => { + if (selectedObject) { + setLayersOpen(false); + } + }, [selectedObject, setLayersOpen]); + + useEffect(() => { + if (!layersOpen || typeof window === "undefined") { + return undefined; + } + + const handleKeyDown = (event) => { + if (event.key === "Escape") { + setLayersOpen(false); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [layersOpen, setLayersOpen]); + + const changedLayerCount = useMemo( + () => MAP_DISPLAY_CONTROLS.filter(([key]) => filters[key] !== DEFAULT_MAP_FILTERS[key]).length, + [filters] + ); + const totalAreaCount = mapObjects.length; + const visibleAreaCount = filters.showZones + ? Math.max(0, totalAreaCount - hiddenAreaCount) + : 0; + const hasNoVisibleAreas = + filters.showZones && totalAreaCount > 0 && visibleAreaCount === 0 && hiddenAreaCount > 0; + const showMapOverview = mode !== "edit" && !selectedObject; + const unlinkedAreaCount = useMemo( + () => mapObjects.filter((object) => !objectZoneId(object)).length, + [mapObjects] + ); + const showDraftOverview = mode === "edit" && !selectedObject; + const draftOverviewRows = [ + { label: "Areas", value: totalAreaCount }, + ...(hiddenAreaCount > 0 ? [{ label: "Hidden", value: hiddenAreaCount }] : []), + ...(unlinkedAreaCount > 0 ? [{ label: "Unlinked", value: unlinkedAreaCount }] : []), + ]; + const hiddenUnmappedItemCount = Math.max(0, unmappedItemCount - visibleUnmappedItems.length); + const hasHiddenUnmappedItems = + filters.showUnmapped && unmappedItemCount > 0 && visibleUnmappedItems.length === 0; + const hiddenAreaLabel = `Show Hidden Areas (${hiddenAreaCount})`; + const showHiddenAreaAction = + showEditorControls && hiddenAreaCount > 0 && filters.showZones && !hasNoVisibleAreas; + const saveDraftLabel = savingAction === "save" ? "Saving..." : "Save Draft"; + const publishLabel = savingAction === "publish" ? "Publishing..." : "Publish"; + const canPublishDraft = Boolean(mapState?.draft_map || hasUnsavedChanges); + const showSaveDraftAction = Boolean(savingAction === "save" || hasUnsavedChanges); + const showPublishAction = Boolean(savingAction === "publish" || canPublishDraft); + const mappedItemSummary = assignedItemCount === visibleAssignedItemCount + ? String(assignedItemCount) + : `${visibleAssignedItemCount} shown`; + const unmappedItemSummary = !filters.showUnmapped && unmappedItemCount > 0 + ? `${unmappedItemCount} hidden` + : visibleUnmappedItems.length === unmappedItemCount + ? String(unmappedItemCount) + : `${visibleUnmappedItems.length} shown`; + const mapOverviewRows = [ + { label: "Areas", value: filters.showZones ? `${visibleAreaCount} shown` : "Hidden" }, + ...(assignedItemCount > 0 ? [{ label: "Mapped items", value: mappedItemSummary }] : []), + ...(unmappedItemCount > 0 ? [{ label: "Unmapped", value: unmappedItemSummary }] : []), + ]; + const resetLayerFilters = () => setFilters({ ...DEFAULT_MAP_FILTERS }); + const showSelectedZoneItems = () => { + setFilters((current) => ({ + ...current, + showMyItems: true, + showOtherItems: true, + showCompleted: true, + })); + }; + const showUnmappedItems = () => { + setFilters((current) => ({ + ...current, + showUnmapped: true, + showMyItems: true, + showOtherItems: true, + showCompleted: true, + })); + }; + const primaryDraftActions = showEditorControls && (showSaveDraftAction || showPublishAction) ? ( +
+ {showSaveDraftAction ? ( + + ) : null} + {showPublishAction ? ( + + ) : null} +
+ ) : null; + const secondaryDraftActions = showEditorControls ? ( +
+ +
+ ) : null; + const editorActions = showEditorControls ? ( +
+ {primaryDraftActions} + {secondaryDraftActions} +
+ ) : null; + + return ( + + ); +} diff --git a/frontend/src/components/maps/LocationMapBottomSheetSections.jsx b/frontend/src/components/maps/LocationMapBottomSheetSections.jsx new file mode 100644 index 0000000..e505dc9 --- /dev/null +++ b/frontend/src/components/maps/LocationMapBottomSheetSections.jsx @@ -0,0 +1,306 @@ +import { useEffect, useState } from "react"; +import { getMapObjectDisplayLabel, objectZoneId } from "../../lib/locationMapUtils"; + +export const MAP_DISPLAY_CONTROLS = [ + ["showZones", "Zones"], + ["showLabels", "Labels"], + ["showCounts", "Counts"], + ["showPins", "Pins"], + ["showMyItems", "Mine"], + ["showOtherItems", "Others"], + ["showCompleted", "Bought"], + ["showUnmapped", "Unmapped"], +]; + +const MAP_ITEM_PREVIEW_LIMIT = 8; + +function MapItemActionRow({ item, onSelectItem }) { + const isBought = Boolean(item.bought); + + return ( +
  • + +
  • + ); +} + +function MapItemActionList({ items, onSelectItem, itemGroupLabel }) { + const [showAllItems, setShowAllItems] = useState(false); + const itemListKey = items.map((item) => item.id).join(":"); + + useEffect(() => { + setShowAllItems(false); + }, [itemListKey]); + + const visibleItems = showAllItems ? items : items.slice(0, MAP_ITEM_PREVIEW_LIMIT); + const overflowItemCount = items.length - visibleItems.length; + const hasOverflow = items.length > MAP_ITEM_PREVIEW_LIMIT; + + return ( +
      + {visibleItems.map((item) => ( + + ))} + {hasOverflow ? ( +
    • + +
    • + ) : null} +
    + ); +} + +export function LocationMapLayerPanel({ filters, setFilters }) { + return ( +
    +
    + {MAP_DISPLAY_CONTROLS.map(([key, label]) => ( + + ))} +
    +
    + ); +} + +export function LocationMapOverview({ ariaLabel, rows }) { + return ( +
    + {rows.map((row) => ( +
    + {row.label} + {row.value} +
    + ))} +
    + ); +} + +function InlineStateAction({ label, value, actionLabel, actionText = "Show", onAction }) { + return ( +
    + {label} + {value} + {onAction ? ( + + ) : null} +
    + ); +} + +export function SelectedMapObjectForm({ + selectedObject, + zones = [], + saving, + onObjectField, + onZoneLinkChange, + onDuplicateObject, + onDeleteObject, +}) { + const selectedObjectLabel = getMapObjectDisplayLabel(selectedObject); + + return ( +
    + + +
    + + +
    +
    + + +
    +
    + ); +} + +export function SelectedZoneItemsPanel({ + selectedZoneItems, + hasHiddenSelectedItems, + hiddenSelectedItemCount, + onShowSelectedZoneItems, + onSelectZoneItem, +}) { + if (selectedZoneItems.length === 0) { + if (!hasHiddenSelectedItems) return null; + + return ( +
    +
    + +
    +
    + ); + } + + return ( +
    + + {hasHiddenSelectedItems ? ( +
    + +
    + ) : null} +
    + ); +} + +export function UnmappedItemsPanel({ + visibleUnmappedItems, + hiddenUnmappedItemCount, + hasHiddenUnmappedItems, + onShowUnmappedItems, + onSelectUnmappedItem, +}) { + if (visibleUnmappedItems.length === 0 && hasHiddenUnmappedItems) { + return ( +
    +
    + +
    +
    + ); + } + + if (visibleUnmappedItems.length === 0) { + return null; + } + + return ( +
    + + {hiddenUnmappedItemCount > 0 ? ( +
    + +
    + ) : null} +
    + ); +} diff --git a/frontend/src/components/maps/LocationMapCanvas.jsx b/frontend/src/components/maps/LocationMapCanvas.jsx new file mode 100644 index 0000000..57be1b4 --- /dev/null +++ b/frontend/src/components/maps/LocationMapCanvas.jsx @@ -0,0 +1,644 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { + clampObjectToMap, + clientPointToMap, + getObjectKey, + itemMatchesMapFilters, +} from "../../lib/locationMapUtils"; +import LocationMapObject from "./LocationMapObject"; + +const DRAG_CHANGE_THRESHOLD = 2; +const NUDGE_KEYS = new Set(["ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft"]); +const SELECTED_OBJECT_SCROLL_PADDING = 24; +const EDGE_PAN_ZONE = 56; +const MAX_EDGE_PAN_STEP = 18; + +function clampScrollPosition(value, maxValue) { + return Math.min(Math.max(0, value), Math.max(0, maxValue)); +} + +function getEdgePanStep(pointerPosition, start, end) { + if (pointerPosition < start + EDGE_PAN_ZONE) { + const intensity = (start + EDGE_PAN_ZONE - pointerPosition) / EDGE_PAN_ZONE; + return -Math.ceil(MAX_EDGE_PAN_STEP * intensity); + } + + if (pointerPosition > end - EDGE_PAN_ZONE) { + const intensity = (pointerPosition - (end - EDGE_PAN_ZONE)) / EDGE_PAN_ZONE; + return Math.ceil(MAX_EDGE_PAN_STEP * intensity); + } + + return 0; +} + +function panScrollElement(scrollElement, deltaX, deltaY) { + if (!scrollElement || (!deltaX && !deltaY)) return; + + scrollElement.scrollLeft = clampScrollPosition( + scrollElement.scrollLeft + deltaX, + scrollElement.scrollWidth - scrollElement.clientWidth + ); + scrollElement.scrollTop = clampScrollPosition( + scrollElement.scrollTop + deltaY, + scrollElement.scrollHeight - scrollElement.clientHeight + ); +} + +function shiftMapObject(object, shiftX, shiftY) { + if (!shiftX && !shiftY) return object; + + return { + ...object, + x: Number(object.x || 0) + shiftX, + y: Number(object.y || 0) + shiftY, + }; +} + +function getSelectedObjectScrollKey(object, selectedObjectKey, zoom) { + return [ + selectedObjectKey, + zoom.toFixed(2), + object.x, + object.y, + object.width, + object.height, + ].join(":"); +} + +function clampResizedObjectToMap(object, mapSize) { + const x = Math.max(0, Math.min(object.x, mapSize.width - 40)); + const y = Math.max(0, Math.min(object.y, mapSize.height - 40)); + return { + ...object, + x, + y, + width: Math.max(40, Math.min(object.width, mapSize.width - x)), + height: Math.max(40, Math.min(object.height, mapSize.height - y)), + }; +} + +function getDraggedObjectCandidate(object, dragState, deltaX, deltaY) { + if (dragState.type === "resize") { + return { + ...object, + width: Math.max(40, dragState.startObject.width + deltaX), + height: Math.max(40, dragState.startObject.height + deltaY), + }; + } + + return { + ...object, + x: dragState.startObject.x + deltaX, + y: dragState.startObject.y + deltaY, + }; +} + +function hasObjectBoundsChanged(object, nextObject) { + return ( + object.x !== nextObject.x || + object.y !== nextObject.y || + object.width !== nextObject.width || + object.height !== nextObject.height + ); +} + +export default function LocationMapCanvas({ + mode, + objects, + filters, + mapSize, + zoom, + selectedObjectKey, + mapItems, + username, + svgRef, + dragState, + editingLocked = false, + hiddenAreaCount = 0, + onAddObject, + onShowZones, + onShowHiddenAreas, + setDragState, + setSelectedObjectKey, + remember, + updateObjects, + expandMapToFitObject, +}) { + const canEditObjects = !editingLocked && mode === "edit"; + const canDragPan = mode === "view" || mode === "edit"; + const hiddenByZonesLayer = objects.length > 0 && !filters.showZones; + const visibleObjectCount = filters.showZones + ? objects.filter((object) => object.visible !== false).length + : 0; + const hasNoVisibleObjects = objects.length > 0 && filters.showZones && visibleObjectCount === 0; + const canAddFirstArea = + mode === "edit" && objects.length === 0 && typeof onAddObject === "function"; + const canRecoverHiddenAreas = + mode === "edit" && hasNoVisibleObjects && hiddenAreaCount > 0 && typeof onShowHiddenAreas === "function"; + const hasEmptyCanvasAction = canAddFirstArea || hiddenByZonesLayer || canRecoverHiddenAreas; + const emptyCanvasMessage = objects.length === 0 + ? "No map areas yet" + : hiddenByZonesLayer + ? "Zones hidden in Layers" + : hasNoVisibleObjects + ? "No visible map areas" + : ""; + const scrollRef = useRef(null); + const panDragRef = useRef(null); + const pendingMapShiftScrollRef = useRef({ x: 0, y: 0 }); + const scheduledMapShiftScrollRef = useRef(null); + const objectDragHistoryCapturedRef = useRef(false); + const suppressPanClickRef = useRef(false); + const autoScrolledObjectViewRef = useRef(null); + const [isPanning, setIsPanning] = useState(false); + const orderedObjects = useMemo(() => { + if (!selectedObjectKey) return objects; + + const selectedObjects = []; + const remainingObjects = []; + objects.forEach((object) => { + if (getObjectKey(object) === selectedObjectKey) { + selectedObjects.push(object); + return; + } + remainingObjects.push(object); + }); + + return [...remainingObjects, ...selectedObjects]; + }, [objects, selectedObjectKey]); + const zoneItemSummary = useMemo(() => { + const assignedCounts = new Map(); + const visibleItemsByZone = new Map(); + + (mapItems || []).forEach((item) => { + const zoneKey = String(item.zone_id || ""); + if (!zoneKey) return; + + assignedCounts.set(zoneKey, (assignedCounts.get(zoneKey) || 0) + 1); + if (!itemMatchesMapFilters(item, filters, username)) return; + + const existingItems = visibleItemsByZone.get(zoneKey) || []; + existingItems.push(item); + visibleItemsByZone.set(zoneKey, existingItems); + }); + + return { assignedCounts, visibleItemsByZone }; + }, [filters, mapItems, username]); + + useEffect(() => () => { + if (scheduledMapShiftScrollRef.current && typeof window !== "undefined") { + window.clearTimeout(scheduledMapShiftScrollRef.current); + } + }, []); + + const flushQueuedMapShiftScroll = () => { + const queuedShift = pendingMapShiftScrollRef.current; + if (!queuedShift.x && !queuedShift.y) return; + + pendingMapShiftScrollRef.current = { x: 0, y: 0 }; + const scrollElement = scrollRef.current; + panScrollElement(scrollElement, queuedShift.x * zoom, queuedShift.y * zoom); + + if (!scrollElement || typeof window === "undefined") return; + + const targetLeft = scrollElement.scrollLeft; + const targetTop = scrollElement.scrollTop; + const restoreScrollTarget = () => { + const currentScrollElement = scrollRef.current; + if (!currentScrollElement) return; + + currentScrollElement.scrollLeft = Math.max(currentScrollElement.scrollLeft, targetLeft); + currentScrollElement.scrollTop = Math.max(currentScrollElement.scrollTop, targetTop); + }; + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + restoreScrollTarget(); + window.setTimeout(restoreScrollTarget, 80); + }); + }); + }; + + const queueMapShiftScroll = (shiftX, shiftY) => { + if (!shiftX && !shiftY) return; + + pendingMapShiftScrollRef.current = { + x: pendingMapShiftScrollRef.current.x + shiftX, + y: pendingMapShiftScrollRef.current.y + shiftY, + }; + + if (typeof window === "undefined" || scheduledMapShiftScrollRef.current) return; + + scheduledMapShiftScrollRef.current = window.setTimeout(() => { + scheduledMapShiftScrollRef.current = null; + window.requestAnimationFrame(flushQueuedMapShiftScroll); + }, 0); + }; + + useEffect(() => { + if (!selectedObjectKey) { + autoScrolledObjectViewRef.current = null; + return undefined; + } + + if (mode !== "edit" || dragState) { + return undefined; + } + + const selectedObject = objects.find((object) => getObjectKey(object) === selectedObjectKey); + if (!selectedObject || !scrollRef.current || typeof window === "undefined") { + return undefined; + } + + const scrollKey = getSelectedObjectScrollKey(selectedObject, selectedObjectKey, zoom); + if (autoScrolledObjectViewRef.current === scrollKey) { + return undefined; + } + autoScrolledObjectViewRef.current = scrollKey; + + const frameId = window.requestAnimationFrame(() => { + const scrollElement = scrollRef.current; + if (!scrollElement) return; + + const objectLeft = selectedObject.x * zoom - SELECTED_OBJECT_SCROLL_PADDING; + const objectTop = selectedObject.y * zoom - SELECTED_OBJECT_SCROLL_PADDING; + const objectRight = (selectedObject.x + selectedObject.width) * zoom + SELECTED_OBJECT_SCROLL_PADDING; + const objectBottom = (selectedObject.y + selectedObject.height) * zoom + SELECTED_OBJECT_SCROLL_PADDING; + let nextLeft = scrollElement.scrollLeft; + let nextTop = scrollElement.scrollTop; + + if (objectLeft < nextLeft) { + nextLeft = objectLeft; + } else if (objectRight > nextLeft + scrollElement.clientWidth) { + nextLeft = objectRight - scrollElement.clientWidth; + } + + if (objectTop < nextTop) { + nextTop = objectTop; + } else if (objectBottom > nextTop + scrollElement.clientHeight) { + nextTop = objectBottom - scrollElement.clientHeight; + } + + scrollElement.scrollTo({ + left: clampScrollPosition(nextLeft, scrollElement.scrollWidth - scrollElement.clientWidth), + top: clampScrollPosition(nextTop, scrollElement.scrollHeight - scrollElement.clientHeight), + }); + }); + + return () => window.cancelAnimationFrame(frameId); + }, [dragState, mode, objects, selectedObjectKey, zoom]); + + const startDrag = (event, object, type) => { + if (!canEditObjects || object.locked || !svgRef.current) return; + event.stopPropagation(); + event.preventDefault(); + objectDragHistoryCapturedRef.current = false; + const point = clientPointToMap(event, svgRef.current, mapSize); + event.currentTarget.setPointerCapture?.(event.pointerId); + setSelectedObjectKey(getObjectKey(object)); + setDragState({ + type, + key: getObjectKey(object), + startPoint: point, + startObject: { ...object }, + }); + }; + + const handlePointerMove = (event) => { + if (!dragState || !svgRef.current) return; + event.preventDefault(); + const scrollElement = scrollRef.current; + if (scrollElement) { + const scrollBounds = scrollElement.getBoundingClientRect(); + panScrollElement( + scrollElement, + getEdgePanStep(event.clientX, scrollBounds.left, scrollBounds.right), + getEdgePanStep(event.clientY, scrollBounds.top, scrollBounds.bottom) + ); + } + + const point = clientPointToMap(event, svgRef.current, mapSize); + const deltaX = point.x - dragState.startPoint.x; + const deltaY = point.y - dragState.startPoint.y; + const hasMeaningfulChange = + Math.abs(deltaX) >= DRAG_CHANGE_THRESHOLD || + Math.abs(deltaY) >= DRAG_CHANGE_THRESHOLD; + + if (!hasMeaningfulChange) return; + const draggedObject = objects.find((object) => getObjectKey(object) === dragState.key); + if (!draggedObject) return; + const nextDraggedObjectCandidate = getDraggedObjectCandidate(draggedObject, dragState, deltaX, deltaY); + const expansion = expandMapToFitObject?.(nextDraggedObjectCandidate) || { + mapSize, + shiftX: 0, + shiftY: 0, + }; + const nextMapSize = expansion.mapSize; + const shiftedDraggedObjectCandidate = shiftMapObject( + nextDraggedObjectCandidate, + expansion.shiftX, + expansion.shiftY + ); + const nextDraggedObject = dragState.type === "resize" + ? clampResizedObjectToMap(shiftedDraggedObjectCandidate, nextMapSize) + : clampObjectToMap(shiftedDraggedObjectCandidate, nextMapSize); + if (!hasObjectBoundsChanged(draggedObject, nextDraggedObject)) return; + + if (!objectDragHistoryCapturedRef.current) { + remember(undefined, dragState.key); + objectDragHistoryCapturedRef.current = true; + } + + if (expansion.shiftX || expansion.shiftY) { + queueMapShiftScroll(expansion.shiftX, expansion.shiftY); + setDragState((currentDragState) => { + if (!currentDragState || currentDragState.key !== dragState.key) return currentDragState; + + return { + ...currentDragState, + startObject: shiftMapObject(currentDragState.startObject, expansion.shiftX, expansion.shiftY), + startPoint: { + x: currentDragState.startPoint.x + expansion.shiftX, + y: currentDragState.startPoint.y + expansion.shiftY, + }, + }; + }); + } + + updateObjects( + (currentObjects) => currentObjects.map((object) => { + if (getObjectKey(object) !== dragState.key) { + return shiftMapObject(object, expansion.shiftX, expansion.shiftY); + } + const candidate = getDraggedObjectCandidate(object, dragState, deltaX, deltaY); + const shiftedCandidate = shiftMapObject(candidate, expansion.shiftX, expansion.shiftY); + return dragState.type === "resize" + ? clampResizedObjectToMap(shiftedCandidate, nextMapSize) + : clampObjectToMap(shiftedCandidate, nextMapSize); + }), + nextMapSize + ); + }; + + const handlePointerUp = () => { + objectDragHistoryCapturedRef.current = false; + setDragState(null); + }; + + const selectObject = (object) => { + if (mode === "view" || canEditObjects) { + setSelectedObjectKey(getObjectKey(object)); + } + }; + + const handleObjectClick = (event, object) => { + event.stopPropagation(); + if (suppressPanClickRef.current) { + suppressPanClickRef.current = false; + return; + } + selectObject(object); + }; + + const handleObjectKeyDown = (event, object) => { + if (event.key === "Escape" && (mode === "view" || canEditObjects)) { + event.stopPropagation(); + event.preventDefault(); + setSelectedObjectKey(null); + return; + } + + if (["Enter", " ", "Spacebar"].includes(event.key)) { + event.stopPropagation(); + event.preventDefault(); + selectObject(object); + return; + } + + if (!canEditObjects || object.locked || !NUDGE_KEYS.has(event.key)) return; + + event.stopPropagation(); + event.preventDefault(); + const nudgeAmount = event.shiftKey ? 25 : 10; + const deltaX = event.key === "ArrowLeft" ? -nudgeAmount : event.key === "ArrowRight" ? nudgeAmount : 0; + const deltaY = event.key === "ArrowUp" ? -nudgeAmount : event.key === "ArrowDown" ? nudgeAmount : 0; + const nextObjectCandidate = { + ...object, + x: object.x + deltaX, + y: object.y + deltaY, + }; + const expansion = expandMapToFitObject?.(nextObjectCandidate) || { + mapSize, + shiftX: 0, + shiftY: 0, + }; + const nextMapSize = expansion.mapSize; + const nextObject = clampObjectToMap( + shiftMapObject(nextObjectCandidate, expansion.shiftX, expansion.shiftY), + nextMapSize + ); + + setSelectedObjectKey(getObjectKey(object)); + if (!hasObjectBoundsChanged(object, nextObject)) return; + + remember(undefined, getObjectKey(object)); + queueMapShiftScroll(expansion.shiftX, expansion.shiftY); + updateObjects( + (currentObjects) => currentObjects.map((candidate) => + getObjectKey(candidate) === getObjectKey(object) + ? nextObject + : shiftMapObject(candidate, expansion.shiftX, expansion.shiftY) + ), + nextMapSize + ); + }; + + const handleShowZones = (event) => { + event.stopPropagation(); + onShowZones?.(); + }; + + const handleAddObject = (event) => { + event.stopPropagation(); + if (editingLocked) return; + onAddObject?.(); + }; + + const handleShowHiddenAreas = (event) => { + event.stopPropagation(); + if (editingLocked) return; + onShowHiddenAreas?.(); + }; + + const clearSelection = () => { + if (suppressPanClickRef.current) { + suppressPanClickRef.current = false; + return; + } + if (!dragState) { + setSelectedObjectKey(null); + } + }; + + const startPan = (event) => { + if (!canDragPan || !scrollRef.current || event.button !== 0) return; + const objectElement = event.target.closest?.(".location-map-object"); + if (canEditObjects && objectElement) return; + event.currentTarget.setPointerCapture?.(event.pointerId); + panDragRef.current = { + startX: event.clientX, + startY: event.clientY, + scrollLeft: scrollRef.current.scrollLeft, + scrollTop: scrollRef.current.scrollTop, + objectKey: objectElement?.getAttribute("data-object-key") || null, + moved: false, + }; + setIsPanning(true); + }; + + const movePan = (event) => { + if (!panDragRef.current || !scrollRef.current) return; + const deltaX = event.clientX - panDragRef.current.startX; + const deltaY = event.clientY - panDragRef.current.startY; + if (Math.abs(deltaX) > 4 || Math.abs(deltaY) > 4) { + panDragRef.current.moved = true; + event.preventDefault(); + } + scrollRef.current.scrollLeft = clampScrollPosition( + panDragRef.current.scrollLeft - deltaX, + scrollRef.current.scrollWidth - scrollRef.current.clientWidth + ); + scrollRef.current.scrollTop = clampScrollPosition( + panDragRef.current.scrollTop - deltaY, + scrollRef.current.scrollHeight - scrollRef.current.clientHeight + ); + }; + + const endPan = (event) => { + if (!panDragRef.current) return; + const wasMoved = panDragRef.current.moved; + const objectKey = panDragRef.current.objectKey; + if (!wasMoved && mode === "view" && objectKey) { + setSelectedObjectKey(objectKey); + suppressPanClickRef.current = true; + } else { + suppressPanClickRef.current = wasMoved; + } + panDragRef.current = null; + setIsPanning(false); + event.currentTarget.releasePointerCapture?.(event.pointerId); + }; + + const renderMapObject = (object, index) => { + const key = getObjectKey(object); + const zoneKey = String(object.zone_id || ""); + const zoneItems = zoneKey ? zoneItemSummary.visibleItemsByZone.get(zoneKey) || [] : []; + const assignedZoneItemCount = zoneKey ? zoneItemSummary.assignedCounts.get(zoneKey) || 0 : 0; + return ( + + ); + }; + + return ( +
    +
    + + + + + + + + {mode === "edit" ? ( + + ) : null} + {orderedObjects.map(renderMapObject)} + + {emptyCanvasMessage ? ( +
    + {emptyCanvasMessage} + {canAddFirstArea ? ( + + ) : hiddenByZonesLayer ? ( + + ) : canRecoverHiddenAreas ? ( + + ) : null} +
    + ) : null} +
    +
    + ); +} diff --git a/frontend/src/components/maps/LocationMapDialogs.jsx b/frontend/src/components/maps/LocationMapDialogs.jsx new file mode 100644 index 0000000..030178e --- /dev/null +++ b/frontend/src/components/maps/LocationMapDialogs.jsx @@ -0,0 +1,46 @@ +import ConfirmBuyModal from "../modals/ConfirmBuyModal"; +import ConfirmSlideModal from "../modals/ConfirmSlideModal"; +import { getMapObjectDisplayLabel } from "../../lib/locationMapUtils"; + +export default function LocationMapDialogs({ + buyModalItem, + buyModalItems, + onBuyCancel, + onBuyConfirm, + onBuyNavigate, + pendingDeleteObject, + onDeleteClose, + onDeleteConfirm, + pendingLeaveTarget, + onLeaveClose, + onLeaveConfirm, +}) { + return ( + <> + {buyModalItem ? ( + + ) : null} + + + + + ); +} diff --git a/frontend/src/components/maps/LocationMapObject.jsx b/frontend/src/components/maps/LocationMapObject.jsx new file mode 100644 index 0000000..4cdc2f4 --- /dev/null +++ b/frontend/src/components/maps/LocationMapObject.jsx @@ -0,0 +1,193 @@ +import { + getMapObjectDisplayLabel, + getObjectKey, +} from "../../lib/locationMapUtils"; + +const PIN_PREVIEW_LIMIT = 12; + +function truncateLabel(value, maxCharacters) { + if (value.length <= maxCharacters) return value; + return `${value.slice(0, Math.max(5, maxCharacters - 3)).trimEnd()}...`; +} + +function fittedLabelLines(object, fallback) { + const rawLabel = getMapObjectDisplayLabel(object, fallback); + const maxCharacters = Math.max(8, Math.floor((Number(object.width) - 28) / 13)); + const canUseTwoLines = Number(object.height) >= 92 && rawLabel.length > maxCharacters; + if (!canUseTwoLines) return [truncateLabel(rawLabel, maxCharacters)]; + + const words = rawLabel.split(/\s+/); + let firstLine = ""; + let splitIndex = 0; + for (let index = 0; index < words.length; index += 1) { + const candidate = firstLine ? `${firstLine} ${words[index]}` : words[index]; + if (candidate.length > maxCharacters && firstLine) break; + firstLine = candidate; + splitIndex = index + 1; + if (candidate.length >= maxCharacters) break; + } + + const secondLine = words.slice(splitIndex).join(" "); + if (!secondLine) return [truncateLabel(firstLine, maxCharacters)]; + return [ + truncateLabel(firstLine, maxCharacters), + truncateLabel(secondLine, maxCharacters), + ]; +} + +export default function LocationMapObject({ + object, + index, + filters, + zoneItems, + assignedZoneItemCount, + isSelected, + canEditObjects, + canSelect, + zoom, + mapSize, + onStartDrag, + onObjectClick, + onObjectKeyDown, +}) { + if (object.visible === false || !filters.showZones) return null; + + const key = getObjectKey(object); + const displayLabel = getMapObjectDisplayLabel(object, index + 1); + const count = zoneItems.length; + const hasHiddenZoneItems = assignedZoneItemCount > count; + const countLabel = hasHiddenZoneItems + ? `${count} shown` + : `${count} item${count === 1 ? "" : "s"}`; + const accessibleLabel = canEditObjects + ? object.locked + ? `Map area ${displayLabel}, locked` + : `Map area ${displayLabel}` + : `Map area ${displayLabel}, ${countLabel}`; + const labelLines = fittedLabelLines(object, "Area"); + const countY = filters.showLabels && labelLines.length > 1 ? object.y + 76 : object.y + 52; + const resizeHandleSize = Math.min(96, Math.max(44, Math.ceil(48 / zoom))); + const resizeHandleInset = 6; + const lockBadgeSize = Math.min(52, Math.max(30, Math.ceil(34 / zoom))); + const lockBadgeInset = 8; + const resizeHandleX = Math.min( + Math.max(0, mapSize.width - resizeHandleSize), + Math.max( + object.x + resizeHandleInset, + object.x + object.width - resizeHandleSize - resizeHandleInset + ) + ); + const resizeHandleY = Math.min( + Math.max(0, mapSize.height - resizeHandleSize), + Math.max( + object.y + resizeHandleInset, + object.y + object.height - resizeHandleSize - resizeHandleInset + ) + ); + const lockBadgeX = Math.min( + Math.max(0, mapSize.width - lockBadgeSize), + Math.max( + object.x + lockBadgeInset, + object.x + object.width - lockBadgeSize - lockBadgeInset + ) + ); + const lockBadgeY = Math.min( + Math.max(0, mapSize.height - lockBadgeSize), + object.y + lockBadgeInset + ); + const pinDots = filters.showPins + ? zoneItems.slice(0, PIN_PREVIEW_LIMIT).map((item, itemIndex) => { + const column = itemIndex % 4; + const row = Math.floor(itemIndex / 4); + return ( + + ); + }) + : null; + + return ( + + onStartDrag(event, object, "move")} + onClick={(event) => onObjectClick(event, object)} + onKeyDown={(event) => onObjectKeyDown(event, object)} + /> + {filters.showLabels ? ( + + {labelLines.map((line, lineIndex) => ( + + {line} + + ))} + + ) : null} + {filters.showCounts ? ( + + {countLabel} + + ) : null} + {pinDots} + {canEditObjects && isSelected && object.locked ? ( + + ) : null} + {canEditObjects && isSelected && !object.locked ? ( + onStartDrag(event, object, "resize")} + onClick={(event) => event.stopPropagation()} + /> + ) : null} + + ); +} diff --git a/frontend/src/components/maps/LocationMapSetupPanel.jsx b/frontend/src/components/maps/LocationMapSetupPanel.jsx new file mode 100644 index 0000000..17d294c --- /dev/null +++ b/frontend/src/components/maps/LocationMapSetupPanel.jsx @@ -0,0 +1,63 @@ +export default function LocationMapSetupPanel({ + hasAnyMap, + canManage, + zoneCount = 0, + saving, + savingAction, + onContinue, + onPreview, + onPublish, + onCreateFromZones, + onCreateBlank, +}) { + const hasZones = zoneCount > 0; + const createFromZonesLabel = `Create From ${zoneCount} Zone${zoneCount === 1 ? "" : "s"}`; + const createBlankLabel = savingAction === "create-blank" ? "Creating..." : "Create Blank Map"; + const createZonesButtonLabel = savingAction === "create-zones" ? "Creating..." : createFromZonesLabel; + const publishLabel = savingAction === "publish" ? "Publishing..." : "Publish"; + const heading = hasAnyMap ? "Draft Map" : canManage ? "No Map" : "No Published Map"; + + return ( +
    +

    {heading}

    + {canManage ? ( +
    +
    + Zones + {zoneCount} +
    +
    + ) : null} + {hasAnyMap && canManage ? ( +
    + + + +
    + ) : !hasAnyMap && canManage ? ( +
    + {!hasZones ? ( + + ) : ( + <> + + + + )} +
    + ) : null} +
    + ); +} diff --git a/frontend/src/components/maps/LocationMapStateViews.jsx b/frontend/src/components/maps/LocationMapStateViews.jsx new file mode 100644 index 0000000..cb9817f --- /dev/null +++ b/frontend/src/components/maps/LocationMapStateViews.jsx @@ -0,0 +1,38 @@ +import LocationMapTopbar from "./LocationMapTopbar"; + +export function LocationMapMessageState({ message = "", location, status = "Loading", onBack }) { + const showTopbar = Boolean(location && onBack); + + return ( +
    + {showTopbar ? ( + + ) : null} +
    +
    +

    {status}

    + {message ?

    {message}

    : null} +
    +
    +
    + ); +} + +export function LocationMapLoadErrorState({ location, loadError, onBack, onRetry }) { + return ( +
    + +
    +
    +

    Map Unavailable

    +

    {loadError}

    +
    + +
    +
    +
    +
    + ); +} diff --git a/frontend/src/components/maps/LocationMapToolbar.jsx b/frontend/src/components/maps/LocationMapToolbar.jsx new file mode 100644 index 0000000..c0a542c --- /dev/null +++ b/frontend/src/components/maps/LocationMapToolbar.jsx @@ -0,0 +1,161 @@ +import { MAX_MAP_ZOOM, MIN_MAP_ZOOM, clampMapZoom } from "../../lib/locationMapUtils"; + +function ToolbarIcon({ name }) { + if (name === "undo") { + return ( + + ); + } + + if (name === "redo") { + return ( + + ); + } + + if (name === "zoom-in") { + return ( + + ); + } + + if (name === "fit") { + return ( + + ); + } + + return ( + + ); +} + +export default function LocationMapToolbar({ + mode, + hasAnyMap, + canManage, + saving, + history = [], + future = [], + zoom, + setZoom, + onFit, + onView, + onEdit, + onUndo, + onRedo, +}) { + const isAtMinZoom = zoom <= MIN_MAP_ZOOM; + const isAtMaxZoom = zoom >= MAX_MAP_ZOOM; + const canUseHistoryControls = mode === "edit" && canManage && hasAnyMap; + + return ( +
    +
    +
    + + {canManage ? ( + + ) : null} +
    +
    + {canUseHistoryControls ? ( +
    + + +
    + ) : null} +
    + + {Math.round(zoom * 100)}% + + +
    +
    + ); +} diff --git a/frontend/src/components/maps/LocationMapTopbar.jsx b/frontend/src/components/maps/LocationMapTopbar.jsx new file mode 100644 index 0000000..09dc012 --- /dev/null +++ b/frontend/src/components/maps/LocationMapTopbar.jsx @@ -0,0 +1,29 @@ +import { getLocationName, getStoreName } from "../../lib/locationMapUtils"; + +export default function LocationMapTopbar({ location, status, onBack }) { + const statusClass = status.toLowerCase().replace(/\s+/g, "-"); + + return ( +
    + +
    +

    {getStoreName(location)}

    + {getLocationName(location)} +
    + + {status} + +
    + ); +} diff --git a/frontend/src/components/modals/ConfirmBuyModal.jsx b/frontend/src/components/modals/ConfirmBuyModal.jsx index 57d093a..0867759 100644 --- a/frontend/src/components/modals/ConfirmBuyModal.jsx +++ b/frontend/src/components/modals/ConfirmBuyModal.jsx @@ -1,6 +1,15 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import "../../styles/ConfirmBuyModal.css"; +function ChevronIcon({ direction }) { + const path = direction === "previous" ? "M15 18l-6-6 6-6" : "M9 18l6-6-6-6"; + return ( + + ); +} + export default function ConfirmBuyModal({ item, onConfirm, @@ -11,6 +20,7 @@ export default function ConfirmBuyModal({ const [quantity, setQuantity] = useState(item.quantity); const [isSubmitting, setIsSubmitting] = useState(false); const maxQuantity = item.quantity; + const titleId = "confirm-buy-modal-title"; useEffect(() => { setQuantity(item.quantity); @@ -21,17 +31,17 @@ export default function ConfirmBuyModal({ const hasPrev = currentIndex > 0; const hasNext = currentIndex < allItems.length - 1; - const handleIncrement = () => { - if (!isSubmitting && quantity < maxQuantity) { - setQuantity((prev) => prev + 1); - } - }; + const handleIncrement = useCallback(() => { + if (isSubmitting) return; - const handleDecrement = () => { - if (!isSubmitting && quantity > 1) { - setQuantity((prev) => prev - 1); - } - }; + setQuantity((prev) => Math.min(maxQuantity, prev + 1)); + }, [isSubmitting, maxQuantity]); + + const handleDecrement = useCallback(() => { + if (isSubmitting) return; + + setQuantity((prev) => Math.max(1, prev - 1)); + }, [isSubmitting]); const handleConfirm = async () => { if (isSubmitting) return; @@ -44,21 +54,58 @@ export default function ConfirmBuyModal({ } }; - const handlePrev = () => { + const handlePrev = useCallback(() => { if (isSubmitting) return; if (hasPrev && onNavigate) { onNavigate(allItems[currentIndex - 1]); } - }; + }, [allItems, currentIndex, hasPrev, isSubmitting, onNavigate]); - const handleNext = () => { + const handleNext = useCallback(() => { if (isSubmitting) return; if (hasNext && onNavigate) { onNavigate(allItems[currentIndex + 1]); } - }; + }, [allItems, currentIndex, hasNext, isSubmitting, onNavigate]); + + useEffect(() => { + const handleKeyDown = (event) => { + if (isSubmitting) return; + + if (event.key === "Escape") { + onCancel(); + return; + } + + if (event.key === "ArrowLeft") { + event.preventDefault(); + handlePrev(); + return; + } + + if (event.key === "ArrowRight") { + event.preventDefault(); + handleNext(); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + handleIncrement(); + return; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + handleDecrement(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handleDecrement, handleIncrement, handleNext, handlePrev, isSubmitting, onCancel]); const imageUrl = item.item_image && item.image_mime_type ? `data:${item.image_mime_type};base64,${item.item_image}` @@ -73,46 +120,62 @@ export default function ConfirmBuyModal({ } }} > -
    event.stopPropagation()}> +
    event.stopPropagation()} + >
    {item.zone &&
    {item.zone}
    } -

    {item.item_name}

    +

    {item.item_name}

    {imageUrl ? ( {item.item_name} ) : ( -
    [ ]
    +
    + +
    )}
    @@ -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);