Add store location map manager #20

Open
nalalangan wants to merge 206 commits from feature/location-map-manager into feature/store-selector-modal
47 changed files with 11981 additions and 69 deletions

View File

@ -24,7 +24,7 @@ If anything conflicts, follow **this** doc.
- `docker compose -f docker-compose.dev.yml up -d --build backend` - `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: - 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` - `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`. - 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. - Do not print or commit real `.env` values while checking or updating local Docker env.

View File

@ -6,6 +6,6 @@ DB_PORT=5432
DB_NAME= DB_NAME=
PORT=5000 PORT=5000
JWT_SECRET=change-me 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_COOKIE_NAME=sid
SESSION_TTL_DAYS=30 SESSION_TTL_DAYS=30

View File

@ -4,6 +4,7 @@ const path = require("path");
const User = require("./models/user.model"); const User = require("./models/user.model");
const requestIdMiddleware = require("./middleware/request-id"); const requestIdMiddleware = require("./middleware/request-id");
const { sendError } = require("./utils/http"); const { sendError } = require("./utils/http");
const { isAllowedCorsOrigin, parseAllowedOrigins } = require("./utils/cors-origins");
const app = express(); const app = express();
app.use(requestIdMiddleware); app.use(requestIdMiddleware);
@ -14,17 +15,11 @@ if (process.env.NODE_ENV !== "production") {
app.use("/test", express.static(path.join(__dirname, "public"))); app.use("/test", express.static(path.join(__dirname, "public")));
} }
const allowedOrigins = (process.env.ALLOWED_ORIGINS || "") const allowedOrigins = parseAllowedOrigins(process.env.ALLOWED_ORIGINS || "");
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
app.use( app.use(
cors({ cors({
origin: function (origin, callback) { origin: function (origin, callback) {
if (!origin) return callback(null, true); if (isAllowedCorsOrigin(origin, allowedOrigins)) 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);
console.error(`CORS blocked origin: ${origin}`); console.error(`CORS blocked origin: ${origin}`);
callback(new Error(`CORS blocked: ${origin}. Add this origin to ALLOWED_ORIGINS environment variable.`)); callback(new Error(`CORS blocked: ${origin}. Add this origin to ALLOWED_ORIGINS environment variable.`));
}, },

View File

@ -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");
}
};

View File

@ -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();
}
};

View File

@ -3,6 +3,7 @@ const router = express.Router();
const controller = require("../controllers/households.controller"); const controller = require("../controllers/households.controller");
const listsController = require("../controllers/lists.controller.v2"); const listsController = require("../controllers/lists.controller.v2");
const availableItemsController = require("../controllers/available-items.controller"); const availableItemsController = require("../controllers/available-items.controller");
const locationMapsController = require("../controllers/location-maps.controller");
const storesController = require("../controllers/stores.controller"); const storesController = require("../controllers/stores.controller");
const auth = require("../middleware/auth"); const auth = require("../middleware/auth");
const { const {
@ -134,6 +135,46 @@ router.delete(
storesController.deleteZone 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( router.get(
"/:householdId/locations/:locationId/available-items", "/:householdId/locations/:locationId/available-items",
auth, auth,

View File

@ -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,
};

View File

@ -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);
});
});

View File

@ -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,
});
});
});

View File

@ -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();
});
});

View File

