import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { useBeforeUnload, 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, clampMapZoom, clampObjectToMap, createClientObject, getMapObjectDisplayLabel, getMapStatus, getObjectKey, itemMatchesMapFilters, itemsForZone, mapForMode, normalizeMapObject, objectZoneId, objectsForMode, prepareObjectsForSave, unmappedItems, } from "../lib/locationMapUtils"; import "../styles/pages/LocationMapManager.css"; const EMPTY_MAP_ITEMS = []; 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 [loadError, setLoadError] = useState(null); 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 [pendingLeaveTarget, setPendingLeaveTarget] = useState(null); const svgRef = useRef(null); const toastRef = useRef(toast); const loadRequestRef = useRef(0); 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 = 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 shouldGuardLeave = canManage && hasUnsavedChanges; 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] ); useBeforeUnload( useCallback((event) => { if (!shouldGuardLeave) return; event.preventDefault(); event.returnValue = ""; }, [shouldGuardLeave]), { capture: true } ); useEffect(() => { if (!shouldGuardLeave) { setPendingLeaveTarget(null); return undefined; } const handleDocumentClick = (event) => { 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 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 }); }; document.addEventListener("click", handleDocumentClick, true); return () => document.removeEventListener("click", handleDocumentClick, true); }, [shouldGuardLeave]); 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 () => { const requestId = loadRequestRef.current + 1; loadRequestRef.current = requestId; if (!activeHousehold?.id || !locationId) { setLoading(false); return; } setLoading(true); setLoadError(null); 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"); 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 && nextCanManage) { setMode("setup"); setEditorTool("pan"); setPreviewDraft(true); } else { setMode("setup"); setEditorTool("pan"); setPreviewDraft(false); setObjects([]); setSelectedObjectKey(null); setHistory([]); setFuture([]); setHasUnsavedChanges(false); setMapDraft({ name: "Store Map", width: DEFAULT_MAP_SIZE.width, height: DEFAULT_MAP_SIZE.height, }); return; } syncMapDraftFromState( nextState, nextState.published_map ? "view" : "edit", !nextState.published_map ); } catch (error) { if (loadRequestRef.current !== requestId) return; const message = getApiErrorMessage(error, "Failed to load map"); toastRef.current.error("Load map failed", message); setMapState(null); setObjects([]); setSelectedObjectKey(null); setHistory([]); setFuture([]); setHasUnsavedChanges(false); setLoadError(message); } finally { if (loadRequestRef.current === requestId) { setLoading(false); } } }, [activeHousehold?.id, activeHousehold?.role, 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]); 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]); 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"); let savedPendingDraft = false; try { if (hasUnsavedChanges) { const saveResponse = await saveLocationMapDraft(activeHousehold.id, locationId, { map: mapDraft, objects: prepareObjectsForSave(objects), }); savedPendingDraft = true; setMapState(saveResponse.data); setMode("edit"); setPreviewDraft(true); syncMapDraftFromState(saveResponse.data, "edit", true); } 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) { const message = getApiErrorMessage(error, "Failed to publish map"); toast.error( "Publish failed", savedPendingDraft ? `${message}. Draft changes were saved.` : message ); } 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 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)); 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 handleShowHiddenAreas = () => { 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."); }; 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("edit"); setPreviewDraft(true); if (!previewDraft && mapState && !hasUnsavedChanges) { syncMapDraftFromState(mapState, "edit", true); } }; const handleFitMap = () => { const scrollElement = svgRef.current?.closest(".location-map-scroll"); const fallbackWidth = typeof window === "undefined" ? DEFAULT_MAP_SIZE.width : window.innerWidth; const fallbackHeight = typeof window === "undefined" ? DEFAULT_MAP_SIZE.height : window.innerHeight; const availableWidth = Math.max(280, (scrollElement?.clientWidth || fallbackWidth) - 24); const availableHeight = Math.max(220, (scrollElement?.clientHeight || fallbackHeight) - 24); const nextZoom = clampMapZoom( Math.min(availableWidth / mapSize.width, availableHeight / mapSize.height) ); 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 (shouldGuardLeave) { setPendingLeaveTarget({ type: "back" }); return; } performBackNavigation(); }, [performBackNavigation, shouldGuardLeave]); const confirmPendingLeave = useCallback(() => { const target = pendingLeaveTarget; setPendingLeaveTarget(null); if (target?.type === "path" && target.path) { navigate(target.path); return; } performBackNavigation(); }, [navigate, pendingLeaveTarget, performBackNavigation]); if (!hasLoaded || householdLoading || loading) { return (

Loading map...

); } if (!activeHousehold) { return (

Select a household to manage maps.

); } if (loadError) { return (

Map Unavailable

{loadError}

); } const hasAnyMap = Boolean(mapState?.draft_map || mapState?.published_map); const hasVisibleOrManageableMap = Boolean(mapState?.published_map || (mapState?.draft_map && canManage)); const status = getMapStatus(mapState, { mode, previewDraft, hasUnsavedChanges }); return (
{hasAnyMap && mode !== "setup" ? ( setEditorTool("pan")} onEditObjects={() => setEditorTool("edit")} onUndo={handleUndo} onRedo={handleRedo} /> ) : null} {!hasAnyMap || (mode === "setup" && mapState?.draft_map && !mapState?.published_map) ? ( { setMode("edit"); setEditorTool("edit"); setPreviewDraft(true); }} onPreview={handlePreviewDraft} onPublish={handlePublish} onCreateFromZones={handleCreateFromZones} onCreateBlank={handleCreateBlank} /> ) : ( <> setFilters((current) => ({ ...current, showZones: true, })) } onShowHiddenAreas={handleShowHiddenAreas} /> )}
setPendingDeleteObject(null)} onConfirm={handleDeleteObject} /> setPendingLeaveTarget(null)} onConfirm={confirmPendingLeave} />
); }