Add store location map manager #20

Open
nalalangan wants to merge 206 commits from feature/location-map-manager into feature/store-selector-modal
2 changed files with 185 additions and 148 deletions
Showing only changes of commit 3f6eade576 - Show all commits

View File

@ -0,0 +1,142 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getLocationMap } from "../api/locationMaps";
import getApiErrorMessage from "../lib/getApiErrorMessage";
import { DEFAULT_MAP_FILTERS, DEFAULT_MAP_SIZE, mapForMode, normalizeMapObject, objectsForMode } from "../lib/locationMapUtils";
const DEFAULT_DRAFT_MAP = {
name: "Store Map",
width: DEFAULT_MAP_SIZE.width,
height: DEFAULT_MAP_SIZE.height,
};
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 [editorTool, setEditorTool] = useState("pan");
const [previewDraft, setPreviewDraft] = useState(false);
const [mapDraft, setMapDraft] = useState(DEFAULT_DRAFT_MAP);
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(DEFAULT_DRAFT_MAP);
}, []);
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);
setEditorTool("pan");
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, 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,
};
}

View File

@ -3,7 +3,6 @@ import { useLocation, useNavigate, useParams } from "react-router-dom";
import {
createBlankLocationMap,
createLocationMapFromZones,
getLocationMap,
publishLocationMapDraft,
saveLocationMapDraft,
} from "../api/locationMaps";
@ -17,10 +16,10 @@ import LocationMapToolbar from "../components/maps/LocationMapToolbar";
import LocationMapTopbar from "../components/maps/LocationMapTopbar";
import ConfirmSlideModal from "../components/modals/ConfirmSlideModal";
import useActionToast from "../hooks/useActionToast";
import useLocationMapDraftState from "../hooks/useLocationMapDraftState";
import useUnsavedMapLeaveGuard from "../hooks/useUnsavedMapLeaveGuard";
import getApiErrorMessage from "../lib/getApiErrorMessage";
import {
DEFAULT_MAP_FILTERS,
DEFAULT_MAP_SIZE,
clampMapZoom,
clampObjectToMap,
@ -31,9 +30,7 @@ import {
itemMatchesMapFilters,
itemsForZone,
mapForMode,
normalizeMapObject,
objectZoneId,
objectsForMode,
prepareObjectsForSave,
unmappedItems,
} from "../lib/locationMapUtils";
@ -51,32 +48,51 @@ export default function LocationMapManager() {
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 {
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 [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 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));
@ -132,117 +148,6 @@ export default function LocationMapManager() {
[activeMap?.height, activeMap?.width, mapDraft.height, mapDraft.width]
);
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)) {
@ -283,16 +188,6 @@ export default function LocationMapManager() {
});
};
const beginSaving = (action) => {
setSavingAction(action);
setSaving(true);
};
const endSaving = () => {
setSaving(false);
setSavingAction(null);
};
const handleCreateBlank = async () => {
if (!activeHousehold?.id || !locationId) return;
beginSaving("create-blank");