import { useCallback, useContext, useEffect, 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, 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 shouldGuardLeave = canManage && hasUnsavedChanges; const mapSize = { width: mapDraft.width || activeMap?.width || DEFAULT_MAP_SIZE.width, height: mapDraft.height || activeMap?.height || DEFAULT_MAP_SIZE.height, }; useBeforeUnload( useCallback((event) => { if (!shouldGuardLeave) return; event.preventDefault(); event.returnValue = ""; }, [shouldGuardLeave]), { capture: true } ); 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 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) { setPendingLeave(true); return; } performBackNavigation(); }, [performBackNavigation, shouldGuardLeave]); if (!hasLoaded || householdLoading || loading) { return (
Loading map...
Select a household to manage maps.