@ -70,6 +70,14 @@ jest.mock("../controllers/available-items.controller", () => ({
updateAvailableItem: jest.fn(), 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", () => ({ jest.mock("../controllers/stores.controller", () => ({
addLocationToStore: jest.fn((req, res) => res.status(201).json({ message: "location" })), addLocationToStore: jest.fn((req, res) => res.status(201).json({ message: "location" })),
createHouseholdStore: jest.fn((req, res) => res.status(201).json({ message: "store" })), createHouseholdStore: jest.fn((req, res) => res.status(201).json({ message: "store" })),
@ -89,6 +97,7 @@ const express = require("express");
const request = require("supertest"); const request = require("supertest");
const router = require("../routes/households.routes"); const router = require("../routes/households.routes");
const householdsController = require("../controllers/households.controller"); const householdsController = require("../controllers/households.controller");
const locationMapsController = require("../controllers/location-maps.controller");
const storesController = require("../controllers/stores.controller"); const storesController = require("../controllers/stores.controller");
describe("store location routes", () => { describe("store location routes", () => {
@ -161,4 +170,51 @@ describe("store location routes", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(storesController.updateZone).toHaveBeenCalled(); 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();
});
}); });

View File

@ -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,
};

View File

@ -1,2 +1,3 @@
VITE_API_URL=http://localhost:5000 # Leave blank to infer the API host from the browser URL, e.g. http://192.168.7.45:5000.
VITE_ALLOWED_HOSTS=localhost,127.0.0.1 VITE_API_URL=
VITE_ALLOWED_HOSTS=localhost,127.0.0.1,192.168.7.45

View File

@ -9,7 +9,8 @@ import { SettingsProvider } from "./context/SettingsContext.jsx";
import { StoreProvider } from "./context/StoreContext.jsx"; import { StoreProvider } from "./context/StoreContext.jsx";
import AdminPanel from "./pages/AdminPanel.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 Login from "./pages/Login.jsx";
import Manage from "./pages/Manage.jsx"; import Manage from "./pages/Manage.jsx";
import Register from "./pages/Register.jsx"; import Register from "./pages/Register.jsx";
@ -48,6 +49,7 @@ function App() {
<Route path="/" element={<GroceryList />} /> <Route path="/" element={<GroceryList />} />
<Route path="/manage" element={<Manage />} /> <Route path="/manage" element={<Manage />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/stores/:storeId/locations/:locationId/map" element={<LocationMapManager />} />
<Route <Route
path="/admin" path="/admin"

View File

@ -0,0 +1,16 @@
import api from "./axios";
export const getLocationMap = (householdId, locationId) =>
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`);

View File

@ -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) ? (
<div className="location-map-editor-actions location-map-primary-actions">
{showSaveDraftAction ? (
<button type="button" className="primary" onClick={onSaveDraft} disabled={saving || !hasUnsavedChanges}>
{saveDraftLabel}
</button>
) : null}
{showPublishAction ? (
<button
type="button"
className="primary"
onClick={onPublish}
disabled={saving || !canPublishDraft}
>
{publishLabel}
</button>
) : null}
</div>
) : null;
const secondaryDraftActions = showEditorControls ? (
<div className="location-map-editor-actions location-map-secondary-actions">
<button type="button" onClick={onAddObject} disabled={saving}>Add Area</button>
</div>
) : null;
const editorActions = showEditorControls ? (
<div className="location-map-editor-action-stack" role="group" aria-label="Map draft actions">
{primaryDraftActions}
{secondaryDraftActions}
</div>
) : null;
return (
<aside className="location-map-bottom-sheet">
<div className="location-map-bottom-sheet-handle" aria-hidden="true" />
<div className="location-map-sheet-header">
<div>
<div className="location-map-sheet-heading">
<strong>{sheetTitle}</strong>
{showHeaderItemCount ? (
<span className="location-map-sheet-count">{selectedItemCountLabel}</span>
) : null}
</div>
</div>
<div className="location-map-layer-actions">
{selectedObject ? (
<button
type="button"
className="location-map-sheet-close"
onClick={onClearSelection}
aria-label="Close selected map area"
>
<span aria-hidden="true">&times;</span>
</button>
) : null}
{changedLayerCount ? (
<button
type="button"
className="location-map-layer-reset"
onClick={resetLayerFilters}
aria-label="Reset layer filters"
title="Reset layer filters"
>
<svg className="location-map-layer-reset-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7v5h5" />
<path d="M5.2 12a7 7 0 1 0 2.1-5" />
</svg>
</button>
) : null}
<button
type="button"
className={`location-map-layer-toggle ${layersOpen ? "active" : ""}`}
onClick={() => setLayersOpen((current) => !current)}
aria-expanded={layersOpen}
aria-label={changedLayerCount ? `Layers, ${changedLayerCount} changed` : "Layers"}
>
Layers
{changedLayerCount ? (
<span className="location-map-layer-count" aria-hidden="true">{changedLayerCount}</span>
) : null}
</button>
</div>
</div>
{layersOpen ? (
<LocationMapLayerPanel filters={filters} setFilters={setFilters} />
) : null}
{showSelectedObjectForm ? (
primaryDraftActions ? (
<div
className="location-map-editor-action-stack location-map-selected-primary-actions"
role="group"
aria-label="Map draft actions"
>
{primaryDraftActions}
</div>
) : null
) : editorActions}
{showHiddenAreaAction ? (
<div className="location-map-hidden-areas">
<button type="button" onClick={onShowHiddenAreas} disabled={saving}>
{hiddenAreaLabel}
</button>
</div>
) : null}
{showMapOverview ? (
<LocationMapOverview
ariaLabel="Map summary"
rows={mapOverviewRows}
/>
) : null}
{showDraftOverview ? (
<LocationMapOverview
ariaLabel="Draft map summary"
rows={draftOverviewRows}
/>
) : null}
{mode === "edit" && selectedObject ? (
<SelectedMapObjectForm
selectedObject={selectedObject}
zones={mapState?.zones || EMPTY_ITEMS}
saving={saving}
onObjectField={onObjectField}
onZoneLinkChange={onZoneLinkChange}
onDuplicateObject={onDuplicateObject}
onDeleteObject={onDeleteObject}
/>
) : mode === "edit" ? (
null
) : selectedObject ? (
<SelectedZoneItemsPanel
selectedZoneItems={selectedZoneItems}
hasHiddenSelectedItems={hasHiddenSelectedItems}
hiddenSelectedItemCount={hiddenSelectedItemCount}
onShowSelectedZoneItems={showSelectedZoneItems}
onSelectZoneItem={onSelectZoneItem}
/>
) : null}
{showUnmappedPanel ? (
<UnmappedItemsPanel
visibleUnmappedItems={visibleUnmappedItems}
hiddenUnmappedItemCount={hiddenUnmappedItemCount}
hasHiddenUnmappedItems={hasHiddenUnmappedItems}
onShowUnmappedItems={showUnmappedItems}
onSelectUnmappedItem={onSelectUnmappedItem}
/>
) : null}
</aside>
);
}

View File

@ -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 (
<li className={`location-map-item-row ${isBought ? "is-bought" : ""}`}>
<button
type="button"
className="location-map-item-button"
onClick={() => {
if (!isBought) onSelectItem?.(item);
}}
disabled={isBought}
aria-label={isBought ? `${item.item_name} bought` : `Mark ${item.item_name} bought`}
>
<span>{item.item_name}</span>
<small>{isBought ? "Bought" : `x${item.quantity}`}</small>
</button>
</li>
);
}
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 (
<ul>
{visibleItems.map((item) => (
<MapItemActionRow key={item.id} item={item} onSelectItem={onSelectItem} />
))}
{hasOverflow ? (
<li className="location-map-list-more">
<button
type="button"
className="location-map-list-more-button"
onClick={() => setShowAllItems((current) => !current)}
aria-label={
showAllItems
? `Show fewer ${itemGroupLabel}`
: `Show ${overflowItemCount} more ${itemGroupLabel}`
}
>
{showAllItems ? "Show fewer" : `+${overflowItemCount} more`}
</button>
</li>
) : null}
</ul>
);
}
export function LocationMapLayerPanel({ filters, setFilters }) {
return (
<div className="location-map-display-panel">
<div className="location-map-display-controls" role="group" aria-label="Layer filters">
{MAP_DISPLAY_CONTROLS.map(([key, label]) => (
<button
key={key}
type="button"
className={filters[key] ? "active" : ""}
aria-pressed={filters[key]}
onClick={() =>
setFilters((current) => ({ ...current, [key]: !current[key] }))
}
>
<span
className={`location-map-layer-state ${filters[key] ? "is-on" : ""}`}
aria-hidden="true"
/>
<span className="location-map-layer-label">{label}</span>
</button>
))}
</div>
</div>
);
}
export function LocationMapOverview({ ariaLabel, rows }) {
return (
<div className="location-map-overview" role="group" aria-label={ariaLabel}>
{rows.map((row) => (
<div className="location-map-overview-row" key={row.label}>
<span>{row.label}</span>
<strong>{row.value}</strong>
</div>
))}
</div>
);
}
function InlineStateAction({ label, value, actionLabel, actionText = "Show", onAction }) {
return (
<div className="location-map-inline-state">
<span>{label}</span>
<strong>{value}</strong>
{onAction ? (
<button type="button" onClick={onAction} aria-label={actionLabel}>
{actionText}
</button>
) : null}
</div>
);
}
export function SelectedMapObjectForm({
selectedObject,
zones = [],
saving,
onObjectField,
onZoneLinkChange,
onDuplicateObject,
onDeleteObject,
}) {
const selectedObjectLabel = getMapObjectDisplayLabel(selectedObject);
return (
<div className="location-map-object-form">
<label className="location-map-field-row">
<span>Label</span>
<input
value={selectedObject.label || ""}
disabled={saving}
onChange={(event) => onObjectField("label", event.target.value)}
/>
</label>
<label className="location-map-field-row">
<span>Zone</span>
<select
aria-label="Linked zone"
value={objectZoneId(selectedObject)}
disabled={saving}
onChange={(event) => onZoneLinkChange(event.target.value)}
>
<option value="">No linked zone</option>
{zones.map((zone) => (
<option key={zone.id} value={zone.id}>{zone.name}</option>
))}
</select>
</label>
<div className="location-map-object-flags">
<button
type="button"
className={selectedObject.visible !== false ? "active" : ""}
aria-pressed={selectedObject.visible !== false}
disabled={saving}
onClick={() => onObjectField("visible", selectedObject.visible === false)}
>
<span
className={`location-map-object-flag-state ${selectedObject.visible !== false ? "is-on" : ""}`}
aria-hidden="true"
/>
<span className="location-map-object-flag-label">Visible</span>
</button>
<button
type="button"
className={selectedObject.locked ? "active" : ""}
aria-pressed={Boolean(selectedObject.locked)}
disabled={saving}
onClick={() => onObjectField("locked", !selectedObject.locked)}
>
<span
className={`location-map-object-flag-state ${selectedObject.locked ? "is-on" : ""}`}
aria-hidden="true"
/>
<span className="location-map-object-flag-label">Locked</span>
</button>
</div>
<div className="location-map-editor-actions">
<button
type="button"
onClick={onDuplicateObject}
disabled={saving}
aria-label={`Duplicate area ${selectedObjectLabel}`}
>
Duplicate
</button>
<button
type="button"
className="danger"
onClick={onDeleteObject}
disabled={saving}
aria-label={`Delete area ${selectedObjectLabel}`}
>
Delete
</button>
</div>
</div>
);
}
export function SelectedZoneItemsPanel({
selectedZoneItems,
hasHiddenSelectedItems,
hiddenSelectedItemCount,
onShowSelectedZoneItems,
onSelectZoneItem,
}) {
if (selectedZoneItems.length === 0) {
if (!hasHiddenSelectedItems) return null;
return (
<div className="location-map-zone-items" role="group" aria-label="Zone items">
<div className="location-map-zone-empty">
<InlineStateAction
label="Hidden"
value={hiddenSelectedItemCount}
actionLabel="Show Zone Items"
onAction={onShowSelectedZoneItems}
/>
</div>
</div>
);
}
return (
<div className="location-map-zone-items" role="group" aria-label="Zone items">
<MapItemActionList
items={selectedZoneItems}
onSelectItem={onSelectZoneItem}
itemGroupLabel="zone items"
/>
{hasHiddenSelectedItems ? (
<div className="location-map-zone-hidden-note">
<InlineStateAction
label="Hidden"
value={hiddenSelectedItemCount}
actionLabel="Show All Zone Items"
actionText="Show All"
onAction={onShowSelectedZoneItems}
/>
</div>
) : null}
</div>
);
}
export function UnmappedItemsPanel({
visibleUnmappedItems,
hiddenUnmappedItemCount,
hasHiddenUnmappedItems,
onShowUnmappedItems,
onSelectUnmappedItem,
}) {
if (visibleUnmappedItems.length === 0 && hasHiddenUnmappedItems) {
return (
<div className="location-map-unmapped-list" role="group" aria-label="Unmapped items">
<div className="location-map-zone-empty">
<InlineStateAction
label="Hidden"
value={hiddenUnmappedItemCount}
actionLabel="Show Unmapped Items"
onAction={onShowUnmappedItems}
/>
</div>
</div>
);
}
if (visibleUnmappedItems.length === 0) {
return null;
}
return (
<div className="location-map-unmapped-list" role="group" aria-label="Unmapped items">
<MapItemActionList
items={visibleUnmappedItems}
onSelectItem={onSelectUnmappedItem}
itemGroupLabel="unmapped items"
/>
{hiddenUnmappedItemCount > 0 ? (
<div className="location-map-zone-hidden-note">
<InlineStateAction
label="Hidden"
value={hiddenUnmappedItemCount}
actionLabel="Show All Unmapped Items"
actionText="Show All"
onAction={onShowUnmappedItems}
/>
</div>
) : null}
</div>
);
}

View File

@ -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 (
<LocationMapObject
key={key}
object={object}
index={index}
filters={filters}
zoneItems={zoneItems}
assignedZoneItemCount={assignedZoneItemCount}
isSelected={key === selectedObjectKey}
canEditObjects={canEditObjects}
canSelect={mode === "view" || canEditObjects}
zoom={zoom}
mapSize={mapSize}
onStartDrag={startDrag}
onObjectClick={handleObjectClick}
onObjectKeyDown={handleObjectKeyDown}
/>
);
};
return (
<section
className={[
"location-map-canvas-shell",
mode === "edit" ? "is-edit-mode" : "is-view-mode",
mode === "edit" ? "is-object-tool" : "is-pan-tool",
isPanning ? "is-panning" : "",
].filter(Boolean).join(" ")}
>
<div
ref={scrollRef}
className="location-map-scroll"
onPointerDown={startPan}
onPointerMove={movePan}
onPointerUp={endPan}
onPointerCancel={endPan}
onPointerLeave={endPan}
onClick={clearSelection}
>
<svg
ref={svgRef}
className={[
"location-map-svg",
canEditObjects ? "is-editable" : "",
].filter(Boolean).join(" ")}
style={{
width: `${mapSize.width * zoom}px`,
height: `${mapSize.height * zoom}px`,
}}
viewBox={`0 0 ${mapSize.width} ${mapSize.height}`}
role="img"
aria-label="Store location map"
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
onClick={clearSelection}
>
<defs>
<pattern id="location-map-grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" />
</pattern>
</defs>
<rect className="location-map-background" width={mapSize.width} height={mapSize.height} />
{mode === "edit" ? (
<rect className="location-map-grid" width={mapSize.width} height={mapSize.height} />
) : null}
{orderedObjects.map(renderMapObject)}
</svg>
{emptyCanvasMessage ? (
<div
className={[
"location-map-empty-canvas",
hasEmptyCanvasAction ? "has-action" : "",
].filter(Boolean).join(" ")}
>
<span>{emptyCanvasMessage}</span>
{canAddFirstArea ? (
<button
type="button"
disabled={editingLocked}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleAddObject}
>
Add Area
</button>
) : hiddenByZonesLayer ? (
<button
type="button"
onPointerDown={(event) => event.stopPropagation()}
onClick={handleShowZones}
>
Show Zones
</button>
) : canRecoverHiddenAreas ? (
<button
type="button"
disabled={editingLocked}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleShowHiddenAreas}
>
Show Hidden
</button>
) : null}
</div>
) : null}
</div>
</section>
);
}

View File

@ -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 ? (
<ConfirmBuyModal
item={buyModalItem}
allItems={buyModalItems}
onNavigate={onBuyNavigate}
onCancel={onBuyCancel}
onConfirm={onBuyConfirm}
/>
) : null}
<ConfirmSlideModal
isOpen={Boolean(pendingDeleteObject)}
title={`Delete ${getMapObjectDisplayLabel(pendingDeleteObject, "this map area")}?`}
confirmLabel="Delete Area"
onClose={onDeleteClose}
onConfirm={onDeleteConfirm}
/>
<ConfirmSlideModal
isOpen={Boolean(pendingLeaveTarget)}
title="Leave with unsaved changes?"
confirmLabel="Leave Map"
onClose={onLeaveClose}
onConfirm={onLeaveConfirm}
/>
</>
);
}

View File

@ -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 (
<circle
key={item.id}
className="location-map-pin"
cx={object.x + 24 + column * 18}
cy={object.y + 30 + row * 18}
r="5"
/>
);
})
: null;
return (
<g
data-object-key={key}
className={[
"location-map-object",
isSelected ? "is-selected" : "",
canEditObjects ? "is-editable" : "",
object.locked ? "is-locked" : "",
].filter(Boolean).join(" ")}
transform={`rotate(${object.rotation || 0} ${object.x + object.width / 2} ${object.y + object.height / 2})`}
>
<rect
x={object.x}
y={object.y}
width={object.width}
height={object.height}
rx="12"
role="button"
aria-label={accessibleLabel}
aria-pressed={isSelected}
tabIndex={canSelect ? 0 : -1}
onPointerDown={(event) => onStartDrag(event, object, "move")}
onClick={(event) => onObjectClick(event, object)}
onKeyDown={(event) => onObjectKeyDown(event, object)}
/>
{filters.showLabels ? (
<text x={object.x + 16} y={object.y + 28} className="location-map-label">
{labelLines.map((line, lineIndex) => (
<tspan key={`${line}-${lineIndex}`} x={object.x + 16} dy={lineIndex === 0 ? 0 : 24}>
{line}
</tspan>
))}
</text>
) : null}
{filters.showCounts ? (
<text x={object.x + 16} y={countY} className="location-map-count">
{countLabel}
</text>
) : null}
{pinDots}
{canEditObjects && isSelected && object.locked ? (
<g
className="location-map-lock-badge"
transform={`translate(${lockBadgeX} ${lockBadgeY})`}
aria-hidden="true"
>
<rect width={lockBadgeSize} height={lockBadgeSize} rx="10" />
<path
d={[
`M ${lockBadgeSize * 0.32} ${lockBadgeSize * 0.48}`,
`V ${lockBadgeSize * 0.4}`,
`C ${lockBadgeSize * 0.32} ${lockBadgeSize * 0.24}, ${lockBadgeSize * 0.68} ${lockBadgeSize * 0.24}, ${lockBadgeSize * 0.68} ${lockBadgeSize * 0.4}`,
`V ${lockBadgeSize * 0.48}`,
].join(" ")}
/>
<rect
className="location-map-lock-body"
x={lockBadgeSize * 0.26}
y={lockBadgeSize * 0.46}
width={lockBadgeSize * 0.48}
height={lockBadgeSize * 0.34}
rx={lockBadgeSize * 0.08}
/>
</g>
) : null}
{canEditObjects && isSelected && !object.locked ? (
<rect
className="location-map-resize-handle"
x={resizeHandleX}
y={resizeHandleY}
width={resizeHandleSize}
height={resizeHandleSize}
rx="8"
onPointerDown={(event) => onStartDrag(event, object, "resize")}
onClick={(event) => event.stopPropagation()}
/>
) : null}
</g>
);
}

View File

@ -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 (
<section className="location-map-setup">
<h2>{heading}</h2>
{canManage ? (
<div className="location-map-setup-rows" role="group" aria-label="Map setup details">
<div className="location-map-setup-row">
<span>Zones</span>
<strong>{zoneCount}</strong>
</div>
</div>
) : null}
{hasAnyMap && canManage ? (
<div className="location-map-setup-actions">
<button type="button" className="btn-primary" onClick={onContinue} disabled={saving}>
Continue Editing
</button>
<button type="button" className="btn-secondary" onClick={onPreview} disabled={saving}>
View Draft
</button>
<button type="button" className="btn-primary" onClick={onPublish} disabled={saving || !canManage}>
{publishLabel}
</button>
</div>
) : !hasAnyMap && canManage ? (
<div className="location-map-setup-actions">
{!hasZones ? (
<button type="button" className="btn-primary" onClick={onCreateBlank} disabled={saving}>
{createBlankLabel}
</button>
) : (
<>
<button type="button" className="btn-primary" onClick={onCreateFromZones} disabled={saving}>
{createZonesButtonLabel}
</button>
<button type="button" className="btn-secondary" onClick={onCreateBlank} disabled={saving}>
{createBlankLabel}
</button>
</>
)}
</div>
) : null}
</section>
);
}

View File

@ -0,0 +1,38 @@
import LocationMapTopbar from "./LocationMapTopbar";
export function LocationMapMessageState({ message = "", location, status = "Loading", onBack }) {
const showTopbar = Boolean(location && onBack);
return (
<div className="location-map-page">
{showTopbar ? (
<LocationMapTopbar location={location} status={status} onBack={onBack} />
) : null}
<main className="location-map-workspace location-map-message-workspace">
<section className="location-map-setup location-map-message-card" aria-live="polite">
<h2>{status}</h2>
{message ? <p className="location-map-state-message">{message}</p> : null}
</section>
</main>
</div>
);
}
export function LocationMapLoadErrorState({ location, loadError, onBack, onRetry }) {
return (
<div className="location-map-page">
<LocationMapTopbar location={location} status="Load Error" onBack={onBack} />
<main className="location-map-workspace">
<section className="location-map-setup location-map-load-error" role="alert" aria-live="polite">
<h2>Map Unavailable</h2>
<p className="location-map-state-message">{loadError}</p>
<div className="location-map-setup-actions">
<button type="button" className="btn-primary" onClick={onRetry}>
Retry
</button>
</div>
</section>
</main>
</div>
);
}

View File

@ -0,0 +1,161 @@
import { MAX_MAP_ZOOM, MIN_MAP_ZOOM, clampMapZoom } from "../../lib/locationMapUtils";
function ToolbarIcon({ name }) {
if (name === "undo") {
return (
<svg className="location-map-toolbar-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 7H4v5" />
<path d="M5 11a7 7 0 1 0 2-5" />
</svg>
);
}
if (name === "redo") {
return (
<svg className="location-map-toolbar-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M15 7h5v5" />
<path d="M19 11a7 7 0 1 1-2-5" />
</svg>
);
}
if (name === "zoom-in") {
return (
<svg className="location-map-toolbar-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M11 5v12" />
<path d="M5 11h12" />
</svg>
);
}
if (name === "fit") {
return (
<svg className="location-map-toolbar-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 4H4v4" />
<path d="M16 4h4v4" />
<path d="M20 16v4h-4" />
<path d="M8 20H4v-4" />
</svg>
);
}
return (
<svg className="location-map-toolbar-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 11h12" />
</svg>
);
}
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 (
<section
className={`location-map-toolbar ${canUseHistoryControls ? "has-history-slot" : ""}`}
aria-label="Map controls"
>
<div className="location-map-mode-group">
<div className="location-map-mode-buttons" aria-label="Map mode">
<button
type="button"
className={mode === "view" ? "active" : ""}
disabled={saving || !hasAnyMap}
onClick={onView}
aria-pressed={mode === "view"}
>
View
</button>
{canManage ? (
<button
type="button"
className={mode === "edit" ? "active" : ""}
disabled={saving || !hasAnyMap}
onClick={onEdit}
aria-pressed={mode === "edit"}
aria-label="Edit Draft"
>
<span className="location-map-label-full">Edit Draft</span>
<span className="location-map-label-short">Edit</span>
</button>
) : null}
</div>
</div>
{canUseHistoryControls ? (
<div
className="location-map-history-buttons"
aria-label="Edit history"
>
<button
type="button"
className="location-map-icon-button"
onClick={onUndo}
disabled={!canUseHistoryControls || saving || history.length === 0}
aria-label="Undo"
title="Undo"
>
<ToolbarIcon name="undo" />
</button>
<button
type="button"
className="location-map-icon-button"
onClick={onRedo}
disabled={!canUseHistoryControls || saving || future.length === 0}
aria-label="Redo"
title="Redo"
>
<ToolbarIcon name="redo" />
</button>
</div>
) : null}
<div className="location-map-zoom-controls" aria-label="Zoom controls">
<button
type="button"
className="location-map-icon-button"
onClick={() => setZoom((value) => Number(clampMapZoom(value - 0.15).toFixed(2)))}
disabled={!hasAnyMap || isAtMinZoom}
aria-label="Zoom out"
title="Zoom out"
>
<ToolbarIcon name="zoom-out" />
</button>
<span>{Math.round(zoom * 100)}%</span>
<button
type="button"
className="location-map-icon-button"
onClick={() => setZoom((value) => Number(clampMapZoom(value + 0.15).toFixed(2)))}
disabled={!hasAnyMap || isAtMaxZoom}
aria-label="Zoom in"
title="Zoom in"
>
<ToolbarIcon name="zoom-in" />
</button>
<button
type="button"
className="location-map-icon-button"
onClick={onFit}
disabled={!hasAnyMap}
aria-label="Fit"
title="Fit"
>
<ToolbarIcon name="fit" />
</button>
</div>
</section>
);
}

View File

@ -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 (
<header className="location-map-topbar">
<button
type="button"
className="location-map-back"
onClick={onBack}
aria-label="Back to grocery list"
>
<span aria-hidden="true">&larr;</span>
</button>
<div className="location-map-title">
<h1>{getStoreName(location)}</h1>
<span>{getLocationName(location)}</span>
</div>
<span
className={`location-map-status location-map-status-${statusClass}`}
role="status"
aria-label={`Map status: ${status}`}
>
{status}
</span>
</header>
);
}

View File

@ -1,6 +1,15 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import "../../styles/ConfirmBuyModal.css"; import "../../styles/ConfirmBuyModal.css";
function ChevronIcon({ direction }) {
const path = direction === "previous" ? "M15 18l-6-6 6-6" : "M9 18l6-6-6-6";
return (
<svg className="confirm-buy-nav-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d={path} />
</svg>
);
}
export default function ConfirmBuyModal({ export default function ConfirmBuyModal({
item, item,
onConfirm, onConfirm,
@ -11,6 +20,7 @@ export default function ConfirmBuyModal({
const [quantity, setQuantity] = useState(item.quantity); const [quantity, setQuantity] = useState(item.quantity);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const maxQuantity = item.quantity; const maxQuantity = item.quantity;
const titleId = "confirm-buy-modal-title";
useEffect(() => { useEffect(() => {
setQuantity(item.quantity); setQuantity(item.quantity);
@ -21,17 +31,17 @@ export default function ConfirmBuyModal({
const hasPrev = currentIndex > 0; const hasPrev = currentIndex > 0;
const hasNext = currentIndex < allItems.length - 1; const hasNext = currentIndex < allItems.length - 1;
const handleIncrement = () => { const handleIncrement = useCallback(() => {
if (!isSubmitting && quantity < maxQuantity) { if (isSubmitting) return;
setQuantity((prev) => prev + 1);
}
};
const handleDecrement = () => { setQuantity((prev) => Math.min(maxQuantity, prev + 1));
if (!isSubmitting && quantity > 1) { }, [isSubmitting, maxQuantity]);
setQuantity((prev) => prev - 1);
} const handleDecrement = useCallback(() => {
}; if (isSubmitting) return;
setQuantity((prev) => Math.max(1, prev - 1));
}, [isSubmitting]);
const handleConfirm = async () => { const handleConfirm = async () => {
if (isSubmitting) return; if (isSubmitting) return;
@ -44,21 +54,58 @@ export default function ConfirmBuyModal({
} }
}; };
const handlePrev = () => { const handlePrev = useCallback(() => {
if (isSubmitting) return; if (isSubmitting) return;
if (hasPrev && onNavigate) { if (hasPrev && onNavigate) {
onNavigate(allItems[currentIndex - 1]); onNavigate(allItems[currentIndex - 1]);
} }
}; }, [allItems, currentIndex, hasPrev, isSubmitting, onNavigate]);
const handleNext = () => { const handleNext = useCallback(() => {
if (isSubmitting) return; if (isSubmitting) return;
if (hasNext && onNavigate) { if (hasNext && onNavigate) {
onNavigate(allItems[currentIndex + 1]); 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 const imageUrl = item.item_image && item.image_mime_type
? `data:${item.image_mime_type};base64,${item.item_image}` ? `data:${item.image_mime_type};base64,${item.item_image}`
@ -73,46 +120,62 @@ export default function ConfirmBuyModal({
} }
}} }}
> >
<div className="confirm-buy-modal" onClick={(event) => event.stopPropagation()}> <div
className="confirm-buy-modal"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
onClick={(event) => event.stopPropagation()}
>
<div className="confirm-buy-header"> <div className="confirm-buy-header">
{item.zone && <div className="confirm-buy-zone">{item.zone}</div>} {item.zone && <div className="confirm-buy-zone">{item.zone}</div>}
<h2 className="confirm-buy-item-name">{item.item_name}</h2> <h2 id={titleId} className="confirm-buy-item-name">{item.item_name}</h2>
</div> </div>
<div className="confirm-buy-image-section"> <div className="confirm-buy-image-section">
<button <button
type="button"
className="confirm-buy-nav-btn confirm-buy-nav-prev" className="confirm-buy-nav-btn confirm-buy-nav-prev"
onClick={handlePrev} onClick={handlePrev}
style={{ visibility: hasPrev ? "visible" : "hidden" }} style={{ visibility: hasPrev ? "visible" : "hidden" }}
disabled={!hasPrev || isSubmitting} disabled={!hasPrev || isSubmitting}
aria-label="Previous item"
title="Previous item"
> >
{"<"} <ChevronIcon direction="previous" />
</button> </button>
<div className="confirm-buy-image-container"> <div className="confirm-buy-image-container">
{imageUrl ? ( {imageUrl ? (
<img src={imageUrl} alt={item.item_name} className="confirm-buy-image" /> <img src={imageUrl} alt={item.item_name} className="confirm-buy-image" />
) : ( ) : (
<div className="confirm-buy-image-placeholder">[ ]</div> <div className="confirm-buy-image-placeholder" role="img" aria-label="No item photo">
<span aria-hidden="true">{"\uD83D\uDCE6"}</span>
</div>
)} )}
</div> </div>
<button <button
type="button"
className="confirm-buy-nav-btn confirm-buy-nav-next" className="confirm-buy-nav-btn confirm-buy-nav-next"
onClick={handleNext} onClick={handleNext}
style={{ visibility: hasNext ? "visible" : "hidden" }} style={{ visibility: hasNext ? "visible" : "hidden" }}
disabled={!hasNext || isSubmitting} disabled={!hasNext || isSubmitting}
aria-label="Next item"
title="Next item"
> >
{">"} <ChevronIcon direction="next" />
</button> </button>
</div> </div>
<div className="confirm-buy-quantity-section"> <div className="confirm-buy-quantity-section">
<div className="confirm-buy-counter"> <div className="confirm-buy-counter">
<button <button
type="button"
onClick={handleDecrement} onClick={handleDecrement}
className="confirm-buy-counter-btn" className="confirm-buy-counter-btn"
disabled={quantity <= 1 || isSubmitting} disabled={quantity <= 1 || isSubmitting}
aria-label="Decrease quantity"
> >
- -
</button> </button>
@ -121,11 +184,14 @@ export default function ConfirmBuyModal({
value={quantity} value={quantity}
readOnly readOnly
className="confirm-buy-counter-display" className="confirm-buy-counter-display"
aria-label="Quantity to mark bought"
/> />
<button <button
type="button"
onClick={handleIncrement} onClick={handleIncrement}
className="confirm-buy-counter-btn" className="confirm-buy-counter-btn"
disabled={quantity >= maxQuantity || isSubmitting} disabled={quantity >= maxQuantity || isSubmitting}
aria-label="Increase quantity"
> >
+ +
</button> </button>
@ -133,10 +199,10 @@ export default function ConfirmBuyModal({
</div> </div>
<div className="confirm-buy-actions"> <div className="confirm-buy-actions">
<button onClick={onCancel} className="confirm-buy-cancel" disabled={isSubmitting}> <button type="button" onClick={onCancel} className="confirm-buy-cancel" disabled={isSubmitting}>
Cancel Cancel
</button> </button>
<button onClick={handleConfirm} className="confirm-buy-confirm" disabled={isSubmitting}> <button type="button" onClick={handleConfirm} className="confirm-buy-confirm" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Mark as Bought"} {isSubmitting ? "Saving..." : "Mark as Bought"}
</button> </button>
</div> </div>

View File

@ -1,5 +1,5 @@
import { useContext, useMemo, useState } from 'react'; 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 { StoreContext } from '../../context/StoreContext';
import '../../styles/components/StoreTabs.css'; import '../../styles/components/StoreTabs.css';
@ -55,6 +55,7 @@ function buildStoreOptions(stores) {
export default function StoreTabs() { export default function StoreTabs() {
const { stores, activeStore, setActiveStore, loading } = useContext(StoreContext); const { stores, activeStore, setActiveStore, loading } = useContext(StoreContext);
const navigate = useNavigate(); const navigate = useNavigate();
const routeLocation = useLocation();
const [activePicker, setActivePicker] = useState(null); const [activePicker, setActivePicker] = useState(null);
const storeOptions = useMemo(() => buildStoreOptions(stores || []), [stores]); const storeOptions = useMemo(() => buildStoreOptions(stores || []), [stores]);
const activeStoreKey = getStoreKey(activeStore); const activeStoreKey = getStoreKey(activeStore);
@ -98,8 +99,12 @@ export default function StoreTabs() {
}; };
const handleManageMap = () => { const handleManageMap = () => {
const locationQuery = selectedLocation?.id ? `&locationId=${selectedLocation.id}` : ""; if (!selectedLocation?.id || !selectedStoreOption?.key) return;
navigate(`/manage?tab=stores${locationQuery}`); navigate(`/stores/${selectedStoreOption.key}/locations/${selectedLocation.id}/map`, {
state: {
returnTo: `${routeLocation.pathname}${routeLocation.search}${routeLocation.hash}`,
},
});
}; };
return ( return (

View File

@ -1 +1,41 @@
export const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:5000"; 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();

View File

@ -84,7 +84,6 @@ export const StoreProvider = ({ children }) => {
useEffect(() => { useEffect(() => {
if (!activeHousehold || stores.length === 0) return; if (!activeHousehold || stores.length === 0) return;
console.log('[StoreContext] Setting active store from:', stores);
const householdStoreStorageKey = `activeHouseholdStoreId_${activeHousehold.id}`; const householdStoreStorageKey = `activeHouseholdStoreId_${activeHousehold.id}`;
const legacyLocationStorageKey = `activeStoreId_${activeHousehold.id}`; const legacyLocationStorageKey = `activeStoreId_${activeHousehold.id}`;
const savedHouseholdStoreId = localStorage.getItem(householdStoreStorageKey); const savedHouseholdStoreId = localStorage.getItem(householdStoreStorageKey);
@ -94,7 +93,6 @@ export const StoreProvider = ({ children }) => {
chooseLocationByStoredIds(stores, activeHousehold.id, savedHouseholdStoreId) || chooseLocationByStoredIds(stores, activeHousehold.id, savedHouseholdStoreId) ||
chooseStoreByHouseholdStoreId(stores, savedHouseholdStoreId); chooseStoreByHouseholdStoreId(stores, savedHouseholdStoreId);
if (store) { if (store) {
console.log('[StoreContext] Found saved household store:', store);
setActiveStoreState(store); setActiveStoreState(store);
return; return;
} }
@ -105,7 +103,6 @@ export const StoreProvider = ({ children }) => {
const savedLocation = stores.find(s => String(s.id) === String(savedLocationId)); const savedLocation = stores.find(s => String(s.id) === String(savedLocationId));
const store = savedLocation || chooseStoreByHouseholdStoreId(stores, getHouseholdStoreKey(savedLocation)); const store = savedLocation || chooseStoreByHouseholdStoreId(stores, getHouseholdStoreKey(savedLocation));
if (store) { if (store) {
console.log('[StoreContext] Migrated saved location to store:', store);
const householdStoreId = getHouseholdStoreKey(store); const householdStoreId = getHouseholdStoreKey(store);
setActiveStoreState(store); setActiveStoreState(store);
localStorage.setItem(householdStoreStorageKey, householdStoreId); localStorage.setItem(householdStoreStorageKey, householdStoreId);
@ -122,7 +119,6 @@ export const StoreProvider = ({ children }) => {
stores, stores,
getHouseholdStoreKey(stores.find(s => s.is_default) || stores[0]) getHouseholdStoreKey(stores.find(s => s.is_default) || stores[0])
); );
console.log('[StoreContext] Using store:', defaultStore);
setActiveStoreState(defaultStore); setActiveStoreState(defaultStore);
const householdStoreId = getHouseholdStoreKey(defaultStore); const householdStoreId = getHouseholdStoreKey(defaultStore);
localStorage.setItem(householdStoreStorageKey, householdStoreId); localStorage.setItem(householdStoreStorageKey, householdStoreId);
@ -146,9 +142,7 @@ export const StoreProvider = ({ children }) => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
console.log('[StoreContext] Loading stores for household:', activeHousehold.id);
const response = await getHouseholdStores(activeHousehold.id); const response = await getHouseholdStores(activeHousehold.id);
console.log('[StoreContext] Loaded stores:', response.data);
setStores(response.data); setStores(response.data);
} catch (err) { } catch (err) {
console.error('[StoreContext] Failed to load stores:', err); console.error('[StoreContext] Failed to load stores:', err);

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -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 };
}

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -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)),
};
}

View File

@ -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 (
<LocationMapMessageState
message="Loading map..."
location={location}
status="Loading"
onBack={requestBackNavigation}
/>
);
}
if (!activeHousehold) {
return (
<LocationMapMessageState
status="Select Household"
/>
);
}
if (loadError) {
return (
<LocationMapLoadErrorState
location={location}
loadError={loadError}
onBack={requestBackNavigation}
onRetry={loadMap}
/>
);
}
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 (
<div className="location-map-page">
<LocationMapTopbar location={location} status={status} onBack={requestBackNavigation} />
<main className="location-map-workspace">
{hasAnyMap && mode !== "setup" ? (
<LocationMapToolbar
mode={mode}
hasAnyMap={hasAnyMap}
canManage={canManage}
saving={saving}
history={history}
future={future}
zoom={zoom}
setZoom={setZoom}
onFit={handleFitMap}
onView={handleViewMode}
onEdit={handleEditMode}
onUndo={handleUndo}
onRedo={handleRedo}
/>
) : null}
{!hasAnyMap || (mode === "setup" && mapState?.draft_map && !mapState?.published_map) ? (
<LocationMapSetupPanel
hasAnyMap={hasVisibleOrManageableMap}
canManage={canManage}
zoneCount={mapState?.zones?.length || 0}
saving={saving}
savingAction={savingAction}
onContinue={() => {
setMode("edit");
setPreviewDraft(true);
}}
onPreview={handlePreviewDraft}
onPublish={handlePublish}
onCreateFromZones={handleCreateFromZones}
onCreateBlank={handleCreateBlank}
/>
) : (
<>
<LocationMapCanvas
mode={mode}
objects={objects}
filters={filters}
mapSize={mapSize}
zoom={zoom}
selectedObjectKey={selectedObjectKey}
mapItems={mapItems}
username={username}
svgRef={svgRef}
dragState={dragState}
editingLocked={saving}
hiddenAreaCount={hiddenAreaCount}
onAddObject={handleAddObject}
setDragState={setDragState}
setSelectedObjectKey={setSelectedObjectKey}
remember={remember}
updateObjects={updateObjects}
expandMapToFitObject={expandMapToFitObject}
onShowZones={() =>
setFilters((current) => ({
...current,
showZones: true,
}))
}
onShowHiddenAreas={handleShowHiddenAreas}
/>
<LocationMapBottomSheet
mode={mode}
canManage={canManage}
filters={filters}
setFilters={setFilters}
layersOpen={layersOpen}
setLayersOpen={setLayersOpen}
selectedObject={selectedObject}
mapObjects={objects}
selectedZoneItems={selectedZoneItems}
visibleAssignedItemCount={visibleAssignedItemCount}
visibleUnmappedItems={visibleUnmappedItems}
unmappedItemCount={unmappedItemCount}
hiddenAreaCount={hiddenAreaCount}
saving={saving}
savingAction={savingAction}
hasUnsavedChanges={hasUnsavedChanges}
mapState={mapState}
onAddObject={handleAddObject}
onSaveDraft={handleSaveDraft}
onPublish={handlePublish}
onObjectField={handleObjectField}
onZoneLinkChange={handleZoneLinkChange}
onDuplicateObject={handleDuplicateObject}
onDeleteObject={requestDeleteObject}
onShowHiddenAreas={handleShowHiddenAreas}
onClearSelection={() => setSelectedObjectKey(null)}
onSelectZoneItem={setBuyModalItem}
onSelectUnmappedItem={setBuyModalItem}
/>
</>
)}
</main>
<LocationMapDialogs
buyModalItem={buyModalItem}
buyModalItems={buyModalItems}
onBuyNavigate={handleMapBuyNavigate}
onBuyCancel={handleMapBuyCancel}
onBuyConfirm={handleMapBuyConfirm}
pendingDeleteObject={pendingDeleteObject}
onDeleteClose={() => setPendingDeleteObject(null)}
onDeleteConfirm={handleDeleteObject}
pendingLeaveTarget={pendingLeaveTarget}
onLeaveClose={clearPendingLeave}
onLeaveConfirm={confirmPendingLeave}
/>
</div>
);
}

View File

@ -52,13 +52,12 @@
} }
.confirm-buy-nav-btn { .confirm-buy-nav-btn {
width: 35px; width: 40px;
height: 35px; height: 40px;
border: var(--border-width-medium) solid var(--color-primary); border: var(--border-width-medium) solid var(--color-primary);
border-radius: var(--border-radius-full); border-radius: var(--border-radius-full);
background: var(--color-bg-surface); background: var(--color-bg-surface);
color: var(--color-primary); color: var(--color-primary);
font-size: 1.8em;
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
transition: var(--transition-base); transition: var(--transition-base);
@ -70,6 +69,18 @@
flex-shrink: 0; 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) { .confirm-buy-nav-btn:hover:not(:disabled) {
background: var(--color-primary); background: var(--color-primary);
color: var(--color-text-inverse); color: var(--color-text-inverse);
@ -100,8 +111,21 @@
} }
.confirm-buy-image-placeholder { .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); 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 { .confirm-buy-quantity-section {
@ -260,9 +284,8 @@
} }
.confirm-buy-nav-btn { .confirm-buy-nav-btn {
width: 30px; width: 40px;
height: 30px; height: 40px;
font-size: 1.6em;
} }
.confirm-buy-counter-btn { .confirm-buy-counter-btn {
@ -299,8 +322,7 @@
} }
.confirm-buy-nav-btn { .confirm-buy-nav-btn {
width: 28px; width: 40px;
height: 28px; height: 40px;
font-size: 1.4em;
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -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({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", 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({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", 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({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", 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(); const request = route.request();
if (request.method() === "PATCH") { 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({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", 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.goto("/");
await page.locator(".glist-sort").selectOption("qty-high");
await openBuyModal(page, "bread"); await openBuyModal(page, "bread");
await page.getByRole("button", { name: "Mark as Bought" }).click(); 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 }) => { 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.goto("/");
await page.locator(".glist-sort").selectOption("az");
await openBuyModal(page, "milk"); await openBuyModal(page, "milk");
await page.getByRole("button", { name: "Mark as Bought" }).click(); 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.goto("/");
await page.locator(".glist-sort").selectOption("qty-low");
await openBuyModal(page, "bravo"); await openBuyModal(page, "bravo");
await page.locator(".confirm-buy-counter-btn").nth(0).click(); await page.locator(".confirm-buy-counter-btn").nth(0).click();

View File

@ -16,8 +16,12 @@ type HouseholdSeed = {
type StoreSeed = { type StoreSeed = {
id?: number; id?: number;
household_store_id?: number;
household_id?: number;
name?: string; name?: string;
location?: string; location?: string;
location_name?: string;
display_name?: string;
is_default?: boolean; is_default?: boolean;
}; };
@ -57,9 +61,21 @@ export async function mockHouseholdAndStoreShell(
invite_code: "ABCD1234", invite_code: "ABCD1234",
...options.household, ...options.household,
}; };
const stores = options.stores || [ const stores = (options.stores || [
{ id: 10, name: "Costco", location: "Warehouse", is_default: true }, { 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 page.route("**/households", async (route) => {
await route.fulfill({ await route.fulfill({
@ -76,6 +92,14 @@ export async function mockHouseholdAndStoreShell(
body: JSON.stringify(stores), 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) { export async function confirmSlide(page: Page) {

File diff suppressed because it is too large Load Diff

View File

@ -40,6 +40,26 @@ async function mockGroceryShell(page: Page, stores: Array<Record<string, unknown
}); });
}); });
await page.route("**/households/1/locations/*/map", async (route) => {
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 page.route("**/households/1/locations/*/list/recent", async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
@ -75,6 +95,13 @@ async function mockGroceryShell(page: Page, stores: Array<Record<string, unknown
test("grocery store selector shows one selected store and picks from a modal", async ({ page }) => { test("grocery store selector shows one selected store and picks from a modal", async ({ page }) => {
await page.setViewportSize({ width: 473, height: 1000 }); 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, [ await mockGroceryShell(page, [
{ {
id: 10, 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 expect.poll(() => page.evaluate(() => localStorage.getItem("activeStoreLocationId_1_100"))).toBe("13");
await page.getByRole("button", { name: "Manage Map" }).click(); 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 }) => { test("single grocery store selector click does not open picker modal", async ({ page }) => {

View File

@ -2,9 +2,12 @@ import react from '@vitejs/plugin-react';
import { defineConfig, loadEnv } from 'vite'; import { defineConfig, loadEnv } from 'vite';
const env = loadEnv('', process.cwd()); 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()) ? 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({ export default defineConfig({
plugins: [react()], plugins: [react()],

View File

@ -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);