Add store location map manager #20
@ -17,9 +17,12 @@ const DISPLAY_CONTROLS = [
|
||||
|
||||
export default function LocationMapBottomSheet({
|
||||
mode,
|
||||
editorTool,
|
||||
canManage,
|
||||
filters,
|
||||
setFilters,
|
||||
layersOpen,
|
||||
setLayersOpen,
|
||||
selectedObject,
|
||||
selectedZoneItems,
|
||||
visibleUnmappedItems,
|
||||
@ -32,43 +35,68 @@ export default function LocationMapBottomSheet({
|
||||
onRedo,
|
||||
onSaveDraft,
|
||||
onPublish,
|
||||
onPanMode,
|
||||
onEditObjects,
|
||||
onEditMode,
|
||||
onObjectField,
|
||||
onZoneLinkChange,
|
||||
onDuplicateObject,
|
||||
onDeleteObject,
|
||||
}) {
|
||||
const selectedTitle = selectedObject?.label || selectedObject?.zone_name || "Map Area";
|
||||
const sheetTitle = mode === "edit"
|
||||
? selectedObject && editorTool === "edit"
|
||||
? "Object Settings"
|
||||
: editorTool === "edit"
|
||||
? "Edit Objects"
|
||||
: "Pan Mode"
|
||||
: selectedObject
|
||||
? selectedTitle
|
||||
: "Map Details";
|
||||
|
||||
return (
|
||||
<aside className="location-map-bottom-sheet">
|
||||
<div className="location-map-sheet-header">
|
||||
<strong>{mode === "edit" ? "Editor" : "Map Details"}</strong>
|
||||
{filters.showUnmapped && visibleUnmappedItems.length > 0 ? (
|
||||
<span>{visibleUnmappedItems.length} unmapped</span>
|
||||
) : null}
|
||||
<div>
|
||||
<strong>{sheetTitle}</strong>
|
||||
{mode === "edit" && canManage ? (
|
||||
<span>{editorTool === "edit" ? "Move, resize, and configure selected areas." : "Drag and zoom without changing objects."}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`location-map-layer-toggle ${layersOpen ? "active" : ""}`}
|
||||
onClick={() => setLayersOpen((current) => !current)}
|
||||
aria-expanded={layersOpen}
|
||||
>
|
||||
Layers
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="location-map-display-controls">
|
||||
{DISPLAY_CONTROLS.map(([key, label]) => (
|
||||
<label key={key}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters[key]}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({ ...current, [key]: event.target.checked }))
|
||||
}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{layersOpen ? (
|
||||
<div className="location-map-display-controls">
|
||||
{DISPLAY_CONTROLS.map(([key, label]) => (
|
||||
<label key={key}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters[key]}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({ ...current, [key]: event.target.checked }))
|
||||
}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{mode === "edit" && canManage ? (
|
||||
<div className="location-map-editor-actions">
|
||||
<button type="button" onClick={onAddObject}>Add Zone Rectangle</button>
|
||||
<button type="button" onClick={onUndo} disabled={history.length === 0}>Undo</button>
|
||||
<button type="button" onClick={onRedo} disabled={future.length === 0}>Redo</button>
|
||||
<div className="location-map-editor-actions location-map-primary-actions">
|
||||
<button type="button" onClick={onAddObject}>Add Area</button>
|
||||
<button type="button" onClick={onSaveDraft} disabled={saving}>Save Draft</button>
|
||||
<button type="button" onClick={onPublish} disabled={saving || !mapState?.draft_map}>Publish</button>
|
||||
<button type="button" onClick={onUndo} disabled={history.length === 0}>Undo</button>
|
||||
<button type="button" onClick={onRedo} disabled={future.length === 0}>Redo</button>
|
||||
</div>
|
||||
) : canManage && mapState?.published_map ? (
|
||||
<div className="location-map-editor-actions">
|
||||
@ -76,7 +104,26 @@ export default function LocationMapBottomSheet({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{mode === "edit" && selectedObject ? (
|
||||
{mode === "edit" && canManage ? (
|
||||
<div className="location-map-mobile-tool-switch" aria-label="Edit tool">
|
||||
<button
|
||||
type="button"
|
||||
className={editorTool === "pan" ? "active" : ""}
|
||||
onClick={onPanMode}
|
||||
>
|
||||
Pan
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={editorTool === "edit" ? "active" : ""}
|
||||
onClick={onEditObjects}
|
||||
>
|
||||
Objects
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{mode === "edit" && editorTool === "edit" && selectedObject ? (
|
||||
<div className="location-map-object-form">
|
||||
<label>
|
||||
Label
|
||||
@ -144,12 +191,23 @@ export default function LocationMapBottomSheet({
|
||||
</div>
|
||||
<div className="location-map-editor-actions">
|
||||
<button type="button" onClick={onDuplicateObject}>Duplicate</button>
|
||||
<button type="button" className="danger" onClick={onDeleteObject}>Delete Rectangle</button>
|
||||
<button type="button" className="danger" onClick={onDeleteObject}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
) : mode === "edit" && editorTool === "edit" ? (
|
||||
<p className="location-map-muted">
|
||||
Tap an area to edit its name, linked zone, size, and position.
|
||||
</p>
|
||||
) : mode === "edit" ? (
|
||||
<p className="location-map-muted">
|
||||
Use Pan Mode to move around the map. Switch to Objects when you want to edit areas.
|
||||
</p>
|
||||
) : selectedObject ? (
|
||||
<div className="location-map-zone-items">
|
||||
<h2>{selectedObject.label || selectedObject.zone_name || "Map Area"}</h2>
|
||||
<div className="location-map-zone-items-title">
|
||||
<h2>{selectedTitle}</h2>
|
||||
<span>{selectedZoneItems.length}</span>
|
||||
</div>
|
||||
{selectedZoneItems.length === 0 ? (
|
||||
<p className="location-map-muted">No visible items assigned to this zone.</p>
|
||||
) : (
|
||||
@ -165,7 +223,7 @@ export default function LocationMapBottomSheet({
|
||||
</div>
|
||||
) : (
|
||||
<p className="location-map-muted">
|
||||
{mode === "edit" ? "Tap a rectangle to edit it." : "Tap a zone to view assigned items."}
|
||||
Tap a zone to view assigned items. Item pins stay hidden unless you turn them on in Layers.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
|
||||
export default function LocationMapCanvas({
|
||||
mode,
|
||||
editorTool,
|
||||
objects,
|
||||
filters,
|
||||
mapSize,
|
||||
@ -20,11 +21,15 @@ export default function LocationMapCanvas({
|
||||
remember,
|
||||
updateObjects,
|
||||
}) {
|
||||
const canEditObjects = mode === "edit" && editorTool === "edit";
|
||||
|
||||
const startDrag = (event, object, type) => {
|
||||
if (mode !== "edit" || object.locked || !svgRef.current) return;
|
||||
if (!canEditObjects || object.locked || !svgRef.current) return;
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
remember();
|
||||
const point = clientPointToMap(event, svgRef.current, mapSize);
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
setSelectedObjectKey(getObjectKey(object));
|
||||
setDragState({
|
||||
type,
|
||||
@ -36,6 +41,7 @@ export default function LocationMapCanvas({
|
||||
|
||||
const handlePointerMove = (event) => {
|
||||
if (!dragState || !svgRef.current) return;
|
||||
event.preventDefault();
|
||||
const point = clientPointToMap(event, svgRef.current, mapSize);
|
||||
const deltaX = point.x - dragState.startPoint.x;
|
||||
const deltaY = point.y - dragState.startPoint.y;
|
||||
@ -63,12 +69,33 @@ export default function LocationMapCanvas({
|
||||
setDragState(null);
|
||||
};
|
||||
|
||||
const handleObjectClick = (event, object) => {
|
||||
event.stopPropagation();
|
||||
if (mode === "view" || canEditObjects) {
|
||||
setSelectedObjectKey(getObjectKey(object));
|
||||
}
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
if (!dragState) {
|
||||
setSelectedObjectKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const fittedLabel = (object, fallback) => {
|
||||
const rawLabel = object.label || object.zone_name || fallback;
|
||||
const maxCharacters = Math.max(8, Math.floor((Number(object.width) - 28) / 14));
|
||||
if (rawLabel.length <= maxCharacters) return rawLabel;
|
||||
return `${rawLabel.slice(0, Math.max(5, maxCharacters - 3))}...`;
|
||||
};
|
||||
|
||||
const renderMapObject = (object, index) => {
|
||||
if (!object.visible || !filters.showZones) return null;
|
||||
const key = getObjectKey(object);
|
||||
const isSelected = key === selectedObjectKey;
|
||||
const zoneItems = itemsForZone(mapItems, object.zone_id, filters, username);
|
||||
const count = zoneItems.length;
|
||||
const countLabel = `${count} item${count === 1 ? "" : "s"}`;
|
||||
const pinDots = filters.showPins
|
||||
? zoneItems.slice(0, 12).map((item, itemIndex) => {
|
||||
const column = itemIndex % 4;
|
||||
@ -88,7 +115,11 @@ export default function LocationMapCanvas({
|
||||
return (
|
||||
<g
|
||||
key={key}
|
||||
className={`location-map-object ${isSelected ? "is-selected" : ""}`}
|
||||
className={[
|
||||
"location-map-object",
|
||||
isSelected ? "is-selected" : "",
|
||||
canEditObjects ? "is-editable" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
transform={`rotate(${object.rotation || 0} ${object.x + object.width / 2} ${object.y + object.height / 2})`}
|
||||
>
|
||||
<rect
|
||||
@ -100,27 +131,27 @@ export default function LocationMapCanvas({
|
||||
role="button"
|
||||
aria-label={`Map area ${object.label || index + 1}`}
|
||||
onPointerDown={(event) => startDrag(event, object, "move")}
|
||||
onClick={() => setSelectedObjectKey(key)}
|
||||
onClick={(event) => handleObjectClick(event, object)}
|
||||
/>
|
||||
{filters.showLabels ? (
|
||||
<text x={object.x + 16} y={object.y + 28} className="location-map-label">
|
||||
{object.label || object.zone_name || "Area"}
|
||||
{fittedLabel(object, "Area")}
|
||||
</text>
|
||||
) : null}
|
||||
{filters.showCounts ? (
|
||||
<text x={object.x + 16} y={object.y + 52} className="location-map-count">
|
||||
{count} item{count === 1 ? "" : "s"}
|
||||
{countLabel}
|
||||
</text>
|
||||
) : null}
|
||||
{pinDots}
|
||||
{mode === "edit" && isSelected ? (
|
||||
{canEditObjects && isSelected ? (
|
||||
<rect
|
||||
className="location-map-resize-handle"
|
||||
x={object.x + object.width - 24}
|
||||
y={object.y + object.height - 24}
|
||||
width="24"
|
||||
height="24"
|
||||
rx="6"
|
||||
x={object.x + object.width - 42}
|
||||
y={object.y + object.height - 42}
|
||||
width="38"
|
||||
height="38"
|
||||
rx="10"
|
||||
onPointerDown={(event) => startDrag(event, object, "resize")}
|
||||
/>
|
||||
) : null}
|
||||
@ -129,11 +160,20 @@ export default function LocationMapCanvas({
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="location-map-canvas-shell">
|
||||
<section
|
||||
className={[
|
||||
"location-map-canvas-shell",
|
||||
mode === "edit" ? "is-edit-mode" : "is-view-mode",
|
||||
editorTool === "edit" ? "is-object-tool" : "is-pan-tool",
|
||||
].filter(Boolean).join(" ")}
|
||||
>
|
||||
<div className="location-map-scroll">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="location-map-svg"
|
||||
className={[
|
||||
"location-map-svg",
|
||||
canEditObjects ? "is-editable" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
style={{
|
||||
width: `${mapSize.width * zoom}px`,
|
||||
height: `${mapSize.height * zoom}px`,
|
||||
@ -144,6 +184,7 @@ export default function LocationMapCanvas({
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
onClick={clearSelection}
|
||||
>
|
||||
<defs>
|
||||
<pattern id="location-map-grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
@ -156,6 +197,11 @@ export default function LocationMapCanvas({
|
||||
) : null}
|
||||
{objects.map(renderMapObject)}
|
||||
</svg>
|
||||
{objects.length === 0 ? (
|
||||
<div className="location-map-empty-canvas">
|
||||
No map areas yet
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@ -1,43 +1,84 @@
|
||||
export default function LocationMapToolbar({
|
||||
mode,
|
||||
editorTool,
|
||||
hasAnyMap,
|
||||
canManage,
|
||||
zoom,
|
||||
setZoom,
|
||||
onFit,
|
||||
onView,
|
||||
onEdit,
|
||||
onPanMode,
|
||||
onEditObjects,
|
||||
}) {
|
||||
return (
|
||||
<section className="location-map-toolbar" aria-label="Map controls">
|
||||
<div className="location-map-mode-buttons">
|
||||
<button
|
||||
type="button"
|
||||
className={mode === "view" ? "active" : ""}
|
||||
disabled={!hasAnyMap}
|
||||
onClick={onView}
|
||||
>
|
||||
View
|
||||
</button>
|
||||
{canManage ? (
|
||||
<div className="location-map-mode-group">
|
||||
<div className="location-map-mode-buttons" aria-label="Map mode">
|
||||
<button
|
||||
type="button"
|
||||
className={mode === "edit" ? "active" : ""}
|
||||
className={mode === "view" ? "active" : ""}
|
||||
disabled={!hasAnyMap}
|
||||
onClick={onEdit}
|
||||
onClick={onView}
|
||||
aria-pressed={mode === "view"}
|
||||
>
|
||||
Edit
|
||||
View
|
||||
</button>
|
||||
{canManage ? (
|
||||
<button
|
||||
type="button"
|
||||
className={mode === "edit" ? "active" : ""}
|
||||
disabled={!hasAnyMap}
|
||||
onClick={onEdit}
|
||||
aria-pressed={mode === "edit"}
|
||||
>
|
||||
Edit Draft
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{mode === "edit" && canManage ? (
|
||||
<div className="location-map-tool-buttons" aria-label="Edit tool">
|
||||
<button
|
||||
type="button"
|
||||
className={editorTool === "pan" ? "active is-pan" : ""}
|
||||
onClick={onPanMode}
|
||||
aria-pressed={editorTool === "pan"}
|
||||
>
|
||||
Pan Mode
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={editorTool === "edit" ? "active is-edit" : ""}
|
||||
onClick={onEditObjects}
|
||||
aria-pressed={editorTool === "edit"}
|
||||
>
|
||||
Edit Objects
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="location-map-zoom-controls">
|
||||
<button type="button" onClick={() => setZoom((value) => Math.max(0.45, value - 0.15))}>
|
||||
<div className="location-map-zoom-controls" aria-label="Zoom controls">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoom((value) => Math.max(0.45, value - 0.15))}
|
||||
disabled={!hasAnyMap}
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span>{Math.round(zoom * 100)}%</span>
|
||||
<button type="button" onClick={() => setZoom((value) => Math.min(1.6, value + 0.15))}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoom((value) => Math.min(1.6, value + 0.15))}
|
||||
disabled={!hasAnyMap}
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button type="button" onClick={onFit} disabled={!hasAnyMap}>
|
||||
Fit
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@ -15,6 +15,7 @@ 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 {
|
||||
@ -46,6 +47,7 @@ export default function LocationMapManager() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [mode, setMode] = useState("setup");
|
||||
const [editorTool, setEditorTool] = useState("pan");
|
||||
const [previewDraft, setPreviewDraft] = useState(false);
|
||||
const [mapDraft, setMapDraft] = useState({
|
||||
name: "Store Map",
|
||||
@ -55,10 +57,12 @@ export default function LocationMapManager() {
|
||||
const [objects, setObjects] = useState([]);
|
||||
const [selectedObjectKey, setSelectedObjectKey] = useState(null);
|
||||
const [filters, setFilters] = useState(DEFAULT_MAP_FILTERS);
|
||||
const [layersOpen, setLayersOpen] = 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);
|
||||
|
||||
@ -87,6 +91,7 @@ export default function LocationMapManager() {
|
||||
|
||||
if (!nextState.draft_map && !nextState.published_map) {
|
||||
setMode("setup");
|
||||
setEditorTool("pan");
|
||||
setPreviewDraft(false);
|
||||
setObjects([]);
|
||||
setMapDraft({
|
||||
@ -99,9 +104,11 @@ export default function LocationMapManager() {
|
||||
|
||||
if (nextState.published_map) {
|
||||
setMode("view");
|
||||
setEditorTool("pan");
|
||||
setPreviewDraft(false);
|
||||
} else if (nextState.draft_map) {
|
||||
setMode("setup");
|
||||
setEditorTool("pan");
|
||||
setPreviewDraft(true);
|
||||
}
|
||||
|
||||
@ -161,6 +168,17 @@ export default function LocationMapManager() {
|
||||
setFuture([]);
|
||||
}, [mapState, mode, previewDraft]);
|
||||
|
||||
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([]);
|
||||
@ -180,6 +198,7 @@ export default function LocationMapManager() {
|
||||
const response = await createBlankLocationMap(activeHousehold.id, locationId, mapDraft);
|
||||
setMapState(response.data);
|
||||
setMode("edit");
|
||||
setEditorTool("edit");
|
||||
setPreviewDraft(true);
|
||||
toast.success("Created map", "Blank draft map created");
|
||||
} catch (error) {
|
||||
@ -196,6 +215,7 @@ export default function LocationMapManager() {
|
||||
const response = await createLocationMapFromZones(activeHousehold.id, locationId, mapDraft);
|
||||
setMapState(response.data);
|
||||
setMode("edit");
|
||||
setEditorTool("edit");
|
||||
setPreviewDraft(true);
|
||||
toast.success("Created starter map", "Zones were added as editable rectangles");
|
||||
} catch (error) {
|
||||
@ -231,6 +251,7 @@ export default function LocationMapManager() {
|
||||
const response = await publishLocationMapDraft(activeHousehold.id, locationId);
|
||||
setMapState(response.data);
|
||||
setMode("view");
|
||||
setEditorTool("pan");
|
||||
setPreviewDraft(false);
|
||||
toast.success("Published map", "Map is now visible to household members");
|
||||
} catch (error) {
|
||||
@ -248,6 +269,7 @@ export default function LocationMapManager() {
|
||||
const nextObject = createClientObject(nextZone, objects.length);
|
||||
setObjects((currentObjects) => [...currentObjects, nextObject]);
|
||||
setSelectedObjectKey(getObjectKey(nextObject));
|
||||
setEditorTool("edit");
|
||||
};
|
||||
|
||||
const handleDuplicateObject = () => {
|
||||
@ -264,15 +286,26 @@ export default function LocationMapManager() {
|
||||
};
|
||||
setObjects((currentObjects) => [...currentObjects, clampObjectToMap(nextObject, mapSize)]);
|
||||
setSelectedObjectKey(getObjectKey(nextObject));
|
||||
setEditorTool("edit");
|
||||
};
|
||||
|
||||
const requestDeleteObject = () => {
|
||||
if (!selectedObject) return;
|
||||
setPendingDeleteObject(selectedObject);
|
||||
};
|
||||
|
||||
const handleDeleteObject = () => {
|
||||
if (!selectedObject) return;
|
||||
if (!pendingDeleteObject) return;
|
||||
remember();
|
||||
setObjects((currentObjects) =>
|
||||
currentObjects.filter((object) => getObjectKey(object) !== getObjectKey(selectedObject))
|
||||
currentObjects.filter((object) => getObjectKey(object) !== getObjectKey(pendingDeleteObject))
|
||||
);
|
||||
setSelectedObjectKey(null);
|
||||
toast.success(
|
||||
"Deleted map area",
|
||||
"Save the draft to keep this change."
|
||||
);
|
||||
setPendingDeleteObject(null);
|
||||
};
|
||||
|
||||
const handleObjectField = (field, value) => {
|
||||
@ -323,6 +356,16 @@ export default function LocationMapManager() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleFitMap = () => {
|
||||
const availableWidth = typeof window === "undefined"
|
||||
? DEFAULT_MAP_SIZE.width
|
||||
: window.innerWidth >= 840
|
||||
? window.innerWidth - 420
|
||||
: window.innerWidth - 24;
|
||||
const nextZoom = Math.max(0.45, Math.min(1.6, (availableWidth - 24) / mapSize.width));
|
||||
setZoom(Number(nextZoom.toFixed(2)));
|
||||
};
|
||||
|
||||
if (!hasLoaded || householdLoading || loading) {
|
||||
return (
|
||||
<div className="location-map-page">
|
||||
@ -349,18 +392,24 @@ export default function LocationMapManager() {
|
||||
<main className="location-map-workspace">
|
||||
<LocationMapToolbar
|
||||
mode={mode}
|
||||
editorTool={editorTool}
|
||||
hasAnyMap={hasAnyMap}
|
||||
canManage={canManage}
|
||||
zoom={zoom}
|
||||
setZoom={setZoom}
|
||||
onFit={handleFitMap}
|
||||
onView={() => {
|
||||
setMode("view");
|
||||
setEditorTool("pan");
|
||||
setPreviewDraft(false);
|
||||
}}
|
||||
onEdit={() => {
|
||||
setMode("edit");
|
||||
setEditorTool("pan");
|
||||
setPreviewDraft(true);
|
||||
}}
|
||||
onPanMode={() => setEditorTool("pan")}
|
||||
onEditObjects={() => setEditorTool("edit")}
|
||||
/>
|
||||
|
||||
{!hasAnyMap || (mode === "setup" && mapState?.draft_map && !mapState?.published_map) ? (
|
||||
@ -381,6 +430,7 @@ export default function LocationMapManager() {
|
||||
<>
|
||||
<LocationMapCanvas
|
||||
mode={mode}
|
||||
editorTool={editorTool}
|
||||
objects={objects}
|
||||
filters={filters}
|
||||
mapSize={mapSize}
|
||||
@ -397,9 +447,12 @@ export default function LocationMapManager() {
|
||||
/>
|
||||
<LocationMapBottomSheet
|
||||
mode={mode}
|
||||
editorTool={editorTool}
|
||||
canManage={canManage}
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
layersOpen={layersOpen}
|
||||
setLayersOpen={setLayersOpen}
|
||||
selectedObject={selectedObject}
|
||||
selectedZoneItems={selectedZoneItems}
|
||||
visibleUnmappedItems={visibleUnmappedItems}
|
||||
@ -412,18 +465,30 @@ export default function LocationMapManager() {
|
||||
onRedo={handleRedo}
|
||||
onSaveDraft={handleSaveDraft}
|
||||
onPublish={handlePublish}
|
||||
onPanMode={() => setEditorTool("pan")}
|
||||
onEditObjects={() => setEditorTool("edit")}
|
||||
onEditMode={() => {
|
||||
setMode("edit");
|
||||
setEditorTool("pan");
|
||||
setPreviewDraft(true);
|
||||
}}
|
||||
onObjectField={handleObjectField}
|
||||
onZoneLinkChange={handleZoneLinkChange}
|
||||
onDuplicateObject={handleDuplicateObject}
|
||||
onDeleteObject={handleDeleteObject}
|
||||
onDeleteObject={requestDeleteObject}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<ConfirmSlideModal
|
||||
isOpen={Boolean(pendingDeleteObject)}
|
||||
title={`Delete ${pendingDeleteObject?.label || "this map area"}?`}
|
||||
description="This removes the area from the draft map. Save the draft after deleting to keep the change."
|
||||
confirmLabel="Delete Area"
|
||||
onClose={() => setPendingDeleteObject(null)}
|
||||
onConfirm={handleDeleteObject}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
.location-map-page {
|
||||
min-height: calc(100vh - 56px);
|
||||
background: var(--color-bg-body);
|
||||
color: var(--color-text-primary);
|
||||
height: calc(100dvh - 56px);
|
||||
min-height: 0;
|
||||
background: #07111d;
|
||||
color: #f8fafc;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.location-map-topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
position: relative;
|
||||
z-index: 25;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
@ -25,8 +26,8 @@
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
background: #15283b;
|
||||
color: #f8fafc;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
@ -41,7 +42,7 @@
|
||||
|
||||
.location-map-title h1 {
|
||||
font-size: clamp(1rem, 3vw, 1.35rem);
|
||||
color: var(--color-text-primary);
|
||||
color: #f8fafc;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@ -50,7 +51,7 @@
|
||||
.location-map-title span {
|
||||
display: block;
|
||||
margin-top: 0.15rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: #9fb3c8;
|
||||
font-size: 0.86rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@ -62,7 +63,7 @@
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 999px;
|
||||
color: var(--color-text-primary);
|
||||
color: #f8fafc;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
@ -85,19 +86,33 @@
|
||||
.location-map-workspace {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.location-map-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem clamp(0.75rem, 2vw, 1.25rem);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
background: rgba(11, 19, 31, 0.98);
|
||||
}
|
||||
|
||||
.location-map-mode-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.location-map-mode-buttons,
|
||||
.location-map-tool-buttons,
|
||||
.location-map-zoom-controls,
|
||||
.location-map-editor-actions {
|
||||
display: flex;
|
||||
@ -106,6 +121,7 @@
|
||||
}
|
||||
|
||||
.location-map-mode-buttons button,
|
||||
.location-map-tool-buttons button,
|
||||
.location-map-zoom-controls button,
|
||||
.location-map-editor-actions button,
|
||||
.location-map-setup-actions button {
|
||||
@ -113,18 +129,32 @@
|
||||
padding: 0.55rem 0.8rem;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
background: #15283b;
|
||||
color: #f8fafc;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.location-map-mode-buttons button.active,
|
||||
.location-map-editor-actions button:nth-last-child(2),
|
||||
.location-map-tool-buttons button.active,
|
||||
.location-map-primary-actions button:nth-child(2),
|
||||
.location-map-primary-actions button:nth-child(3),
|
||||
.location-map-setup-actions .btn-primary {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
color: #06111f;
|
||||
}
|
||||
|
||||
.location-map-tool-buttons button.is-pan.active {
|
||||
border-color: rgba(245, 158, 11, 0.78);
|
||||
background: rgba(245, 158, 11, 0.22);
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.location-map-tool-buttons button.is-edit.active {
|
||||
border-color: rgb(45, 212, 191);
|
||||
background: rgba(20, 184, 166, 0.24);
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.location-map-editor-actions button.danger {
|
||||
@ -133,6 +163,7 @@
|
||||
}
|
||||
|
||||
.location-map-mode-buttons button:disabled,
|
||||
.location-map-tool-buttons button:disabled,
|
||||
.location-map-zoom-controls button:disabled,
|
||||
.location-map-editor-actions button:disabled,
|
||||
.location-map-setup-actions button:disabled {
|
||||
@ -142,7 +173,7 @@
|
||||
|
||||
.location-map-zoom-controls span {
|
||||
min-width: 54px;
|
||||
color: var(--color-text-secondary);
|
||||
color: #cbd5e1;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
@ -150,18 +181,25 @@
|
||||
|
||||
.location-map-canvas-shell {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
max-width: 100vw;
|
||||
overflow: hidden;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.location-map-scroll {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
min-height: 48vh;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(20, 184, 166, 0.08), transparent 40%),
|
||||
linear-gradient(180deg, rgba(15, 23, 42, 0.2), rgba(15, 23, 42, 0.04));
|
||||
background-color: #101b2a;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
|
||||
@ -171,6 +209,10 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.location-map-svg.is-editable {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.location-map-background {
|
||||
fill: rgba(15, 23, 34, 0.92);
|
||||
stroke: rgba(148, 163, 184, 0.28);
|
||||
@ -188,16 +230,25 @@
|
||||
stroke: rgba(96, 165, 250, 0.72);
|
||||
stroke-width: 2;
|
||||
cursor: pointer;
|
||||
transition: fill 120ms ease, stroke 120ms ease, stroke-width 120ms ease;
|
||||
}
|
||||
|
||||
.location-map-object.is-editable rect:first-child {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.location-map-object.is-editable rect:first-child:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.location-map-object.is-selected rect:first-child {
|
||||
fill: rgba(20, 184, 166, 0.26);
|
||||
fill: rgba(20, 184, 166, 0.34);
|
||||
stroke: rgb(45, 212, 191);
|
||||
stroke-width: 4;
|
||||
}
|
||||
|
||||
.location-map-label {
|
||||
fill: var(--color-text-primary);
|
||||
fill: #f8fafc;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
pointer-events: none;
|
||||
@ -222,6 +273,23 @@
|
||||
stroke: rgba(15, 23, 42, 0.9);
|
||||
stroke-width: 2;
|
||||
cursor: nwse-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.location-map-empty-canvas {
|
||||
position: sticky;
|
||||
left: 50%;
|
||||
bottom: 1rem;
|
||||
width: fit-content;
|
||||
max-width: calc(100% - 2rem);
|
||||
transform: translateX(-50%);
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 999px;
|
||||
background: rgba(17, 28, 42, 0.9);
|
||||
color: #cbd5e1;
|
||||
font-weight: 800;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.location-map-bottom-sheet {
|
||||
@ -236,13 +304,46 @@
|
||||
.location-map-sheet-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.location-map-sheet-header > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.location-map-sheet-header strong {
|
||||
display: block;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.location-map-sheet-header > div span {
|
||||
display: block;
|
||||
margin-top: 0.2rem;
|
||||
color: #9fb3c8;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.location-map-sheet-header span,
|
||||
.location-map-muted {
|
||||
color: var(--color-text-secondary);
|
||||
color: #9fb3c8;
|
||||
}
|
||||
|
||||
.location-map-layer-toggle {
|
||||
min-height: 40px;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 34, 0.72);
|
||||
color: #f8fafc;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.location-map-layer-toggle.active {
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(59, 130, 246, 0.22);
|
||||
}
|
||||
|
||||
.location-map-display-controls {
|
||||
@ -253,7 +354,7 @@
|
||||
}
|
||||
|
||||
.location-map-display-controls label {
|
||||
min-height: 34px;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
@ -261,7 +362,7 @@
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 34, 0.58);
|
||||
color: var(--color-text-primary);
|
||||
color: #f8fafc;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
@ -276,6 +377,31 @@
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.location-map-primary-actions button {
|
||||
flex: 1 1 96px;
|
||||
}
|
||||
|
||||
.location-map-mobile-tool-switch {
|
||||
display: none;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.location-map-mobile-tool-switch button {
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 8px;
|
||||
background: #15283b;
|
||||
color: #f8fafc;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.location-map-mobile-tool-switch button.active {
|
||||
border-color: rgb(45, 212, 191);
|
||||
background: rgba(20, 184, 166, 0.24);
|
||||
}
|
||||
|
||||
.location-map-object-form {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
@ -284,7 +410,7 @@
|
||||
.location-map-object-form label {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: #9fb3c8;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
@ -295,8 +421,8 @@
|
||||
padding: 0.55rem 0.65rem;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
background: #15283b;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.location-map-object-grid {
|
||||
@ -306,10 +432,28 @@
|
||||
}
|
||||
|
||||
.location-map-zone-items h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.location-map-zone-items-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
.location-map-zone-items-title span {
|
||||
min-width: 34px;
|
||||
padding: 0.2rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(96, 165, 250, 0.18);
|
||||
color: #f8fafc;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 900;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.location-map-zone-items ul,
|
||||
.location-map-unmapped-list ul {
|
||||
display: grid;
|
||||
@ -327,10 +471,11 @@
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 34, 0.56);
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.location-map-zone-items small {
|
||||
color: var(--color-text-secondary);
|
||||
color: #cbd5e1;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@ -350,7 +495,7 @@
|
||||
padding: 1.15rem;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-surface);
|
||||
background: #111c2a;
|
||||
}
|
||||
|
||||
.location-map-setup h2 {
|
||||
@ -360,7 +505,7 @@
|
||||
|
||||
.location-map-setup p {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: #9fb3c8;
|
||||
}
|
||||
|
||||
.location-map-setup-actions {
|
||||
@ -370,7 +515,7 @@
|
||||
|
||||
.location-map-loading {
|
||||
margin: 3rem auto;
|
||||
color: var(--color-text-secondary);
|
||||
color: #9fb3c8;
|
||||
}
|
||||
|
||||
@media (min-width: 840px) {
|
||||
@ -391,6 +536,14 @@
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.location-map-canvas-shell {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.location-map-bottom-sheet {
|
||||
max-height: 38dvh;
|
||||
}
|
||||
|
||||
.location-map-topbar {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
@ -402,6 +555,28 @@
|
||||
|
||||
.location-map-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.location-map-mode-group,
|
||||
.location-map-mode-buttons,
|
||||
.location-map-tool-buttons,
|
||||
.location-map-zoom-controls {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.location-map-mode-buttons button,
|
||||
.location-map-tool-buttons button,
|
||||
.location-map-zoom-controls button {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.location-map-tool-buttons {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.location-map-mobile-tool-switch {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.location-map-display-controls {
|
||||
|
||||
@ -234,6 +234,8 @@ test("admin creates a map from zones, saves a draft, and publishes it", async ({
|
||||
await page.getByRole("button", { name: "Create Map From Existing Zones" }).click();
|
||||
await expect(page.getByText("Bakery")).toBeVisible();
|
||||
await expect(page.getByText("Frozen Foods")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Pan Mode" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Edit Objects" })).toBeVisible();
|
||||
await expect(page.locator(".location-map-pin")).toHaveCount(0);
|
||||
|
||||
const bakeryObject = page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first();
|
||||
@ -258,6 +260,7 @@ test("admin creates a map from zones, saves a draft, and publishes it", async ({
|
||||
await expect(page.getByText("sourdough")).toBeVisible();
|
||||
await expect(page.getByText("frozen salmon")).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "Layers" }).click();
|
||||
await page.getByLabel("Unmapped").check();
|
||||
await expect(page.getByText("loose batteries")).toBeVisible();
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user