grocery-app/frontend/src/pages/LocationMapManager.jsx
2026-06-03 16:56:32 -07:00

577 lines
19 KiB
JavaScript

import { useCallback, useContext, useEffect, useRef, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import {
createBlankLocationMap,
createLocationMapFromZones,
getLocationMap,
publishLocationMapDraft,
saveLocationMapDraft,
} from "../api/locationMaps";
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 LocationMapSetupPanel from "../components/maps/LocationMapSetupPanel";
import LocationMapToolbar from "../components/maps/LocationMapToolbar";
import LocationMapTopbar from "../components/maps/LocationMapTopbar";
import ConfirmSlideModal from "../components/modals/ConfirmSlideModal";
import useActionToast from "../hooks/useActionToast";
import getApiErrorMessage from "../lib/getApiErrorMessage";
import {
DEFAULT_MAP_FILTERS,
DEFAULT_MAP_SIZE,
clampObjectToMap,
createClientObject,
getMapStatus,
getObjectKey,
itemsForZone,
mapForMode,
normalizeMapObject,
objectZoneId,
objectsForMode,
prepareObjectsForSave,
unmappedItems,
} from "../lib/locationMapUtils";
import "../styles/pages/LocationMapManager.css";
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 [mapState, setMapState] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [savingAction, setSavingAction] = useState(null);
const [mode, setMode] = useState("setup");
const [editorTool, setEditorTool] = useState("pan");
const [previewDraft, setPreviewDraft] = useState(false);
const [mapDraft, setMapDraft] = useState({
name: "Store Map",
width: DEFAULT_MAP_SIZE.width,
height: DEFAULT_MAP_SIZE.height,
});
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 [pendingDeleteObject, setPendingDeleteObject] = useState(null);
const [pendingLeave, setPendingLeave] = useState(false);
const svgRef = useRef(null);
const toastRef = useRef(toast);
const location = mapState?.location || stores.find((store) => String(store.id) === String(locationId));
const canManage = Boolean(mapState?.can_manage ?? ["owner", "admin"].includes(activeHousehold?.role));
const activeMap = mapForMode(mapState, mode, previewDraft);
const selectedObject = objects.find((object) => getObjectKey(object) === selectedObjectKey) || null;
const selectedZoneItems = selectedObject?.zone_id
? itemsForZone(mapState?.items || [], selectedObject.zone_id, filters, username)
: [];
const visibleUnmappedItems = unmappedItems(mapState?.items || [], filters, username);
const mapSize = {
width: mapDraft.width || activeMap?.width || DEFAULT_MAP_SIZE.width,
height: mapDraft.height || activeMap?.height || DEFAULT_MAP_SIZE.height,
};
const syncMapDraftFromState = useCallback((nextState, nextMode, nextPreviewDraft) => {
const nextMap = mapForMode(nextState, nextMode, nextPreviewDraft);
const nextObjects = objectsForMode(nextState, nextMode, nextPreviewDraft);
if (!nextMap) return;
setMapDraft({
name: nextMap.name || "Store Map",
width: nextMap.width || DEFAULT_MAP_SIZE.width,
height: nextMap.height || DEFAULT_MAP_SIZE.height,
});
setObjects(nextObjects.map(normalizeMapObject));
setSelectedObjectKey(null);
setHistory([]);
setFuture([]);
setHasUnsavedChanges(false);
}, []);
const loadMap = useCallback(async () => {
if (!activeHousehold?.id || !locationId) return;
setLoading(true);
try {
const response = await getLocationMap(activeHousehold.id, locationId);
const nextState = response.data;
setMapState(nextState);
if (!nextState.draft_map && !nextState.published_map) {
setMode("setup");
setEditorTool("pan");
setPreviewDraft(false);
setObjects([]);
setHasUnsavedChanges(false);
setMapDraft({
name: "Store Map",
width: DEFAULT_MAP_SIZE.width,
height: DEFAULT_MAP_SIZE.height,
});
return;
}
if (nextState.published_map) {
setMode("view");
setEditorTool("pan");
setPreviewDraft(false);
} else if (nextState.draft_map) {
setMode("setup");
setEditorTool("pan");
setPreviewDraft(true);
}
syncMapDraftFromState(
nextState,
nextState.published_map ? "view" : "edit",
!nextState.published_map
);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to load map");
toastRef.current.error("Load map failed", message);
setMapState(null);
} finally {
setLoading(false);
}
}, [activeHousehold?.id, locationId, syncMapDraftFromState]);
useEffect(() => {
toastRef.current = toast;
}, [toast]);
useEffect(() => {
if (!householdLoading && hasLoaded) {
loadMap();
}
}, [hasLoaded, householdLoading, loadMap]);
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]);
useEffect(() => {
if (mode !== "edit") {
setEditorTool("pan");
return;
}
if (editorTool === "pan") {
setSelectedObjectKey(null);
}
}, [editorTool, mode]);
const remember = (currentObjects = objects) => {
setHistory((previous) => [...previous.slice(-19), currentObjects.map((object) => ({ ...object }))]);
setFuture([]);
};
const updateObjects = (updater) => {
setHasUnsavedChanges(true);
setObjects((currentObjects) => {
const nextObjects = updater(currentObjects).map((object) => clampObjectToMap(object, mapSize));
return nextObjects;
});
};
const beginSaving = (action) => {
setSavingAction(action);
setSaving(true);
};
const endSaving = () => {
setSaving(false);
setSavingAction(null);
};
const handleCreateBlank = async () => {
if (!activeHousehold?.id || !locationId) return;
beginSaving("create-blank");
try {
const response = await createBlankLocationMap(activeHousehold.id, locationId, mapDraft);
setMapState(response.data);
setMode("edit");
setEditorTool("edit");
setPreviewDraft(true);
syncMapDraftFromState(response.data, "edit", true);
toast.success("Created map", "Blank draft map created");
} catch (error) {
toast.error("Create map failed", getApiErrorMessage(error, "Failed to create map"));
} finally {
endSaving();
}
};
const handleCreateFromZones = async () => {
if (!activeHousehold?.id || !locationId) return;
beginSaving("create-zones");
try {
const response = await createLocationMapFromZones(activeHousehold.id, locationId, mapDraft);
setMapState(response.data);
setMode("edit");
setEditorTool("edit");
setPreviewDraft(true);
syncMapDraftFromState(response.data, "edit", true);
toast.success("Created starter map", "Zones were added as editable rectangles");
} catch (error) {
toast.error("Create map failed", getApiErrorMessage(error, "Failed to create map from zones"));
} finally {
endSaving();
}
};
const handleSaveDraft = async () => {
if (!activeHousehold?.id || !locationId) return;
beginSaving("save");
try {
const response = await saveLocationMapDraft(activeHousehold.id, locationId, {
map: mapDraft,
objects: prepareObjectsForSave(objects),
});
setMapState(response.data);
setMode("edit");
setPreviewDraft(true);
syncMapDraftFromState(response.data, "edit", true);
toast.success("Saved draft", "Map draft saved");
} catch (error) {
toast.error("Save draft failed", getApiErrorMessage(error, "Failed to save map draft"));
} finally {
endSaving();
}
};
const handlePublish = async () => {
if (!activeHousehold?.id || !locationId) return;
beginSaving("publish");
try {
if (hasUnsavedChanges) {
await saveLocationMapDraft(activeHousehold.id, locationId, {
map: mapDraft,
objects: prepareObjectsForSave(objects),
});
}
const response = await publishLocationMapDraft(activeHousehold.id, locationId);
setMapState(response.data);
setMode("view");
setEditorTool("pan");
setPreviewDraft(false);
syncMapDraftFromState(response.data, "view", false);
toast.success(
"Published map",
hasUnsavedChanges
? "Latest edits were saved and published"
: "Map is now visible to household members"
);
} catch (error) {
toast.error("Publish failed", getApiErrorMessage(error, "Failed to publish map"));
} finally {
endSaving();
}
};
const handleAddObject = () => {
if (!canManage) return;
remember();
const usedZoneIds = new Set(objects.map((object) => objectZoneId(object)).filter(Boolean));
const nextZone = (mapState?.zones || []).find((zone) => !usedZoneIds.has(String(zone.id))) || mapState?.zones?.[0];
const nextObject = createClientObject(nextZone, objects.length);
setObjects((currentObjects) => [...currentObjects, nextObject]);
setSelectedObjectKey(getObjectKey(nextObject));
setEditorTool("edit");
setHasUnsavedChanges(true);
};
const handleDuplicateObject = () => {
if (!selectedObject) return;
remember();
const nextObject = {
...selectedObject,
id: undefined,
client_id: `copy-${Date.now()}`,
label: `${selectedObject.label || "Area"} Copy`,
x: selectedObject.x + 28,
y: selectedObject.y + 28,
sort_order: objects.length + 1,
};
setObjects((currentObjects) => [...currentObjects, clampObjectToMap(nextObject, mapSize)]);
setSelectedObjectKey(getObjectKey(nextObject));
setEditorTool("edit");
setHasUnsavedChanges(true);
};
const requestDeleteObject = () => {
if (!selectedObject) return;
setPendingDeleteObject(selectedObject);
};
const handleDeleteObject = () => {
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);
};
const handleObjectField = (field, value) => {
if (!selectedObject) return;
remember();
updateObjects((currentObjects) =>
currentObjects.map((object) =>
getObjectKey(object) === getObjectKey(selectedObject)
? { ...object, [field]: value }
: object
)
);
};
const handleZoneLinkChange = (zoneId) => {
const zone = (mapState?.zones || []).find((candidate) => String(candidate.id) === String(zoneId));
handleObjectField("zone_id", zone?.id || null);
setObjects((currentObjects) =>
currentObjects.map((object) =>
getObjectKey(object) === selectedObjectKey
? {
...object,
zone_name: zone?.name || null,
label: object.label || zone?.name || "",
}
: object
)
);
};
const handleUndo = () => {
setHistory((previous) => {
if (previous.length === 0) return previous;
const priorObjects = previous[previous.length - 1];
setFuture((nextFuture) => [objects.map((object) => ({ ...object })), ...nextFuture.slice(0, 19)]);
setObjects(priorObjects);
setHasUnsavedChanges(true);
return previous.slice(0, -1);
});
};
const handleRedo = () => {
setFuture((previous) => {
if (previous.length === 0) return previous;
const nextObjects = previous[0];
setHistory((nextHistory) => [...nextHistory.slice(-19), objects.map((object) => ({ ...object }))]);
setObjects(nextObjects);
setHasUnsavedChanges(true);
return previous.slice(1);
});
};
const handlePreviewDraft = () => {
setMode("view");
setEditorTool("pan");
setPreviewDraft(true);
setSelectedObjectKey(null);
};
const handleViewMode = () => {
setMode("view");
setEditorTool("pan");
if (hasUnsavedChanges) {
setPreviewDraft(true);
setSelectedObjectKey(null);
return;
}
setPreviewDraft(false);
if (mapState) {
syncMapDraftFromState(mapState, "view", false);
}
};
const handleEditMode = () => {
setMode("edit");
setEditorTool("pan");
setPreviewDraft(true);
if (!previewDraft && mapState && !hasUnsavedChanges) {
syncMapDraftFromState(mapState, "edit", true);
}
};
const handleFitMap = () => {
const availableWidth = typeof window === "undefined"
? DEFAULT_MAP_SIZE.width
: window.innerWidth >= 840
? window.innerWidth - 420
: window.innerWidth - 24;
const nextZoom = Math.max(0.45, Math.min(1.6, (availableWidth - 24) / mapSize.width));
setZoom(Number(nextZoom.toFixed(2)));
};
const performBackNavigation = useCallback(() => {
if (typeof returnPath === "string" && returnPath.startsWith("/")) {
navigate(returnPath, { replace: true });
return;
}
navigate("/");
}, [navigate, returnPath]);
const handleBack = useCallback(() => {
if (hasUnsavedChanges && canManage) {
setPendingLeave(true);
return;
}
performBackNavigation();
}, [canManage, hasUnsavedChanges, performBackNavigation]);
if (!hasLoaded || householdLoading || loading) {
return (
<div className="location-map-page">
<p className="location-map-loading">Loading map...</p>
</div>
);
}
if (!activeHousehold) {
return (
<div className="location-map-page">
<p className="location-map-loading">Select a household to manage maps.</p>
</div>
);
}
const hasAnyMap = Boolean(mapState?.draft_map || mapState?.published_map);
const status = getMapStatus(mapState, { mode, previewDraft, hasUnsavedChanges });
return (
<div className="location-map-page">
<LocationMapTopbar location={location} status={status} onBack={handleBack} />
<main className="location-map-workspace">
{hasAnyMap && mode !== "setup" ? (
<LocationMapToolbar
mode={mode}
editorTool={editorTool}
hasAnyMap={hasAnyMap}
canManage={canManage}
saving={saving}
zoom={zoom}
setZoom={setZoom}
onFit={handleFitMap}
onView={handleViewMode}
onEdit={handleEditMode}
onPanMode={() => setEditorTool("pan")}
onEditObjects={() => setEditorTool("edit")}
/>
) : null}
{!hasAnyMap || (mode === "setup" && mapState?.draft_map && !mapState?.published_map) ? (
<LocationMapSetupPanel
hasAnyMap={hasAnyMap}
canManage={canManage}
zoneCount={mapState?.zones?.length || 0}
saving={saving}
savingAction={savingAction}
onContinue={() => setMode("edit")}
onPreview={handlePreviewDraft}
onPublish={handlePublish}
onCreateFromZones={handleCreateFromZones}
onCreateBlank={handleCreateBlank}
/>
) : (
<>
<LocationMapCanvas
mode={mode}
editorTool={editorTool}
objects={objects}
filters={filters}
mapSize={mapSize}
zoom={zoom}
selectedObjectKey={selectedObjectKey}
mapItems={mapState?.items || []}
username={username}
svgRef={svgRef}
dragState={dragState}
editingLocked={saving}
setDragState={setDragState}
setSelectedObjectKey={setSelectedObjectKey}
remember={remember}
updateObjects={updateObjects}
/>
<LocationMapBottomSheet
mode={mode}
editorTool={editorTool}
canManage={canManage}
filters={filters}
setFilters={setFilters}
layersOpen={layersOpen}
setLayersOpen={setLayersOpen}
selectedObject={selectedObject}
selectedZoneItems={selectedZoneItems}
visibleUnmappedItems={visibleUnmappedItems}
history={history}
future={future}
saving={saving}
savingAction={savingAction}
hasUnsavedChanges={hasUnsavedChanges}
mapState={mapState}
onAddObject={handleAddObject}
onUndo={handleUndo}
onRedo={handleRedo}
onSaveDraft={handleSaveDraft}
onPublish={handlePublish}
onPreviewDraft={handlePreviewDraft}
onPanMode={() => setEditorTool("pan")}
onEditObjects={() => setEditorTool("edit")}
onEditMode={handleEditMode}
onObjectField={handleObjectField}
onZoneLinkChange={handleZoneLinkChange}
onClearSelection={() => setSelectedObjectKey(null)}
onDuplicateObject={handleDuplicateObject}
onDeleteObject={requestDeleteObject}
/>
</>
)}
</main>
<ConfirmSlideModal
isOpen={Boolean(pendingDeleteObject)}
title={`Delete ${pendingDeleteObject?.label || "this map area"}?`}
description="This removes the area from the draft map. Save the draft after deleting to keep the change."
confirmLabel="Delete Area"
onClose={() => setPendingDeleteObject(null)}
onConfirm={handleDeleteObject}
/>
<ConfirmSlideModal
isOpen={pendingLeave}
title="Leave with unsaved changes?"
description="Your draft edits are only on this screen right now. Save the draft before leaving if you want to keep them."
confirmLabel="Leave Map"
onClose={() => setPendingLeave(false)}
onConfirm={() => {
setPendingLeave(false);
performBackNavigation();
}}
/>
</div>
);
}