import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { useLocation, useNavigate, useParams } from "react-router-dom"; import { getItemByName, markBought } from "../api/list"; 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 { LocationMapLoadErrorState, LocationMapMessageState, } from "../components/maps/LocationMapStateViews"; import LocationMapToolbar from "../components/maps/LocationMapToolbar"; import LocationMapTopbar from "../components/maps/LocationMapTopbar"; import ConfirmBuyModal from "../components/modals/ConfirmBuyModal"; import ConfirmSlideModal from "../components/modals/ConfirmSlideModal"; import useActionToast from "../hooks/useActionToast"; import useLocationMapDraftActions from "../hooks/useLocationMapDraftActions"; import useLocationMapDraftState from "../hooks/useLocationMapDraftState"; import useLocationMapObjectActions from "../hooks/useLocationMapObjectActions"; import useLocationMapViewControls from "../hooks/useLocationMapViewControls"; import useUnsavedMapLeaveGuard from "../hooks/useUnsavedMapLeaveGuard"; import { DEFAULT_MAP_SIZE, getMapObjectDisplayLabel, getMapStatus, getObjectKey, itemMatchesMapFilters, itemsForZone, mapForMode, unmappedItems, } from "../lib/locationMapUtils"; import getApiErrorMessage from "../lib/getApiErrorMessage"; import "../styles/pages/LocationMapManager.css"; 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 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 [buyModalItem, setBuyModalItem] = useState(null); const mapBuyScopeRef = useRef({ activeHouseholdId: null, locationId: null }); const { beginSaving, dragState, editorTool, endSaving, filters, future, hasUnsavedChanges, history, layersOpen, loadError, loadMap, loading, mapDraft, mapState, mode, objects, previewDraft, saving, savingAction, selectedObjectKey, setDragState, setEditorTool, setFilters, setFuture, setHasUnsavedChanges, setHistory, setLayersOpen, 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 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 buyModalItems = useMemo( () => (selectedObject ? selectedZoneItems : visibleUnmappedItems).filter((item) => !item.bought), [selectedObject, selectedZoneItems, visibleUnmappedItems] ); 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 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: activeHousehold?.id, locationId, }; }, [activeHousehold?.id, locationId]); const isCurrentMapBuyScope = useCallback((activeHouseholdId, scopedLocationId) => ( String(mapBuyScopeRef.current.activeHouseholdId || "") === String(activeHouseholdId || "") && String(mapBuyScopeRef.current.locationId || "") === String(scopedLocationId || "") ), []); const handleMapBuyCancel = useCallback(() => { setBuyModalItem(null); }, []); const handleMapBuyNavigate = useCallback((item) => { setBuyModalItem(item); }, []); const handleMapBuyConfirm = useCallback(async (quantity) => { if (!activeHousehold?.id || !locationId || !buyModalItem) { setBuyModalItem(null); return; } const activeHouseholdId = activeHousehold.id; 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(activeHouseholdId, scopedLocationId, item.item_name, quantity, true); if (!isCurrentMapBuyScope(activeHouseholdId, 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(activeHouseholdId, scopedLocationId, item.item_name); if (!isCurrentMapBuyScope(activeHouseholdId, scopedLocationId)) return; updatedItem = response.data || getLocallyReducedMapItem(item, quantity); } catch { if (!isCurrentMapBuyScope(activeHouseholdId, 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(activeHouseholdId, scopedLocationId)) return; const message = getApiErrorMessage(error, "Failed to mark item as bought"); toast.error("Mark item bought failed", `Mark item bought failed: ${message}`); } }, [activeHousehold?.id, buyModalItem, buyModalItems, isCurrentMapBuyScope, locationId, toast, updateMapItems]); 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] ); useEffect(() => { setBuyModalItem(null); }, [activeHousehold?.id, locationId]); 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, setEditorTool, setMapState, setMode, setPreviewDraft, syncMapDraftFromState, toast, }); const { handleAddObject, handleDeleteObject, handleDuplicateObject, handleObjectField, handleShowHiddenAreas, handleZoneLinkChange, pendingDeleteObject, remember, requestDeleteObject, setPendingDeleteObject, updateObjects, } = useLocationMapObjectActions({ canManage, hiddenAreaCount, mapSize, mapState, objects, selectedObject, selectedObjectKey, setEditorTool, setFuture, setHasUnsavedChanges, setHistory, setObjects, setSelectedObjectKey, toast, }); useEffect(() => { setPendingDeleteObject(null); }, [activeHousehold?.id, locationId, setPendingDeleteObject]); const { handleEditMode, handleFitMap, handlePreviewDraft, handleRedo, handleUndo, handleViewMode, } = useLocationMapViewControls({ editorTool, filters, hasUnsavedChanges, mapSize, mapState, mode, objects, previewDraft, selectedObjectKey, setEditorTool, setFuture, setHasUnsavedChanges, setHistory, setMode, setObjects, setPreviewDraft, setSelectedObjectKey, setZoom, svgRef, syncMapDraftFromState, }); 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, }); if (!hasLoaded || householdLoading || loading) { return ; } if (!activeHousehold) { return ; } if (loadError) { return ( ); } 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} /> )}
{buyModalItem ? ( ) : null} setPendingDeleteObject(null)} onConfirm={handleDeleteObject} />
); }