Add store location map manager #20

Open
nalalangan wants to merge 206 commits from feature/location-map-manager into feature/store-selector-modal
6 changed files with 476 additions and 88 deletions
Showing only changes of commit c100174ea8 - Show all commits

View File

@ -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>
)}

View File

@ -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>
);

View File

@ -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>
);

View File

@ -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>
);
}

View File

@ -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 {

View File

@ -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();
});