diff --git a/frontend/src/components/maps/LocationMapBottomSheet.jsx b/frontend/src/components/maps/LocationMapBottomSheet.jsx
index 0fdbcec..061054f 100644
--- a/frontend/src/components/maps/LocationMapBottomSheet.jsx
+++ b/frontend/src/components/maps/LocationMapBottomSheet.jsx
@@ -29,12 +29,14 @@ export default function LocationMapBottomSheet({
history,
future,
saving,
+ hasUnsavedChanges,
mapState,
onAddObject,
onUndo,
onRedo,
onSaveDraft,
onPublish,
+ onPreviewDraft,
onPanMode,
onEditObjects,
onEditMode,
@@ -93,8 +95,9 @@ export default function LocationMapBottomSheet({
{mode === "edit" && canManage ? (
-
-
+
+
+
diff --git a/frontend/src/pages/LocationMapManager.jsx b/frontend/src/pages/LocationMapManager.jsx
index 2f0d063..6efe2f6 100644
--- a/frontend/src/pages/LocationMapManager.jsx
+++ b/frontend/src/pages/LocationMapManager.jsx
@@ -58,6 +58,7 @@ export default function LocationMapManager() {
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([]);
@@ -80,6 +81,23 @@ export default function LocationMapManager() {
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;
@@ -94,6 +112,7 @@ export default function LocationMapManager() {
setEditorTool("pan");
setPreviewDraft(false);
setObjects([]);
+ setHasUnsavedChanges(false);
setMapDraft({
name: "Store Map",
width: DEFAULT_MAP_SIZE.width,
@@ -112,18 +131,10 @@ export default function LocationMapManager() {
setPreviewDraft(true);
}
- const nextMap = nextState.published_map || nextState.draft_map;
- setMapDraft({
- name: nextMap?.name || "Store Map",
- width: nextMap?.width || DEFAULT_MAP_SIZE.width,
- height: nextMap?.height || DEFAULT_MAP_SIZE.height,
- });
- setObjects(
- objectsForMode(
- nextState,
- nextState.published_map ? "view" : "edit",
- !nextState.published_map
- ).map(normalizeMapObject)
+ syncMapDraftFromState(
+ nextState,
+ nextState.published_map ? "view" : "edit",
+ !nextState.published_map
);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to load map");
@@ -132,7 +143,7 @@ export default function LocationMapManager() {
} finally {
setLoading(false);
}
- }, [activeHousehold?.id, locationId]);
+ }, [activeHousehold?.id, locationId, syncMapDraftFromState]);
useEffect(() => {
toastRef.current = toast;
@@ -151,23 +162,6 @@ export default function LocationMapManager() {
}
}, [activeStore?.id, locationId, setActiveStore, stores]);
- useEffect(() => {
- if (!mapState) return;
- const nextMap = mapForMode(mapState, mode, previewDraft);
- const nextObjects = objectsForMode(mapState, mode, previewDraft);
- 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([]);
- }, [mapState, mode, previewDraft]);
-
useEffect(() => {
if (mode !== "edit") {
setEditorTool("pan");
@@ -185,6 +179,7 @@ export default function LocationMapManager() {
};
const updateObjects = (updater) => {
+ setHasUnsavedChanges(true);
setObjects((currentObjects) => {
const nextObjects = updater(currentObjects).map((object) => clampObjectToMap(object, mapSize));
return nextObjects;
@@ -200,6 +195,7 @@ export default function LocationMapManager() {
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"));
@@ -217,6 +213,7 @@ export default function LocationMapManager() {
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"));
@@ -236,6 +233,7 @@ export default function LocationMapManager() {
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"));
@@ -253,6 +251,7 @@ export default function LocationMapManager() {
setMode("view");
setEditorTool("pan");
setPreviewDraft(false);
+ syncMapDraftFromState(response.data, "view", false);
toast.success("Published map", "Map is now visible to household members");
} catch (error) {
toast.error("Publish failed", getApiErrorMessage(error, "Failed to publish map"));
@@ -270,6 +269,7 @@ export default function LocationMapManager() {
setObjects((currentObjects) => [...currentObjects, nextObject]);
setSelectedObjectKey(getObjectKey(nextObject));
setEditorTool("edit");
+ setHasUnsavedChanges(true);
};
const handleDuplicateObject = () => {
@@ -287,6 +287,7 @@ export default function LocationMapManager() {
setObjects((currentObjects) => [...currentObjects, clampObjectToMap(nextObject, mapSize)]);
setSelectedObjectKey(getObjectKey(nextObject));
setEditorTool("edit");
+ setHasUnsavedChanges(true);
};
const requestDeleteObject = () => {
@@ -301,6 +302,7 @@ export default function LocationMapManager() {
currentObjects.filter((object) => getObjectKey(object) !== getObjectKey(pendingDeleteObject))
);
setSelectedObjectKey(null);
+ setHasUnsavedChanges(true);
toast.success(
"Deleted map area",
"Save the draft to keep this change."
@@ -342,6 +344,7 @@ export default function LocationMapManager() {
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);
});
};
@@ -352,10 +355,41 @@ export default function LocationMapManager() {
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
@@ -383,7 +417,7 @@ export default function LocationMapManager() {
}
const hasAnyMap = Boolean(mapState?.draft_map || mapState?.published_map);
- const status = getMapStatus(mapState);
+ const status = hasUnsavedChanges ? "Unsaved Draft" : getMapStatus(mapState);
return (
@@ -398,16 +432,8 @@ export default function LocationMapManager() {
zoom={zoom}
setZoom={setZoom}
onFit={handleFitMap}
- onView={() => {
- setMode("view");
- setEditorTool("pan");
- setPreviewDraft(false);
- }}
- onEdit={() => {
- setMode("edit");
- setEditorTool("pan");
- setPreviewDraft(true);
- }}
+ onView={handleViewMode}
+ onEdit={handleEditMode}
onPanMode={() => setEditorTool("pan")}
onEditObjects={() => setEditorTool("edit")}
/>
@@ -418,10 +444,7 @@ export default function LocationMapManager() {
canManage={canManage}
saving={saving}
onContinue={() => setMode("edit")}
- onPreview={() => {
- setMode("view");
- setPreviewDraft(true);
- }}
+ onPreview={handlePreviewDraft}
onPublish={handlePublish}
onCreateFromZones={handleCreateFromZones}
onCreateBlank={handleCreateBlank}
@@ -459,19 +482,17 @@ export default function LocationMapManager() {
history={history}
future={future}
saving={saving}
+ hasUnsavedChanges={hasUnsavedChanges}
mapState={mapState}
onAddObject={handleAddObject}
onUndo={handleUndo}
onRedo={handleRedo}
onSaveDraft={handleSaveDraft}
onPublish={handlePublish}
+ onPreviewDraft={handlePreviewDraft}
onPanMode={() => setEditorTool("pan")}
onEditObjects={() => setEditorTool("edit")}
- onEditMode={() => {
- setMode("edit");
- setEditorTool("pan");
- setPreviewDraft(true);
- }}
+ onEditMode={handleEditMode}
onObjectField={handleObjectField}
onZoneLinkChange={handleZoneLinkChange}
onDuplicateObject={handleDuplicateObject}
diff --git a/frontend/src/styles/pages/LocationMapManager.css b/frontend/src/styles/pages/LocationMapManager.css
index 89d49fe..7da36c6 100644
--- a/frontend/src/styles/pages/LocationMapManager.css
+++ b/frontend/src/styles/pages/LocationMapManager.css
@@ -78,6 +78,11 @@
border-color: rgba(245, 158, 11, 0.36);
}
+.location-map-status-unsaved-draft {
+ background: rgba(244, 114, 182, 0.2);
+ border-color: rgba(244, 114, 182, 0.42);
+}
+
.location-map-status-published {
background: rgba(20, 184, 166, 0.18);
border-color: rgba(20, 184, 166, 0.34);
@@ -137,8 +142,7 @@
.location-map-mode-buttons button.active,
.location-map-tool-buttons button.active,
-.location-map-primary-actions button:nth-child(2),
-.location-map-primary-actions button:nth-child(3),
+.location-map-editor-actions button.primary,
.location-map-setup-actions .btn-primary {
border-color: var(--color-primary);
background: var(--color-primary);
diff --git a/frontend/tests/location-map-manager.spec.ts b/frontend/tests/location-map-manager.spec.ts
index 3b42c92..97b0eb9 100644
--- a/frontend/tests/location-map-manager.spec.ts
+++ b/frontend/tests/location-map-manager.spec.ts
@@ -254,7 +254,16 @@ test("admin creates a map from zones, saves a draft, and publishes it", async ({
const bakeryObject = page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first();
await bakeryObject.click();
await page.getByRole("textbox", { name: "Label" }).fill("Bread Wall");
+ await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
+ await page.getByRole("button", { name: "Preview Draft" }).click();
+ await expect(page.locator(".location-map-object", { hasText: "Bread Wall" })).toBeVisible();
+ await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
+
+ await page.getByRole("button", { name: "Edit Draft" }).click();
+ await page.getByRole("button", { name: "Edit Objects" }).click();
+ await page.locator(".location-map-object", { hasText: "Bread Wall" }).locator("rect").first().click();
await page.getByRole("button", { name: "Save Draft" }).click();
+ await expect(page.locator(".location-map-status")).toHaveText("Draft");
expect(savedPayload).not.toBeNull();
expect(savedPayload?.objects).toEqual(