Compare commits

..

2 Commits

Author SHA1 Message Date
4c8c197e17 Merge pull request 'feature-custom-store-locations' (#4) from feature-custom-store-locations into main
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 37s
Build & Deploy Costco Grocery List / deploy (push) Successful in 6s
Build & Deploy Costco Grocery List / notify (push) Successful in 0s
Reviewed-on: #4
2026-05-31 00:35:29 -09:00
76817fb969 Merge pull request 'chore: harden reliability checks' (#2) from main-new into main
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 30s
Build & Deploy Costco Grocery List / deploy (push) Successful in 5s
Build & Deploy Costco Grocery List / notify (push) Successful in 0s
Reviewed-on: #2
2026-05-25 14:28:32 -09:00
63 changed files with 866 additions and 14702 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 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. - 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`.
- 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.
@ -162,8 +162,6 @@ For `app/api/**/[param]/route.ts`:
- Touch: long-press affordance for item-level actions when no visible button. - Touch: long-press affordance for item-level actions when no visible button.
- Mouse: hover affordance on interactive rows/cards. - Mouse: hover affordance on interactive rows/cards.
- Tap targets remain >= 40px on mobile. - Tap targets remain >= 40px on mobile.
- Avoid redundant nearby labels. If a tab, section title, count chip, row label, or control state already communicates the meaning, do not repeat it with an eyebrow label, explanatory zero-state sentence, or duplicate card label.
- Prefer compact label/value rows for dense settings controls instead of stacked labels with large vertical gaps.
- Modal overlays must close on outside click/tap. - Modal overlays must close on outside click/tap.
- For every frontend action that manipulates database state, show a toast/bubble notification with basic outcome details (action + target + success/failure). - For every frontend action that manipulates database state, show a toast/bubble notification with basic outcome details (action + target + success/failure).
- Frontend destructive actions should use the shared `ConfirmSlideModal` pattern instead of browser `confirm()` unless there is a documented exception. - Frontend destructive actions should use the shared `ConfirmSlideModal` pattern instead of browser `confirm()` unless there is a documented exception.

View File

@ -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:3010,http://127.0.0.1:3010,http://192.168.7.45:3010 ALLOWED_ORIGINS=http://localhost:3000
SESSION_COOKIE_NAME=sid SESSION_COOKIE_NAME=sid
SESSION_TTL_DAYS=30 SESSION_TTL_DAYS=30

View File

@ -4,7 +4,6 @@ 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);
@ -15,11 +14,17 @@ 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 = parseAllowedOrigins(process.env.ALLOWED_ORIGINS || ""); const allowedOrigins = (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 (isAllowedCorsOrigin(origin, allowedOrigins)) return callback(null, true); if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) return callback(null, true);
if (/^http:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
if (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
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

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

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

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

View File

@ -3,7 +3,6 @@ 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 {
@ -135,46 +134,6 @@ 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

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

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

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

@ -1,235 +0,0 @@
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,14 +70,6 @@ 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" })),
@ -97,7 +89,6 @@ 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", () => {
@ -170,51 +161,4 @@ 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

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

View File

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

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

View File

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

View File

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

View File

@ -9,8 +9,7 @@ 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";
@ -49,7 +48,6 @@ 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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

@ -1,29 +0,0 @@
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,15 +1,6 @@
import { useCallback, useEffect, useState } from "react"; import { 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,
@ -20,7 +11,6 @@ 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);
@ -31,17 +21,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 = useCallback(() => { const handleIncrement = () => {
if (isSubmitting) return; if (!isSubmitting && quantity < maxQuantity) {
setQuantity((prev) => prev + 1);
}
};
setQuantity((prev) => Math.min(maxQuantity, prev + 1)); const handleDecrement = () => {
}, [isSubmitting, maxQuantity]); if (!isSubmitting && quantity > 1) {
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;
@ -54,58 +44,21 @@ export default function ConfirmBuyModal({
} }
}; };
const handlePrev = useCallback(() => { const handlePrev = () => {
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 = useCallback(() => { const handleNext = () => {
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}`
@ -120,62 +73,46 @@ export default function ConfirmBuyModal({
} }
}} }}
> >
<div <div className="confirm-buy-modal" onClick={(event) => event.stopPropagation()}>
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 id={titleId} className="confirm-buy-item-name">{item.item_name}</h2> <h2 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" role="img" aria-label="No item photo"> <div className="confirm-buy-image-placeholder">[ ]</div>
<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>
@ -184,14 +121,11 @@ 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>
@ -199,10 +133,10 @@ export default function ConfirmBuyModal({
</div> </div>
<div className="confirm-buy-actions"> <div className="confirm-buy-actions">
<button type="button" onClick={onCancel} className="confirm-buy-cancel" disabled={isSubmitting}> <button onClick={onCancel} className="confirm-buy-cancel" disabled={isSubmitting}>
Cancel Cancel
</button> </button>
<button type="button" onClick={handleConfirm} className="confirm-buy-confirm" disabled={isSubmitting}> <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,74 +1,11 @@
import { useContext, useMemo, useState } from 'react'; import { useContext } from 'react';
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';
const DEFAULT_LOCATION_NAME = "Default Location";
function getStoreKey(store) {
return String(store?.household_store_id ?? store?.store_id ?? store?.id ?? "");
}
function getStoreName(store) {
return store?.name || store?.display_name || "Store";
}
function getLocationName(store) {
if (!store) return "Location";
const locationName = store.location_name || "";
if (!locationName || locationName === DEFAULT_LOCATION_NAME) {
return "Default";
}
return locationName;
}
function chooseStoreLocation(locations) {
if (!locations || locations.length === 0) return null;
return (
locations.find((store) => store.location_name === "Default Location" || store.display_name === store.name) ||
locations.find((store) => store.is_default) ||
locations[0]
);
}
function buildStoreOptions(stores) {
const grouped = new Map();
for (const store of stores) {
const key = getStoreKey(store);
if (!grouped.has(key)) {
grouped.set(key, {
key,
name: getStoreName(store),
locations: [],
});
}
grouped.get(key).locations.push(store);
}
return Array.from(grouped.values()).map((group) => ({
...group,
store: chooseStoreLocation(group.locations),
}));
}
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 routeLocation = useLocation();
const [activePicker, setActivePicker] = useState(null);
const storeOptions = useMemo(() => buildStoreOptions(stores || []), [stores]);
const activeStoreKey = getStoreKey(activeStore);
const selectedStoreOption =
storeOptions.find((option) => option.key === activeStoreKey) || storeOptions[0];
const selectedLocations = selectedStoreOption?.locations || [];
const selectedLocation =
selectedLocations.find((location) => String(location.id) === String(activeStore?.id)) ||
selectedStoreOption?.store;
const canPickStore = storeOptions.length > 1 && !loading;
const canPickLocation = selectedLocations.length > 1 && !loading;
if (!stores || stores.length === 0 || storeOptions.length === 0) { if (!stores || stores.length === 0) {
return ( return (
<div className="store-tabs"> <div className="store-tabs">
<div className="store-tabs-empty"> <div className="store-tabs-empty">
@ -78,125 +15,20 @@ export default function StoreTabs() {
); );
} }
const handleStoreTriggerClick = () => {
if (!canPickStore) return;
setActivePicker("store");
};
const handleLocationTriggerClick = () => {
if (!canPickLocation) return;
setActivePicker("location");
};
const handleStoreSelect = (store) => {
setActiveStore(store);
setActivePicker(null);
};
const handleLocationSelect = (location) => {
setActiveStore(location);
setActivePicker(null);
};
const handleManageMap = () => {
if (!selectedLocation?.id || !selectedStoreOption?.key) return;
navigate(`/stores/${selectedStoreOption.key}/locations/${selectedLocation.id}/map`, {
state: {
returnTo: `${routeLocation.pathname}${routeLocation.search}${routeLocation.hash}`,
},
});
};
return ( return (
<div className="store-tabs"> <div className="store-tabs">
<div className="store-tabs-container"> <div className="store-tabs-container">
<button {stores.map(store => (
type="button" <button
className={`store-selector-trigger ${canPickStore ? "is-interactive" : ""}`} key={store.id}
onClick={handleStoreTriggerClick} className={`store-tab ${store.id === activeStore?.id ? 'active' : ''}`}
disabled={loading} onClick={() => setActiveStore(store)}
aria-haspopup={canPickStore ? "dialog" : undefined} disabled={loading}
aria-expanded={canPickStore ? activePicker === "store" : undefined} >
aria-label={`Store: ${selectedStoreOption.name}`} <span className="store-name">{store.display_name || store.name}</span>
> </button>
<span className="store-name">{selectedStoreOption.name}</span> ))}
{canPickStore ? <span className="store-selector-caret" aria-hidden="true" /> : null}
</button>
<button
type="button"
className={`store-selector-trigger location-selector-trigger ${canPickLocation ? "is-interactive" : ""}`}
onClick={handleLocationTriggerClick}
disabled={loading}
aria-haspopup={canPickLocation ? "dialog" : undefined}
aria-expanded={canPickLocation ? activePicker === "location" : undefined}
aria-label={`Location: ${getLocationName(selectedLocation)}`}
>
<span className="store-name">{getLocationName(selectedLocation)}</span>
{canPickLocation ? <span className="store-selector-caret" aria-hidden="true" /> : null}
</button>
<button
type="button"
className="store-map-button"
onClick={handleManageMap}
disabled={loading || !selectedLocation}
>
Manage Map
</button>
</div> </div>
{activePicker === "store" ? (
<div className="store-selector-modal-overlay" onClick={() => setActivePicker(null)}>
<div
className="store-selector-modal"
role="dialog"
aria-modal="true"
aria-label="Select store"
onClick={(event) => event.stopPropagation()}
>
<h2>Stores</h2>
<div className="store-selector-list">
{storeOptions.map((option) => (
<button
key={option.key}
type="button"
className={`store-selector-option ${option.key === selectedStoreOption.key ? "active" : ""}`}
onClick={() => handleStoreSelect(option.store)}
>
{option.name}
</button>
))}
</div>
</div>
</div>
) : null}
{activePicker === "location" ? (
<div className="store-selector-modal-overlay" onClick={() => setActivePicker(null)}>
<div
className="store-selector-modal"
role="dialog"
aria-modal="true"
aria-label="Select location"
onClick={(event) => event.stopPropagation()}
>
<h2>Locations</h2>
<div className="store-selector-list">
{selectedLocations.map((location) => (
<button
key={location.id}
type="button"
className={`store-selector-option ${String(location.id) === String(selectedLocation?.id) ? "active" : ""}`}
onClick={() => handleLocationSelect(location)}
>
{getLocationName(location)}
</button>
))}
</div>
</div>
</div>
) : null}
</div> </div>
); );
} }

View File

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

@ -4,48 +4,6 @@ import { getHouseholdStores, getLocationZones } from '../api/stores';
import { AuthContext } from './AuthContext'; import { AuthContext } from './AuthContext';
import { HouseholdContext } from './HouseholdContext'; import { HouseholdContext } from './HouseholdContext';
const DEFAULT_LOCATION_NAME = "Default Location";
function getHouseholdStoreKey(store) {
return String(store?.household_store_id ?? store?.store_id ?? store?.id ?? "");
}
function isDefaultLocationName(store) {
return store?.location_name === DEFAULT_LOCATION_NAME || store?.display_name === store?.name;
}
function chooseStoreLocation(locations) {
if (!locations || locations.length === 0) return null;
return (
locations.find(isDefaultLocationName) ||
locations.find((store) => store.is_default) ||
locations[0]
);
}
function chooseStoreByHouseholdStoreId(stores, householdStoreId) {
const matchingLocations = stores.filter(
(store) => getHouseholdStoreKey(store) === String(householdStoreId)
);
return chooseStoreLocation(matchingLocations);
}
function getLocationStorageKey(householdId, householdStoreId) {
return `activeStoreLocationId_${householdId}_${householdStoreId}`;
}
function chooseLocationByStoredIds(stores, householdId, householdStoreId) {
const storageKey = getLocationStorageKey(householdId, householdStoreId);
const savedLocationId = localStorage.getItem(storageKey);
if (!savedLocationId) return null;
return stores.find(
(store) =>
String(store.id) === String(savedLocationId) &&
getHouseholdStoreKey(store) === String(householdStoreId)
);
}
export const StoreContext = createContext({ export const StoreContext = createContext({
stores: [], stores: [],
activeStore: null, activeStore: null,
@ -84,48 +42,24 @@ export const StoreProvider = ({ children }) => {
useEffect(() => { useEffect(() => {
if (!activeHousehold || stores.length === 0) return; if (!activeHousehold || stores.length === 0) return;
const householdStoreStorageKey = `activeHouseholdStoreId_${activeHousehold.id}`; console.log('[StoreContext] Setting active store from:', stores);
const legacyLocationStorageKey = `activeStoreId_${activeHousehold.id}`; const storageKey = `activeStoreId_${activeHousehold.id}`;
const savedHouseholdStoreId = localStorage.getItem(householdStoreStorageKey); const savedStoreId = localStorage.getItem(storageKey);
if (savedHouseholdStoreId) { if (savedStoreId) {
const store = const store = stores.find(s => String(s.id) === String(savedStoreId));
chooseLocationByStoredIds(stores, activeHousehold.id, savedHouseholdStoreId) ||
chooseStoreByHouseholdStoreId(stores, savedHouseholdStoreId);
if (store) { if (store) {
console.log('[StoreContext] Found saved store:', store);
setActiveStoreState(store); setActiveStoreState(store);
return; return;
} }
} }
const savedLocationId = localStorage.getItem(legacyLocationStorageKey); // No saved store or not found, use default or first one
if (savedLocationId) { const defaultStore = stores.find(s => s.is_default) || stores[0];
const savedLocation = stores.find(s => String(s.id) === String(savedLocationId)); console.log('[StoreContext] Using store:', defaultStore);
const store = savedLocation || chooseStoreByHouseholdStoreId(stores, getHouseholdStoreKey(savedLocation));
if (store) {
const householdStoreId = getHouseholdStoreKey(store);
setActiveStoreState(store);
localStorage.setItem(householdStoreStorageKey, householdStoreId);
localStorage.setItem(
getLocationStorageKey(activeHousehold.id, householdStoreId),
String(store.id)
);
return;
}
}
// No saved store or not found, use the default store's representative location.
const defaultStore = chooseStoreByHouseholdStoreId(
stores,
getHouseholdStoreKey(stores.find(s => s.is_default) || stores[0])
);
setActiveStoreState(defaultStore); setActiveStoreState(defaultStore);
const householdStoreId = getHouseholdStoreKey(defaultStore); localStorage.setItem(storageKey, defaultStore.id);
localStorage.setItem(householdStoreStorageKey, householdStoreId);
localStorage.setItem(
getLocationStorageKey(activeHousehold.id, householdStoreId),
String(defaultStore.id)
);
}, [stores, activeHousehold]); }, [stores, activeHousehold]);
useEffect(() => { useEffect(() => {
@ -142,7 +76,9 @@ 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);
@ -163,13 +99,8 @@ export const StoreProvider = ({ children }) => {
const setActiveStore = (store) => { const setActiveStore = (store) => {
setActiveStoreState(store); setActiveStoreState(store);
if (store && activeHousehold) { if (store && activeHousehold) {
const storageKey = `activeHouseholdStoreId_${activeHousehold.id}`; const storageKey = `activeStoreId_${activeHousehold.id}`;
const householdStoreId = getHouseholdStoreKey(store); localStorage.setItem(storageKey, String(store.id));
localStorage.setItem(storageKey, householdStoreId);
localStorage.setItem(
getLocationStorageKey(activeHousehold.id, householdStoreId),
String(store.id)
);
} }
}; };

View File

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

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

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

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

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

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

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

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

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

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

@ -1,483 +0,0 @@
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,12 +52,13 @@
} }
.confirm-buy-nav-btn { .confirm-buy-nav-btn {
width: 40px; width: 35px;
height: 40px; height: 35px;
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);
@ -69,18 +70,6 @@
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);
@ -111,21 +100,8 @@
} }
.confirm-buy-image-placeholder { .confirm-buy-image-placeholder {
width: min(58%, 150px); font-size: 4em;
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 {
@ -284,8 +260,9 @@
} }
.confirm-buy-nav-btn { .confirm-buy-nav-btn {
width: 40px; width: 30px;
height: 40px; height: 30px;
font-size: 1.6em;
} }
.confirm-buy-counter-btn { .confirm-buy-counter-btn {
@ -322,7 +299,8 @@
} }
.confirm-buy-nav-btn { .confirm-buy-nav-btn {
width: 40px; width: 28px;
height: 40px; height: 28px;
font-size: 1.4em;
} }
} }

View File

@ -1,170 +1,78 @@
.store-tabs { .store-tabs {
background: transparent; background: var(--surface-color);
border-bottom: none; border-bottom: 2px solid var(--border-color);
margin-bottom: 1rem; margin-bottom: 1.5rem;
} }
.store-tabs-container { .store-tabs-container {
display: flex; display: flex;
flex-direction: column;
align-items: center;
justify-content: center; justify-content: center;
gap: 0.5rem; gap: 0.25rem;
padding: 0.5rem 0 0; overflow-x: auto;
padding: 0.5rem 1rem 0;
width: 100%; width: 100%;
} }
.store-selector-trigger { .store-tabs-container::-webkit-scrollbar {
width: min(100%, 420px); height: 4px;
min-height: 44px; }
.store-tabs-container::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 2px;
}
.store-tab {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: center;
gap: 0.75rem; gap: 0.5rem;
padding: 0.75rem 1rem; padding: 0.75rem 1.5rem;
background: var(--color-bg-surface); background: transparent;
border: 1px solid var(--color-border-light); border: none;
border-radius: 8px; border-bottom: 3px solid transparent;
color: var(--color-text-primary); color: var(--text-secondary);
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 500;
transition: all 0.2s ease;
text-align: left;
}
.store-selector-trigger.is-interactive {
cursor: pointer;
}
.store-selector-trigger.is-interactive:hover,
.store-selector-trigger.is-interactive:focus-visible {
color: var(--color-text-primary);
background: var(--color-bg-hover);
border-color: var(--color-primary);
outline: none;
}
.store-selector-trigger:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.location-selector-trigger {
min-height: 40px;
font-size: 0.95rem;
}
.store-map-button {
width: min(100%, 420px);
min-height: 38px;
padding: 0.6rem 1rem;
border: 1px solid var(--color-border-light);
border-radius: 8px;
background: var(--color-bg-elevated);
color: var(--color-text-primary);
font: inherit;
font-weight: 700;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
white-space: nowrap;
text-align: center;
} }
.store-map-button:hover, .store-tab:hover {
.store-map-button:focus-visible { color: var(--text-color);
border-color: var(--color-primary); background: var(--hover-color);
background: var(--color-bg-hover);
outline: none;
} }
.store-map-button:disabled { .store-tab.active {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
}
.store-tab:disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
.store-name { .store-name {
font-weight: 500; font-weight: 500;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.store-selector-caret { .default-badge {
width: 0.55rem; padding: 0.125rem 0.5rem;
height: 0.55rem; background: var(--primary-color-light);
flex: 0 0 auto; color: var(--primary-color);
border-right: 2px solid currentColor; font-size: 0.75rem;
border-bottom: 2px solid currentColor; font-weight: 600;
transform: rotate(45deg) translateY(-2px); border-radius: 12px;
opacity: 0.8; text-transform: uppercase;
letter-spacing: 0.5px;
} }
.store-tabs-empty { .store-tabs-empty {
padding: 1rem; padding: 1rem;
text-align: center; text-align: center;
color: var(--color-text-secondary); color: var(--text-secondary);
font-style: italic; font-style: italic;
} }
.store-selector-modal-overlay {
position: fixed;
inset: 0;
z-index: 1400;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 5rem 1rem 1rem;
background: rgba(3, 10, 18, 0.58);
}
.store-selector-modal {
width: min(100%, 420px);
max-height: min(70vh, 560px);
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--color-border-light);
border-radius: 8px;
background: var(--color-bg-surface);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35);
}
.store-selector-modal h2 {
margin: 0;
color: var(--color-text-primary);
font-size: 1.05rem;
}
.store-selector-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow-y: auto;
overscroll-behavior: contain;
}
.store-selector-option {
width: 100%;
min-height: 44px;
padding: 0.75rem 0.9rem;
border: 1px solid var(--color-border-light);
border-radius: 8px;
background: var(--color-bg-elevated);
color: var(--color-text-primary);
font: inherit;
font-weight: 700;
text-align: left;
cursor: pointer;
}
.store-selector-option:hover,
.store-selector-option:focus-visible {
border-color: var(--color-primary);
background: var(--color-bg-hover);
outline: none;
}
.store-selector-option.active {
border-color: var(--color-primary);
color: var(--color-primary);
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -64,19 +64,7 @@ async function setupBuyModalRoutes(
}); });
}); });
await page.route("**/households/1/locations/10/zones", async (route) => { await page.route("**/households/1/stores/10/list/recent", 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",
@ -84,7 +72,7 @@ async function setupBuyModalRoutes(
}); });
}); });
await page.route("**/households/1/locations/10/list/suggestions**", async (route) => { await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
@ -92,7 +80,7 @@ async function setupBuyModalRoutes(
}); });
}); });
await page.route("**/households/1/locations/10/list/classification**", async (route) => { await page.route("**/households/1/stores/10/list/classification**", async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
@ -100,7 +88,7 @@ async function setupBuyModalRoutes(
}); });
}); });
await page.route("**/households/1/locations/10/list/item**", async (route) => { await page.route("**/households/1/stores/10/list/item**", async (route) => {
const request = route.request(); const request = route.request();
if (request.method() === "PATCH") { if (request.method() === "PATCH") {
@ -168,7 +156,7 @@ async function setupBuyModalRoutes(
}); });
}); });
await page.route("**/households/1/locations/10/list", async (route) => { await page.route("**/households/1/stores/10/list", async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
@ -195,11 +183,12 @@ 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("milk"); await expect(page.locator(".confirm-buy-item-name")).toHaveText("apples");
}); });
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 }) => {
@ -212,6 +201,7 @@ 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();
@ -229,6 +219,7 @@ 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,12 +16,8 @@ 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;
}; };
@ -61,21 +57,9 @@ export async function mockHouseholdAndStoreShell(
invite_code: "ABCD1234", invite_code: "ABCD1234",
...options.household, ...options.household,
}; };
const stores = (options.stores || [ const stores = options.stores || [
{ id: 10, household_store_id: 100, name: "Costco", location_name: "Warehouse", is_default: true }, { id: 10, name: "Costco", location: "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({
@ -92,14 +76,6 @@ 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) {

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,228 +0,0 @@
import { expect, test, type Page } from "@playwright/test";
import { mockConfig, seedAuthStorage } from "./helpers/e2e";
const household = { id: 1, name: "Selector House", role: "owner", invite_code: "ABCD1234" };
const user = { id: 1, username: "selector-user", role: "owner" };
async function mockGroceryShell(page: Page, stores: Array<Record<string, unknown>>) {
await seedAuthStorage(page, { username: "selector-user", role: "owner" });
await mockConfig(page);
await page.route("**/households", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([household]),
});
});
await page.route("**/households/1/stores", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(stores),
});
});
await page.route("**/households/1/members", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([user]),
});
});
await page.route("**/households/1/locations/*/zones", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ zones: [] }),
});
});
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 route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/households/1/locations/*/list/suggestions**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/households/1/locations/*/list/item**", async (route) => {
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ message: "Item not found" }),
});
});
await page.route("**/households/1/locations/*/list", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [] }),
});
});
}
test("grocery store selector shows one selected store and picks from a modal", async ({ page }) => {
await page.setViewportSize({ width: 473, height: 1000 });
const storeContextMessages: string[] = [];
page.on("console", (message) => {
const text = message.text();
if (text.includes("[StoreContext]")) {
storeContextMessages.push(text);
}
});
await mockGroceryShell(page, [
{
id: 10,
household_store_id: 100,
name: "Costco",
location_name: "Eastvale",
display_name: "Costco - Eastvale",
is_default: true,
},
{
id: 13,
household_store_id: 100,
name: "Costco",
location_name: "Fontana",
display_name: "Costco - Fontana",
is_default: false,
},
{
id: 11,
household_store_id: 200,
name: "99 Ranch",
location_name: "Eastvale",
display_name: "99 Ranch - Eastvale",
is_default: false,
},
{
id: 12,
household_store_id: 300,
name: "Stater Bros",
location_name: "Ontario Ranch",
display_name: "Stater Bros - Ontario Ranch",
is_default: false,
},
]);
await page.goto("/");
const storeSelector = page.locator(".store-selector-trigger").first();
const locationSelector = page.locator(".location-selector-trigger");
await expect(storeSelector).toBeVisible();
await expect(storeSelector).toContainText("Costco");
await expect(locationSelector).toBeVisible();
await expect(locationSelector).toContainText("Eastvale");
await expect(page.getByRole("button", { name: "Manage Map" })).toBeVisible();
await expect(page.locator(".store-tabs")).not.toContainText("Costco - Eastvale");
await expect(page.locator(".store-tabs")).not.toContainText("99 Ranch");
await expect(page.locator(".store-tabs")).not.toContainText("Stater Bros");
await storeSelector.click();
const modal = page.getByRole("dialog", { name: "Select store" });
await expect(modal).toBeVisible();
await expect(modal.getByRole("button", { name: "Costco" })).toBeVisible();
await expect(modal.getByRole("button", { name: "99 Ranch" })).toBeVisible();
await expect(modal.getByRole("button", { name: "Stater Bros" })).toBeVisible();
await expect(modal).not.toContainText("Eastvale");
await expect(modal).not.toContainText("Ontario Ranch");
await modal.getByRole("button", { name: "99 Ranch" }).click();
await expect(modal).toHaveCount(0);
await expect(storeSelector).toContainText("99 Ranch");
await expect(locationSelector).toContainText("Eastvale");
await expect.poll(() => page.evaluate(() => localStorage.getItem("activeHouseholdStoreId_1"))).toBe("200");
await expect.poll(() => page.evaluate(() => localStorage.getItem("activeStoreLocationId_1_200"))).toBe("11");
await storeSelector.click();
await expect(modal).toBeVisible();
await page.locator(".store-selector-modal-overlay").click({ position: { x: 4, y: 4 } });
await expect(modal).toHaveCount(0);
await storeSelector.click();
await modal.getByRole("button", { name: "Costco" }).click();
await locationSelector.click();
const locationModal = page.getByRole("dialog", { name: "Select location" });
await expect(locationModal).toBeVisible();
await expect(locationModal.getByRole("button", { name: "Eastvale" })).toBeVisible();
await expect(locationModal.getByRole("button", { name: "Fontana" })).toBeVisible();
await expect(locationModal).not.toContainText("Costco - Eastvale");
await locationModal.getByRole("button", { name: "Fontana" }).click();
await expect(locationModal).toHaveCount(0);
await expect(storeSelector).toContainText("Costco");
await expect(locationSelector).toContainText("Fontana");
await expect.poll(() => page.evaluate(() => localStorage.getItem("activeHouseholdStoreId_1"))).toBe("100");
await expect.poll(() => page.evaluate(() => localStorage.getItem("activeStoreLocationId_1_100"))).toBe("13");
await page.getByRole("button", { name: "Manage Map" }).click();
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 }) => {
await mockGroceryShell(page, [
{
id: 10,
household_store_id: 100,
name: "Costco",
location_name: "Default Location",
display_name: "Costco",
is_default: true,
},
]);
await page.goto("/");
const storeSelector = page.locator(".store-selector-trigger").first();
const locationSelector = page.locator(".location-selector-trigger");
await expect(storeSelector).toBeVisible();
await expect(storeSelector).toContainText("Costco");
await expect(locationSelector).toBeVisible();
await expect(locationSelector).toContainText("Default");
await storeSelector.click();
await expect(page.getByRole("dialog", { name: "Select store" })).toHaveCount(0);
await locationSelector.click();
await expect(page.getByRole("dialog", { name: "Select location" })).toHaveCount(0);
});

View File

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

View File

@ -2,12 +2,9 @@ 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 configuredAllowedHosts = env.VITE_ALLOWED_HOSTS const allowedHosts = 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

@ -1,66 +0,0 @@
-- 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);