Compare commits

...

234 Commits

Author SHA1 Message Date
Nico
815cd1b37f fix: trim map message empty state 2026-06-17 10:36:26 -07:00
Nico
0580914516 fix: compact map confirmation dialogs 2026-06-17 00:49:02 -07:00
Nico
c9fbbafe58 fix: focus selected map area editor 2026-06-17 00:34:35 -07:00
Nico
5ba538065f fix: expand map from every edge 2026-06-17 00:22:01 -07:00
Nico
1c50c67410 fix: expand map while dragging areas 2026-06-16 22:44:32 -07:00
Nico
dedfc30998 test: cover member map buying flow 2026-06-16 22:30:55 -07:00
Nico
5ad3c46836 fix: allow private lan cors origins 2026-06-16 09:13:17 -07:00
Nico
1c081038dd fix: adjust buy quantities with arrow keys 2026-06-16 00:52:26 -07:00
Nico
37614ea499 fix: navigate buy modal with arrow keys 2026-06-16 00:39:14 -07:00
Nico
25b1efc214 fix: keep added map areas in bounds 2026-06-16 00:25:45 -07:00
Nico
fc883e9f62 fix: request map area delete from keyboard 2026-06-16 00:20:12 -07:00
Nico
d3194959fc fix: save map drafts with shortcut 2026-06-16 00:15:03 -07:00
Nico
662fdbb6c5 fix: close selected map area with escape 2026-06-16 00:03:49 -07:00
Nico
11bace3c1b fix: support map undo shortcuts 2026-06-15 23:55:32 -07:00
Nico
ff68413e62 fix: close map layers with escape 2026-06-15 23:35:13 -07:00
Nico
407d8a6943 fix: expose map item panels 2026-06-15 23:27:09 -07:00
Nico
82eab81149 fix: expose map layer filter group 2026-06-15 23:19:40 -07:00
Nico
35ce919cd2 fix: expose map overview sections 2026-06-15 23:08:31 -07:00
Nico
32a4724858 fix: expose map status to assistive tech 2026-06-15 23:00:15 -07:00
Nico
57ed1dc70f fix: name selected map area actions 2026-06-15 18:11:24 -07:00
Nico
7251cec05e fix: name map buy modal dialog 2026-06-15 16:47:04 -07:00
Nico
567d4b6088 fix: close map buy modal with escape 2026-06-15 16:27:14 -07:00
Nico
853a30093f fix: label map buy quantity controls 2026-06-15 16:22:31 -07:00
Nico
b09edae159 fix: clear map leave guard history 2026-06-15 16:11:20 -07:00
Nico
14c01d6152 fix: improve map buy modal controls 2026-06-15 16:03:46 -07:00
Nico
984b55a4da fix: clear clean map undo state 2026-06-15 15:27:30 -07:00
Nico
4a866763ac fix: guard map browser history leaves 2026-06-15 15:08:07 -07:00
Nico
ed40554ef3 fix: preserve map selection history 2026-06-15 13:21:48 -07:00
Nico
dcafea4a95 fix: ignore no-op map field edits 2026-06-14 23:50:34 -07:00
Nico
2fb7bc6d45 fix: reset expanded map item lists 2026-06-14 23:42:41 -07:00
Nico
66d217dfc0 fix: keep selected map area after publish 2026-06-14 23:16:29 -07:00
Nico
4290b5bb50 fix: keep selected map area in view mode 2026-06-14 23:08:14 -07:00
Nico
d116129c62 fix: simplify map state messages 2026-06-14 22:58:28 -07:00
Nico
a482828244 fix: support lan dev api access 2026-06-04 23:42:32 -07:00
Nico
8ba41b06c6 fix: stabilize mobile map toolbar 2026-06-04 23:33:02 -07:00
Nico
932033682d fix: ignore no-op map edge edits 2026-06-04 23:21:07 -07:00
Nico
ac028bc446 fix: keep map area edits inside canvas 2026-06-04 23:09:28 -07:00
Nico
03a4817c5e fix: keep selected map handle reachable on zoom 2026-06-04 22:57:26 -07:00
Nico
870253eee5 fix: refit mobile maps after viewport changes 2026-06-04 22:45:40 -07:00
Nico
90404b778a fix: keep selected map area reachable 2026-06-04 22:26:30 -07:00
Nico
771436b4ca fix: simplify map edit controls 2026-06-04 22:12:47 -07:00
Nico
8ad160196c fix: simplify draft preview controls 2026-06-04 21:54:10 -07:00
Nico
95afcf3978 fix: announce map area item counts 2026-06-04 21:46:59 -07:00
Nico
1ab359d343 fix: announce locked map areas 2026-06-04 21:35:10 -07:00
Nico
67ae2763b2 fix: show locked map area badge 2026-06-04 21:25:15 -07:00
Nico
691d27fe1b fix: clarify map area toggle states 2026-06-04 21:14:45 -07:00
Nico
635347bea2 fix: clarify member map empty state 2026-06-04 21:05:48 -07:00
Nico
4c753579dd fix: stabilize map toolbar controls 2026-06-04 20:54:53 -07:00
Nico
05c9577c71 fix: size map item actions for touch 2026-06-04 20:35:38 -07:00
Nico
40776821a6 fix: omit redundant unmapped map heading 2026-06-04 20:28:14 -07:00
Nico
2af22f6bb2 fix: compact map layer reset 2026-06-04 20:20:43 -07:00
Nico
1020d11382 fix: unify map edit tool toggle 2026-06-04 20:13:31 -07:00
Nico
a7a4a8714a fix: stabilize mobile map toolbar 2026-06-04 20:06:50 -07:00
Nico
4e276797c6 fix: clarify draft viewing status 2026-06-04 19:52:02 -07:00
Nico
e5927a4811 fix: clarify draft view action 2026-06-04 19:41:52 -07:00
Nico
0c8f18e1e5 fix: hide unavailable map draft actions 2026-06-04 19:29:17 -07:00
Nico
15324c25f2 fix: compact map preview topbar 2026-06-04 19:03:43 -07:00
Nico
3bd55846a4 fix: hide zero map overview rows 2026-06-04 18:54:08 -07:00
Nico
a45f02b727 fix: hide zero draft summary rows 2026-06-04 18:39:51 -07:00
Nico
5262e89d11 fix: omit empty zone detail rows 2026-06-04 18:28:21 -07:00
Nico
7af995f561 fix: hide unavailable map setup action 2026-06-04 18:19:06 -07:00
Nico
76f3b8b086 fix: compact map setup panel 2026-06-04 18:09:06 -07:00
Nico
a2db8cd655 fix: compact map state cards 2026-06-04 17:59:24 -07:00
Nico
5f1eb30080 fix: stabilize map history controls 2026-06-04 17:47:29 -07:00
Nico
11979af190 fix: keep selected map areas on top 2026-06-04 17:37:19 -07:00
Nico
85061b93d1 fix: compact map toolbar icons 2026-06-04 17:25:35 -07:00
Nico
f25307cb47 refactor: extract map draft state helpers 2026-06-04 17:14:50 -07:00
Nico
f397f19e48 refactor: split location map manager 2026-06-04 17:04:09 -07:00
Nico
26b05d9729 test: cover fitted map panning 2026-06-04 16:49:33 -07:00
Nico
0778c1a71c fix: fit compact store maps 2026-06-04 16:41:28 -07:00
Nico
bf60bd6a3f fix: auto-fit mobile store maps 2026-06-04 16:18:11 -07:00
Nico
faf96ef929 fix: stabilize mobile map toolbar 2026-06-04 16:00:37 -07:00
Nico
155ab77541 fix: compact map message state 2026-06-04 15:47:20 -07:00
Nico
1a01c788f6 fix: compact map load error state 2026-06-04 15:37:15 -07:00
Nico
b96cb65d6f fix: compact map sheet empty states 2026-06-04 15:28:43 -07:00
Nico
913d458fd6 fix: add map sheet close action 2026-06-04 15:17:06 -07:00
Nico
9f4420b23c fix: close map layers on mode switches 2026-06-04 15:03:03 -07:00
Nico
0d05bf761e fix: keep map context while loading 2026-06-04 14:51:48 -07:00
Nico
8f06f3e072 fix: ignore stale map buy responses 2026-06-04 14:42:23 -07:00
Nico
cfa6e70c13 fix: unlock map controls after stale save 2026-06-04 14:29:55 -07:00
Nico
9772d7e043 fix: ignore stale map draft action responses 2026-06-04 14:18:16 -07:00
Nico
27a50d599c fix: close stale map delete confirmation 2026-06-04 14:07:19 -07:00
Nico
a2837facaa fix: close stale map buy modal on location change 2026-06-04 13:59:12 -07:00
Nico
5e5b6f9cbd fix: reset map layers on location load 2026-06-04 13:49:25 -07:00
Nico
10d442698b fix: compact mobile map toolbar 2026-06-04 13:40:08 -07:00
Nico
a862eba0fa fix: keep selected map area after save 2026-06-04 13:31:32 -07:00
Nico
64b8071578 fix: reset map scroll on fit 2026-06-04 13:20:57 -07:00
Nico
37a7b79cd8 test: cover unmapped bought map items 2026-06-04 13:09:43 -07:00
Nico
8510c1180a fix: keep bought map items in layer state 2026-06-04 13:01:22 -07:00
Nico
e0417783e9 fix: stabilize mobile map toolbar 2026-06-04 12:45:17 -07:00
Nico
8614c0d984 fix: include recent bought items in map data 2026-06-04 12:37:53 -07:00
Nico
48aab43220 test: cover location map draft visibility 2026-06-04 12:28:02 -07:00
Nico
1874851b37 test: update buy modal e2e for location lists 2026-06-04 12:20:04 -07:00
Nico
d5b38b6711 fix: align buy modal image placeholder 2026-06-04 12:09:57 -07:00
Nico
abda001208 fix: keep map buy flow moving after refresh failure 2026-06-04 11:54:57 -07:00
Nico
bddae7d489 fix: advance map buy modal after partial buys 2026-06-04 11:38:10 -07:00
Nico
628a4f48c2 fix: expand long zone map item lists 2026-06-04 11:29:52 -07:00
Nico
4c42808396 fix: expand long unmapped map lists 2026-06-04 11:19:43 -07:00
Nico
1658b7f979 fix: buy unmapped map items in place 2026-06-04 11:11:01 -07:00
Nico
a92c0a69c2 fix: prevent rebuying map completed items 2026-06-04 10:56:31 -07:00
Nico
35b514911b fix: buy map zone items in place 2026-06-04 10:48:06 -07:00
Nico
ada2193085 fix: clear map selection with escape 2026-06-04 10:27:36 -07:00
Nico
1b7aaa1989 fix: collapse map layers on zone selection 2026-06-04 10:17:30 -07:00
Nico
6a5d90df09 fix: focus selected map zone details 2026-06-04 10:06:54 -07:00
Nico
95ac122266 fix: stabilize map toolbar controls 2026-06-04 09:49:20 -07:00
Nico
aff9b2cac9 fix: add mobile map sheet handle 2026-06-04 09:28:26 -07:00
Nico
604025588e refactor: extract map view controls 2026-06-04 09:13:39 -07:00
Nico
bc7b8e41f8 fix: stabilize mobile map toolbar slots 2026-06-04 08:58:04 -07:00
Nico
95b14b447f refactor: extract map object actions 2026-06-04 08:41:54 -07:00
Nico
03814b8b4c refactor: extract map draft actions 2026-06-04 08:29:15 -07:00
Nico
3f6eade576 refactor: extract map draft state hook 2026-06-04 08:16:44 -07:00
Nico
d5cfd22014 fix: compact map setup states 2026-06-04 07:53:12 -07:00
Nico
ea05691a65 fix: stabilize mobile map toolbar slots 2026-06-04 07:38:49 -07:00
Nico
19d345ac9a refactor: split map object rendering 2026-06-04 07:33:48 -07:00
Nico
505d9ce0f2 refactor: split map bottom sheet sections 2026-06-04 07:17:26 -07:00
Nico
11ac4c9eb2 fix: avoid duplicate hidden map recovery 2026-06-04 07:05:38 -07:00
Nico
214df9faec fix: use toggle buttons for map area flags 2026-06-04 06:57:00 -07:00
Nico
69488bb1ba fix: size mobile map actions for touch 2026-06-04 06:41:12 -07:00
Nico
f86709f45e fix: pan mobile map while editing 2026-06-04 06:32:17 -07:00
Nico
a0feb0e0c9 refactor: extract map leave guard 2026-06-04 06:22:31 -07:00
Nico
90878a6d7f fix: silence store selector debug logs 2026-06-04 06:11:14 -07:00
Nico
f9aa38e57a fix: wrap narrow map toolbar controls 2026-06-04 06:01:50 -07:00
Nico
97a502362d fix: reserve mobile map history slot 2026-06-04 05:52:53 -07:00
Nico
be15ac9fd4 fix: guard unsaved map household switch 2026-06-04 05:38:33 -07:00
Nico
0fec9dcc3e fix: guard unsaved map logout 2026-06-04 05:29:42 -07:00
Nico
d78dca7b1a fix: guard unsaved map app navigation 2026-06-04 05:19:49 -07:00
Nico
5324ee893a fix: stabilize mobile map toolbar width 2026-06-04 05:06:57 -07:00
Nico
34c818b035 fix: scope map object zone links 2026-06-04 04:50:25 -07:00
Nico
44cc376e1b fix: ignore stale map load responses 2026-06-04 04:40:09 -07:00
Nico
567087ab53 fix: show retry state for map load failures 2026-06-04 04:28:59 -07:00
Nico
1f52d43ca4 fix: stabilize map manager size props 2026-06-04 04:14:42 -07:00
Nico
b0cfaeba60 fix: memoize map bottom sheet summaries 2026-06-04 04:05:36 -07:00
Nico
c945a71f45 fix: stabilize mobile map toolbar widths 2026-06-04 03:55:53 -07:00
Nico
9390bfdf53 fix: memoize map manager derived data 2026-06-04 03:48:47 -07:00
Nico
2a3e028281 fix: clarify bought item map layer 2026-06-04 03:38:57 -07:00
Nico
e5ccc26a89 fix: summarize draft map areas 2026-06-04 03:30:10 -07:00
Nico
8808dad38e fix: clarify map edit tool labels 2026-06-04 03:20:21 -07:00
Nico
7f73a3aed3 fix: summarize unselected location maps 2026-06-04 03:09:19 -07:00
Nico
4fd3807ebf fix: stabilize mobile map toolbar controls 2026-06-04 02:58:26 -07:00
Nico
af08bf5c94 fix: compact map topbar back control 2026-06-04 02:44:19 -07:00
Nico
bf34cc341a fix: precompute map zone item counts 2026-06-04 02:33:43 -07:00
Nico
59b82650a6 fix: use toggle pills for map layers 2026-06-04 02:22:52 -07:00
Nico
80f7831647 fix: simplify mobile map editor controls 2026-06-04 02:12:33 -07:00
Nico
e3998acc12 fix: label map object types 2026-06-04 01:36:59 -07:00
Nico
6c9323cdd8 fix: align draft setup actions 2026-06-04 01:27:20 -07:00
Nico
24f2283caf fix: remove duplicate map edit action 2026-06-04 01:18:01 -07:00
Nico
493b0b3d34 fix: expose map layer reset 2026-06-04 01:05:24 -07:00
Nico
164a95e058 fix: compact map geometry controls 2026-06-04 00:34:18 -07:00
Nico
2b534eed6a fix: hide draft maps from members 2026-06-03 23:47:24 -07:00
Nico
b941d5ffed fix: clarify partial unmapped map filters 2026-06-03 23:15:00 -07:00
Nico
76dcea237e fix: clarify partial map item filters 2026-06-03 23:04:53 -07:00
Nico
e2128a4ad7 fix: lock canvas recovery while saving 2026-06-03 22:52:51 -07:00
Nico
ba48ece884 fix: add first map area from canvas 2026-06-03 22:42:35 -07:00
Nico
784d0f4d48 fix: recover hidden areas from map canvas 2026-06-03 22:24:40 -07:00
Nico
0a16ad3e7e fix: clarify filtered zone map counts 2026-06-03 22:08:09 -07:00
Nico
214e2626fe fix: explain hidden unmapped map items 2026-06-03 21:53:39 -07:00
Nico
0ab839ef7d fix: clarify locked map area controls 2026-06-03 21:44:28 -07:00
Nico
daa283b160 fix: recover hidden map areas 2026-06-03 21:36:22 -07:00
Nico
5b8b7ee2b8 fix: clear hidden map area selection 2026-06-03 21:25:23 -07:00
Nico
1bcf4ea6d1 fix: preserve cleared map area labels 2026-06-03 21:09:27 -07:00
Nico
306812d692 fix: reuse map area display names 2026-06-03 20:43:52 -07:00
Nico
415a7223cd fix: keep saved map drafts after publish failure 2026-06-03 20:34:13 -07:00
Nico
daef7a86de fix: preserve map area accessible names 2026-06-03 20:10:31 -07:00
Nico
ff78149336 fix: disable map zoom controls at limits 2026-06-03 19:54:01 -07:00
Nico
46293cd3c2 fix: prioritize selected map draft actions 2026-06-03 19:42:00 -07:00
Nico
0701a62ff1 fix: mention keyboard map nudging 2026-06-03 19:09:47 -07:00
Nico
c8a29d20a8 fix: nudge map areas with arrow keys 2026-06-03 19:02:24 -07:00
Nico
e09a95e172 fix: explain hidden map zones 2026-06-03 18:44:29 -07:00
Nico
aaf0876b63 fix: support keyboard map area selection 2026-06-03 18:15:11 -07:00
Nico
381a21015c fix: fit maps on mobile viewports 2026-06-03 18:02:55 -07:00
Nico
bff830dd4b fix: bound map object numeric edits 2026-06-03 17:29:57 -07:00
Nico
f8b3a684b8 fix: warn before leaving unsaved map drafts 2026-06-03 17:18:04 -07:00
Nico
fd22cc1fe8 fix: lock map actions while saving 2026-06-03 16:56:32 -07:00
Nico
c236efdd72 fix: show unmapped map list overflow 2026-06-03 16:41:03 -07:00
Nico
24a6405a1a fix: reset map layer filters 2026-06-03 15:50:56 -07:00
Nico
3ac80c9a5b fix: prioritize blank map setup 2026-06-03 15:41:04 -07:00
Nico
a8f747984d fix: compact selected zone panel 2026-06-03 15:22:54 -07:00
Nico
757a5fb5b7 fix: close map object settings 2026-06-03 15:12:49 -07:00
Nico
bc6cfb97a1 fix: show changed map layers 2026-06-03 15:03:54 -07:00
Nico
eef8accf45 fix: identify selected map area 2026-06-03 14:52:42 -07:00
Nico
eb89c1f6fc fix: prioritize map draft actions 2026-06-03 14:43:06 -07:00
Nico
18b11edba9 fix: compact mobile map toolbar 2026-06-03 14:28:53 -07:00
Nico
ff6468bd71 fix: compact mobile map status chip 2026-06-03 14:12:01 -07:00
Nico
c0ed1c0472 fix: clarify map setup without zones 2026-06-03 14:02:00 -07:00
Nico
1eaddd8a07 fix: avoid dirty map state on selection 2026-06-03 13:49:33 -07:00
Nico
04b538011e fix: publish pending map edits 2026-06-03 13:38:44 -07:00
Nico
138c3e3aa8 fix: clarify map zone item empty states 2026-06-03 13:21:26 -07:00
Nico
607f74e679 fix: clarify member map empty state 2026-06-03 13:05:30 -07:00
Nico
0361420813 fix: distinguish mobile map edit modes 2026-06-03 12:56:56 -07:00
Nico
ad74262604 test: cover mobile map editor layout 2026-06-03 12:48:07 -07:00
Nico
82e948ee66 fix: confirm leaving unsaved map edits 2026-06-03 12:37:02 -07:00
Nico
d370e23529 fix: declutter map setup state 2026-06-03 12:27:37 -07:00
Nico
023aef10ec fix: enlarge map resize handle 2026-06-03 12:18:53 -07:00
Nico
c87aa8f474 test: cover starter map layout 2026-06-03 12:09:22 -07:00
Nico
96f1cf4625 fix: clarify selected zone item count 2026-06-03 11:55:08 -07:00
Nico
80f09f324d fix: compact map counts without labels 2026-06-03 11:44:01 -07:00
Nico
5b964eef1f fix: wrap long map labels 2026-06-03 11:35:49 -07:00
Nico
7bb76a8eb4 fix: clarify visible map status 2026-06-03 11:25:20 -07:00
Nico
6cddc1f95a test: cover draft map persistence 2026-06-03 11:11:05 -07:00
Nico
66e5c41563 fix: prioritize map object settings 2026-06-03 11:01:56 -07:00
Nico
9d538f97bf fix: preserve map return context 2026-06-03 10:39:15 -07:00
Nico
2a7fa1871f test: cover map object editing flow 2026-06-03 02:14:41 -07:00
Nico
6f57b485d8 feat: preserve draft map previews 2026-06-03 01:59:28 -07:00
Nico
b739be29ba feat: add drag pan for location maps 2026-06-03 01:44:40 -07:00
Nico
c100174ea8 feat: polish location map editor 2026-06-03 01:23:12 -07:00
Nico
78893545b2 feat: add location map manager 2026-06-02 01:59:20 -07:00
Nico
23df814fb3 feat: add grocery location selector 2026-06-02 01:02:25 -07:00
Nico
b4abcf3380 feat: add grocery store selector modal 2026-06-02 00:27:46 -07:00
5fd9d68fe3 Merge pull request 'Add drag reorder for zones' (#18) from feature/drag-reorder-zones into feature-custom-store-locations 2026-05-31 19:42:57 -09:00
Nico
5e2b2e6e5a feat: add drag reorder for zones 2026-05-31 21:41:33 -07:00
d94a169417 Merge pull request 'Remove default location card label' (#17) from feature/remove-default-location-label into feature-custom-store-locations 2026-05-31 19:30:07 -09:00
Nico
fb35b2b695 fix: remove default location card label 2026-05-31 21:28:41 -07:00
bafbc0fdac Merge pull request 'Show store manager counts' (#16) from feature/show-store-manager-counts into feature-custom-store-locations 2026-05-31 19:22:54 -09:00
Nico
346fb917b7 feat: show store manager counts 2026-05-31 21:21:12 -07:00
27a6a50744 Merge pull request 'Compact add store control' (#15) from feature/compact-add-store-control into feature-custom-store-locations 2026-05-31 19:07:58 -09:00
Nico
f80ea472ce fix: compact add store control 2026-05-31 21:06:41 -07:00
8387e22b4f Merge pull request 'Flatten store location cards' (#14) from feature/flatten-store-location-cards into feature-custom-store-locations 2026-05-31 18:58:19 -09:00
Nico
4b98740a7b fix: flatten store location cards 2026-05-31 20:56:45 -07:00
737aa6d66e Merge pull request 'Reduce manage page redundant labels' (#13) from feature/reduce-manage-page-redundancy into feature-custom-store-locations 2026-05-31 18:49:06 -09:00
Nico
0ef35c3f92 fix: reduce manage page redundant labels 2026-05-31 20:47:41 -07:00
6891fc2235 Merge pull request 'Tighten member list spacing' (#12) from feature/compact-member-list-spacing into feature-custom-store-locations 2026-05-31 18:41:14 -09:00
Nico
a432db93bc Tighten member list spacing 2026-05-31 20:40:05 -07:00
99d42f6dd0 Merge pull request 'Move store zones and locations into modals' (#11) from feature/store-management-modals into feature-custom-store-locations 2026-05-31 17:47:17 -09:00
Nico
31eade066f Move store zones and locations into modals 2026-05-31 19:45:14 -07:00
ce04ac6951 Merge pull request 'Compact store delete count label' (#10) from feature/compact-store-delete-label into feature-custom-store-locations 2026-05-31 17:22:59 -09:00
Nico
3816f255a0 fix: compact store delete count label 2026-05-31 19:21:08 -07:00
fe9f1eeacc Merge pull request 'Add store item bulk delete mode' (#9) from feature/store-item-bulk-delete into feature-custom-store-locations 2026-05-31 17:01:05 -09:00
Nico
7e46e25366 feat: add store item bulk delete mode 2026-05-31 18:59:16 -07:00
f968d304cc Merge pull request 'Simplify store item manager rows' (#8) from feature/store-item-manager-cleanup into feature-custom-store-locations 2026-05-31 16:43:56 -09:00
Nico
6252c0538f fix: simplify store item manager rows 2026-05-31 18:42:25 -07:00
eef2c15e8c Merge pull request 'Use grocery placeholder icon in store items' (#7) from feature/store-item-placeholder-icon into feature-custom-store-locations 2026-05-31 16:17:16 -09:00
Nico
6731ba3d09 fix: use item placeholder icon in store manager 2026-05-31 18:15:52 -07:00
cb38b051b3 Merge pull request 'Move member actions into a modal' (#6) from feature/member-actions-modal into feature-custom-store-locations 2026-05-31 16:07:49 -09:00
Nico
ec7b403546 feat: move member actions into modal 2026-05-31 18:06:06 -07:00
63 changed files with 14698 additions and 862 deletions

View File

@ -24,7 +24,7 @@ If anything conflicts, follow **this** doc.
- `docker compose -f docker-compose.dev.yml up -d --build backend`
- After backend env/CORS changes, recreate the backend service so `backend/.env` is reloaded:
- `docker compose -f docker-compose.dev.yml up -d --force-recreate --no-deps backend`
- For the Docker frontend on port `3010`, `ALLOWED_ORIGINS` must include the exact browser origin, for example `http://localhost:3010` and `http://127.0.0.1:3010`.
- For the Docker frontend on port `3010`, `ALLOWED_ORIGINS` must include exact loopback browser origins such as `http://localhost:3010` and `http://127.0.0.1:3010`; private LAN origins such as `http://192.168.7.45:3010` are allowed by the backend CORS helper for local device testing.
- Verify the restarted API with `GET http://127.0.0.1:5000/` and `GET http://127.0.0.1:5000/config`.
- Do not print or commit real `.env` values while checking or updating local Docker env.
@ -162,6 +162,8 @@ For `app/api/**/[param]/route.ts`:
- Touch: long-press affordance for item-level actions when no visible button.
- Mouse: hover affordance on interactive rows/cards.
- Tap targets remain >= 40px on mobile.
- Avoid redundant nearby labels. If a tab, section title, count chip, row label, or control state already communicates the meaning, do not repeat it with an eyebrow label, explanatory zero-state sentence, or duplicate card label.
- Prefer compact label/value rows for dense settings controls instead of stacked labels with large vertical gaps.
- Modal overlays must close on outside click/tap.
- For every frontend action that manipulates database state, show a toast/bubble notification with basic outcome details (action + target + success/failure).
- Frontend destructive actions should use the shared `ConfirmSlideModal` pattern instead of browser `confirm()` unless there is a documented exception.

View File

@ -6,6 +6,6 @@ DB_PORT=5432
DB_NAME=
PORT=5000
JWT_SECRET=change-me
ALLOWED_ORIGINS=http://localhost:3000
ALLOWED_ORIGINS=http://localhost:3010,http://127.0.0.1:3010,http://192.168.7.45:3010
SESSION_COOKIE_NAME=sid
SESSION_TTL_DAYS=30

View File

@ -4,6 +4,7 @@ const path = require("path");
const User = require("./models/user.model");
const requestIdMiddleware = require("./middleware/request-id");
const { sendError } = require("./utils/http");
const { isAllowedCorsOrigin, parseAllowedOrigins } = require("./utils/cors-origins");
const app = express();
app.use(requestIdMiddleware);
@ -14,17 +15,11 @@ if (process.env.NODE_ENV !== "production") {
app.use("/test", express.static(path.join(__dirname, "public")));
}
const allowedOrigins = (process.env.ALLOWED_ORIGINS || "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
const allowedOrigins = parseAllowedOrigins(process.env.ALLOWED_ORIGINS || "");
app.use(
cors({
origin: function (origin, callback) {
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) return callback(null, true);
if (/^http:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
if (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
if (isAllowedCorsOrigin(origin, allowedOrigins)) return callback(null, true);
console.error(`CORS blocked origin: ${origin}`);
callback(new Error(`CORS blocked: ${origin}. Add this origin to ALLOWED_ORIGINS environment variable.`));
},

View File

@ -0,0 +1,131 @@
const locationMapModel = require("../models/location-map.model");
const { sendError } = require("../utils/http");
const { logError } = require("../utils/logger");
function parsePositiveInteger(value) {
const parsed = Number.parseInt(String(value), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function canManageMaps(req) {
return ["owner", "admin"].includes(req.household?.role);
}
function getScope(req) {
return {
householdId: parsePositiveInteger(req.params.householdId),
locationId: parsePositiveInteger(req.params.locationId),
};
}
function validateScope(res, scope) {
if (!scope.householdId || !scope.locationId) {
sendError(res, 400, "Household ID and location ID must be positive integers");
return false;
}
return true;
}
function sendMapState(res, state, req) {
if (!state) {
return sendError(res, 404, "Store location not found");
}
return res.json({
...state,
can_manage: canManageMaps(req),
});
}
exports.getLocationMap = async (req, res) => {
const scope = getScope(req);
if (!validateScope(res, scope)) return;
try {
const state = await locationMapModel.getMapState(
scope.householdId,
scope.locationId,
canManageMaps(req)
);
sendMapState(res, state, req);
} catch (error) {
logError(req, "locationMaps.getLocationMap", error);
sendError(res, 500, "Failed to load location map");
}
};
exports.createBlankMap = async (req, res) => {
const scope = getScope(req);
if (!validateScope(res, scope)) return;
try {
const state = await locationMapModel.saveBlankDraft(
scope.householdId,
scope.locationId,
req.user.id,
req.body?.map || req.body || {}
);
res.status(201);
sendMapState(res, state, req);
} catch (error) {
logError(req, "locationMaps.createBlankMap", error);
sendError(res, 500, "Failed to create blank map");
}
};
exports.createMapFromZones = async (req, res) => {
const scope = getScope(req);
if (!validateScope(res, scope)) return;
try {
const state = await locationMapModel.createDraftFromZones(
scope.householdId,
scope.locationId,
req.user.id,
req.body?.map || req.body || {}
);
res.status(201);
sendMapState(res, state, req);
} catch (error) {
logError(req, "locationMaps.createMapFromZones", error);
sendError(res, 500, "Failed to create map from zones");
}
};
exports.saveDraft = async (req, res) => {
const scope = getScope(req);
if (!validateScope(res, scope)) return;
try {
const state = await locationMapModel.saveDraft(
scope.householdId,
scope.locationId,
req.user.id,
req.body || {}
);
sendMapState(res, state, req);
} catch (error) {
logError(req, "locationMaps.saveDraft", error);
sendError(res, 500, "Failed to save map draft");
}
};
exports.publishDraft = async (req, res) => {
const scope = getScope(req);
if (!validateScope(res, scope)) return;
try {
const state = await locationMapModel.publishDraft(
scope.householdId,
scope.locationId,
req.user.id
);
if (!state) {
return sendError(res, 404, "No draft map to publish");
}
sendMapState(res, state, req);
} catch (error) {
logError(req, "locationMaps.publishDraft", error);
sendError(res, 500, "Failed to publish map");
}
};

View File

@ -0,0 +1,536 @@
const pool = require("../db/pool");
const storeModel = require("./store.model");
const {
buildStarterObjectsFromZones,
getStarterMapSize,
} = require("../services/location-map-layout.service");
const DEFAULT_MAP_WIDTH = 1000;
const DEFAULT_MAP_HEIGHT = 700;
const DEFAULT_MAP_NAME = "Store Map";
const MAP_OBJECT_TYPES = new Set([
"zone",
"aisle",
"entrance",
"checkout",
"shelf",
"wall",
"department",
"anchor",
"other",
]);
function toNumber(value, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function toPositiveNumber(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function toPositiveInteger(value, fallback) {
const parsed = Number.parseInt(String(value), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function normalizeMapRow(row) {
if (!row) return null;
return {
...row,
width: toPositiveInteger(row.width, DEFAULT_MAP_WIDTH),
height: toPositiveInteger(row.height, DEFAULT_MAP_HEIGHT),
version: toPositiveInteger(row.version, 1),
};
}
function normalizeObjectRow(row) {
if (!row) return null;
return {
...row,
x: toNumber(row.x),
y: toNumber(row.y),
width: toPositiveNumber(row.width, 160),
height: toPositiveNumber(row.height, 100),
rotation: toNumber(row.rotation),
locked: Boolean(row.locked),
visible: row.visible !== false,
};
}
function sanitizeMapPayload(payload = {}) {
return {
name:
typeof payload.name === "string" && payload.name.trim()
? payload.name.trim().slice(0, 120)
: DEFAULT_MAP_NAME,
width: toPositiveInteger(payload.width, DEFAULT_MAP_WIDTH),
height: toPositiveInteger(payload.height, DEFAULT_MAP_HEIGHT),
};
}
function sanitizeObjectPayload(object = {}, index = 0) {
const type = MAP_OBJECT_TYPES.has(object.type) ? object.type : "zone";
const rawZoneId = object.zone_id ?? object.zoneId;
const zoneId = rawZoneId === null || rawZoneId === "" || rawZoneId === undefined
? null
: toPositiveInteger(rawZoneId, null);
return {
zone_id: zoneId,
type,
label:
typeof object.label === "string" && object.label.trim()
? object.label.trim().slice(0, 160)
: "",
x: toNumber(object.x),
y: toNumber(object.y),
width: toPositiveNumber(object.width, 160),
height: toPositiveNumber(object.height, 100),
rotation: toNumber(object.rotation),
locked: Boolean(object.locked),
visible: object.visible !== false,
sort_order: toPositiveInteger(object.sort_order ?? object.sortOrder, index + 1),
metadata_json:
object.metadata_json && typeof object.metadata_json === "object" && !Array.isArray(object.metadata_json)
? object.metadata_json
: {},
};
}
async function getMaps(db, householdId, locationId, includeDraft) {
const statuses = includeDraft ? ["draft", "published"] : ["published"];
const result = await db.query(
`SELECT
id,
household_id,
store_location_id,
name,
width,
height,
status,
version,
created_by,
updated_by,
published_at,
created_at,
updated_at
FROM location_maps
WHERE household_id = $1
AND store_location_id = $2
AND status = ANY($3::text[])
ORDER BY
CASE status WHEN 'draft' THEN 0 WHEN 'published' THEN 1 ELSE 2 END,
updated_at DESC`,
[householdId, locationId, statuses]
);
return result.rows.map(normalizeMapRow);
}
async function getObjects(db, mapId) {
if (!mapId) return [];
const result = await db.query(
`SELECT
lmo.id,
lmo.location_map_id,
lmo.zone_id,
lmo.type,
lmo.label,
lmo.x,
lmo.y,
lmo.width,
lmo.height,
lmo.rotation,
lmo.locked,
lmo.visible,
lmo.sort_order,
lmo.metadata_json,
slz.name AS zone_name,
lmo.created_at,
lmo.updated_at
FROM location_map_objects lmo
LEFT JOIN store_location_zones slz ON slz.id = lmo.zone_id
WHERE lmo.location_map_id = $1
ORDER BY lmo.sort_order ASC, lmo.id ASC`,
[mapId]
);
return result.rows.map(normalizeObjectRow);
}
async function getZoneSummaries(db, householdId, locationId) {
const result = await db.query(
`WITH map_items AS (
SELECT
hl.id,
resolved_zone.id AS zone_id
FROM household_lists hl
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
LEFT JOIN household_item_classifications hic
ON hic.household_id = hl.household_id
AND hic.store_location_id = hl.store_location_id
AND hic.household_store_item_id = hl.household_store_item_id
LEFT JOIN store_location_zones resolved_zone
ON resolved_zone.household_id = hl.household_id
AND resolved_zone.store_location_id = hl.store_location_id
AND resolved_zone.is_active = TRUE
AND (
resolved_zone.id = hic.zone_id
OR (
hic.zone_id IS NULL
AND hic.zone IS NOT NULL
AND resolved_zone.normalized_name = LOWER(BTRIM(hic.zone))
)
)
WHERE hl.household_id = $1
AND hl.store_location_id = $2
AND hl.bought = FALSE
),
item_counts AS (
SELECT zone_id, COUNT(*)::int AS item_count
FROM map_items
WHERE zone_id IS NOT NULL
GROUP BY zone_id
)
SELECT
slz.id,
slz.name,
slz.sort_order,
slz.color,
slz.map_metadata,
slz.is_active,
COALESCE(item_counts.item_count, 0)::int AS item_count
FROM store_location_zones slz
LEFT JOIN item_counts ON item_counts.zone_id = slz.id
WHERE slz.household_id = $1
AND slz.store_location_id = $2
AND slz.is_active = TRUE
ORDER BY slz.sort_order ASC, slz.name ASC`,
[householdId, locationId]
);
return result.rows;
}
async function getMapItems(db, householdId, locationId) {
const result = await db.query(
`SELECT
hl.id,
hl.household_store_item_id AS item_id,
hsi.name AS item_name,
hl.quantity,
hl.bought,
resolved_zone.id AS zone_id,
COALESCE(resolved_zone.name, hic.zone) AS zone,
(
SELECT ARRAY_AGG(active_added_by_users.user_label ORDER BY active_added_by_users.last_added_on DESC, active_added_by_users.user_label)
FROM (
SELECT
COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label,
MAX(active_history.added_on) AS last_added_on
FROM (
SELECT
hlh.*,
COALESCE(
SUM(hlh.quantity) OVER (
PARTITION BY hlh.household_list_id
ORDER BY hlh.added_on DESC, hlh.id DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
),
0
) AS newer_quantity
FROM household_list_history hlh
WHERE hlh.household_list_id = hl.id
) active_history
JOIN users u ON active_history.added_by = u.id
WHERE active_history.newer_quantity < GREATEST(hl.quantity, 0)
GROUP BY user_label
) active_added_by_users
) AS added_by_users
FROM household_lists hl
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
LEFT JOIN household_item_classifications hic
ON hic.household_id = hl.household_id
AND hic.store_location_id = hl.store_location_id
AND hic.household_store_item_id = hl.household_store_item_id
LEFT JOIN store_location_zones resolved_zone
ON resolved_zone.household_id = hl.household_id
AND resolved_zone.store_location_id = hl.store_location_id
AND resolved_zone.is_active = TRUE
AND (
resolved_zone.id = hic.zone_id
OR (
hic.zone_id IS NULL
AND hic.zone IS NOT NULL
AND resolved_zone.normalized_name = LOWER(BTRIM(hic.zone))
)
)
WHERE hl.household_id = $1
AND hl.store_location_id = $2
AND (
hl.bought = FALSE
OR hl.modified_on >= NOW() - INTERVAL '24 hours'
)
ORDER BY resolved_zone.sort_order ASC NULLS LAST, hsi.name ASC`,
[householdId, locationId]
);
return result.rows.map((row) => ({
...row,
added_by_users: Array.isArray(row.added_by_users) ? row.added_by_users : [],
}));
}
async function getActiveZoneIds(db, householdId, locationId) {
const result = await db.query(
`SELECT id
FROM store_location_zones
WHERE household_id = $1
AND store_location_id = $2
AND is_active = TRUE`,
[householdId, locationId]
);
return new Set(result.rows.map((row) => Number(row.id)));
}
async function insertObjects(db, mapId, objects, validZoneIds = null) {
for (let index = 0; index < objects.length; index += 1) {
const object = sanitizeObjectPayload(objects[index], index);
const scopedZoneId =
object.zone_id && validZoneIds && !validZoneIds.has(Number(object.zone_id))
? null
: object.zone_id;
await db.query(
`INSERT INTO location_map_objects
(
location_map_id,
zone_id,
type,
label,
x,
y,
width,
height,
rotation,
locked,
visible,
sort_order,
metadata_json
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)`,
[
mapId,
scopedZoneId,
object.type,
object.label,
object.x,
object.y,
object.width,
object.height,
object.rotation,
object.locked,
object.visible,
object.sort_order,
JSON.stringify(object.metadata_json),
]
);
}
}
async function upsertDraftMap(db, householdId, locationId, userId, mapPayload) {
const map = sanitizeMapPayload(mapPayload);
const result = await db.query(
`INSERT INTO location_maps
(household_id, store_location_id, name, width, height, status, version, created_by, updated_by, updated_at)
VALUES ($1, $2, $3, $4, $5, 'draft', 1, $6, $6, NOW())
ON CONFLICT (store_location_id) WHERE status = 'draft'
DO UPDATE SET
name = EXCLUDED.name,
width = EXCLUDED.width,
height = EXCLUDED.height,
updated_by = EXCLUDED.updated_by,
updated_at = NOW()
RETURNING
id,
household_id,
store_location_id,
name,
width,
height,
status,
version,
created_by,
updated_by,
published_at,
created_at,
updated_at`,
[householdId, locationId, map.name, map.width, map.height, userId]
);
return normalizeMapRow(result.rows[0]);
}
exports.getMapState = async (householdId, locationId, includeDraft = false) => {
const location = await storeModel.getLocationById(householdId, locationId);
if (!location) return null;
const maps = await getMaps(pool, householdId, locationId, includeDraft);
const draftMap = includeDraft ? maps.find((map) => map.status === "draft") || null : null;
const publishedMap = maps.find((map) => map.status === "published") || null;
const activeMap = draftMap || publishedMap;
const [draftObjects, publishedObjects, zones, items] = await Promise.all([
getObjects(pool, draftMap?.id),
getObjects(pool, publishedMap?.id),
getZoneSummaries(pool, householdId, locationId),
getMapItems(pool, householdId, locationId),
]);
const objects = draftMap ? draftObjects : publishedObjects;
return {
location,
map: activeMap || null,
draft_map: draftMap,
published_map: publishedMap,
objects,
draft_objects: draftObjects,
published_objects: publishedObjects,
zones,
items,
unmapped_count: items.filter((item) => !item.zone_id).length,
};
};
exports.saveBlankDraft = async (householdId, locationId, userId, mapPayload = {}) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const map = await upsertDraftMap(client, householdId, locationId, userId, mapPayload);
await client.query("DELETE FROM location_map_objects WHERE location_map_id = $1", [map.id]);
await client.query("COMMIT");
return exports.getMapState(householdId, locationId, true);
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
};
exports.createDraftFromZones = async (householdId, locationId, userId, mapPayload = {}) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const zones = await getZoneSummaries(client, householdId, locationId);
const map = await upsertDraftMap(client, householdId, locationId, userId, mapPayload);
await client.query("DELETE FROM location_map_objects WHERE location_map_id = $1", [map.id]);
const starterSize = getStarterMapSize(zones, map);
if (starterSize.width !== map.width || starterSize.height !== map.height) {
await client.query(
`UPDATE location_maps
SET width = $1,
height = $2,
updated_at = NOW(),
updated_by = $3
WHERE id = $4`,
[starterSize.width, starterSize.height, userId, map.id]
);
map.width = starterSize.width;
map.height = starterSize.height;
}
await insertObjects(
client,
map.id,
buildStarterObjectsFromZones(zones, map.width, map.height)
);
await client.query("COMMIT");
return exports.getMapState(householdId, locationId, true);
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
};
exports.saveDraft = async (householdId, locationId, userId, payload = {}) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const map = await upsertDraftMap(client, householdId, locationId, userId, payload.map || {});
const validZoneIds = await getActiveZoneIds(client, householdId, locationId);
await client.query("DELETE FROM location_map_objects WHERE location_map_id = $1", [map.id]);
await insertObjects(client, map.id, Array.isArray(payload.objects) ? payload.objects : [], validZoneIds);
await client.query("COMMIT");
return exports.getMapState(householdId, locationId, true);
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
};
exports.publishDraft = async (householdId, locationId, userId) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const draftResult = await client.query(
`SELECT id
FROM location_maps
WHERE household_id = $1
AND store_location_id = $2
AND status = 'draft'
FOR UPDATE`,
[householdId, locationId]
);
if (draftResult.rowCount === 0) {
await client.query("ROLLBACK");
return null;
}
const versionResult = await client.query(
`SELECT COALESCE(MAX(version), 0)::int AS version
FROM location_maps
WHERE household_id = $1
AND store_location_id = $2`,
[householdId, locationId]
);
const nextVersion = (versionResult.rows[0]?.version || 0) + 1;
await client.query(
`UPDATE location_maps
SET status = 'archived', updated_by = $3, updated_at = NOW()
WHERE household_id = $1
AND store_location_id = $2
AND status = 'published'`,
[householdId, locationId, userId]
);
await client.query(
`UPDATE location_maps
SET status = 'published',
version = $3,
published_at = NOW(),
updated_by = $4,
updated_at = NOW()
WHERE household_id = $1
AND store_location_id = $2
AND id = $5`,
[householdId, locationId, nextVersion, userId, draftResult.rows[0].id]
);
await client.query("COMMIT");
return exports.getMapState(householdId, locationId, true);
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
};

View File

@ -134,6 +134,8 @@ exports.getHouseholdStores = async (householdId) => {
sl.address,
sl.is_default,
sl.map_data,
COALESCE(zone_counts.zone_count, 0)::int AS zone_count,
COALESCE(item_counts.item_count, 0)::int AS item_count,
sl.created_at,
sl.updated_at,
CASE
@ -142,6 +144,19 @@ exports.getHouseholdStores = async (householdId) => {
END AS display_name
FROM store_locations sl
JOIN household_custom_stores hcs ON hcs.id = sl.household_store_id
LEFT JOIN LATERAL (
SELECT COUNT(*)::int AS zone_count
FROM store_location_zones slz
WHERE slz.household_id = sl.household_id
AND slz.store_location_id = sl.id
AND slz.is_active = TRUE
) zone_counts ON TRUE
LEFT JOIN LATERAL (
SELECT COUNT(*)::int AS item_count
FROM household_store_items hsi
WHERE hsi.household_id = sl.household_id
AND hsi.store_location_id = sl.id
) item_counts ON TRUE
WHERE sl.household_id = $1
ORDER BY sl.is_default DESC, hcs.name ASC, sl.name ASC`,
[householdId, DEFAULT_LOCATION_NAME]

View File

@ -3,6 +3,7 @@ const router = express.Router();
const controller = require("../controllers/households.controller");
const listsController = require("../controllers/lists.controller.v2");
const availableItemsController = require("../controllers/available-items.controller");
const locationMapsController = require("../controllers/location-maps.controller");
const storesController = require("../controllers/stores.controller");
const auth = require("../middleware/auth");
const {
@ -134,6 +135,46 @@ router.delete(
storesController.deleteZone
);
router.get(
"/:householdId/locations/:locationId/map",
auth,
householdAccess,
locationAccess,
locationMapsController.getLocationMap
);
router.post(
"/:householdId/locations/:locationId/map/blank",
auth,
householdAccess,
locationAccess,
requireHouseholdAdmin,
locationMapsController.createBlankMap
);
router.post(
"/:householdId/locations/:locationId/map/from-zones",
auth,
householdAccess,
locationAccess,
requireHouseholdAdmin,
locationMapsController.createMapFromZones
);
router.put(
"/:householdId/locations/:locationId/map/draft",
auth,
householdAccess,
locationAccess,
requireHouseholdAdmin,
locationMapsController.saveDraft
);
router.post(
"/:householdId/locations/:locationId/map/publish",
auth,
householdAccess,
locationAccess,
requireHouseholdAdmin,
locationMapsController.publishDraft
);
router.get(
"/:householdId/locations/:locationId/available-items",
auth,

View File

@ -0,0 +1,71 @@
const DEFAULT_MAP_WIDTH = 1000;
const DEFAULT_MAP_HEIGHT = 700;
const STARTER_GAP = 28;
const STARTER_MIN_OBJECT_WIDTH = 120;
const STARTER_MIN_OBJECT_HEIGHT = 80;
const STARTER_TARGET_ROW_HEIGHT = 210;
function toPositiveInteger(value, fallback) {
const parsed = Number.parseInt(String(value), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function roundToTwo(value) {
return Math.round(value * 100) / 100;
}
function getStarterGrid(zones) {
const zoneCount = Array.isArray(zones) ? zones.length : 0;
const columnCount = zoneCount <= 4 ? 2 : 3;
const rowCount = Math.ceil(Math.max(zoneCount, 1) / columnCount);
return { columnCount, rowCount };
}
function getStarterMapSize(zones, mapSize = {}) {
const { columnCount, rowCount } = getStarterGrid(zones);
const currentWidth = toPositiveInteger(mapSize.width, DEFAULT_MAP_WIDTH);
const currentHeight = toPositiveInteger(mapSize.height, DEFAULT_MAP_HEIGHT);
const requiredWidth =
columnCount * STARTER_MIN_OBJECT_WIDTH + STARTER_GAP * (columnCount + 1);
const minimumGridHeight =
rowCount * STARTER_MIN_OBJECT_HEIGHT + STARTER_GAP * (rowCount + 1);
const preferredGridHeight = rowCount * STARTER_TARGET_ROW_HEIGHT;
return {
width: Math.max(currentWidth, requiredWidth),
height: Math.max(currentHeight, minimumGridHeight, preferredGridHeight),
};
}
function buildStarterObjectsFromZones(zones, width, height) {
if (!Array.isArray(zones) || !zones.length) return [];
const mapSize = getStarterMapSize(zones, { width, height });
const { columnCount, rowCount } = getStarterGrid(zones);
const cellWidth = (mapSize.width - STARTER_GAP * (columnCount + 1)) / columnCount;
const cellHeight = (mapSize.height - STARTER_GAP * (rowCount + 1)) / rowCount;
return zones.map((zone, index) => {
const column = index % columnCount;
const row = Math.floor(index / columnCount);
return {
zone_id: zone.id,
type: "zone",
label: zone.name,
x: roundToTwo(STARTER_GAP + column * (cellWidth + STARTER_GAP)),
y: roundToTwo(STARTER_GAP + row * (cellHeight + STARTER_GAP)),
width: Math.max(STARTER_MIN_OBJECT_WIDTH, roundToTwo(cellWidth)),
height: Math.max(STARTER_MIN_OBJECT_HEIGHT, roundToTwo(cellHeight)),
rotation: 0,
locked: false,
visible: true,
sort_order: index + 1,
metadata_json: { starter: true },
};
});
}
module.exports = {
buildStarterObjectsFromZones,
getStarterMapSize,
};

View File

@ -0,0 +1,37 @@
const {
isAllowedCorsOrigin,
isPrivateLanHostname,
parseAllowedOrigins,
} = require("../utils/cors-origins");
describe("CORS origin helpers", () => {
test("parses configured allowed origins", () => {
expect(parseAllowedOrigins(" http://localhost:3010, http://127.0.0.1:3010 ,,")).toEqual([
"http://localhost:3010",
"http://127.0.0.1:3010",
]);
});
test("allows exact configured origins", () => {
const allowedOrigins = ["http://localhost:3010"];
expect(isAllowedCorsOrigin("http://localhost:3010", allowedOrigins)).toBe(true);
});
test("allows browser access from the local private network", () => {
expect(isAllowedCorsOrigin("http://192.168.7.45:3010", [])).toBe(true);
});
test("recognizes RFC1918 private IPv4 hostnames", () => {
expect(isPrivateLanHostname("10.1.2.3")).toBe(true);
expect(isPrivateLanHostname("172.16.0.1")).toBe(true);
expect(isPrivateLanHostname("172.31.255.254")).toBe(true);
expect(isPrivateLanHostname("192.168.7.45")).toBe(true);
});
test("rejects public and lookalike hostnames", () => {
expect(isAllowedCorsOrigin("http://192.168.7.45.example.test:3010", [])).toBe(false);
expect(isAllowedCorsOrigin("http://203.0.113.10:3010", [])).toBe(false);
expect(isAllowedCorsOrigin("ftp://192.168.7.45:3010", [])).toBe(false);
});
});

View File

@ -0,0 +1,78 @@
const {
buildStarterObjectsFromZones,
getStarterMapSize,
} = require("../services/location-map-layout.service");
function makeZones(count) {
return Array.from({ length: count }, (_, index) => ({
id: index + 1,
name: `Zone ${index + 1}`,
}));
}
function overlaps(first, second) {
return !(
first.x + first.width <= second.x ||
second.x + second.width <= first.x ||
first.y + first.height <= second.y ||
second.y + second.height <= first.y
);
}
describe("location map starter layout", () => {
test("creates bounded starter rectangles for many zones", () => {
const zones = makeZones(13);
const mapSize = getStarterMapSize(zones, { width: 1000, height: 700 });
const objects = buildStarterObjectsFromZones(zones, mapSize.width, mapSize.height);
expect(mapSize).toEqual({ width: 1000, height: 1050 });
expect(objects).toHaveLength(13);
objects.forEach((object, index) => {
expect(object).toEqual(
expect.objectContaining({
zone_id: zones[index].id,
type: "zone",
label: zones[index].name,
locked: false,
visible: true,
sort_order: index + 1,
metadata_json: { starter: true },
})
);
expect(object.x).toBeGreaterThanOrEqual(0);
expect(object.y).toBeGreaterThanOrEqual(0);
expect(object.width).toBeGreaterThanOrEqual(120);
expect(object.height).toBeGreaterThanOrEqual(80);
expect(object.x + object.width).toBeLessThanOrEqual(mapSize.width);
expect(object.y + object.height).toBeLessThanOrEqual(mapSize.height);
});
for (let firstIndex = 0; firstIndex < objects.length; firstIndex += 1) {
for (let secondIndex = firstIndex + 1; secondIndex < objects.length; secondIndex += 1) {
expect(overlaps(objects[firstIndex], objects[secondIndex])).toBe(false);
}
}
});
test("expands cramped map dimensions before placing starter rectangles", () => {
const zones = makeZones(5);
const mapSize = getStarterMapSize(zones, { width: 300, height: 180 });
const objects = buildStarterObjectsFromZones(zones, 300, 180);
expect(mapSize).toEqual({ width: 472, height: 420 });
expect(objects).toHaveLength(5);
objects.forEach((object) => {
expect(object.x + object.width).toBeLessThanOrEqual(mapSize.width);
expect(object.y + object.height).toBeLessThanOrEqual(mapSize.height);
});
});
test("returns no objects for locations with no zones", () => {
expect(buildStarterObjectsFromZones([], 1000, 700)).toEqual([]);
expect(getStarterMapSize([], { width: 1000, height: 700 })).toEqual({
width: 1000,
height: 700,
});
});
});

View File

@ -0,0 +1,235 @@
jest.mock("../db/pool", () => ({
query: jest.fn(),
connect: jest.fn(),
}));
jest.mock("../models/store.model", () => ({
getLocationById: jest.fn(),
}));
const pool = require("../db/pool");
const storeModel = require("../models/store.model");
const LocationMap = require("../models/location-map.model");
function createClient() {
return {
query: jest.fn(),
release: jest.fn(),
};
}
describe("location-map.model", () => {
beforeEach(() => {
jest.clearAllMocks();
});
function mockMapStateQueries() {
const location = {
id: 10,
household_id: 1,
name: "Costco",
location_name: "Eastvale",
};
const draftMap = {
id: 900,
household_id: 1,
store_location_id: 10,
name: "Draft Map",
width: 1000,
height: 700,
status: "draft",
version: 3,
};
const publishedMap = {
id: 901,
household_id: 1,
store_location_id: 10,
name: "Published Map",
width: 1000,
height: 700,
status: "published",
version: 2,
};
const draftObject = {
id: 1100,
location_map_id: 900,
zone_id: 501,
type: "zone",
label: "Draft Bakery",
x: 10,
y: 20,
width: 200,
height: 120,
rotation: 0,
locked: false,
visible: true,
sort_order: 1,
};
const publishedObject = {
...draftObject,
id: 1200,
location_map_id: 901,
label: "Published Bakery",
};
const zones = [{ id: 501, name: "Bakery", sort_order: 10, item_count: 1 }];
const items = [
{
id: 3001,
item_id: 2001,
item_name: "sourdough",
quantity: 1,
bought: false,
zone_id: 501,
zone: "Bakery",
added_by_users: ["Owner"],
},
{
id: 3002,
item_id: 2002,
item_name: "bagels",
quantity: 1,
bought: true,
zone_id: 501,
zone: "Bakery",
added_by_users: ["Owner"],
},
];
storeModel.getLocationById.mockResolvedValue(location);
pool.query.mockImplementation(async (sql, params = []) => {
const query = String(sql);
if (query.includes("FROM location_maps") && query.includes("status = ANY")) {
const statuses = params[2] || [];
return {
rows: [draftMap, publishedMap].filter((map) => statuses.includes(map.status)),
};
}
if (query.includes("FROM location_map_objects")) {
const mapId = params[0];
return {
rows: mapId === 900 ? [draftObject] : mapId === 901 ? [publishedObject] : [],
};
}
if (query.includes("FROM store_location_zones")) {
return { rows: zones };
}
if (query.includes("FROM household_lists hl")) {
return { rows: items };
}
return { rows: [], rowCount: 0 };
});
return { draftMap, publishedMap, draftObject, publishedObject, location, zones, items };
}
test("hides draft map state from non-managers", async () => {
const { publishedMap, publishedObject, location, zones, items } = mockMapStateQueries();
const state = await LocationMap.getMapState(1, 10, false);
expect(storeModel.getLocationById).toHaveBeenCalledWith(1, 10);
expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining("FROM location_maps"),
[1, 10, ["published"]]
);
expect(state).toEqual(expect.objectContaining({
location,
map: expect.objectContaining({ id: publishedMap.id, status: "published" }),
draft_map: null,
published_map: expect.objectContaining({ id: publishedMap.id, status: "published" }),
objects: [expect.objectContaining({ id: publishedObject.id, label: "Published Bakery" })],
draft_objects: [],
published_objects: [expect.objectContaining({ id: publishedObject.id })],
zones,
items,
unmapped_count: 0,
}));
});
test("includes draft and published map state for managers", async () => {
const { draftMap, publishedMap, draftObject, publishedObject } = mockMapStateQueries();
const state = await LocationMap.getMapState(1, 10, true);
expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining("FROM location_maps"),
[1, 10, ["draft", "published"]]
);
expect(state.map).toEqual(expect.objectContaining({ id: draftMap.id, status: "draft" }));
expect(state.draft_map).toEqual(expect.objectContaining({ id: draftMap.id, status: "draft" }));
expect(state.published_map).toEqual(expect.objectContaining({ id: publishedMap.id, status: "published" }));
expect(state.objects).toEqual([expect.objectContaining({ id: draftObject.id, label: "Draft Bakery" })]);
expect(state.draft_objects).toEqual([expect.objectContaining({ id: draftObject.id })]);
expect(state.published_objects).toEqual([expect.objectContaining({ id: publishedObject.id })]);
});
test("loads active and recent bought rows for map item layers", async () => {
const { items } = mockMapStateQueries();
const state = await LocationMap.getMapState(1, 10, true);
expect(state.items).toEqual(items);
expect(state.items.some((item) => item.bought)).toBe(true);
expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining("OR hl.modified_on >= NOW() - INTERVAL '24 hours'"),
[1, 10]
);
});
test("unlinks saved map objects from zones outside the current location", async () => {
const client = createClient();
pool.connect.mockResolvedValue(client);
const getMapStateSpy = jest.spyOn(LocationMap, "getMapState").mockResolvedValue({
draft_map: { id: 900 },
draft_objects: [],
});
client.query.mockImplementation(async (sql) => {
if (sql === "BEGIN" || sql === "COMMIT") {
return { rows: [], rowCount: 0 };
}
if (String(sql).includes("INSERT INTO location_maps")) {
return {
rows: [
{
id: 900,
household_id: 1,
store_location_id: 10,
name: "Store Map",
width: 1000,
height: 700,
status: "draft",
version: 1,
},
],
};
}
if (String(sql).includes("FROM store_location_zones")) {
return { rows: [{ id: 501 }] };
}
return { rows: [], rowCount: 0 };
});
await LocationMap.saveDraft(1, 10, 42, {
objects: [
{ zone_id: 501, label: "Valid zone" },
{ zone_id: 999, label: "Foreign zone" },
],
});
const insertCalls = client.query.mock.calls.filter(([sql]) =>
String(sql).includes("INSERT INTO location_map_objects")
);
expect(insertCalls).toHaveLength(2);
expect(insertCalls[0][1][1]).toBe(501);
expect(insertCalls[1][1][1]).toBeNull();
expect(client.query).toHaveBeenCalledWith(
expect.stringContaining("FROM store_location_zones"),
[1, 10]
);
expect(getMapStateSpy).toHaveBeenCalledWith(1, 10, true);
expect(client.query).toHaveBeenCalledWith("COMMIT");
expect(client.release).toHaveBeenCalled();
});
});

View File

@ -70,6 +70,14 @@ jest.mock("../controllers/available-items.controller", () => ({
updateAvailableItem: jest.fn(),
}));
jest.mock("../controllers/location-maps.controller", () => ({
createBlankMap: jest.fn((req, res) => res.status(201).json({ message: "blank map" })),
createMapFromZones: jest.fn((req, res) => res.status(201).json({ message: "zone map" })),
getLocationMap: jest.fn((req, res) => res.json({ map: null, objects: [] })),
publishDraft: jest.fn((req, res) => res.json({ message: "published map" })),
saveDraft: jest.fn((req, res) => res.json({ message: "saved draft" })),
}));
jest.mock("../controllers/stores.controller", () => ({
addLocationToStore: jest.fn((req, res) => res.status(201).json({ message: "location" })),
createHouseholdStore: jest.fn((req, res) => res.status(201).json({ message: "store" })),
@ -89,6 +97,7 @@ const express = require("express");
const request = require("supertest");
const router = require("../routes/households.routes");
const householdsController = require("../controllers/households.controller");
const locationMapsController = require("../controllers/location-maps.controller");
const storesController = require("../controllers/stores.controller");
describe("store location routes", () => {
@ -161,4 +170,51 @@ describe("store location routes", () => {
expect(response.status).toBe(200);
expect(storesController.updateZone).toHaveBeenCalled();
});
test("members can view a location map", async () => {
const response = await request(app)
.get("/households/1/locations/2/map")
.set("x-household-role", "member");
expect(response.status).toBe(200);
expect(locationMapsController.getLocationMap).toHaveBeenCalled();
});
test("members cannot create or publish location maps", async () => {
const createResponse = await request(app)
.post("/households/1/locations/2/map/from-zones")
.set("x-household-role", "member")
.send({});
const publishResponse = await request(app)
.post("/households/1/locations/2/map/publish")
.set("x-household-role", "member")
.send({});
expect(createResponse.status).toBe(403);
expect(publishResponse.status).toBe(403);
expect(locationMapsController.createMapFromZones).not.toHaveBeenCalled();
expect(locationMapsController.publishDraft).not.toHaveBeenCalled();
});
test("admins can create, save, and publish location maps", async () => {
const blankResponse = await request(app)
.post("/households/1/locations/2/map/blank")
.set("x-household-role", "admin")
.send({ map: { name: "Costco Map" } });
const saveResponse = await request(app)
.put("/households/1/locations/2/map/draft")
.set("x-household-role", "admin")
.send({ objects: [] });
const publishResponse = await request(app)
.post("/households/1/locations/2/map/publish")
.set("x-household-role", "admin")
.send({});
expect(blankResponse.status).toBe(201);
expect(saveResponse.status).toBe(200);
expect(publishResponse.status).toBe(200);
expect(locationMapsController.createBlankMap).toHaveBeenCalled();
expect(locationMapsController.saveDraft).toHaveBeenCalled();
expect(locationMapsController.publishDraft).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,48 @@
jest.mock("../db/pool", () => ({
query: jest.fn(),
}));
const pool = require("../db/pool");
const Stores = require("../models/store.model");
describe("store.model", () => {
beforeEach(() => {
pool.query.mockReset();
});
test("lists household stores with location manager counts", async () => {
pool.query.mockResolvedValueOnce({
rows: [
{
location_id: 10,
id: 10,
household_id: 1,
household_store_id: 100,
name: "Costco",
location_name: "Default Location",
is_default: true,
zone_count: 2,
item_count: 5,
display_name: "Costco",
},
],
});
const result = await Stores.getHouseholdStores(1);
expect(result).toEqual([
expect.objectContaining({
id: 10,
display_name: "Costco",
zone_count: 2,
item_count: 5,
}),
]);
expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining("AS zone_count"),
[1, "Default Location"]
);
expect(pool.query.mock.calls[0][0]).toContain("FROM store_location_zones slz");
expect(pool.query.mock.calls[0][0]).toContain("FROM household_store_items hsi");
});
});

View File

@ -0,0 +1,54 @@
function parseAllowedOrigins(value = "") {
return String(value)
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
}
function parseIpv4(hostname) {
const parts = hostname.split(".");
if (parts.length !== 4) return null;
const octets = parts.map((part) => {
if (!/^\d{1,3}$/.test(part)) return null;
const value = Number(part);
return value >= 0 && value <= 255 ? value : null;
});
return octets.some((octet) => octet === null) ? null : octets;
}
function isPrivateLanHostname(hostname) {
const octets = parseIpv4(hostname);
if (!octets) return false;
const [first, second] = octets;
return (
first === 10 ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168)
);
}
function isAllowedCorsOrigin(origin, allowedOrigins = []) {
if (!origin) return true;
if (allowedOrigins.includes(origin)) return true;
try {
const url = new URL(origin);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return false;
}
return isPrivateLanHostname(url.hostname);
} catch {
return false;
}
}
module.exports = {
isAllowedCorsOrigin,
isPrivateLanHostname,
parseAllowedOrigins,
};

View File

@ -24,6 +24,7 @@ This directory contains practical project documentation. Root-level rules still
## Guides
- `guides/api-documentation.md`: REST API reference. Verify against current code before changing APIs.
- `guides/frontend-readme.md`: frontend development notes.
- `guides/management-modal-patterns.md`: reusable modal patterns for managing scoped item/list records.
- `guides/MOBILE_RESPONSIVE_AUDIT.md`: mobile design and audit checklist.
- `guides/setup-checklist.md`: older setup checklist; prefer `DEVELOPMENT.md` for current commands.

View File

@ -0,0 +1,47 @@
# Management Modal Patterns
Use this guide for modals that manage scoped lists of app-owned records, such as store items, store zones, and store locations.
Current adopters:
- Store item catalog management.
- Store location management.
- Store location zone management.
## Purpose
- Management modals should keep users in the current workflow while they inspect, edit, add, or remove records for a scoped parent.
- The parent scope must be obvious in the title, for example `Costco Items`.
- Modals should avoid repeating table labels inside every row. Use row layout, grouping, and the edit surface for detail.
## Structure
- Header: title, one short description when the scope is not obvious, and a close button.
- Primary toolbar: search input plus the primary create action inline.
- Bulk toolbar: destructive or multi-select actions above the list, separate from search/create controls.
- List: compact rows with the record's primary identity and any essential visual affordance.
- Editor: clicking or tapping a row opens the edit/settings modal for that record.
- Confirmation: destructive actions must use `ConfirmSlideModal`, not browser dialogs.
## Row Behavior
- Normal mode: the entire row opens settings for that record.
- Delete mode: the row toggles selected/unselected state and does not open settings.
- Selection state must be visible on the row and must not rely only on color.
- Avoid per-row action buttons when the same action applies to every row.
## Bulk Delete Pattern
- Show a `Delete Items` button above the list for users with delete permission.
- Clicking `Delete Items` enters delete mode, clears any previous selection, and changes the button to `Confirm Delete (#)`.
- Show a `Cancel` button while delete mode is active.
- Disable confirm while zero items are selected.
- Clicking confirm opens `ConfirmSlideModal`; only the slide confirmation performs the mutation.
- On success, exit delete mode, clear selection, refresh the list, and show a toast.
- On failure, keep the modal open and show a toast with the API error summary.
## Permission Rules
- Keep authorization server-side. Client visibility only improves UX.
- Members can open item settings when the API allows them to manage item details.
- Delete controls should be shown only to owners/admins when deletion is admin-scoped.
## Accessibility
- Modal containers should use dialog semantics when practical.
- Rows that perform actions should be keyboard reachable.
- Delete-mode rows should expose selected state with `aria-pressed` or an equivalent state.
- Buttons must have stable labels that describe the action in the current mode.

View File

@ -1,2 +1,3 @@
VITE_API_URL=http://localhost:5000
VITE_ALLOWED_HOSTS=localhost,127.0.0.1
# Leave blank to infer the API host from the browser URL, e.g. http://192.168.7.45:5000.
VITE_API_URL=
VITE_ALLOWED_HOSTS=localhost,127.0.0.1,192.168.7.45

View File

@ -10,6 +10,7 @@ import { StoreProvider } from "./context/StoreContext.jsx";
import AdminPanel from "./pages/AdminPanel.jsx";
import GroceryList from "./pages/GroceryList.jsx";
import LocationMapManager from "./pages/LocationMapManager.jsx";
import Login from "./pages/Login.jsx";
import Manage from "./pages/Manage.jsx";
import Register from "./pages/Register.jsx";
@ -48,6 +49,7 @@ function App() {
<Route path="/" element={<GroceryList />} />
<Route path="/manage" element={<Manage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/stores/:storeId/locations/:locationId/map" element={<LocationMapManager />} />
<Route
path="/admin"

View File

@ -0,0 +1,16 @@
import api from "./axios";
export const getLocationMap = (householdId, locationId) =>
api.get(`/households/${householdId}/locations/${locationId}/map`);
export const createBlankLocationMap = (householdId, locationId, map = {}) =>
api.post(`/households/${householdId}/locations/${locationId}/map/blank`, { map });
export const createLocationMapFromZones = (householdId, locationId, map = {}) =>
api.post(`/households/${householdId}/locations/${locationId}/map/from-zones`, { map });
export const saveLocationMapDraft = (householdId, locationId, payload) =>
api.put(`/households/${householdId}/locations/${locationId}/map/draft`, payload);
export const publishLocationMapDraft = (householdId, locationId) =>
api.post(`/households/${householdId}/locations/${locationId}/map/publish`);

View File

@ -71,6 +71,7 @@ export default function ManageHousehold() {
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
const [pendingRoleChange, setPendingRoleChange] = useState(null);
const [pendingMemberRemoval, setPendingMemberRemoval] = useState(null);
const [selectedMember, setSelectedMember] = useState(null);
const isManager = ["owner", "admin"].includes(activeHousehold?.role);
const isOwner = activeHousehold?.role === "owner";
@ -85,6 +86,19 @@ export default function ManageHousehold() {
}
}, [activeHousehold?.id, isManager]);
useEffect(() => {
if (!selectedMember) return undefined;
const handleKeyDown = (event) => {
if (event.key === "Escape") {
setSelectedMember(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedMember]);
const loadMembers = async () => {
if (!activeHousehold?.id) return;
setLoading(true);
@ -360,13 +374,34 @@ export default function ManageHousehold() {
const managerCount = members.filter((member) => ["owner", "admin"].includes(member.role)).length;
const memberCount = members.filter((member) => member.role === "member").length;
const selectedRoleMeta = selectedMember
? ROLE_METADATA[selectedMember.role] || { icon: "👤", label: selectedMember.role }
: null;
const selectedMemberIsSelf = selectedMember?.id === parseInt(userId, 10);
const canManageSelectedMember =
Boolean(selectedMember) &&
isManager &&
!selectedMemberIsSelf &&
selectedMember.role !== "owner";
const selectedMemberNextRole = selectedMember?.role === "admin" ? "member" : "admin";
const openMemberRoleChange = (nextRole) => {
if (!selectedMember) return;
handleUpdateRole(selectedMember.id, nextRole, selectedMember.username);
setSelectedMember(null);
};
const openMemberRemoval = () => {
if (!selectedMember) return;
handleRemoveMember(selectedMember.id, selectedMember.username);
setSelectedMember(null);
};
return (
<div className="manage-household">
<section key="household-name" className="manage-section">
<div className="manage-section-header">
<div>
<p className="manage-section-eyebrow">Household</p>
<h2>Identity</h2>
</div>
</div>
@ -411,7 +446,6 @@ export default function ManageHousehold() {
<section key="join-and-invites" className="manage-section">
<div className="manage-section-header">
<div>
<p className="manage-section-eyebrow">Entry Rules</p>
<h2>Invite Links</h2>
</div>
</div>
@ -435,9 +469,7 @@ export default function ManageHousehold() {
{inviteLoading ? (
<p>Loading invite settings...</p>
) : pendingRequests.length === 0 ? (
<p className="section-description">No pending join requests right now.</p>
) : (
) : pendingRequests.length > 0 ? (
<div className="pending-requests-list">
{pendingRequests.map((request) => {
const requesterLabel = getRequesterLabel(request);
@ -473,7 +505,7 @@ export default function ManageHousehold() {
);
})}
</div>
)}
) : null}
<div className="invite-controls">
<label>
@ -547,7 +579,6 @@ export default function ManageHousehold() {
<section key="members" className="manage-section">
<div className="manage-section-header">
<div>
<p className="manage-section-eyebrow">People</p>
<h2>Members ({members.length})</h2>
</div>
</div>
@ -560,7 +591,13 @@ export default function ManageHousehold() {
const isSelf = member.id === parseInt(userId, 10);
return (
<div key={member.id} className="member-card">
<button
key={member.id}
type="button"
className="member-card member-card-button"
onClick={() => setSelectedMember(member)}
aria-label={`Open member actions for ${member.username}`}
>
<div className="member-main">
<div className="member-info">
<span className={`member-role member-role-${member.role}`}>
@ -570,46 +607,76 @@ export default function ManageHousehold() {
{isSelf && <span className="member-self-pill">You</span>}
</div>
</div>
{isManager && !isSelf && member.role !== "owner" && (
<div className="member-actions">
{isOwner && (
<button
onClick={() => handleUpdateRole(member.id, "owner", member.username)}
className="btn-primary btn-small member-owner-action"
>
Make Owner
</button>
)}
<button
onClick={() => handleUpdateRole(
member.id,
member.role === "admin" ? "member" : "admin",
member.username
)}
className="btn-secondary btn-small member-role-action"
>
{member.role === "admin" ? "Make Member" : "Make Admin"}
</button>
<button
onClick={() => handleRemoveMember(member.id, member.username)}
className="btn-danger btn-small"
>
Remove
</button>
</div>
)}
</div>
);
})}
</div>
)}
</section>
{selectedMember && (
<div className="member-actions-modal-overlay" onClick={() => setSelectedMember(null)}>
<div
className="member-actions-modal"
role="dialog"
aria-modal="true"
aria-labelledby="member-actions-title"
onClick={(event) => event.stopPropagation()}
>
<div className="member-actions-modal-header">
<div className="member-actions-modal-copy">
<h3 id="member-actions-title">{selectedMember.username}</h3>
<span className={`member-role member-role-${selectedMember.role}`}>
{selectedRoleMeta.icon} {selectedRoleMeta.label}
</span>
</div>
<button
type="button"
className="member-actions-modal-close"
onClick={() => setSelectedMember(null)}
aria-label="Close member actions"
>
&times;
</button>
</div>
{canManageSelectedMember ? (
<div className="member-actions-modal-actions">
{isOwner && (
<button
type="button"
onClick={() => openMemberRoleChange("owner")}
className="btn-primary member-owner-action"
>
Make Owner
</button>
)}
<button
type="button"
onClick={() => openMemberRoleChange(selectedMemberNextRole)}
className="btn-secondary member-role-action"
>
{selectedMember.role === "admin" ? "Make Member" : "Make Admin"}
</button>
<button
type="button"
onClick={openMemberRemoval}
className="btn-danger"
>
Remove
</button>
</div>
) : (
<p className="member-actions-modal-empty">No actions available for this member.</p>
)}
</div>
</div>
)}
{(isManager || isMemberOnly) && (
<section key="danger-zone" className="manage-section danger-zone">
<div className="manage-section-header">
<div>
<p className="manage-section-eyebrow">Final Actions</p>
<h2>Danger Zone</h2>
</div>
{isMemberOnly ? (

View File

@ -1,21 +1,14 @@
import { useContext, useEffect, useMemo, useState } from "react";
import {
addLocationToStore,
createHouseholdStore,
createLocationZone,
deleteLocationZone,
getLocationZones,
removeLocation,
setDefaultLocation,
updateLocationZone,
} from "../../api/stores";
import StoreAvailableItemsManager from "./StoreAvailableItemsManager";
import { useContext, useMemo, useState } from "react";
import { createHouseholdStore } from "../../api/stores";
import { HouseholdContext } from "../../context/HouseholdContext";
import { StoreContext } from "../../context/StoreContext";
import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage";
import "../../styles/components/manage/ManageStores.css";
import StoreAvailableItemsManager from "./StoreAvailableItemsManager";
import StoreLocationManager from "./StoreLocationManager";
import StoreZoneManager from "./StoreZoneManager";
import "../../styles/components/manage/StoreAvailableItemsManager.css";
import "../../styles/components/manage/ManageStores.css";
function groupLocationsByStore(locations) {
const grouped = new Map();
@ -36,163 +29,8 @@ function groupLocationsByStore(locations) {
return Array.from(grouped.values()).sort((a, b) => a.name.localeCompare(b.name));
}
function ZoneManager({ householdId, location, canManage, refreshActiveZones }) {
const toast = useActionToast();
const [isOpen, setIsOpen] = useState(false);
const [zones, setZones] = useState([]);
const [loading, setLoading] = useState(false);
const [newZoneName, setNewZoneName] = useState("");
const loadZones = async () => {
if (!householdId || !location?.id) return;
setLoading(true);
try {
const response = await getLocationZones(householdId, location.id);
setZones(response.data?.zones || []);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to load zones");
toast.error("Load zones failed", `Load zones failed: ${message}`);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (isOpen) {
loadZones();
}
}, [isOpen, householdId, location?.id]);
const handleCreateZone = async () => {
const name = newZoneName.trim();
if (!name) return;
try {
const nextSortOrder =
zones.length > 0 ? Math.max(...zones.map((zone) => zone.sort_order || 0)) + 10 : 10;
await createLocationZone(householdId, location.id, {
name,
sort_order: nextSortOrder,
});
setNewZoneName("");
await loadZones();
await refreshActiveZones();
toast.success("Added zone", `Added zone ${name}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to add zone");
toast.error("Add zone failed", `Add zone failed: ${message}`);
}
};
const handleMoveZone = async (zone, direction) => {
const currentIndex = zones.findIndex((candidate) => candidate.id === zone.id);
const swapIndex = currentIndex + direction;
if (currentIndex < 0 || swapIndex < 0 || swapIndex >= zones.length) return;
const other = zones[swapIndex];
try {
await Promise.all([
updateLocationZone(householdId, location.id, zone.id, {
sort_order: other.sort_order,
}),
updateLocationZone(householdId, location.id, other.id, {
sort_order: zone.sort_order,
}),
]);
await loadZones();
await refreshActiveZones();
} catch (error) {
const message = getApiErrorMessage(error, "Failed to reorder zones");
toast.error("Reorder zones failed", `Reorder zones failed: ${message}`);
}
};
const handleDeleteZone = async (zone) => {
if (!confirm(`Remove zone "${zone.name}" from ${location.display_name || location.name}?`)) {
return;
}
try {
await deleteLocationZone(householdId, location.id, zone.id);
await loadZones();
await refreshActiveZones();
toast.success("Removed zone", `Removed zone ${zone.name}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to remove zone");
toast.error("Remove zone failed", `Remove zone failed: ${message}`);
}
};
return (
<div className="store-zones-panel">
<button
type="button"
className="btn-secondary btn-small"
onClick={() => setIsOpen((current) => !current)}
>
{isOpen ? "Hide Zones" : "Manage Zones"}
</button>
{isOpen ? (
<div className="store-zones-content">
{canManage ? (
<div className="store-zone-create-row">
<input
value={newZoneName}
onChange={(event) => setNewZoneName(event.target.value)}
placeholder="New zone name"
/>
<button type="button" className="btn-primary btn-small" onClick={handleCreateZone}>
Add Zone
</button>
</div>
) : null}
{loading ? (
<p className="empty-message">Loading zones...</p>
) : zones.length === 0 ? (
<p className="empty-message">No zones for this location.</p>
) : (
<div className="store-zone-list">
{zones.map((zone, index) => (
<div key={zone.id} className="store-zone-row">
<span className="store-zone-order">{index + 1}</span>
<span className="store-zone-name">{zone.name}</span>
{canManage ? (
<div className="store-zone-actions">
<button
type="button"
className="btn-secondary btn-small"
disabled={index === 0}
onClick={() => handleMoveZone(zone, -1)}
>
Up
</button>
<button
type="button"
className="btn-secondary btn-small"
disabled={index === zones.length - 1}
onClick={() => handleMoveZone(zone, 1)}
>
Down
</button>
<button
type="button"
className="btn-danger btn-small"
onClick={() => handleDeleteZone(zone)}
>
Remove
</button>
</div>
) : null}
</div>
))}
</div>
)}
</div>
) : null}
</div>
);
function locationLabel(location) {
return location.display_name || location.name;
}
export default function ManageStores() {
@ -204,12 +42,7 @@ export default function ManageStores() {
refreshZones,
} = useContext(StoreContext);
const toast = useActionToast();
const [createForm, setCreateForm] = useState({
name: "",
location_name: "",
address: "",
});
const [locationDrafts, setLocationDrafts] = useState({});
const [newStoreName, setNewStoreName] = useState("");
const [saving, setSaving] = useState(false);
const isAdmin = ["owner", "admin"].includes(activeHousehold?.role);
@ -225,18 +58,17 @@ export default function ManageStores() {
const handleCreateStore = async (event) => {
event.preventDefault();
if (!createForm.name.trim()) return;
const storeName = newStoreName.trim();
if (!storeName) return;
setSaving(true);
try {
await createHouseholdStore(activeHousehold.id, {
name: createForm.name.trim(),
location_name: createForm.location_name.trim() || "Default Location",
address: createForm.address.trim() || null,
name: storeName,
});
setCreateForm({ name: "", location_name: "", address: "" });
setNewStoreName("");
await refreshAfterStoreChange();
toast.success("Created store", `Created store ${createForm.name.trim()}`);
toast.success("Created store", `Created store ${storeName}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to create store");
toast.error("Create store failed", `Create store failed: ${message}`);
@ -245,60 +77,29 @@ export default function ManageStores() {
}
};
const handleAddLocation = async (householdStoreId, storeName) => {
const draft = locationDrafts[householdStoreId] || {};
const name = String(draft.name || "").trim();
if (!name) return;
try {
await addLocationToStore(activeHousehold.id, householdStoreId, {
name,
address: String(draft.address || "").trim() || null,
});
setLocationDrafts((current) => ({
...current,
[householdStoreId]: { name: "", address: "" },
}));
await refreshAfterStoreChange();
toast.success("Added location", `Added ${name} to ${storeName}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to add location");
toast.error("Add location failed", `Add location failed: ${message}`);
}
};
const handleSetDefault = async (location) => {
try {
await setDefaultLocation(activeHousehold.id, location.id);
await refreshAfterStoreChange();
toast.success("Updated default location", `Default location set to ${location.display_name || location.name}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to set default location");
toast.error("Set default failed", `Set default failed: ${message}`);
}
};
const handleRemoveLocation = async (location) => {
const label = location.display_name || location.name;
if (!confirm(`Remove ${label} from this household?`)) return;
try {
await removeLocation(activeHousehold.id, location.id);
await refreshAfterStoreChange();
toast.success("Removed location", `Removed ${label}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to remove location");
toast.error("Remove location failed", `Remove location failed: ${message}`);
}
};
return (
<div className="manage-stores">
<section className="manage-section">
<div className="manage-stores-topline">
<h2>Store Locations ({householdStores.length})</h2>
{isAdmin ? (
<form className="add-store-inline" onSubmit={handleCreateStore}>
<input
value={newStoreName}
onChange={(event) => setNewStoreName(event.target.value)}
placeholder="Store name"
aria-label="Store name"
required
/>
<button type="submit" className="btn-primary" disabled={saving}>
{saving ? "Adding..." : "Add"}
</button>
</form>
) : null}
</div>
<p className="manage-stores-help">
Stores and locations are private to this household. Each location has its own zones,
item defaults, and shopping order.
Stores are private to this household. Locations define map-specific zones, item
placement, and shopping order.
</p>
{householdStores.length === 0 ? (
<p className="empty-message">No store locations added yet.</p>
@ -306,133 +107,64 @@ export default function ManageStores() {
<div className="stores-list">
{groupedStores.map((storeGroup) => (
<div key={storeGroup.household_store_id} className="store-card">
<div className="store-card-header">
<div className="store-info">
<h3>{storeGroup.name}</h3>
</div>
<StoreLocationManager
householdId={activeHousehold.id}
storeGroup={storeGroup}
allLocationCount={householdStores.length}
canManage={isAdmin}
refreshAfterStoreChange={refreshAfterStoreChange}
/>
</div>
<div className="store-location-list">
{storeGroup.locations.map((location) => (
{storeGroup.locations.map((location) => {
const label = locationLabel(location);
const showLocationName =
storeGroup.locations.length > 1 || label !== storeGroup.name;
return (
<div key={location.id} className="store-location-row">
<div className="store-info">
<strong>{location.display_name || location.name}</strong>
<div className="store-info store-location-copy">
{showLocationName ? <strong>{label}</strong> : null}
{location.address ? (
<p className="store-location">{location.address}</p>
) : null}
{location.is_default ? (
<p className="store-location">Default shopping location</p>
) : null}
</div>
<div className="store-actions">
{isAdmin && !location.is_default ? (
<button
type="button"
onClick={() => handleSetDefault(location)}
className="btn-secondary btn-small"
>
Set Default
</button>
) : null}
{isAdmin ? (
<button
type="button"
onClick={() => handleRemoveLocation(location)}
className="btn-danger btn-small"
disabled={householdStores.length === 1}
title={householdStores.length === 1 ? "Cannot remove last location" : ""}
>
Remove
</button>
) : null}
</div>
<ZoneManager
<div className="store-location-controls">
<StoreZoneManager
householdId={activeHousehold.id}
location={location}
canManage={isAdmin}
refreshActiveZones={refreshZones}
refreshStoreCounts={refreshStores}
zoneCount={Number(location.zone_count ?? location.zoneCount ?? 0)}
/>
<StoreAvailableItemsManager
householdId={activeHousehold.id}
store={location}
isAdmin={isAdmin}
refreshStoreCounts={refreshStores}
itemCount={Number(location.item_count ?? location.itemCount ?? 0)}
/>
</div>
))}
</div>
{isAdmin ? (
<div className="add-location-panel">
<input
value={locationDrafts[storeGroup.household_store_id]?.name || ""}
onChange={(event) =>
setLocationDrafts((current) => ({
...current,
[storeGroup.household_store_id]: {
...(current[storeGroup.household_store_id] || {}),
name: event.target.value,
},
}))
}
placeholder="Location name"
/>
<input
value={locationDrafts[storeGroup.household_store_id]?.address || ""}
onChange={(event) =>
setLocationDrafts((current) => ({
...current,
[storeGroup.household_store_id]: {
...(current[storeGroup.household_store_id] || {}),
address: event.target.value,
},
}))
}
placeholder="Address or notes"
/>
<button
type="button"
className="btn-primary btn-small"
onClick={() => handleAddLocation(storeGroup.household_store_id, storeGroup.name)}
>
Add Location
</button>
);
})}
</div>
) : null}
</div>
))}
</div>
)}
</section>
{isAdmin ? (
<section className="manage-section">
<h2>Add Store</h2>
<form className="add-store-panel" onSubmit={handleCreateStore}>
<input
value={createForm.name}
onChange={(event) => setCreateForm((current) => ({ ...current, name: event.target.value }))}
placeholder="Store name, e.g. Costco"
required
/>
<input
value={createForm.location_name}
onChange={(event) =>
setCreateForm((current) => ({ ...current, location_name: event.target.value }))
}
placeholder="Location name, e.g. Fontana"
/>
<input
value={createForm.address}
onChange={(event) => setCreateForm((current) => ({ ...current, address: event.target.value }))}
placeholder="Address or notes"
/>
<button type="submit" className="btn-primary" disabled={saving}>
{saving ? "Adding..." : "+ Add Store"}
</button>
</form>
</section>
) : activeStore ? (
{!isAdmin && activeStore ? (
<p className="manage-stores-note">
Household members can manage item defaults. Only owners and admins can manage stores,
locations, zones, and item deletion.

View File

@ -20,10 +20,17 @@ function itemImageSource(item) {
return `data:${mimeType};base64,${item.item_image}`;
}
export default function StoreAvailableItemsManager({ householdId, store, isAdmin }) {
export default function StoreAvailableItemsManager({
householdId,
store,
isAdmin,
refreshStoreCounts,
itemCount = 0,
}) {
const toast = useActionToast();
const [isOpen, setIsOpen] = useState(false);
const [items, setItems] = useState([]);
const [displayItemCount, setDisplayItemCount] = useState(itemCount);
const [zones, setZones] = useState([]);
const [catalogReady, setCatalogReady] = useState(true);
const [catalogMessage, setCatalogMessage] = useState("");
@ -31,7 +38,12 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
const [loading, setLoading] = useState(false);
const [editorItem, setEditorItem] = useState(null);
const [showEditor, setShowEditor] = useState(false);
const [pendingDeleteItem, setPendingDeleteItem] = useState(null);
const [deleteMode, setDeleteMode] = useState(false);
const [selectedDeleteIds, setSelectedDeleteIds] = useState(() => new Set());
const [pendingDeleteItems, setPendingDeleteItems] = useState([]);
const selectedDeleteItems = items.filter((item) => selectedDeleteIds.has(item.item_id));
const selectedDeleteCount = selectedDeleteItems.length;
const loadItems = useCallback(async (search = query) => {
if (!householdId || !store?.id) {
@ -80,9 +92,15 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
loadZones();
}, [isOpen, query, loadItems, loadZones]);
useEffect(() => {
setDisplayItemCount(itemCount);
}, [itemCount]);
const closeManager = () => {
setIsOpen(false);
setPendingDeleteItem(null);
setDeleteMode(false);
setSelectedDeleteIds(new Set());
setPendingDeleteItems([]);
};
const handleUpdate = async (payload) => {
@ -108,6 +126,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
setShowEditor(false);
setEditorItem(null);
await loadItems(query);
await refreshStoreCounts?.();
} catch (error) {
const message = getApiErrorMessage(error, "Failed to update store item");
toast.error("Update store item failed", `Update store item failed: ${message}`);
@ -115,19 +134,65 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
}
};
const openEditor = (item) => {
setEditorItem(item);
setShowEditor(true);
};
const toggleDeleteSelection = (itemId) => {
setSelectedDeleteIds((currentIds) => {
const nextIds = new Set(currentIds);
if (nextIds.has(itemId)) {
nextIds.delete(itemId);
} else {
nextIds.add(itemId);
}
return nextIds;
});
};
const startDeleteMode = () => {
setDeleteMode(true);
setSelectedDeleteIds(new Set());
};
const cancelDeleteMode = () => {
setDeleteMode(false);
setSelectedDeleteIds(new Set());
};
const confirmSelectedDelete = () => {
if (selectedDeleteCount === 0) {
return;
}
setPendingDeleteItems(selectedDeleteItems);
};
const handleDeleteConfirm = async () => {
if (!pendingDeleteItem) {
if (pendingDeleteItems.length === 0) {
return;
}
try {
await deleteAvailableItem(householdId, store.id, pendingDeleteItem.item_id);
toast.success("Deleted store item", `Deleted ${pendingDeleteItem.item_name} from ${store.display_name || store.name}`);
setPendingDeleteItem(null);
await Promise.all(
pendingDeleteItems.map((item) => deleteAvailableItem(householdId, store.id, item.item_id))
);
const count = pendingDeleteItems.length;
toast.success(
count === 1 ? "Deleted store item" : "Deleted store items",
`Deleted ${count} ${count === 1 ? "item" : "items"} from ${store.display_name || store.name}`
);
setPendingDeleteItems([]);
setDeleteMode(false);
setSelectedDeleteIds(new Set());
await loadItems(query);
await refreshStoreCounts?.();
} catch (error) {
const message = getApiErrorMessage(error, "Failed to delete store item");
toast.error("Delete store item failed", `Delete store item failed: ${message}`);
toast.error("Delete store items failed", `Delete store items failed: ${message}`);
}
};
@ -138,7 +203,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
className="btn-secondary btn-small store-available-items-trigger"
onClick={() => setIsOpen(true)}
>
Manage Items
Manage Items ({displayItemCount})
</button>
{isOpen ? (
@ -186,6 +251,28 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
</button>
</div>
{isAdmin && catalogReady && items.length > 0 ? (
<div className="store-items-bulk-toolbar">
<button
type="button"
className="btn-danger btn-small store-items-delete-toggle"
disabled={deleteMode ? selectedDeleteCount === 0 : loading}
onClick={deleteMode ? confirmSelectedDelete : startDeleteMode}
>
{deleteMode ? `Confirm Delete (${selectedDeleteCount})` : "Delete Items"}
</button>
{deleteMode ? (
<button
type="button"
className="btn-secondary btn-small store-items-delete-cancel"
onClick={cancelDeleteMode}
>
Cancel
</button>
) : null}
</div>
) : null}
<div className="store-items-modal-body">
{!catalogReady ? (
<p className="empty-message">Run the latest database migrations to enable store item management.</p>
@ -195,26 +282,40 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
<p className="empty-message">No household items found for this store yet.</p>
) : (
<div className="store-items-table">
<div className="store-items-table-head" aria-hidden="true">
<span>Item</span>
<span>Store Defaults</span>
<span>Actions</span>
</div>
<div className="store-items-table-body">
{items.map((item) => {
const imageSrc = itemImageSource(item);
const details = [item.item_type, item.item_group, item.zone].filter(Boolean);
const isSelectedForDelete = selectedDeleteIds.has(item.item_id);
return (
<div key={item.item_id} className="store-items-table-row">
<button
key={item.item_id}
type="button"
className={`store-items-table-row store-items-table-row-button ${deleteMode ? "is-delete-selectable" : ""} ${isSelectedForDelete ? "is-selected" : ""}`}
aria-label={
deleteMode
? `${isSelectedForDelete ? "Deselect" : "Select"} ${item.item_name} for deletion`
: `Edit settings for ${item.item_name}`
}
aria-pressed={deleteMode ? isSelectedForDelete : undefined}
onClick={() => {
if (deleteMode) {
toggleDeleteSelection(item.item_id);
} else {
openEditor(item);
}
}}
>
<div className="store-items-table-cell store-items-table-item">
<span className="store-items-mobile-label">Item</span>
<div className="store-available-items-summary">
{imageSrc ? (
<img src={imageSrc} alt="" className="store-available-items-thumb" />
) : (
<span className="store-available-items-thumb store-available-items-thumb-placeholder">
{item.item_name?.slice(0, 1).toUpperCase() || "?"}
<span
className="store-available-items-thumb store-available-items-thumb-placeholder"
aria-hidden="true"
>
{"\uD83D\uDCE6"}
</span>
)}
<div className="store-available-items-copy">
@ -223,38 +324,12 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
</div>
</div>
<div className="store-items-table-cell">
<span className="store-items-mobile-label">Store Defaults</span>
<span className="store-items-defaults-text">
{details.join(" | ") || "No store defaults set"}
{deleteMode ? (
<span className="store-items-delete-indicator" aria-hidden="true">
{isSelectedForDelete ? "✓" : ""}
</span>
</div>
<div className="store-items-table-cell store-items-table-actions">
<span className="store-items-mobile-label">Actions</span>
<div className="store-available-items-actions">
<button
type="button"
className="btn-secondary btn-small"
onClick={() => {
setEditorItem(item);
setShowEditor(true);
}}
>
Edit Settings
</button>
{isAdmin ? (
<button
type="button"
className="btn-danger btn-small"
onClick={() => setPendingDeleteItem(item)}
>
Delete Item
</button>
) : null}
</div>
</div>
</div>
</button>
);
})}
</div>
@ -277,15 +352,19 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
/>
<ConfirmSlideModal
isOpen={Boolean(pendingDeleteItem)}
title={pendingDeleteItem ? `Delete ${pendingDeleteItem.item_name}?` : "Delete item?"}
isOpen={pendingDeleteItems.length > 0}
title={
pendingDeleteItems.length === 1
? `Delete ${pendingDeleteItems[0].item_name}?`
: `Delete ${pendingDeleteItems.length} items?`
}
description={
pendingDeleteItem
? `Slide to confirm. This permanently deletes ${pendingDeleteItem.item_name} from ${store.display_name || store.name} for this household, including current list entries and history.`
pendingDeleteItems.length > 0
? `Slide to confirm. This permanently deletes ${pendingDeleteItems.length === 1 ? pendingDeleteItems[0].item_name : `${pendingDeleteItems.length} items`} from ${store.display_name || store.name} for this household, including current list entries and history.`
: ""
}
confirmLabel="Delete Item"
onClose={() => setPendingDeleteItem(null)}
confirmLabel={pendingDeleteItems.length === 1 ? "Delete Item" : "Delete Items"}
onClose={() => setPendingDeleteItems([])}
onConfirm={handleDeleteConfirm}
/>
</>

View File

@ -0,0 +1,381 @@
import { useState } from "react";
import {
addLocationToStore,
removeLocation,
setDefaultLocation,
updateLocation,
} from "../../api/stores";
import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage";
import ConfirmSlideModal from "../modals/ConfirmSlideModal";
function locationLabel(location) {
return location.display_name || location.name;
}
function locationEditName(location) {
return location.location_name || location.name || "";
}
function LocationSettingsModal({
location,
draft,
setDraft,
canManage,
onCancel,
onSave,
onSetDefault,
}) {
if (!location) return null;
return (
<div className="store-items-modal-overlay" onClick={onCancel}>
<div className="store-items-modal store-settings-modal" onClick={(event) => event.stopPropagation()}>
<div className="store-items-modal-header">
<div>
<h3>{locationLabel(location)} Settings</h3>
<p>Update this location name, notes, or default status.</p>
</div>
<button
type="button"
className="store-items-modal-close"
onClick={onCancel}
aria-label="Close location settings"
>
x
</button>
</div>
<div className="store-settings-form">
<label>
<span>Location name</span>
<input
value={draft.name}
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
disabled={!canManage}
/>
</label>
<label>
<span>Address or notes</span>
<input
value={draft.address}
onChange={(event) => setDraft((current) => ({ ...current, address: event.target.value }))}
disabled={!canManage}
/>
</label>
{canManage ? (
<div className="store-settings-actions">
{!location.is_default ? (
<button type="button" className="btn-secondary btn-small" onClick={onSetDefault}>
Set Default
</button>
) : null}
<button type="button" className="btn-primary btn-small" onClick={onSave}>
Save Changes
</button>
</div>
) : null}
</div>
</div>
</div>
);
}
export default function StoreLocationManager({
householdId,
storeGroup,
allLocationCount,
canManage,
refreshAfterStoreChange,
}) {
const toast = useActionToast();
const locationCount = storeGroup.locations.length;
const [isOpen, setIsOpen] = useState(false);
const [locationDraft, setLocationDraft] = useState({ name: "", address: "" });
const [deleteMode, setDeleteMode] = useState(false);
const [selectedDeleteIds, setSelectedDeleteIds] = useState(() => new Set());
const [pendingDeleteLocations, setPendingDeleteLocations] = useState([]);
const [editingLocation, setEditingLocation] = useState(null);
const [editingLocationDraft, setEditingLocationDraft] = useState({ name: "", address: "" });
const selectedDeleteLocations = storeGroup.locations.filter((location) =>
selectedDeleteIds.has(location.id)
);
const selectedDeleteCount = selectedDeleteLocations.length;
const canConfirmDelete = selectedDeleteCount > 0 && allLocationCount - selectedDeleteCount >= 1;
const closeManager = () => {
setIsOpen(false);
setDeleteMode(false);
setSelectedDeleteIds(new Set());
setPendingDeleteLocations([]);
setEditingLocation(null);
};
const handleAddLocation = async () => {
const name = locationDraft.name.trim();
if (!name) return;
try {
await addLocationToStore(householdId, storeGroup.household_store_id, {
name,
address: locationDraft.address.trim() || null,
});
setLocationDraft({ name: "", address: "" });
await refreshAfterStoreChange();
toast.success("Added location", `Added ${name} to ${storeGroup.name}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to add location");
toast.error("Add location failed", `Add location failed: ${message}`);
}
};
const openLocationSettings = (location) => {
setEditingLocation(location);
setEditingLocationDraft({
name: locationEditName(location),
address: location.address || "",
});
};
const handleSaveLocation = async () => {
if (!editingLocation) return;
const name = editingLocationDraft.name.trim();
if (!name) return;
try {
await updateLocation(householdId, editingLocation.id, {
name,
address: editingLocationDraft.address.trim() || null,
});
await refreshAfterStoreChange();
setEditingLocation(null);
toast.success("Updated location", `Updated ${name}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to update location");
toast.error("Update location failed", `Update location failed: ${message}`);
}
};
const handleSetDefault = async () => {
if (!editingLocation) return;
try {
await setDefaultLocation(householdId, editingLocation.id);
await refreshAfterStoreChange();
setEditingLocation(null);
toast.success("Updated default location", `Default location set to ${locationLabel(editingLocation)}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to set default location");
toast.error("Set default failed", `Set default failed: ${message}`);
}
};
const toggleLocationSelection = (locationId) => {
setSelectedDeleteIds((currentIds) => {
const nextIds = new Set(currentIds);
if (nextIds.has(locationId)) {
nextIds.delete(locationId);
} else {
nextIds.add(locationId);
}
return nextIds;
});
};
const startDeleteMode = () => {
setDeleteMode(true);
setSelectedDeleteIds(new Set());
};
const cancelDeleteMode = () => {
setDeleteMode(false);
setSelectedDeleteIds(new Set());
};
const confirmSelectedDelete = () => {
if (!canConfirmDelete) return;
setPendingDeleteLocations(selectedDeleteLocations);
};
const handleDeleteConfirm = async () => {
if (pendingDeleteLocations.length === 0) {
return;
}
try {
await Promise.all(
pendingDeleteLocations.map((location) => removeLocation(householdId, location.id))
);
const count = pendingDeleteLocations.length;
await refreshAfterStoreChange();
setPendingDeleteLocations([]);
setDeleteMode(false);
setSelectedDeleteIds(new Set());
toast.success(
count === 1 ? "Removed location" : "Removed locations",
`Removed ${count} ${count === 1 ? "location" : "locations"} from ${storeGroup.name}`
);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to remove locations");
toast.error("Remove locations failed", `Remove locations failed: ${message}`);
}
};
return (
<>
<button
type="button"
className="btn-secondary btn-small store-location-manager-trigger"
onClick={() => setIsOpen(true)}
>
Manage Locations ({locationCount})
</button>
{isOpen ? (
<div className="store-items-modal-overlay" onClick={closeManager}>
<div className="store-items-modal" onClick={(event) => event.stopPropagation()}>
<div className="store-items-modal-header">
<div>
<h3>{storeGroup.name} Locations</h3>
<p>Manage locations, defaults, and location notes for this store.</p>
</div>
<button
type="button"
className="store-items-modal-close"
onClick={closeManager}
aria-label="Close manage locations modal"
>
x
</button>
</div>
{canManage ? (
<div className="store-items-modal-toolbar store-management-create-row store-location-create-row">
<input
value={locationDraft.name}
onChange={(event) =>
setLocationDraft((current) => ({ ...current, name: event.target.value }))
}
placeholder="Location name"
/>
<input
value={locationDraft.address}
onChange={(event) =>
setLocationDraft((current) => ({ ...current, address: event.target.value }))
}
placeholder="Address or notes"
/>
<button type="button" className="btn-primary btn-small" onClick={handleAddLocation}>
Add Location
</button>
</div>
) : null}
{canManage && storeGroup.locations.length > 0 ? (
<div className="store-items-bulk-toolbar">
<button
type="button"
className="btn-danger btn-small store-items-delete-toggle"
disabled={deleteMode ? !canConfirmDelete : allLocationCount <= 1}
onClick={deleteMode ? confirmSelectedDelete : startDeleteMode}
title={
deleteMode && selectedDeleteCount > 0 && !canConfirmDelete
? "At least one household location must remain"
: ""
}
>
{deleteMode ? `Confirm Delete (${selectedDeleteCount})` : "Delete Locations"}
</button>
{deleteMode ? (
<button
type="button"
className="btn-secondary btn-small store-items-delete-cancel"
onClick={cancelDeleteMode}
>
Cancel
</button>
) : null}
</div>
) : null}
<div className="store-items-modal-body">
<div className="store-items-table">
<div className="store-items-table-body">
{storeGroup.locations.map((location) => {
const isSelectedForDelete = selectedDeleteIds.has(location.id);
return (
<button
key={location.id}
type="button"
className={`store-items-table-row store-items-table-row-button store-management-row ${deleteMode ? "is-delete-selectable" : ""} ${isSelectedForDelete ? "is-selected" : ""}`}
aria-label={
deleteMode
? `${isSelectedForDelete ? "Deselect" : "Select"} ${locationLabel(location)} for deletion`
: `Edit location ${locationLabel(location)}`
}
aria-pressed={deleteMode ? isSelectedForDelete : undefined}
onClick={() => {
if (deleteMode) {
toggleLocationSelection(location.id);
} else {
openLocationSettings(location);
}
}}
>
<span className="store-management-name">
{locationLabel(location)}
{location.is_default ? (
<span className="store-management-badge">Default</span>
) : null}
</span>
{location.address ? (
<span className="store-management-meta">{location.address}</span>
) : null}
{deleteMode ? (
<span className="store-items-delete-indicator" aria-hidden="true">
{isSelectedForDelete ? "\u2713" : ""}
</span>
) : null}
</button>
);
})}
</div>
</div>
</div>
</div>
</div>
) : null}
<LocationSettingsModal
location={editingLocation}
draft={editingLocationDraft}
setDraft={setEditingLocationDraft}
canManage={canManage}
onCancel={() => setEditingLocation(null)}
onSave={handleSaveLocation}
onSetDefault={handleSetDefault}
/>
<ConfirmSlideModal
isOpen={pendingDeleteLocations.length > 0}
title={
pendingDeleteLocations.length === 1
? `Delete ${locationLabel(pendingDeleteLocations[0])}?`
: `Delete ${pendingDeleteLocations.length} locations?`
}
description={
pendingDeleteLocations.length > 0
? `Slide to confirm. This removes ${pendingDeleteLocations.length === 1 ? locationLabel(pendingDeleteLocations[0]) : `${pendingDeleteLocations.length} locations`} from this household.`
: ""
}
confirmLabel={pendingDeleteLocations.length === 1 ? "Delete Location" : "Delete Locations"}
onClose={() => setPendingDeleteLocations([])}
onConfirm={handleDeleteConfirm}
/>
</>
);
}

View File

@ -0,0 +1,506 @@
import { useEffect, useRef, useState } from "react";
import {
createLocationZone,
deleteLocationZone,
getLocationZones,
updateLocationZone,
} from "../../api/stores";
import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage";
import ConfirmSlideModal from "../modals/ConfirmSlideModal";
function locationLabel(location) {
return location.display_name || location.name;
}
function ZoneSettingsModal({
zone,
draft,
setDraft,
canManage,
canMoveUp,
canMoveDown,
onCancel,
onSave,
onMove,
}) {
if (!zone) return null;
return (
<div className="store-items-modal-overlay" onClick={onCancel}>
<div className="store-items-modal store-settings-modal" onClick={(event) => event.stopPropagation()}>
<div className="store-items-modal-header">
<div>
<h3>{zone.name} Settings</h3>
<p>Update this zone name or adjust its shopping order.</p>
</div>
<button
type="button"
className="store-items-modal-close"
onClick={onCancel}
aria-label="Close zone settings"
>
x
</button>
</div>
<div className="store-settings-form">
<label>
<span>Zone name</span>
<input
value={draft.name}
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
disabled={!canManage}
/>
</label>
{canManage ? (
<div className="store-settings-actions">
<button
type="button"
className="btn-secondary btn-small"
disabled={!canMoveUp}
onClick={() => onMove(-1)}
>
Move Up
</button>
<button
type="button"
className="btn-secondary btn-small"
disabled={!canMoveDown}
onClick={() => onMove(1)}
>
Move Down
</button>
<button type="button" className="btn-primary btn-small" onClick={onSave}>
Save Changes
</button>
</div>
) : null}
</div>
</div>
</div>
);
}
export default function StoreZoneManager({
householdId,
location,
canManage,
refreshActiveZones,
refreshStoreCounts,
zoneCount = 0,
}) {
const toast = useActionToast();
const [isOpen, setIsOpen] = useState(false);
const [zones, setZones] = useState([]);
const [displayZoneCount, setDisplayZoneCount] = useState(zoneCount);
const [loading, setLoading] = useState(false);
const [newZoneName, setNewZoneName] = useState("");
const [deleteMode, setDeleteMode] = useState(false);
const [selectedDeleteIds, setSelectedDeleteIds] = useState(() => new Set());
const [pendingDeleteZones, setPendingDeleteZones] = useState([]);
const [editingZone, setEditingZone] = useState(null);
const [editingZoneDraft, setEditingZoneDraft] = useState({ name: "" });
const [draggedZoneId, setDraggedZoneId] = useState(null);
const [dragOverZoneId, setDragOverZoneId] = useState(null);
const [reordering, setReordering] = useState(false);
const draggedZoneIdRef = useRef(null);
const hasDraggedZoneRef = useRef(false);
const selectedDeleteZones = zones.filter((zone) => selectedDeleteIds.has(zone.id));
const selectedDeleteCount = selectedDeleteZones.length;
const canDragReorder = canManage && !deleteMode && !loading && !reordering;
const loadZones = async () => {
if (!householdId || !location?.id) return;
setLoading(true);
try {
const response = await getLocationZones(householdId, location.id);
const nextZones = response.data?.zones || [];
setZones(nextZones);
setDisplayZoneCount(nextZones.length);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to load zones");
toast.error("Load zones failed", `Load zones failed: ${message}`);
} finally {
setLoading(false);
}
};
const closeManager = () => {
setIsOpen(false);
setDeleteMode(false);
setSelectedDeleteIds(new Set());
setPendingDeleteZones([]);
setEditingZone(null);
setDraggedZoneId(null);
setDragOverZoneId(null);
draggedZoneIdRef.current = null;
hasDraggedZoneRef.current = false;
};
const openZoneSettings = (zone) => {
setEditingZone(zone);
setEditingZoneDraft({ name: zone.name || "" });
};
const toggleZoneSelection = (zoneId) => {
setSelectedDeleteIds((currentIds) => {
const nextIds = new Set(currentIds);
if (nextIds.has(zoneId)) {
nextIds.delete(zoneId);
} else {
nextIds.add(zoneId);
}
return nextIds;
});
};
const startDeleteMode = () => {
setDeleteMode(true);
setSelectedDeleteIds(new Set());
};
const cancelDeleteMode = () => {
setDeleteMode(false);
setSelectedDeleteIds(new Set());
};
const confirmSelectedDelete = () => {
if (selectedDeleteCount === 0) return;
setPendingDeleteZones(selectedDeleteZones);
};
useEffect(() => {
if (isOpen) {
loadZones();
}
}, [isOpen, householdId, location?.id]);
useEffect(() => {
setDisplayZoneCount(zoneCount);
}, [zoneCount]);
const handleCreateZone = async () => {
const name = newZoneName.trim();
if (!name) return;
try {
const nextSortOrder =
zones.length > 0 ? Math.max(...zones.map((zone) => zone.sort_order || 0)) + 10 : 10;
await createLocationZone(householdId, location.id, {
name,
sort_order: nextSortOrder,
});
setNewZoneName("");
await loadZones();
await refreshActiveZones();
await refreshStoreCounts?.();
toast.success("Added zone", `Added zone ${name}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to add zone");
toast.error("Add zone failed", `Add zone failed: ${message}`);
}
};
const persistZoneOrder = async (orderedZones) => {
const zonesWithSortOrder = orderedZones.map((zone, index) => ({
...zone,
sort_order: (index + 1) * 10,
}));
const changedZones = zonesWithSortOrder.filter((zone) => {
const currentZone = zones.find((candidate) => candidate.id === zone.id);
return currentZone && currentZone.sort_order !== zone.sort_order;
});
if (changedZones.length === 0) return true;
setReordering(true);
setZones(zonesWithSortOrder);
try {
await Promise.all(
changedZones.map((zone) =>
updateLocationZone(householdId, location.id, zone.id, {
sort_order: zone.sort_order,
})
)
);
await loadZones();
await refreshActiveZones();
toast.success("Reordered zones", "Updated shopping order");
return true;
} catch (error) {
const message = getApiErrorMessage(error, "Failed to reorder zones");
toast.error("Reorder zones failed", `Reorder zones failed: ${message}`);
await loadZones();
return false;
} finally {
setReordering(false);
}
};
const moveZone = async (fromIndex, toIndex) => {
if (fromIndex < 0 || toIndex < 0 || fromIndex >= zones.length || toIndex >= zones.length) {
return false;
}
const orderedZones = [...zones];
const [movedZone] = orderedZones.splice(fromIndex, 1);
orderedZones.splice(toIndex, 0, movedZone);
return persistZoneOrder(orderedZones);
};
const handleMoveZone = async (zone, direction) => {
const currentIndex = zones.findIndex((candidate) => candidate.id === zone.id);
const moved = await moveZone(currentIndex, currentIndex + direction);
if (moved) {
setEditingZone(null);
}
};
const handleZoneDragStart = (event, zoneId) => {
if (!canDragReorder) {
event.preventDefault();
return;
}
draggedZoneIdRef.current = zoneId;
hasDraggedZoneRef.current = true;
setDraggedZoneId(zoneId);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", String(zoneId));
};
const clearZoneDrag = () => {
draggedZoneIdRef.current = null;
setDraggedZoneId(null);
setDragOverZoneId(null);
window.setTimeout(() => {
hasDraggedZoneRef.current = false;
}, 0);
};
const handleZoneDrop = async (event, targetZoneId) => {
event.preventDefault();
const sourceZoneId = draggedZoneIdRef.current || event.dataTransfer.getData("text/plain");
clearZoneDrag();
if (!sourceZoneId || String(sourceZoneId) === String(targetZoneId)) return;
const sourceIndex = zones.findIndex((zone) => String(zone.id) === String(sourceZoneId));
const targetIndex = zones.findIndex((zone) => String(zone.id) === String(targetZoneId));
await moveZone(sourceIndex, targetIndex);
};
const handleSaveZone = async () => {
if (!editingZone) return;
const name = editingZoneDraft.name.trim();
if (!name) return;
try {
await updateLocationZone(householdId, location.id, editingZone.id, { name });
await loadZones();
await refreshActiveZones();
setEditingZone(null);
toast.success("Updated zone", `Updated zone ${name}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to update zone");
toast.error("Update zone failed", `Update zone failed: ${message}`);
}
};
const handleDeleteConfirm = async () => {
if (pendingDeleteZones.length === 0) {
return;
}
try {
await Promise.all(
pendingDeleteZones.map((zone) => deleteLocationZone(householdId, location.id, zone.id))
);
const count = pendingDeleteZones.length;
await loadZones();
await refreshActiveZones();
await refreshStoreCounts?.();
setPendingDeleteZones([]);
setDeleteMode(false);
setSelectedDeleteIds(new Set());
toast.success(count === 1 ? "Removed zone" : "Removed zones", `Removed ${count} ${count === 1 ? "zone" : "zones"}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to remove zones");
toast.error("Remove zones failed", `Remove zones failed: ${message}`);
}
};
const editingZoneIndex = editingZone
? zones.findIndex((zone) => zone.id === editingZone.id)
: -1;
return (
<>
<button
type="button"
className="btn-secondary btn-small store-zone-manager-trigger"
onClick={() => setIsOpen(true)}
>
Manage Zones ({displayZoneCount})
</button>
{isOpen ? (
<div className="store-items-modal-overlay" onClick={closeManager}>
<div className="store-items-modal" onClick={(event) => event.stopPropagation()}>
<div className="store-items-modal-header">
<div>
<h3>{locationLabel(location)} Zones</h3>
<p>Manage shopping order zones for this location.</p>
</div>
<button
type="button"
className="store-items-modal-close"
onClick={closeManager}
aria-label="Close manage zones modal"
>
x
</button>
</div>
{canManage ? (
<div className="store-items-modal-toolbar store-management-create-row">
<input
value={newZoneName}
onChange={(event) => setNewZoneName(event.target.value)}
placeholder="New zone name"
/>
<button type="button" className="btn-primary btn-small" onClick={handleCreateZone}>
Add Zone
</button>
</div>
) : null}
{canManage && zones.length > 0 ? (
<div className="store-items-bulk-toolbar">
<button
type="button"
className="btn-danger btn-small store-items-delete-toggle"
disabled={deleteMode ? selectedDeleteCount === 0 : loading}
onClick={deleteMode ? confirmSelectedDelete : startDeleteMode}
>
{deleteMode ? `Confirm Delete (${selectedDeleteCount})` : "Delete Zones"}
</button>
{deleteMode ? (
<button
type="button"
className="btn-secondary btn-small store-items-delete-cancel"
onClick={cancelDeleteMode}
>
Cancel
</button>
) : null}
</div>
) : null}
<div className="store-items-modal-body">
{loading ? (
<p className="empty-message">Loading zones...</p>
) : zones.length === 0 ? (
<p className="empty-message">No zones for this location.</p>
) : (
<div className="store-items-table">
<div className="store-items-table-body">
{zones.map((zone, index) => {
const isSelectedForDelete = selectedDeleteIds.has(zone.id);
return (
<button
key={zone.id}
type="button"
className={`store-items-table-row store-items-table-row-button store-management-row store-management-row-with-order ${canDragReorder ? "store-management-row-with-drag" : ""} ${draggedZoneId === zone.id ? "is-dragging" : ""} ${dragOverZoneId === zone.id ? "is-drag-target" : ""} ${deleteMode ? "is-delete-selectable" : ""} ${isSelectedForDelete ? "is-selected" : ""}`}
aria-label={
deleteMode
? `${isSelectedForDelete ? "Deselect" : "Select"} ${zone.name} for deletion`
: `Edit zone ${zone.name}`
}
aria-pressed={deleteMode ? isSelectedForDelete : undefined}
onDragOver={(event) => {
if (!canDragReorder || !draggedZoneIdRef.current) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
setDragOverZoneId(zone.id);
}}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setDragOverZoneId(null);
}
}}
onDrop={(event) => handleZoneDrop(event, zone.id)}
onClick={() => {
if (hasDraggedZoneRef.current) return;
if (deleteMode) {
toggleZoneSelection(zone.id);
} else {
openZoneSettings(zone);
}
}}
>
{canDragReorder ? (
<span
className="store-zone-drag-handle"
draggable
aria-hidden="true"
title="Drag to reorder"
onClick={(event) => event.stopPropagation()}
onDragStart={(event) => handleZoneDragStart(event, zone.id)}
onDragEnd={clearZoneDrag}
/>
) : null}
<span className="store-management-order">{index + 1}</span>
<span className="store-management-name">{zone.name}</span>
{deleteMode ? (
<span className="store-items-delete-indicator" aria-hidden="true">
{isSelectedForDelete ? "\u2713" : ""}
</span>
) : null}
</button>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
) : null}
<ZoneSettingsModal
zone={editingZone}
draft={editingZoneDraft}
setDraft={setEditingZoneDraft}
canManage={canManage}
canMoveUp={editingZoneIndex > 0}
canMoveDown={editingZoneIndex >= 0 && editingZoneIndex < zones.length - 1}
onCancel={() => setEditingZone(null)}
onSave={handleSaveZone}
onMove={(direction) => handleMoveZone(editingZone, direction)}
/>
<ConfirmSlideModal
isOpen={pendingDeleteZones.length > 0}
title={
pendingDeleteZones.length === 1
? `Delete ${pendingDeleteZones[0].name}?`
: `Delete ${pendingDeleteZones.length} zones?`
}
description={
pendingDeleteZones.length > 0
? `Slide to confirm. This removes ${pendingDeleteZones.length === 1 ? pendingDeleteZones[0].name : `${pendingDeleteZones.length} zones`} from ${locationLabel(location)}.`
: ""
}
confirmLabel={pendingDeleteZones.length === 1 ? "Delete Zone" : "Delete Zones"}
onClose={() => setPendingDeleteZones([])}
onConfirm={handleDeleteConfirm}
/>
</>
);
}

View File

@ -0,0 +1,323 @@
import { useEffect, useMemo } from "react";
import {
DEFAULT_MAP_FILTERS,
getMapObjectDisplayLabel,
objectZoneId,
} from "../../lib/locationMapUtils";
import {
LocationMapLayerPanel,
LocationMapOverview,
MAP_DISPLAY_CONTROLS,
SelectedMapObjectForm,
SelectedZoneItemsPanel,
UnmappedItemsPanel,
} from "./LocationMapBottomSheetSections";
const EMPTY_ITEMS = [];
export default function LocationMapBottomSheet({
mode,
canManage,
filters,
setFilters,
layersOpen,
setLayersOpen,
selectedObject,
mapObjects = [],
selectedZoneItems = EMPTY_ITEMS,
visibleAssignedItemCount = 0,
visibleUnmappedItems = EMPTY_ITEMS,
unmappedItemCount = 0,
hiddenAreaCount,
saving,
savingAction,
hasUnsavedChanges,
mapState,
onAddObject,
onSaveDraft,
onPublish,
onObjectField,
onZoneLinkChange,
onDuplicateObject,
onDeleteObject,
onShowHiddenAreas,
onClearSelection,
onSelectZoneItem,
onSelectUnmappedItem,
}) {
const selectedTitle = getMapObjectDisplayLabel(selectedObject);
const selectedZoneId = objectZoneId(selectedObject);
const mapItems = mapState?.items || EMPTY_ITEMS;
const { assignedItemCount, selectedAssignedItems } = useMemo(() => {
const nextSelectedAssignedItems = [];
let nextAssignedItemCount = 0;
mapItems.forEach((item) => {
if (!item.zone_id) return;
nextAssignedItemCount += 1;
if (selectedZoneId && String(item.zone_id) === selectedZoneId) {
nextSelectedAssignedItems.push(item);
}
});
return {
assignedItemCount: nextAssignedItemCount,
selectedAssignedItems: nextSelectedAssignedItems,
};
}, [mapItems, selectedZoneId]);
const hiddenSelectedItemCount = Math.max(0, selectedAssignedItems.length - selectedZoneItems.length);
const hasHiddenSelectedItems = hiddenSelectedItemCount > 0;
const selectedItemCountLabel = hasHiddenSelectedItems
? `${selectedZoneItems.length} shown`
: `${selectedZoneItems.length} item${selectedZoneItems.length === 1 ? "" : "s"}`;
const sheetTitle = mode === "edit"
? selectedObject
? selectedTitle
: "Edit Areas"
: selectedObject
? selectedTitle
: "Map Details";
const showHeaderItemCount = mode !== "edit" && Boolean(selectedObject);
const showEditorControls = mode === "edit" && canManage;
const showSelectedObjectForm = Boolean(mode === "edit" && selectedObject);
const showUnmappedPanel = filters.showUnmapped && !selectedObject;
useEffect(() => {
if (selectedObject) {
setLayersOpen(false);
}
}, [selectedObject, setLayersOpen]);
useEffect(() => {
if (!layersOpen || typeof window === "undefined") {
return undefined;
}
const handleKeyDown = (event) => {
if (event.key === "Escape") {
setLayersOpen(false);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [layersOpen, setLayersOpen]);
const changedLayerCount = useMemo(
() => MAP_DISPLAY_CONTROLS.filter(([key]) => filters[key] !== DEFAULT_MAP_FILTERS[key]).length,
[filters]
);
const totalAreaCount = mapObjects.length;
const visibleAreaCount = filters.showZones
? Math.max(0, totalAreaCount - hiddenAreaCount)
: 0;
const hasNoVisibleAreas =
filters.showZones && totalAreaCount > 0 && visibleAreaCount === 0 && hiddenAreaCount > 0;
const showMapOverview = mode !== "edit" && !selectedObject;
const unlinkedAreaCount = useMemo(
() => mapObjects.filter((object) => !objectZoneId(object)).length,
[mapObjects]
);
const showDraftOverview = mode === "edit" && !selectedObject;
const draftOverviewRows = [
{ label: "Areas", value: totalAreaCount },
...(hiddenAreaCount > 0 ? [{ label: "Hidden", value: hiddenAreaCount }] : []),
...(unlinkedAreaCount > 0 ? [{ label: "Unlinked", value: unlinkedAreaCount }] : []),
];
const hiddenUnmappedItemCount = Math.max(0, unmappedItemCount - visibleUnmappedItems.length);
const hasHiddenUnmappedItems =
filters.showUnmapped && unmappedItemCount > 0 && visibleUnmappedItems.length === 0;
const hiddenAreaLabel = `Show Hidden Areas (${hiddenAreaCount})`;
const showHiddenAreaAction =
showEditorControls && hiddenAreaCount > 0 && filters.showZones && !hasNoVisibleAreas;
const saveDraftLabel = savingAction === "save" ? "Saving..." : "Save Draft";
const publishLabel = savingAction === "publish" ? "Publishing..." : "Publish";
const canPublishDraft = Boolean(mapState?.draft_map || hasUnsavedChanges);
const showSaveDraftAction = Boolean(savingAction === "save" || hasUnsavedChanges);
const showPublishAction = Boolean(savingAction === "publish" || canPublishDraft);
const mappedItemSummary = assignedItemCount === visibleAssignedItemCount
? String(assignedItemCount)
: `${visibleAssignedItemCount} shown`;
const unmappedItemSummary = !filters.showUnmapped && unmappedItemCount > 0
? `${unmappedItemCount} hidden`
: visibleUnmappedItems.length === unmappedItemCount
? String(unmappedItemCount)
: `${visibleUnmappedItems.length} shown`;
const mapOverviewRows = [
{ label: "Areas", value: filters.showZones ? `${visibleAreaCount} shown` : "Hidden" },
...(assignedItemCount > 0 ? [{ label: "Mapped items", value: mappedItemSummary }] : []),
...(unmappedItemCount > 0 ? [{ label: "Unmapped", value: unmappedItemSummary }] : []),
];
const resetLayerFilters = () => setFilters({ ...DEFAULT_MAP_FILTERS });
const showSelectedZoneItems = () => {
setFilters((current) => ({
...current,
showMyItems: true,
showOtherItems: true,
showCompleted: true,
}));
};
const showUnmappedItems = () => {
setFilters((current) => ({
...current,
showUnmapped: true,
showMyItems: true,
showOtherItems: true,
showCompleted: true,
}));
};
const primaryDraftActions = showEditorControls && (showSaveDraftAction || showPublishAction) ? (
<div className="location-map-editor-actions location-map-primary-actions">
{showSaveDraftAction ? (
<button type="button" className="primary" onClick={onSaveDraft} disabled={saving || !hasUnsavedChanges}>
{saveDraftLabel}
</button>
) : null}
{showPublishAction ? (
<button
type="button"
className="primary"
onClick={onPublish}
disabled={saving || !canPublishDraft}
>
{publishLabel}
</button>
) : null}
</div>
) : null;
const secondaryDraftActions = showEditorControls ? (
<div className="location-map-editor-actions location-map-secondary-actions">
<button type="button" onClick={onAddObject} disabled={saving}>Add Area</button>
</div>
) : null;
const editorActions = showEditorControls ? (
<div className="location-map-editor-action-stack" role="group" aria-label="Map draft actions">
{primaryDraftActions}
{secondaryDraftActions}
</div>
) : null;
return (
<aside className="location-map-bottom-sheet">
<div className="location-map-bottom-sheet-handle" aria-hidden="true" />
<div className="location-map-sheet-header">
<div>
<div className="location-map-sheet-heading">
<strong>{sheetTitle}</strong>
{showHeaderItemCount ? (
<span className="location-map-sheet-count">{selectedItemCountLabel}</span>
) : null}
</div>
</div>
<div className="location-map-layer-actions">
{selectedObject ? (
<button
type="button"
className="location-map-sheet-close"
onClick={onClearSelection}
aria-label="Close selected map area"
>
<span aria-hidden="true">&times;</span>
</button>
) : null}
{changedLayerCount ? (
<button
type="button"
className="location-map-layer-reset"
onClick={resetLayerFilters}
aria-label="Reset layer filters"
title="Reset layer filters"
>
<svg className="location-map-layer-reset-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7v5h5" />
<path d="M5.2 12a7 7 0 1 0 2.1-5" />
</svg>
</button>
) : null}
<button
type="button"
className={`location-map-layer-toggle ${layersOpen ? "active" : ""}`}
onClick={() => setLayersOpen((current) => !current)}
aria-expanded={layersOpen}
aria-label={changedLayerCount ? `Layers, ${changedLayerCount} changed` : "Layers"}
>
Layers
{changedLayerCount ? (
<span className="location-map-layer-count" aria-hidden="true">{changedLayerCount}</span>
) : null}
</button>
</div>
</div>
{layersOpen ? (
<LocationMapLayerPanel filters={filters} setFilters={setFilters} />
) : null}
{showSelectedObjectForm ? (
primaryDraftActions ? (
<div
className="location-map-editor-action-stack location-map-selected-primary-actions"
role="group"
aria-label="Map draft actions"
>
{primaryDraftActions}
</div>
) : null
) : editorActions}
{showHiddenAreaAction ? (
<div className="location-map-hidden-areas">
<button type="button" onClick={onShowHiddenAreas} disabled={saving}>
{hiddenAreaLabel}
</button>
</div>
) : null}
{showMapOverview ? (
<LocationMapOverview
ariaLabel="Map summary"
rows={mapOverviewRows}
/>
) : null}
{showDraftOverview ? (
<LocationMapOverview
ariaLabel="Draft map summary"
rows={draftOverviewRows}
/>
) : null}
{mode === "edit" && selectedObject ? (
<SelectedMapObjectForm
selectedObject={selectedObject}
zones={mapState?.zones || EMPTY_ITEMS}
saving={saving}
onObjectField={onObjectField}
onZoneLinkChange={onZoneLinkChange}
onDuplicateObject={onDuplicateObject}
onDeleteObject={onDeleteObject}
/>
) : mode === "edit" ? (
null
) : selectedObject ? (
<SelectedZoneItemsPanel
selectedZoneItems={selectedZoneItems}
hasHiddenSelectedItems={hasHiddenSelectedItems}
hiddenSelectedItemCount={hiddenSelectedItemCount}
onShowSelectedZoneItems={showSelectedZoneItems}
onSelectZoneItem={onSelectZoneItem}
/>
) : null}
{showUnmappedPanel ? (
<UnmappedItemsPanel
visibleUnmappedItems={visibleUnmappedItems}
hiddenUnmappedItemCount={hiddenUnmappedItemCount}
hasHiddenUnmappedItems={hasHiddenUnmappedItems}
onShowUnmappedItems={showUnmappedItems}
onSelectUnmappedItem={onSelectUnmappedItem}
/>
) : null}
</aside>
);
}

View File

@ -0,0 +1,306 @@
import { useEffect, useState } from "react";
import { getMapObjectDisplayLabel, objectZoneId } from "../../lib/locationMapUtils";
export const MAP_DISPLAY_CONTROLS = [
["showZones", "Zones"],
["showLabels", "Labels"],
["showCounts", "Counts"],
["showPins", "Pins"],
["showMyItems", "Mine"],
["showOtherItems", "Others"],
["showCompleted", "Bought"],
["showUnmapped", "Unmapped"],
];
const MAP_ITEM_PREVIEW_LIMIT = 8;
function MapItemActionRow({ item, onSelectItem }) {
const isBought = Boolean(item.bought);
return (
<li className={`location-map-item-row ${isBought ? "is-bought" : ""}`}>
<button
type="button"
className="location-map-item-button"
onClick={() => {
if (!isBought) onSelectItem?.(item);
}}
disabled={isBought}
aria-label={isBought ? `${item.item_name} bought` : `Mark ${item.item_name} bought`}
>
<span>{item.item_name}</span>
<small>{isBought ? "Bought" : `x${item.quantity}`}</small>
</button>
</li>
);
}
function MapItemActionList({ items, onSelectItem, itemGroupLabel }) {
const [showAllItems, setShowAllItems] = useState(false);
const itemListKey = items.map((item) => item.id).join(":");
useEffect(() => {
setShowAllItems(false);
}, [itemListKey]);
const visibleItems = showAllItems ? items : items.slice(0, MAP_ITEM_PREVIEW_LIMIT);
const overflowItemCount = items.length - visibleItems.length;
const hasOverflow = items.length > MAP_ITEM_PREVIEW_LIMIT;
return (
<ul>
{visibleItems.map((item) => (
<MapItemActionRow key={item.id} item={item} onSelectItem={onSelectItem} />
))}
{hasOverflow ? (
<li className="location-map-list-more">
<button
type="button"
className="location-map-list-more-button"
onClick={() => setShowAllItems((current) => !current)}
aria-label={
showAllItems
? `Show fewer ${itemGroupLabel}`
: `Show ${overflowItemCount} more ${itemGroupLabel}`
}
>
{showAllItems ? "Show fewer" : `+${overflowItemCount} more`}
</button>
</li>
) : null}
</ul>
);
}
export function LocationMapLayerPanel({ filters, setFilters }) {
return (
<div className="location-map-display-panel">
<div className="location-map-display-controls" role="group" aria-label="Layer filters">
{MAP_DISPLAY_CONTROLS.map(([key, label]) => (
<button
key={key}
type="button"
className={filters[key] ? "active" : ""}
aria-pressed={filters[key]}
onClick={() =>
setFilters((current) => ({ ...current, [key]: !current[key] }))
}
>
<span
className={`location-map-layer-state ${filters[key] ? "is-on" : ""}`}
aria-hidden="true"
/>
<span className="location-map-layer-label">{label}</span>
</button>
))}
</div>
</div>
);
}
export function LocationMapOverview({ ariaLabel, rows }) {
return (
<div className="location-map-overview" role="group" aria-label={ariaLabel}>
{rows.map((row) => (
<div className="location-map-overview-row" key={row.label}>
<span>{row.label}</span>
<strong>{row.value}</strong>
</div>
))}
</div>
);
}
function InlineStateAction({ label, value, actionLabel, actionText = "Show", onAction }) {
return (
<div className="location-map-inline-state">
<span>{label}</span>
<strong>{value}</strong>
{onAction ? (
<button type="button" onClick={onAction} aria-label={actionLabel}>
{actionText}
</button>
) : null}
</div>
);
}
export function SelectedMapObjectForm({
selectedObject,
zones = [],
saving,
onObjectField,
onZoneLinkChange,
onDuplicateObject,
onDeleteObject,
}) {
const selectedObjectLabel = getMapObjectDisplayLabel(selectedObject);
return (
<div className="location-map-object-form">
<label className="location-map-field-row">
<span>Label</span>
<input
value={selectedObject.label || ""}
disabled={saving}
onChange={(event) => onObjectField("label", event.target.value)}
/>
</label>
<label className="location-map-field-row">
<span>Zone</span>
<select
aria-label="Linked zone"
value={objectZoneId(selectedObject)}
disabled={saving}
onChange={(event) => onZoneLinkChange(event.target.value)}
>
<option value="">No linked zone</option>
{zones.map((zone) => (
<option key={zone.id} value={zone.id}>{zone.name}</option>
))}
</select>
</label>
<div className="location-map-object-flags">
<button
type="button"
className={selectedObject.visible !== false ? "active" : ""}
aria-pressed={selectedObject.visible !== false}
disabled={saving}
onClick={() => onObjectField("visible", selectedObject.visible === false)}
>
<span
className={`location-map-object-flag-state ${selectedObject.visible !== false ? "is-on" : ""}`}
aria-hidden="true"
/>
<span className="location-map-object-flag-label">Visible</span>
</button>
<button
type="button"
className={selectedObject.locked ? "active" : ""}
aria-pressed={Boolean(selectedObject.locked)}
disabled={saving}
onClick={() => onObjectField("locked", !selectedObject.locked)}
>
<span
className={`location-map-object-flag-state ${selectedObject.locked ? "is-on" : ""}`}
aria-hidden="true"
/>
<span className="location-map-object-flag-label">Locked</span>
</button>
</div>
<div className="location-map-editor-actions">
<button
type="button"
onClick={onDuplicateObject}
disabled={saving}
aria-label={`Duplicate area ${selectedObjectLabel}`}
>
Duplicate
</button>
<button
type="button"
className="danger"
onClick={onDeleteObject}
disabled={saving}
aria-label={`Delete area ${selectedObjectLabel}`}
>
Delete
</button>
</div>
</div>
);
}
export function SelectedZoneItemsPanel({
selectedZoneItems,
hasHiddenSelectedItems,
hiddenSelectedItemCount,
onShowSelectedZoneItems,
onSelectZoneItem,
}) {
if (selectedZoneItems.length === 0) {
if (!hasHiddenSelectedItems) return null;
return (
<div className="location-map-zone-items" role="group" aria-label="Zone items">
<div className="location-map-zone-empty">
<InlineStateAction
label="Hidden"
value={hiddenSelectedItemCount}
actionLabel="Show Zone Items"
onAction={onShowSelectedZoneItems}
/>
</div>
</div>
);
}
return (
<div className="location-map-zone-items" role="group" aria-label="Zone items">
<MapItemActionList
items={selectedZoneItems}
onSelectItem={onSelectZoneItem}
itemGroupLabel="zone items"
/>
{hasHiddenSelectedItems ? (
<div className="location-map-zone-hidden-note">
<InlineStateAction
label="Hidden"
value={hiddenSelectedItemCount}
actionLabel="Show All Zone Items"
actionText="Show All"
onAction={onShowSelectedZoneItems}
/>
</div>
) : null}
</div>
);
}
export function UnmappedItemsPanel({
visibleUnmappedItems,
hiddenUnmappedItemCount,
hasHiddenUnmappedItems,
onShowUnmappedItems,
onSelectUnmappedItem,
}) {
if (visibleUnmappedItems.length === 0 && hasHiddenUnmappedItems) {
return (
<div className="location-map-unmapped-list" role="group" aria-label="Unmapped items">
<div className="location-map-zone-empty">
<InlineStateAction
label="Hidden"
value={hiddenUnmappedItemCount}
actionLabel="Show Unmapped Items"
onAction={onShowUnmappedItems}
/>
</div>
</div>
);
}
if (visibleUnmappedItems.length === 0) {
return null;
}
return (
<div className="location-map-unmapped-list" role="group" aria-label="Unmapped items">
<MapItemActionList
items={visibleUnmappedItems}
onSelectItem={onSelectUnmappedItem}
itemGroupLabel="unmapped items"
/>
{hiddenUnmappedItemCount > 0 ? (
<div className="location-map-zone-hidden-note">
<InlineStateAction
label="Hidden"
value={hiddenUnmappedItemCount}
actionLabel="Show All Unmapped Items"
actionText="Show All"
onAction={onShowUnmappedItems}
/>
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,644 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
clampObjectToMap,
clientPointToMap,
getObjectKey,
itemMatchesMapFilters,
} from "../../lib/locationMapUtils";
import LocationMapObject from "./LocationMapObject";
const DRAG_CHANGE_THRESHOLD = 2;
const NUDGE_KEYS = new Set(["ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft"]);
const SELECTED_OBJECT_SCROLL_PADDING = 24;
const EDGE_PAN_ZONE = 56;
const MAX_EDGE_PAN_STEP = 18;
function clampScrollPosition(value, maxValue) {
return Math.min(Math.max(0, value), Math.max(0, maxValue));
}
function getEdgePanStep(pointerPosition, start, end) {
if (pointerPosition < start + EDGE_PAN_ZONE) {
const intensity = (start + EDGE_PAN_ZONE - pointerPosition) / EDGE_PAN_ZONE;
return -Math.ceil(MAX_EDGE_PAN_STEP * intensity);
}
if (pointerPosition > end - EDGE_PAN_ZONE) {
const intensity = (pointerPosition - (end - EDGE_PAN_ZONE)) / EDGE_PAN_ZONE;
return Math.ceil(MAX_EDGE_PAN_STEP * intensity);
}
return 0;
}
function panScrollElement(scrollElement, deltaX, deltaY) {
if (!scrollElement || (!deltaX && !deltaY)) return;
scrollElement.scrollLeft = clampScrollPosition(
scrollElement.scrollLeft + deltaX,
scrollElement.scrollWidth - scrollElement.clientWidth
);
scrollElement.scrollTop = clampScrollPosition(
scrollElement.scrollTop + deltaY,
scrollElement.scrollHeight - scrollElement.clientHeight
);
}
function shiftMapObject(object, shiftX, shiftY) {
if (!shiftX && !shiftY) return object;
return {
...object,
x: Number(object.x || 0) + shiftX,
y: Number(object.y || 0) + shiftY,
};
}
function getSelectedObjectScrollKey(object, selectedObjectKey, zoom) {
return [
selectedObjectKey,
zoom.toFixed(2),
object.x,
object.y,
object.width,
object.height,
].join(":");
}
function clampResizedObjectToMap(object, mapSize) {
const x = Math.max(0, Math.min(object.x, mapSize.width - 40));
const y = Math.max(0, Math.min(object.y, mapSize.height - 40));
return {
...object,
x,
y,
width: Math.max(40, Math.min(object.width, mapSize.width - x)),
height: Math.max(40, Math.min(object.height, mapSize.height - y)),
};
}
function getDraggedObjectCandidate(object, dragState, deltaX, deltaY) {
if (dragState.type === "resize") {
return {
...object,
width: Math.max(40, dragState.startObject.width + deltaX),
height: Math.max(40, dragState.startObject.height + deltaY),
};
}
return {
...object,
x: dragState.startObject.x + deltaX,
y: dragState.startObject.y + deltaY,
};
}
function hasObjectBoundsChanged(object, nextObject) {
return (
object.x !== nextObject.x ||
object.y !== nextObject.y ||
object.width !== nextObject.width ||
object.height !== nextObject.height
);
}
export default function LocationMapCanvas({
mode,
objects,
filters,
mapSize,
zoom,
selectedObjectKey,
mapItems,
username,
svgRef,
dragState,
editingLocked = false,
hiddenAreaCount = 0,
onAddObject,
onShowZones,
onShowHiddenAreas,
setDragState,
setSelectedObjectKey,
remember,
updateObjects,
expandMapToFitObject,
}) {
const canEditObjects = !editingLocked && mode === "edit";
const canDragPan = mode === "view" || mode === "edit";
const hiddenByZonesLayer = objects.length > 0 && !filters.showZones;
const visibleObjectCount = filters.showZones
? objects.filter((object) => object.visible !== false).length
: 0;
const hasNoVisibleObjects = objects.length > 0 && filters.showZones && visibleObjectCount === 0;
const canAddFirstArea =
mode === "edit" && objects.length === 0 && typeof onAddObject === "function";
const canRecoverHiddenAreas =
mode === "edit" && hasNoVisibleObjects && hiddenAreaCount > 0 && typeof onShowHiddenAreas === "function";
const hasEmptyCanvasAction = canAddFirstArea || hiddenByZonesLayer || canRecoverHiddenAreas;
const emptyCanvasMessage = objects.length === 0
? "No map areas yet"
: hiddenByZonesLayer
? "Zones hidden in Layers"
: hasNoVisibleObjects
? "No visible map areas"
: "";
const scrollRef = useRef(null);
const panDragRef = useRef(null);
const pendingMapShiftScrollRef = useRef({ x: 0, y: 0 });
const scheduledMapShiftScrollRef = useRef(null);
const objectDragHistoryCapturedRef = useRef(false);
const suppressPanClickRef = useRef(false);
const autoScrolledObjectViewRef = useRef(null);
const [isPanning, setIsPanning] = useState(false);
const orderedObjects = useMemo(() => {
if (!selectedObjectKey) return objects;
const selectedObjects = [];
const remainingObjects = [];
objects.forEach((object) => {
if (getObjectKey(object) === selectedObjectKey) {
selectedObjects.push(object);
return;
}
remainingObjects.push(object);
});
return [...remainingObjects, ...selectedObjects];
}, [objects, selectedObjectKey]);
const zoneItemSummary = useMemo(() => {
const assignedCounts = new Map();
const visibleItemsByZone = new Map();
(mapItems || []).forEach((item) => {
const zoneKey = String(item.zone_id || "");
if (!zoneKey) return;
assignedCounts.set(zoneKey, (assignedCounts.get(zoneKey) || 0) + 1);
if (!itemMatchesMapFilters(item, filters, username)) return;
const existingItems = visibleItemsByZone.get(zoneKey) || [];
existingItems.push(item);
visibleItemsByZone.set(zoneKey, existingItems);
});
return { assignedCounts, visibleItemsByZone };
}, [filters, mapItems, username]);
useEffect(() => () => {
if (scheduledMapShiftScrollRef.current && typeof window !== "undefined") {
window.clearTimeout(scheduledMapShiftScrollRef.current);
}
}, []);
const flushQueuedMapShiftScroll = () => {
const queuedShift = pendingMapShiftScrollRef.current;
if (!queuedShift.x && !queuedShift.y) return;
pendingMapShiftScrollRef.current = { x: 0, y: 0 };
const scrollElement = scrollRef.current;
panScrollElement(scrollElement, queuedShift.x * zoom, queuedShift.y * zoom);
if (!scrollElement || typeof window === "undefined") return;
const targetLeft = scrollElement.scrollLeft;
const targetTop = scrollElement.scrollTop;
const restoreScrollTarget = () => {
const currentScrollElement = scrollRef.current;
if (!currentScrollElement) return;
currentScrollElement.scrollLeft = Math.max(currentScrollElement.scrollLeft, targetLeft);
currentScrollElement.scrollTop = Math.max(currentScrollElement.scrollTop, targetTop);
};
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
restoreScrollTarget();
window.setTimeout(restoreScrollTarget, 80);
});
});
};
const queueMapShiftScroll = (shiftX, shiftY) => {
if (!shiftX && !shiftY) return;
pendingMapShiftScrollRef.current = {
x: pendingMapShiftScrollRef.current.x + shiftX,
y: pendingMapShiftScrollRef.current.y + shiftY,
};
if (typeof window === "undefined" || scheduledMapShiftScrollRef.current) return;
scheduledMapShiftScrollRef.current = window.setTimeout(() => {
scheduledMapShiftScrollRef.current = null;
window.requestAnimationFrame(flushQueuedMapShiftScroll);
}, 0);
};
useEffect(() => {
if (!selectedObjectKey) {
autoScrolledObjectViewRef.current = null;
return undefined;
}
if (mode !== "edit" || dragState) {
return undefined;
}
const selectedObject = objects.find((object) => getObjectKey(object) === selectedObjectKey);
if (!selectedObject || !scrollRef.current || typeof window === "undefined") {
return undefined;
}
const scrollKey = getSelectedObjectScrollKey(selectedObject, selectedObjectKey, zoom);
if (autoScrolledObjectViewRef.current === scrollKey) {
return undefined;
}
autoScrolledObjectViewRef.current = scrollKey;
const frameId = window.requestAnimationFrame(() => {
const scrollElement = scrollRef.current;
if (!scrollElement) return;
const objectLeft = selectedObject.x * zoom - SELECTED_OBJECT_SCROLL_PADDING;
const objectTop = selectedObject.y * zoom - SELECTED_OBJECT_SCROLL_PADDING;
const objectRight = (selectedObject.x + selectedObject.width) * zoom + SELECTED_OBJECT_SCROLL_PADDING;
const objectBottom = (selectedObject.y + selectedObject.height) * zoom + SELECTED_OBJECT_SCROLL_PADDING;
let nextLeft = scrollElement.scrollLeft;
let nextTop = scrollElement.scrollTop;
if (objectLeft < nextLeft) {
nextLeft = objectLeft;
} else if (objectRight > nextLeft + scrollElement.clientWidth) {
nextLeft = objectRight - scrollElement.clientWidth;
}
if (objectTop < nextTop) {
nextTop = objectTop;
} else if (objectBottom > nextTop + scrollElement.clientHeight) {
nextTop = objectBottom - scrollElement.clientHeight;
}
scrollElement.scrollTo({
left: clampScrollPosition(nextLeft, scrollElement.scrollWidth - scrollElement.clientWidth),
top: clampScrollPosition(nextTop, scrollElement.scrollHeight - scrollElement.clientHeight),
});
});
return () => window.cancelAnimationFrame(frameId);
}, [dragState, mode, objects, selectedObjectKey, zoom]);
const startDrag = (event, object, type) => {
if (!canEditObjects || object.locked || !svgRef.current) return;
event.stopPropagation();
event.preventDefault();
objectDragHistoryCapturedRef.current = false;
const point = clientPointToMap(event, svgRef.current, mapSize);
event.currentTarget.setPointerCapture?.(event.pointerId);
setSelectedObjectKey(getObjectKey(object));
setDragState({
type,
key: getObjectKey(object),
startPoint: point,
startObject: { ...object },
});
};
const handlePointerMove = (event) => {
if (!dragState || !svgRef.current) return;
event.preventDefault();
const scrollElement = scrollRef.current;
if (scrollElement) {
const scrollBounds = scrollElement.getBoundingClientRect();
panScrollElement(
scrollElement,
getEdgePanStep(event.clientX, scrollBounds.left, scrollBounds.right),
getEdgePanStep(event.clientY, scrollBounds.top, scrollBounds.bottom)
);
}
const point = clientPointToMap(event, svgRef.current, mapSize);
const deltaX = point.x - dragState.startPoint.x;
const deltaY = point.y - dragState.startPoint.y;
const hasMeaningfulChange =
Math.abs(deltaX) >= DRAG_CHANGE_THRESHOLD ||
Math.abs(deltaY) >= DRAG_CHANGE_THRESHOLD;
if (!hasMeaningfulChange) return;
const draggedObject = objects.find((object) => getObjectKey(object) === dragState.key);
if (!draggedObject) return;
const nextDraggedObjectCandidate = getDraggedObjectCandidate(draggedObject, dragState, deltaX, deltaY);
const expansion = expandMapToFitObject?.(nextDraggedObjectCandidate) || {
mapSize,
shiftX: 0,
shiftY: 0,
};
const nextMapSize = expansion.mapSize;
const shiftedDraggedObjectCandidate = shiftMapObject(
nextDraggedObjectCandidate,
expansion.shiftX,
expansion.shiftY
);
const nextDraggedObject = dragState.type === "resize"
? clampResizedObjectToMap(shiftedDraggedObjectCandidate, nextMapSize)
: clampObjectToMap(shiftedDraggedObjectCandidate, nextMapSize);
if (!hasObjectBoundsChanged(draggedObject, nextDraggedObject)) return;
if (!objectDragHistoryCapturedRef.current) {
remember(undefined, dragState.key);
objectDragHistoryCapturedRef.current = true;
}
if (expansion.shiftX || expansion.shiftY) {
queueMapShiftScroll(expansion.shiftX, expansion.shiftY);
setDragState((currentDragState) => {
if (!currentDragState || currentDragState.key !== dragState.key) return currentDragState;
return {
...currentDragState,
startObject: shiftMapObject(currentDragState.startObject, expansion.shiftX, expansion.shiftY),
startPoint: {
x: currentDragState.startPoint.x + expansion.shiftX,
y: currentDragState.startPoint.y + expansion.shiftY,
},
};
});
}
updateObjects(
(currentObjects) => currentObjects.map((object) => {
if (getObjectKey(object) !== dragState.key) {
return shiftMapObject(object, expansion.shiftX, expansion.shiftY);
}
const candidate = getDraggedObjectCandidate(object, dragState, deltaX, deltaY);
const shiftedCandidate = shiftMapObject(candidate, expansion.shiftX, expansion.shiftY);
return dragState.type === "resize"
? clampResizedObjectToMap(shiftedCandidate, nextMapSize)
: clampObjectToMap(shiftedCandidate, nextMapSize);
}),
nextMapSize
);
};
const handlePointerUp = () => {
objectDragHistoryCapturedRef.current = false;
setDragState(null);
};
const selectObject = (object) => {
if (mode === "view" || canEditObjects) {
setSelectedObjectKey(getObjectKey(object));
}
};
const handleObjectClick = (event, object) => {
event.stopPropagation();
if (suppressPanClickRef.current) {
suppressPanClickRef.current = false;
return;
}
selectObject(object);
};
const handleObjectKeyDown = (event, object) => {
if (event.key === "Escape" && (mode === "view" || canEditObjects)) {
event.stopPropagation();
event.preventDefault();
setSelectedObjectKey(null);
return;
}
if (["Enter", " ", "Spacebar"].includes(event.key)) {
event.stopPropagation();
event.preventDefault();
selectObject(object);
return;
}
if (!canEditObjects || object.locked || !NUDGE_KEYS.has(event.key)) return;
event.stopPropagation();
event.preventDefault();
const nudgeAmount = event.shiftKey ? 25 : 10;
const deltaX = event.key === "ArrowLeft" ? -nudgeAmount : event.key === "ArrowRight" ? nudgeAmount : 0;
const deltaY = event.key === "ArrowUp" ? -nudgeAmount : event.key === "ArrowDown" ? nudgeAmount : 0;
const nextObjectCandidate = {
...object,
x: object.x + deltaX,
y: object.y + deltaY,
};
const expansion = expandMapToFitObject?.(nextObjectCandidate) || {
mapSize,
shiftX: 0,
shiftY: 0,
};
const nextMapSize = expansion.mapSize;
const nextObject = clampObjectToMap(
shiftMapObject(nextObjectCandidate, expansion.shiftX, expansion.shiftY),
nextMapSize
);
setSelectedObjectKey(getObjectKey(object));
if (!hasObjectBoundsChanged(object, nextObject)) return;
remember(undefined, getObjectKey(object));
queueMapShiftScroll(expansion.shiftX, expansion.shiftY);
updateObjects(
(currentObjects) => currentObjects.map((candidate) =>
getObjectKey(candidate) === getObjectKey(object)
? nextObject
: shiftMapObject(candidate, expansion.shiftX, expansion.shiftY)
),
nextMapSize
);
};
const handleShowZones = (event) => {
event.stopPropagation();
onShowZones?.();
};
const handleAddObject = (event) => {
event.stopPropagation();
if (editingLocked) return;
onAddObject?.();
};
const handleShowHiddenAreas = (event) => {
event.stopPropagation();
if (editingLocked) return;
onShowHiddenAreas?.();
};
const clearSelection = () => {
if (suppressPanClickRef.current) {
suppressPanClickRef.current = false;
return;
}
if (!dragState) {
setSelectedObjectKey(null);
}
};
const startPan = (event) => {
if (!canDragPan || !scrollRef.current || event.button !== 0) return;
const objectElement = event.target.closest?.(".location-map-object");
if (canEditObjects && objectElement) return;
event.currentTarget.setPointerCapture?.(event.pointerId);
panDragRef.current = {
startX: event.clientX,
startY: event.clientY,
scrollLeft: scrollRef.current.scrollLeft,
scrollTop: scrollRef.current.scrollTop,
objectKey: objectElement?.getAttribute("data-object-key") || null,
moved: false,
};
setIsPanning(true);
};
const movePan = (event) => {
if (!panDragRef.current || !scrollRef.current) return;
const deltaX = event.clientX - panDragRef.current.startX;
const deltaY = event.clientY - panDragRef.current.startY;
if (Math.abs(deltaX) > 4 || Math.abs(deltaY) > 4) {
panDragRef.current.moved = true;
event.preventDefault();
}
scrollRef.current.scrollLeft = clampScrollPosition(
panDragRef.current.scrollLeft - deltaX,
scrollRef.current.scrollWidth - scrollRef.current.clientWidth
);
scrollRef.current.scrollTop = clampScrollPosition(
panDragRef.current.scrollTop - deltaY,
scrollRef.current.scrollHeight - scrollRef.current.clientHeight
);
};
const endPan = (event) => {
if (!panDragRef.current) return;
const wasMoved = panDragRef.current.moved;
const objectKey = panDragRef.current.objectKey;
if (!wasMoved && mode === "view" && objectKey) {
setSelectedObjectKey(objectKey);
suppressPanClickRef.current = true;
} else {
suppressPanClickRef.current = wasMoved;
}
panDragRef.current = null;
setIsPanning(false);
event.currentTarget.releasePointerCapture?.(event.pointerId);
};
const renderMapObject = (object, index) => {
const key = getObjectKey(object);
const zoneKey = String(object.zone_id || "");
const zoneItems = zoneKey ? zoneItemSummary.visibleItemsByZone.get(zoneKey) || [] : [];
const assignedZoneItemCount = zoneKey ? zoneItemSummary.assignedCounts.get(zoneKey) || 0 : 0;
return (
<LocationMapObject
key={key}
object={object}
index={index}
filters={filters}
zoneItems={zoneItems}
assignedZoneItemCount={assignedZoneItemCount}
isSelected={key === selectedObjectKey}
canEditObjects={canEditObjects}
canSelect={mode === "view" || canEditObjects}
zoom={zoom}
mapSize={mapSize}
onStartDrag={startDrag}
onObjectClick={handleObjectClick}
onObjectKeyDown={handleObjectKeyDown}
/>
);
};
return (
<section
className={[
"location-map-canvas-shell",
mode === "edit" ? "is-edit-mode" : "is-view-mode",
mode === "edit" ? "is-object-tool" : "is-pan-tool",
isPanning ? "is-panning" : "",
].filter(Boolean).join(" ")}
>
<div
ref={scrollRef}
className="location-map-scroll"
onPointerDown={startPan}
onPointerMove={movePan}
onPointerUp={endPan}
onPointerCancel={endPan}
onPointerLeave={endPan}
onClick={clearSelection}
>
<svg
ref={svgRef}
className={[
"location-map-svg",
canEditObjects ? "is-editable" : "",
].filter(Boolean).join(" ")}
style={{
width: `${mapSize.width * zoom}px`,
height: `${mapSize.height * zoom}px`,
}}
viewBox={`0 0 ${mapSize.width} ${mapSize.height}`}
role="img"
aria-label="Store location map"
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
onClick={clearSelection}
>
<defs>
<pattern id="location-map-grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" />
</pattern>
</defs>
<rect className="location-map-background" width={mapSize.width} height={mapSize.height} />
{mode === "edit" ? (
<rect className="location-map-grid" width={mapSize.width} height={mapSize.height} />
) : null}
{orderedObjects.map(renderMapObject)}
</svg>
{emptyCanvasMessage ? (
<div
className={[
"location-map-empty-canvas",
hasEmptyCanvasAction ? "has-action" : "",
].filter(Boolean).join(" ")}
>
<span>{emptyCanvasMessage}</span>
{canAddFirstArea ? (
<button
type="button"
disabled={editingLocked}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleAddObject}
>
Add Area
</button>
) : hiddenByZonesLayer ? (
<button
type="button"
onPointerDown={(event) => event.stopPropagation()}
onClick={handleShowZones}
>
Show Zones
</button>
) : canRecoverHiddenAreas ? (
<button
type="button"
disabled={editingLocked}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleShowHiddenAreas}
>
Show Hidden
</button>
) : null}
</div>
) : null}
</div>
</section>
);
}

View File

@ -0,0 +1,46 @@
import ConfirmBuyModal from "../modals/ConfirmBuyModal";
import ConfirmSlideModal from "../modals/ConfirmSlideModal";
import { getMapObjectDisplayLabel } from "../../lib/locationMapUtils";
export default function LocationMapDialogs({
buyModalItem,
buyModalItems,
onBuyCancel,
onBuyConfirm,
onBuyNavigate,
pendingDeleteObject,
onDeleteClose,
onDeleteConfirm,
pendingLeaveTarget,
onLeaveClose,
onLeaveConfirm,
}) {
return (
<>
{buyModalItem ? (
<ConfirmBuyModal
item={buyModalItem}
allItems={buyModalItems}
onNavigate={onBuyNavigate}
onCancel={onBuyCancel}
onConfirm={onBuyConfirm}
/>
) : null}
<ConfirmSlideModal
isOpen={Boolean(pendingDeleteObject)}
title={`Delete ${getMapObjectDisplayLabel(pendingDeleteObject, "this map area")}?`}
confirmLabel="Delete Area"
onClose={onDeleteClose}
onConfirm={onDeleteConfirm}
/>
<ConfirmSlideModal
isOpen={Boolean(pendingLeaveTarget)}
title="Leave with unsaved changes?"
confirmLabel="Leave Map"
onClose={onLeaveClose}
onConfirm={onLeaveConfirm}
/>
</>
);
}

View File

@ -0,0 +1,193 @@
import {
getMapObjectDisplayLabel,
getObjectKey,
} from "../../lib/locationMapUtils";
const PIN_PREVIEW_LIMIT = 12;
function truncateLabel(value, maxCharacters) {
if (value.length <= maxCharacters) return value;
return `${value.slice(0, Math.max(5, maxCharacters - 3)).trimEnd()}...`;
}
function fittedLabelLines(object, fallback) {
const rawLabel = getMapObjectDisplayLabel(object, fallback);
const maxCharacters = Math.max(8, Math.floor((Number(object.width) - 28) / 13));
const canUseTwoLines = Number(object.height) >= 92 && rawLabel.length > maxCharacters;
if (!canUseTwoLines) return [truncateLabel(rawLabel, maxCharacters)];
const words = rawLabel.split(/\s+/);
let firstLine = "";
let splitIndex = 0;
for (let index = 0; index < words.length; index += 1) {
const candidate = firstLine ? `${firstLine} ${words[index]}` : words[index];
if (candidate.length > maxCharacters && firstLine) break;
firstLine = candidate;
splitIndex = index + 1;
if (candidate.length >= maxCharacters) break;
}
const secondLine = words.slice(splitIndex).join(" ");
if (!secondLine) return [truncateLabel(firstLine, maxCharacters)];
return [
truncateLabel(firstLine, maxCharacters),
truncateLabel(secondLine, maxCharacters),
];
}
export default function LocationMapObject({
object,
index,
filters,
zoneItems,
assignedZoneItemCount,
isSelected,
canEditObjects,
canSelect,
zoom,
mapSize,
onStartDrag,
onObjectClick,
onObjectKeyDown,
}) {
if (object.visible === false || !filters.showZones) return null;
const key = getObjectKey(object);
const displayLabel = getMapObjectDisplayLabel(object, index + 1);
const count = zoneItems.length;
const hasHiddenZoneItems = assignedZoneItemCount > count;
const countLabel = hasHiddenZoneItems
? `${count} shown`
: `${count} item${count === 1 ? "" : "s"}`;
const accessibleLabel = canEditObjects
? object.locked
? `Map area ${displayLabel}, locked`
: `Map area ${displayLabel}`
: `Map area ${displayLabel}, ${countLabel}`;
const labelLines = fittedLabelLines(object, "Area");
const countY = filters.showLabels && labelLines.length > 1 ? object.y + 76 : object.y + 52;
const resizeHandleSize = Math.min(96, Math.max(44, Math.ceil(48 / zoom)));
const resizeHandleInset = 6;
const lockBadgeSize = Math.min(52, Math.max(30, Math.ceil(34 / zoom)));
const lockBadgeInset = 8;
const resizeHandleX = Math.min(
Math.max(0, mapSize.width - resizeHandleSize),
Math.max(
object.x + resizeHandleInset,
object.x + object.width - resizeHandleSize - resizeHandleInset
)
);
const resizeHandleY = Math.min(
Math.max(0, mapSize.height - resizeHandleSize),
Math.max(
object.y + resizeHandleInset,
object.y + object.height - resizeHandleSize - resizeHandleInset
)
);
const lockBadgeX = Math.min(
Math.max(0, mapSize.width - lockBadgeSize),
Math.max(
object.x + lockBadgeInset,
object.x + object.width - lockBadgeSize - lockBadgeInset
)
);
const lockBadgeY = Math.min(
Math.max(0, mapSize.height - lockBadgeSize),
object.y + lockBadgeInset
);
const pinDots = filters.showPins
? zoneItems.slice(0, PIN_PREVIEW_LIMIT).map((item, itemIndex) => {
const column = itemIndex % 4;
const row = Math.floor(itemIndex / 4);
return (
<circle
key={item.id}
className="location-map-pin"
cx={object.x + 24 + column * 18}
cy={object.y + 30 + row * 18}
r="5"
/>
);
})
: null;
return (
<g
data-object-key={key}
className={[
"location-map-object",
isSelected ? "is-selected" : "",
canEditObjects ? "is-editable" : "",
object.locked ? "is-locked" : "",
].filter(Boolean).join(" ")}
transform={`rotate(${object.rotation || 0} ${object.x + object.width / 2} ${object.y + object.height / 2})`}
>
<rect
x={object.x}
y={object.y}
width={object.width}
height={object.height}
rx="12"
role="button"
aria-label={accessibleLabel}
aria-pressed={isSelected}
tabIndex={canSelect ? 0 : -1}
onPointerDown={(event) => onStartDrag(event, object, "move")}
onClick={(event) => onObjectClick(event, object)}
onKeyDown={(event) => onObjectKeyDown(event, object)}
/>
{filters.showLabels ? (
<text x={object.x + 16} y={object.y + 28} className="location-map-label">
{labelLines.map((line, lineIndex) => (
<tspan key={`${line}-${lineIndex}`} x={object.x + 16} dy={lineIndex === 0 ? 0 : 24}>
{line}
</tspan>
))}
</text>
) : null}
{filters.showCounts ? (
<text x={object.x + 16} y={countY} className="location-map-count">
{countLabel}
</text>
) : null}
{pinDots}
{canEditObjects && isSelected && object.locked ? (
<g
className="location-map-lock-badge"
transform={`translate(${lockBadgeX} ${lockBadgeY})`}
aria-hidden="true"
>
<rect width={lockBadgeSize} height={lockBadgeSize} rx="10" />
<path
d={[
`M ${lockBadgeSize * 0.32} ${lockBadgeSize * 0.48}`,
`V ${lockBadgeSize * 0.4}`,
`C ${lockBadgeSize * 0.32} ${lockBadgeSize * 0.24}, ${lockBadgeSize * 0.68} ${lockBadgeSize * 0.24}, ${lockBadgeSize * 0.68} ${lockBadgeSize * 0.4}`,
`V ${lockBadgeSize * 0.48}`,
].join(" ")}
/>
<rect
className="location-map-lock-body"
x={lockBadgeSize * 0.26}
y={lockBadgeSize * 0.46}
width={lockBadgeSize * 0.48}
height={lockBadgeSize * 0.34}
rx={lockBadgeSize * 0.08}
/>
</g>
) : null}
{canEditObjects && isSelected && !object.locked ? (
<rect
className="location-map-resize-handle"
x={resizeHandleX}
y={resizeHandleY}
width={resizeHandleSize}
height={resizeHandleSize}
rx="8"
onPointerDown={(event) => onStartDrag(event, object, "resize")}
onClick={(event) => event.stopPropagation()}
/>
) : null}
</g>
);
}

View File

@ -0,0 +1,63 @@
export default function LocationMapSetupPanel({
hasAnyMap,
canManage,
zoneCount = 0,
saving,
savingAction,
onContinue,
onPreview,
onPublish,
onCreateFromZones,
onCreateBlank,
}) {
const hasZones = zoneCount > 0;
const createFromZonesLabel = `Create From ${zoneCount} Zone${zoneCount === 1 ? "" : "s"}`;
const createBlankLabel = savingAction === "create-blank" ? "Creating..." : "Create Blank Map";
const createZonesButtonLabel = savingAction === "create-zones" ? "Creating..." : createFromZonesLabel;
const publishLabel = savingAction === "publish" ? "Publishing..." : "Publish";
const heading = hasAnyMap ? "Draft Map" : canManage ? "No Map" : "No Published Map";
return (
<section className="location-map-setup">
<h2>{heading}</h2>
{canManage ? (
<div className="location-map-setup-rows" role="group" aria-label="Map setup details">
<div className="location-map-setup-row">
<span>Zones</span>
<strong>{zoneCount}</strong>
</div>
</div>
) : null}
{hasAnyMap && canManage ? (
<div className="location-map-setup-actions">
<button type="button" className="btn-primary" onClick={onContinue} disabled={saving}>
Continue Editing
</button>
<button type="button" className="btn-secondary" onClick={onPreview} disabled={saving}>
View Draft
</button>
<button type="button" className="btn-primary" onClick={onPublish} disabled={saving || !canManage}>
{publishLabel}
</button>
</div>
) : !hasAnyMap && canManage ? (
<div className="location-map-setup-actions">
{!hasZones ? (
<button type="button" className="btn-primary" onClick={onCreateBlank} disabled={saving}>
{createBlankLabel}
</button>
) : (
<>
<button type="button" className="btn-primary" onClick={onCreateFromZones} disabled={saving}>
{createZonesButtonLabel}
</button>
<button type="button" className="btn-secondary" onClick={onCreateBlank} disabled={saving}>
{createBlankLabel}
</button>
</>
)}
</div>
) : null}
</section>
);
}

View File

@ -0,0 +1,38 @@
import LocationMapTopbar from "./LocationMapTopbar";
export function LocationMapMessageState({ message = "", location, status = "Loading", onBack }) {
const showTopbar = Boolean(location && onBack);
return (
<div className="location-map-page">
{showTopbar ? (
<LocationMapTopbar location={location} status={status} onBack={onBack} />
) : null}
<main className="location-map-workspace location-map-message-workspace">
<section className="location-map-setup location-map-message-card" aria-live="polite">
<h2>{status}</h2>
{message ? <p className="location-map-state-message">{message}</p> : null}
</section>
</main>
</div>
);
}
export function LocationMapLoadErrorState({ location, loadError, onBack, onRetry }) {
return (
<div className="location-map-page">
<LocationMapTopbar location={location} status="Load Error" onBack={onBack} />
<main className="location-map-workspace">
<section className="location-map-setup location-map-load-error" role="alert" aria-live="polite">
<h2>Map Unavailable</h2>
<p className="location-map-state-message">{loadError}</p>
<div className="location-map-setup-actions">
<button type="button" className="btn-primary" onClick={onRetry}>
Retry
</button>
</div>
</section>
</main>
</div>
);
}

View File

@ -0,0 +1,161 @@
import { MAX_MAP_ZOOM, MIN_MAP_ZOOM, clampMapZoom } from "../../lib/locationMapUtils";
function ToolbarIcon({ name }) {
if (name === "undo") {
return (
<svg className="location-map-toolbar-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 7H4v5" />
<path d="M5 11a7 7 0 1 0 2-5" />
</svg>
);
}
if (name === "redo") {
return (
<svg className="location-map-toolbar-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M15 7h5v5" />
<path d="M19 11a7 7 0 1 1-2-5" />
</svg>
);
}
if (name === "zoom-in") {
return (
<svg className="location-map-toolbar-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M11 5v12" />
<path d="M5 11h12" />
</svg>
);
}
if (name === "fit") {
return (
<svg className="location-map-toolbar-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 4H4v4" />
<path d="M16 4h4v4" />
<path d="M20 16v4h-4" />
<path d="M8 20H4v-4" />
</svg>
);
}
return (
<svg className="location-map-toolbar-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 11h12" />
</svg>
);
}
export default function LocationMapToolbar({
mode,
hasAnyMap,
canManage,
saving,
history = [],
future = [],
zoom,
setZoom,
onFit,
onView,
onEdit,
onUndo,
onRedo,
}) {
const isAtMinZoom = zoom <= MIN_MAP_ZOOM;
const isAtMaxZoom = zoom >= MAX_MAP_ZOOM;
const canUseHistoryControls = mode === "edit" && canManage && hasAnyMap;
return (
<section
className={`location-map-toolbar ${canUseHistoryControls ? "has-history-slot" : ""}`}
aria-label="Map controls"
>
<div className="location-map-mode-group">
<div className="location-map-mode-buttons" aria-label="Map mode">
<button
type="button"
className={mode === "view" ? "active" : ""}
disabled={saving || !hasAnyMap}
onClick={onView}
aria-pressed={mode === "view"}
>
View
</button>
{canManage ? (
<button
type="button"
className={mode === "edit" ? "active" : ""}
disabled={saving || !hasAnyMap}
onClick={onEdit}
aria-pressed={mode === "edit"}
aria-label="Edit Draft"
>
<span className="location-map-label-full">Edit Draft</span>
<span className="location-map-label-short">Edit</span>
</button>
) : null}
</div>
</div>
{canUseHistoryControls ? (
<div
className="location-map-history-buttons"
aria-label="Edit history"
>
<button
type="button"
className="location-map-icon-button"
onClick={onUndo}
disabled={!canUseHistoryControls || saving || history.length === 0}
aria-label="Undo"
title="Undo"
>
<ToolbarIcon name="undo" />
</button>
<button
type="button"
className="location-map-icon-button"
onClick={onRedo}
disabled={!canUseHistoryControls || saving || future.length === 0}
aria-label="Redo"
title="Redo"
>
<ToolbarIcon name="redo" />
</button>
</div>
) : null}
<div className="location-map-zoom-controls" aria-label="Zoom controls">
<button
type="button"
className="location-map-icon-button"
onClick={() => setZoom((value) => Number(clampMapZoom(value - 0.15).toFixed(2)))}
disabled={!hasAnyMap || isAtMinZoom}
aria-label="Zoom out"
title="Zoom out"
>
<ToolbarIcon name="zoom-out" />
</button>
<span>{Math.round(zoom * 100)}%</span>
<button
type="button"
className="location-map-icon-button"
onClick={() => setZoom((value) => Number(clampMapZoom(value + 0.15).toFixed(2)))}
disabled={!hasAnyMap || isAtMaxZoom}
aria-label="Zoom in"
title="Zoom in"
>
<ToolbarIcon name="zoom-in" />
</button>
<button
type="button"
className="location-map-icon-button"
onClick={onFit}
disabled={!hasAnyMap}
aria-label="Fit"
title="Fit"
>
<ToolbarIcon name="fit" />
</button>
</div>
</section>
);
}

View File

@ -0,0 +1,29 @@
import { getLocationName, getStoreName } from "../../lib/locationMapUtils";
export default function LocationMapTopbar({ location, status, onBack }) {
const statusClass = status.toLowerCase().replace(/\s+/g, "-");
return (
<header className="location-map-topbar">
<button
type="button"
className="location-map-back"
onClick={onBack}
aria-label="Back to grocery list"
>
<span aria-hidden="true">&larr;</span>
</button>
<div className="location-map-title">
<h1>{getStoreName(location)}</h1>
<span>{getLocationName(location)}</span>
</div>
<span
className={`location-map-status location-map-status-${statusClass}`}
role="status"
aria-label={`Map status: ${status}`}
>
{status}
</span>
</header>
);
}

View File

@ -1,6 +1,15 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import "../../styles/ConfirmBuyModal.css";
function ChevronIcon({ direction }) {
const path = direction === "previous" ? "M15 18l-6-6 6-6" : "M9 18l6-6-6-6";
return (
<svg className="confirm-buy-nav-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d={path} />
</svg>
);
}
export default function ConfirmBuyModal({
item,
onConfirm,
@ -11,6 +20,7 @@ export default function ConfirmBuyModal({
const [quantity, setQuantity] = useState(item.quantity);
const [isSubmitting, setIsSubmitting] = useState(false);
const maxQuantity = item.quantity;
const titleId = "confirm-buy-modal-title";
useEffect(() => {
setQuantity(item.quantity);
@ -21,17 +31,17 @@ export default function ConfirmBuyModal({
const hasPrev = currentIndex > 0;
const hasNext = currentIndex < allItems.length - 1;
const handleIncrement = () => {
if (!isSubmitting && quantity < maxQuantity) {
setQuantity((prev) => prev + 1);
}
};
const handleIncrement = useCallback(() => {
if (isSubmitting) return;
const handleDecrement = () => {
if (!isSubmitting && quantity > 1) {
setQuantity((prev) => prev - 1);
}
};
setQuantity((prev) => Math.min(maxQuantity, prev + 1));
}, [isSubmitting, maxQuantity]);
const handleDecrement = useCallback(() => {
if (isSubmitting) return;
setQuantity((prev) => Math.max(1, prev - 1));
}, [isSubmitting]);
const handleConfirm = async () => {
if (isSubmitting) return;
@ -44,22 +54,59 @@ export default function ConfirmBuyModal({
}
};
const handlePrev = () => {
const handlePrev = useCallback(() => {
if (isSubmitting) return;
if (hasPrev && onNavigate) {
onNavigate(allItems[currentIndex - 1]);
}
};
}, [allItems, currentIndex, hasPrev, isSubmitting, onNavigate]);
const handleNext = () => {
const handleNext = useCallback(() => {
if (isSubmitting) return;
if (hasNext && onNavigate) {
onNavigate(allItems[currentIndex + 1]);
}
}, [allItems, currentIndex, hasNext, isSubmitting, onNavigate]);
useEffect(() => {
const handleKeyDown = (event) => {
if (isSubmitting) return;
if (event.key === "Escape") {
onCancel();
return;
}
if (event.key === "ArrowLeft") {
event.preventDefault();
handlePrev();
return;
}
if (event.key === "ArrowRight") {
event.preventDefault();
handleNext();
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
handleIncrement();
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
handleDecrement();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleDecrement, handleIncrement, handleNext, handlePrev, isSubmitting, onCancel]);
const imageUrl = item.item_image && item.image_mime_type
? `data:${item.image_mime_type};base64,${item.item_image}`
: null;
@ -73,46 +120,62 @@ export default function ConfirmBuyModal({
}
}}
>
<div className="confirm-buy-modal" onClick={(event) => event.stopPropagation()}>
<div
className="confirm-buy-modal"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
onClick={(event) => event.stopPropagation()}
>
<div className="confirm-buy-header">
{item.zone && <div className="confirm-buy-zone">{item.zone}</div>}
<h2 className="confirm-buy-item-name">{item.item_name}</h2>
<h2 id={titleId} className="confirm-buy-item-name">{item.item_name}</h2>
</div>
<div className="confirm-buy-image-section">
<button
type="button"
className="confirm-buy-nav-btn confirm-buy-nav-prev"
onClick={handlePrev}
style={{ visibility: hasPrev ? "visible" : "hidden" }}
disabled={!hasPrev || isSubmitting}
aria-label="Previous item"
title="Previous item"
>
{"<"}
<ChevronIcon direction="previous" />
</button>
<div className="confirm-buy-image-container">
{imageUrl ? (
<img src={imageUrl} alt={item.item_name} className="confirm-buy-image" />
) : (
<div className="confirm-buy-image-placeholder">[ ]</div>
<div className="confirm-buy-image-placeholder" role="img" aria-label="No item photo">
<span aria-hidden="true">{"\uD83D\uDCE6"}</span>
</div>
)}
</div>
<button
type="button"
className="confirm-buy-nav-btn confirm-buy-nav-next"
onClick={handleNext}
style={{ visibility: hasNext ? "visible" : "hidden" }}
disabled={!hasNext || isSubmitting}
aria-label="Next item"
title="Next item"
>
{">"}
<ChevronIcon direction="next" />
</button>
</div>
<div className="confirm-buy-quantity-section">
<div className="confirm-buy-counter">
<button
type="button"
onClick={handleDecrement}
className="confirm-buy-counter-btn"
disabled={quantity <= 1 || isSubmitting}
aria-label="Decrease quantity"
>
-
</button>
@ -121,11 +184,14 @@ export default function ConfirmBuyModal({
value={quantity}
readOnly
className="confirm-buy-counter-display"
aria-label="Quantity to mark bought"
/>
<button
type="button"
onClick={handleIncrement}
className="confirm-buy-counter-btn"
disabled={quantity >= maxQuantity || isSubmitting}
aria-label="Increase quantity"
>
+
</button>
@ -133,10 +199,10 @@ export default function ConfirmBuyModal({
</div>
<div className="confirm-buy-actions">
<button onClick={onCancel} className="confirm-buy-cancel" disabled={isSubmitting}>
<button type="button" onClick={onCancel} className="confirm-buy-cancel" disabled={isSubmitting}>
Cancel
</button>
<button onClick={handleConfirm} className="confirm-buy-confirm" disabled={isSubmitting}>
<button type="button" onClick={handleConfirm} className="confirm-buy-confirm" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Mark as Bought"}
</button>
</div>

View File

@ -1,11 +1,74 @@
import { useContext } from 'react';
import { useContext, useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { StoreContext } from '../../context/StoreContext';
import '../../styles/components/StoreTabs.css';
const DEFAULT_LOCATION_NAME = "Default Location";
function getStoreKey(store) {
return String(store?.household_store_id ?? store?.store_id ?? store?.id ?? "");
}
function getStoreName(store) {
return store?.name || store?.display_name || "Store";
}
function getLocationName(store) {
if (!store) return "Location";
const locationName = store.location_name || "";
if (!locationName || locationName === DEFAULT_LOCATION_NAME) {
return "Default";
}
return locationName;
}
function chooseStoreLocation(locations) {
if (!locations || locations.length === 0) return null;
return (
locations.find((store) => store.location_name === "Default Location" || store.display_name === store.name) ||
locations.find((store) => store.is_default) ||
locations[0]
);
}
function buildStoreOptions(stores) {
const grouped = new Map();
for (const store of stores) {
const key = getStoreKey(store);
if (!grouped.has(key)) {
grouped.set(key, {
key,
name: getStoreName(store),
locations: [],
});
}
grouped.get(key).locations.push(store);
}
return Array.from(grouped.values()).map((group) => ({
...group,
store: chooseStoreLocation(group.locations),
}));
}
export default function StoreTabs() {
const { stores, activeStore, setActiveStore, loading } = useContext(StoreContext);
const navigate = useNavigate();
const routeLocation = useLocation();
const [activePicker, setActivePicker] = useState(null);
const storeOptions = useMemo(() => buildStoreOptions(stores || []), [stores]);
const activeStoreKey = getStoreKey(activeStore);
const selectedStoreOption =
storeOptions.find((option) => option.key === activeStoreKey) || storeOptions[0];
const selectedLocations = selectedStoreOption?.locations || [];
const selectedLocation =
selectedLocations.find((location) => String(location.id) === String(activeStore?.id)) ||
selectedStoreOption?.store;
const canPickStore = storeOptions.length > 1 && !loading;
const canPickLocation = selectedLocations.length > 1 && !loading;
if (!stores || stores.length === 0) {
if (!stores || stores.length === 0 || storeOptions.length === 0) {
return (
<div className="store-tabs">
<div className="store-tabs-empty">
@ -15,20 +78,125 @@ export default function StoreTabs() {
);
}
const handleStoreTriggerClick = () => {
if (!canPickStore) return;
setActivePicker("store");
};
const handleLocationTriggerClick = () => {
if (!canPickLocation) return;
setActivePicker("location");
};
const handleStoreSelect = (store) => {
setActiveStore(store);
setActivePicker(null);
};
const handleLocationSelect = (location) => {
setActiveStore(location);
setActivePicker(null);
};
const handleManageMap = () => {
if (!selectedLocation?.id || !selectedStoreOption?.key) return;
navigate(`/stores/${selectedStoreOption.key}/locations/${selectedLocation.id}/map`, {
state: {
returnTo: `${routeLocation.pathname}${routeLocation.search}${routeLocation.hash}`,
},
});
};
return (
<div className="store-tabs">
<div className="store-tabs-container">
{stores.map(store => (
<button
key={store.id}
className={`store-tab ${store.id === activeStore?.id ? 'active' : ''}`}
onClick={() => setActiveStore(store)}
type="button"
className={`store-selector-trigger ${canPickStore ? "is-interactive" : ""}`}
onClick={handleStoreTriggerClick}
disabled={loading}
aria-haspopup={canPickStore ? "dialog" : undefined}
aria-expanded={canPickStore ? activePicker === "store" : undefined}
aria-label={`Store: ${selectedStoreOption.name}`}
>
<span className="store-name">{store.display_name || store.name}</span>
<span className="store-name">{selectedStoreOption.name}</span>
{canPickStore ? <span className="store-selector-caret" aria-hidden="true" /> : null}
</button>
<button
type="button"
className={`store-selector-trigger location-selector-trigger ${canPickLocation ? "is-interactive" : ""}`}
onClick={handleLocationTriggerClick}
disabled={loading}
aria-haspopup={canPickLocation ? "dialog" : undefined}
aria-expanded={canPickLocation ? activePicker === "location" : undefined}
aria-label={`Location: ${getLocationName(selectedLocation)}`}
>
<span className="store-name">{getLocationName(selectedLocation)}</span>
{canPickLocation ? <span className="store-selector-caret" aria-hidden="true" /> : null}
</button>
<button
type="button"
className="store-map-button"
onClick={handleManageMap}
disabled={loading || !selectedLocation}
>
Manage Map
</button>
</div>
{activePicker === "store" ? (
<div className="store-selector-modal-overlay" onClick={() => setActivePicker(null)}>
<div
className="store-selector-modal"
role="dialog"
aria-modal="true"
aria-label="Select store"
onClick={(event) => event.stopPropagation()}
>
<h2>Stores</h2>
<div className="store-selector-list">
{storeOptions.map((option) => (
<button
key={option.key}
type="button"
className={`store-selector-option ${option.key === selectedStoreOption.key ? "active" : ""}`}
onClick={() => handleStoreSelect(option.store)}
>
{option.name}
</button>
))}
</div>
</div>
</div>
) : null}
{activePicker === "location" ? (
<div className="store-selector-modal-overlay" onClick={() => setActivePicker(null)}>
<div
className="store-selector-modal"
role="dialog"
aria-modal="true"
aria-label="Select location"
onClick={(event) => event.stopPropagation()}
>
<h2>Locations</h2>
<div className="store-selector-list">
{selectedLocations.map((location) => (
<button
key={location.id}
type="button"
className={`store-selector-option ${String(location.id) === String(selectedLocation?.id) ? "active" : ""}`}
onClick={() => handleLocationSelect(location)}
>
{getLocationName(location)}
</button>
))}
</div>
</div>
</div>
) : null}
</div>
);
}

View File

@ -1 +1,41 @@
export const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:5000";
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
function trimTrailingSlash(value: string) {
return value.replace(/\/+$/, "");
}
function getDefaultApiBaseUrl() {
if (typeof window === "undefined") {
return "http://localhost:5000";
}
const protocol = window.location.protocol === "https:" ? "https:" : "http:";
return `${protocol}//${window.location.hostname}:5000`;
}
function shouldUseBrowserHostForApi(url: URL) {
if (typeof window === "undefined") {
return false;
}
return LOOPBACK_HOSTS.has(url.hostname) && !LOOPBACK_HOSTS.has(window.location.hostname);
}
function resolveApiBaseUrl() {
const configuredApiUrl = import.meta.env.VITE_API_URL?.trim();
if (!configuredApiUrl) {
return getDefaultApiBaseUrl();
}
try {
const apiUrl = new URL(configuredApiUrl);
if (shouldUseBrowserHostForApi(apiUrl)) {
apiUrl.hostname = window.location.hostname;
}
return trimTrailingSlash(apiUrl.toString());
} catch {
return trimTrailingSlash(configuredApiUrl);
}
}
export const API_BASE_URL = resolveApiBaseUrl();

View File

@ -4,6 +4,48 @@ import { getHouseholdStores, getLocationZones } from '../api/stores';
import { AuthContext } from './AuthContext';
import { HouseholdContext } from './HouseholdContext';
const DEFAULT_LOCATION_NAME = "Default Location";
function getHouseholdStoreKey(store) {
return String(store?.household_store_id ?? store?.store_id ?? store?.id ?? "");
}
function isDefaultLocationName(store) {
return store?.location_name === DEFAULT_LOCATION_NAME || store?.display_name === store?.name;
}
function chooseStoreLocation(locations) {
if (!locations || locations.length === 0) return null;
return (
locations.find(isDefaultLocationName) ||
locations.find((store) => store.is_default) ||
locations[0]
);
}
function chooseStoreByHouseholdStoreId(stores, householdStoreId) {
const matchingLocations = stores.filter(
(store) => getHouseholdStoreKey(store) === String(householdStoreId)
);
return chooseStoreLocation(matchingLocations);
}
function getLocationStorageKey(householdId, householdStoreId) {
return `activeStoreLocationId_${householdId}_${householdStoreId}`;
}
function chooseLocationByStoredIds(stores, householdId, householdStoreId) {
const storageKey = getLocationStorageKey(householdId, householdStoreId);
const savedLocationId = localStorage.getItem(storageKey);
if (!savedLocationId) return null;
return stores.find(
(store) =>
String(store.id) === String(savedLocationId) &&
getHouseholdStoreKey(store) === String(householdStoreId)
);
}
export const StoreContext = createContext({
stores: [],
activeStore: null,
@ -42,24 +84,48 @@ export const StoreProvider = ({ children }) => {
useEffect(() => {
if (!activeHousehold || stores.length === 0) return;
console.log('[StoreContext] Setting active store from:', stores);
const storageKey = `activeStoreId_${activeHousehold.id}`;
const savedStoreId = localStorage.getItem(storageKey);
const householdStoreStorageKey = `activeHouseholdStoreId_${activeHousehold.id}`;
const legacyLocationStorageKey = `activeStoreId_${activeHousehold.id}`;
const savedHouseholdStoreId = localStorage.getItem(householdStoreStorageKey);
if (savedStoreId) {
const store = stores.find(s => String(s.id) === String(savedStoreId));
if (savedHouseholdStoreId) {
const store =
chooseLocationByStoredIds(stores, activeHousehold.id, savedHouseholdStoreId) ||
chooseStoreByHouseholdStoreId(stores, savedHouseholdStoreId);
if (store) {
console.log('[StoreContext] Found saved store:', store);
setActiveStoreState(store);
return;
}
}
// No saved store or not found, use default or first one
const defaultStore = stores.find(s => s.is_default) || stores[0];
console.log('[StoreContext] Using store:', defaultStore);
const savedLocationId = localStorage.getItem(legacyLocationStorageKey);
if (savedLocationId) {
const savedLocation = stores.find(s => String(s.id) === String(savedLocationId));
const store = savedLocation || chooseStoreByHouseholdStoreId(stores, getHouseholdStoreKey(savedLocation));
if (store) {
const householdStoreId = getHouseholdStoreKey(store);
setActiveStoreState(store);
localStorage.setItem(householdStoreStorageKey, householdStoreId);
localStorage.setItem(
getLocationStorageKey(activeHousehold.id, householdStoreId),
String(store.id)
);
return;
}
}
// No saved store or not found, use the default store's representative location.
const defaultStore = chooseStoreByHouseholdStoreId(
stores,
getHouseholdStoreKey(stores.find(s => s.is_default) || stores[0])
);
setActiveStoreState(defaultStore);
localStorage.setItem(storageKey, defaultStore.id);
const householdStoreId = getHouseholdStoreKey(defaultStore);
localStorage.setItem(householdStoreStorageKey, householdStoreId);
localStorage.setItem(
getLocationStorageKey(activeHousehold.id, householdStoreId),
String(defaultStore.id)
);
}, [stores, activeHousehold]);
useEffect(() => {
@ -76,9 +142,7 @@ export const StoreProvider = ({ children }) => {
setLoading(true);
setError(null);
try {
console.log('[StoreContext] Loading stores for household:', activeHousehold.id);
const response = await getHouseholdStores(activeHousehold.id);
console.log('[StoreContext] Loaded stores:', response.data);
setStores(response.data);
} catch (err) {
console.error('[StoreContext] Failed to load stores:', err);
@ -99,8 +163,13 @@ export const StoreProvider = ({ children }) => {
const setActiveStore = (store) => {
setActiveStoreState(store);
if (store && activeHousehold) {
const storageKey = `activeStoreId_${activeHousehold.id}`;
localStorage.setItem(storageKey, String(store.id));
const storageKey = `activeHouseholdStoreId_${activeHousehold.id}`;
const householdStoreId = getHouseholdStoreKey(store);
localStorage.setItem(storageKey, householdStoreId);
localStorage.setItem(
getLocationStorageKey(activeHousehold.id, householdStoreId),
String(store.id)
);
}
};

View File

@ -0,0 +1,145 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getItemByName, markBought } from "../api/list";
import getApiErrorMessage from "../lib/getApiErrorMessage";
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 useLocationMapBuying({
activeHouseholdId,
locationId,
selectedObject,
selectedZoneItems = EMPTY_MAP_ITEMS,
setMapState,
toast,
visibleUnmappedItems = EMPTY_MAP_ITEMS,
}) {
const [buyModalItem, setBuyModalItem] = useState(null);
const mapBuyScopeRef = useRef({ activeHouseholdId: null, locationId: null });
const buyModalItems = useMemo(
() => (selectedObject ? selectedZoneItems : visibleUnmappedItems).filter((item) => !item.bought),
[selectedObject, selectedZoneItems, visibleUnmappedItems]
);
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,
locationId,
};
}, [activeHouseholdId, locationId]);
useEffect(() => {
setBuyModalItem(null);
}, [activeHouseholdId, locationId]);
const isCurrentMapBuyScope = useCallback((scopedHouseholdId, scopedLocationId) => (
String(mapBuyScopeRef.current.activeHouseholdId || "") === String(scopedHouseholdId || "") &&
String(mapBuyScopeRef.current.locationId || "") === String(scopedLocationId || "")
), []);
const handleMapBuyCancel = useCallback(() => {
setBuyModalItem(null);
}, []);
const handleMapBuyNavigate = useCallback((item) => {
setBuyModalItem(item);
}, []);
const handleMapBuyConfirm = useCallback(async (quantity) => {
if (!activeHouseholdId || !locationId || !buyModalItem) {
setBuyModalItem(null);
return;
}
const scopedHouseholdId = activeHouseholdId;
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(scopedHouseholdId, scopedLocationId, item.item_name, quantity, true);
if (!isCurrentMapBuyScope(scopedHouseholdId, 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(scopedHouseholdId, scopedLocationId, item.item_name);
if (!isCurrentMapBuyScope(scopedHouseholdId, scopedLocationId)) return;
updatedItem = response.data || getLocallyReducedMapItem(item, quantity);
} catch {
if (!isCurrentMapBuyScope(scopedHouseholdId, 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(scopedHouseholdId, scopedLocationId)) return;
const message = getApiErrorMessage(error, "Failed to mark item as bought");
toast.error("Mark item bought failed", `Mark item bought failed: ${message}`);
}
}, [
activeHouseholdId,
buyModalItem,
buyModalItems,
isCurrentMapBuyScope,
locationId,
toast,
updateMapItems,
]);
return {
buyModalItem,
buyModalItems,
handleMapBuyCancel,
handleMapBuyConfirm,
handleMapBuyNavigate,
setBuyModalItem,
};
}

View File

@ -0,0 +1,83 @@
import { useMemo } from "react";
import {
DEFAULT_MAP_SIZE,
getObjectKey,
itemMatchesMapFilters,
itemsForZone,
mapForMode,
unmappedItems,
} from "../lib/locationMapUtils";
const EMPTY_MAP_ITEMS = [];
export default function useLocationMapDerivedState({
filters,
mapDraft,
mapState,
mode,
objects,
previewDraft,
selectedObjectKey,
username,
}) {
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 { 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 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]
);
return {
hiddenAreaCount,
mapItems,
mapSize,
selectedObject,
selectedZoneItems,
unmappedItemCount,
visibleAssignedItemCount,
visibleUnmappedItems,
};
}

View File

@ -0,0 +1,144 @@
import { useCallback, useEffect, useRef } from "react";
import {
createBlankLocationMap,
createLocationMapFromZones,
publishLocationMapDraft,
saveLocationMapDraft,
} from "../api/locationMaps";
import getApiErrorMessage from "../lib/getApiErrorMessage";
import { getObjectKey, prepareObjectsForSave } from "../lib/locationMapUtils";
export default function useLocationMapDraftActions({
activeHouseholdId,
beginSaving,
endSaving,
hasUnsavedChanges,
locationId,
mapDraft,
objects,
selectedObjectKey,
setMapState,
setMode,
setPreviewDraft,
syncMapDraftFromState,
toast,
}) {
const latestScopeRef = useRef({ activeHouseholdId, locationId });
const selectedObjectIndex = selectedObjectKey
? objects.findIndex((object) => getObjectKey(object) === selectedObjectKey)
: -1;
useEffect(() => {
latestScopeRef.current = { activeHouseholdId, locationId };
}, [activeHouseholdId, locationId]);
const isCurrentScope = useCallback(() => (
String(latestScopeRef.current.activeHouseholdId ?? "") === String(activeHouseholdId ?? "") &&
String(latestScopeRef.current.locationId ?? "") === String(locationId ?? "")
), [activeHouseholdId, locationId]);
const applyDraftResponse = useCallback((response, nextMode = "edit", nextPreviewDraft = true, options = {}) => {
setMapState(response.data);
setMode(nextMode);
setPreviewDraft(nextPreviewDraft);
syncMapDraftFromState(response.data, nextMode, nextPreviewDraft, options);
}, [setMapState, setMode, setPreviewDraft, syncMapDraftFromState]);
const handleCreateBlank = useCallback(async () => {
if (!activeHouseholdId || !locationId) return;
beginSaving("create-blank");
try {
const response = await createBlankLocationMap(activeHouseholdId, locationId, mapDraft);
if (!isCurrentScope()) return;
applyDraftResponse(response);
toast.success("Created map", "Blank draft map created");
} catch (error) {
if (!isCurrentScope()) return;
toast.error("Create map failed", getApiErrorMessage(error, "Failed to create map"));
} finally {
if (isCurrentScope()) endSaving();
}
}, [
activeHouseholdId, applyDraftResponse, beginSaving, endSaving, isCurrentScope,
locationId, mapDraft, toast,
]);
const handleCreateFromZones = useCallback(async () => {
if (!activeHouseholdId || !locationId) return;
beginSaving("create-zones");
try {
const response = await createLocationMapFromZones(activeHouseholdId, locationId, mapDraft);
if (!isCurrentScope()) return;
applyDraftResponse(response);
toast.success("Created starter map", "Zones were added as editable rectangles");
} catch (error) {
if (!isCurrentScope()) return;
toast.error("Create map failed", getApiErrorMessage(error, "Failed to create map from zones"));
} finally {
if (isCurrentScope()) endSaving();
}
}, [
activeHouseholdId, applyDraftResponse, beginSaving, endSaving, isCurrentScope,
locationId, mapDraft, toast,
]);
const handleSaveDraft = useCallback(async () => {
if (!activeHouseholdId || !locationId) return;
beginSaving("save");
try {
const response = await saveLocationMapDraft(activeHouseholdId, locationId, {
map: mapDraft,
objects: prepareObjectsForSave(objects),
});
if (!isCurrentScope()) return;
applyDraftResponse(response, "edit", true, { preserveSelectedIndex: selectedObjectIndex });
toast.success("Saved draft", "Map draft saved");
} catch (error) {
if (!isCurrentScope()) return;
toast.error("Save draft failed", getApiErrorMessage(error, "Failed to save map draft"));
} finally {
if (isCurrentScope()) endSaving();
}
}, [
activeHouseholdId, applyDraftResponse, beginSaving, endSaving, isCurrentScope, locationId,
mapDraft, objects, selectedObjectIndex, toast,
]);
const handlePublish = useCallback(async () => {
if (!activeHouseholdId || !locationId) return;
beginSaving("publish");
let savedPendingDraft = false;
try {
if (hasUnsavedChanges) {
const saveResponse = await saveLocationMapDraft(activeHouseholdId, locationId, {
map: mapDraft,
objects: prepareObjectsForSave(objects),
});
if (!isCurrentScope()) return;
savedPendingDraft = true;
applyDraftResponse(saveResponse, "edit", true, { preserveSelectedIndex: selectedObjectIndex });
}
const response = await publishLocationMapDraft(activeHouseholdId, locationId);
if (!isCurrentScope()) return;
applyDraftResponse(response, "view", false, { preserveSelectedIndex: selectedObjectIndex });
toast.success(
"Published map",
hasUnsavedChanges
? "Latest edits were saved and published"
: "Map is now visible to household members"
);
} catch (error) {
if (!isCurrentScope()) return;
const message = getApiErrorMessage(error, "Failed to publish map");
toast.error("Publish failed", savedPendingDraft ? `${message}. Draft changes were saved.` : message);
} finally {
if (isCurrentScope()) endSaving();
}
}, [
activeHouseholdId, applyDraftResponse, beginSaving, endSaving, hasUnsavedChanges, isCurrentScope,
locationId, mapDraft, objects, selectedObjectIndex, toast,
]);
return { handleCreateBlank, handleCreateFromZones, handlePublish, handleSaveDraft };
}

View File

@ -0,0 +1,134 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getLocationMap } from "../api/locationMaps";
import getApiErrorMessage from "../lib/getApiErrorMessage";
import { getDefaultMapDraft, getMapDraftSnapshot } from "../lib/locationMapDraftStateUtils";
import { DEFAULT_MAP_FILTERS } from "../lib/locationMapUtils";
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 [previewDraft, setPreviewDraft] = useState(false);
const [mapDraft, setMapDraft] = useState(getDefaultMapDraft);
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(getDefaultMapDraft());
}, []);
const syncMapDraftFromState = useCallback((nextState, nextMode, nextPreviewDraft, options = {}) => {
const snapshot = getMapDraftSnapshot(nextState, nextMode, nextPreviewDraft, options);
if (!snapshot) return;
setMapDraft(snapshot.mapDraft);
setObjects(snapshot.objects);
setSelectedObjectKey(snapshot.selectedObjectKey);
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);
setSaving(false);
setSavingAction(null);
setFilters({ ...DEFAULT_MAP_FILTERS });
setLayersOpen(false);
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");
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, endSaving, filters, future, hasUnsavedChanges, history, layersOpen,
loadError, loadMap, loading, mapDraft, mapState, mode, objects, previewDraft, saving,
savingAction, selectedObjectKey, setDragState, setFilters, setFuture,
setHasUnsavedChanges, setHistory, setLayersOpen, setMapState, setMode, setObjects,
setMapDraft, setPreviewDraft, setSelectedObjectKey, setZoom, syncMapDraftFromState, zoom,
};
}

View File

@ -0,0 +1,190 @@
import { useCallback, useState } from "react";
import {
clampObjectToMap,
createClientObject,
getMapObjectDisplayLabel,
getObjectKey,
objectZoneId,
} from "../lib/locationMapUtils";
const EMPTY_ZONES = [];
const MAP_EDGE_GROW_PADDING = 360;
function createHistorySnapshot(objects, selectedObjectKey) {
return {
objects: objects.map((object) => ({ ...object })),
selectedObjectKey: selectedObjectKey ?? null,
};
}
export default function useLocationMapObjectActions({
canManage,
hiddenAreaCount,
mapSize,
mapState,
objects,
selectedObject,
selectedObjectKey,
setFuture,
setHasUnsavedChanges,
setHistory,
setMapDraft,
setObjects,
setSelectedObjectKey,
toast,
}) {
const [pendingDeleteObject, setPendingDeleteObject] = useState(null);
const zones = mapState?.zones || EMPTY_ZONES;
const remember = useCallback((currentObjects = objects, currentSelectedObjectKey = selectedObjectKey) => {
setHistory((previous) => [
...previous.slice(-19),
createHistorySnapshot(currentObjects, currentSelectedObjectKey),
]);
setFuture([]);
}, [objects, selectedObjectKey, setFuture, setHistory]);
const updateObjects = useCallback((updater, bounds = mapSize) => {
setHasUnsavedChanges(true);
setObjects((currentObjects) =>
updater(currentObjects).map((object) => clampObjectToMap(object, bounds))
);
}, [mapSize, setHasUnsavedChanges, setObjects]);
const expandMapToFitObject = useCallback((object) => {
const shiftX = object.x < 0 ? Math.ceil(Math.abs(object.x) + MAP_EDGE_GROW_PADDING) : 0;
const shiftY = object.y < 0 ? Math.ceil(Math.abs(object.y) + MAP_EDGE_GROW_PADDING) : 0;
const shiftedObject = {
...object,
x: Number(object.x || 0) + shiftX,
y: Number(object.y || 0) + shiftY,
};
const nextWidth = Math.max(
mapSize.width + shiftX,
Math.ceil(shiftedObject.x + Number(object.width || 0) + MAP_EDGE_GROW_PADDING)
);
const nextHeight = Math.max(
mapSize.height + shiftY,
Math.ceil(shiftedObject.y + Number(object.height || 0) + MAP_EDGE_GROW_PADDING)
);
const nextMapSize = { width: nextWidth, height: nextHeight };
if (nextWidth > mapSize.width || nextHeight > mapSize.height || shiftX > 0 || shiftY > 0) {
setMapDraft((currentDraft) => ({
...currentDraft,
width: Math.max(Number(currentDraft.width || 0), nextWidth),
height: Math.max(Number(currentDraft.height || 0), nextHeight),
}));
setHasUnsavedChanges(true);
}
return { mapSize: nextMapSize, shiftX, shiftY };
}, [mapSize.height, mapSize.width, setHasUnsavedChanges, setMapDraft]);
const handleAddObject = useCallback(() => {
if (!canManage) return;
remember();
const usedZoneIds = new Set(objects.map((object) => objectZoneId(object)).filter(Boolean));
const nextZone = zones.find((zone) => !usedZoneIds.has(String(zone.id))) || zones[0];
const nextObject = clampObjectToMap(createClientObject(nextZone, objects.length), mapSize);
setObjects((currentObjects) => [...currentObjects, nextObject]);
setSelectedObjectKey(getObjectKey(nextObject));
setHasUnsavedChanges(true);
}, [canManage, mapSize, objects, remember, setHasUnsavedChanges, setObjects, setSelectedObjectKey, zones]);
const handleDuplicateObject = useCallback(() => {
if (!selectedObject) return;
remember();
const displayLabel = getMapObjectDisplayLabel(selectedObject, "Area");
const nextObject = {
...selectedObject,
id: undefined,
client_id: `copy-${Date.now()}`,
label: `${displayLabel} Copy`,
x: selectedObject.x + 28,
y: selectedObject.y + 28,
sort_order: objects.length + 1,
};
setObjects((currentObjects) => [...currentObjects, clampObjectToMap(nextObject, mapSize)]);
setSelectedObjectKey(getObjectKey(nextObject));
setHasUnsavedChanges(true);
}, [mapSize, objects.length, remember, selectedObject, setHasUnsavedChanges, setObjects, setSelectedObjectKey]);
const requestDeleteObject = useCallback(() => {
if (selectedObject) setPendingDeleteObject(selectedObject);
}, [selectedObject]);
const handleDeleteObject = useCallback(() => {
if (!pendingDeleteObject) return;
remember();
setObjects((currentObjects) =>
currentObjects.filter((object) => getObjectKey(object) !== getObjectKey(pendingDeleteObject))
);
setSelectedObjectKey(null);
setHasUnsavedChanges(true);
toast.success("Deleted map area", "Save the draft to keep this change.");
setPendingDeleteObject(null);
}, [pendingDeleteObject, remember, setHasUnsavedChanges, setObjects, setSelectedObjectKey, toast]);
const handleShowHiddenAreas = useCallback(() => {
if (!canManage || hiddenAreaCount === 0) return;
remember();
updateObjects((currentObjects) =>
currentObjects.map((object) => (
object.visible === false ? { ...object, visible: true } : object
))
);
toast.success("Showed hidden areas", "Save the draft to keep this change.");
}, [canManage, hiddenAreaCount, remember, toast, updateObjects]);
const handleObjectField = useCallback((field, value) => {
if (!selectedObject) return;
if (selectedObject[field] === value) return;
remember();
updateObjects((currentObjects) =>
currentObjects.map((object) => (
getObjectKey(object) === getObjectKey(selectedObject) ? { ...object, [field]: value } : object
))
);
}, [remember, selectedObject, updateObjects]);
const handleZoneLinkChange = useCallback((zoneId) => {
if (!selectedObject) return;
const zone = zones.find((candidate) => String(candidate.id) === String(zoneId));
const nextZoneId = zone?.id || null;
const nextZoneName = zone?.name || null;
const nextLabel = selectedObject.label ?? zone?.name ?? "";
if (
(selectedObject.zone_id || null) === nextZoneId &&
(selectedObject.zone_name || null) === nextZoneName &&
selectedObject.label === nextLabel
) {
return;
}
remember();
updateObjects((currentObjects) =>
currentObjects.map((object) => (
getObjectKey(object) === selectedObjectKey
? { ...object, zone_id: nextZoneId, zone_name: nextZoneName, label: object.label ?? zone?.name ?? "" }
: object
))
);
}, [remember, selectedObject, selectedObjectKey, updateObjects, zones]);
return {
handleAddObject,
handleDeleteObject,
handleDuplicateObject,
handleObjectField,
handleShowHiddenAreas,
handleZoneLinkChange,
expandMapToFitObject,
pendingDeleteObject,
remember,
requestDeleteObject,
setPendingDeleteObject,
updateObjects,
};
}

View File

@ -0,0 +1,220 @@
import { useCallback, useEffect, useRef } from "react";
import useMobileMapAutoFit from "./useMobileMapAutoFit";
import { getObjectKey } from "../lib/locationMapUtils";
function cloneMapObjects(objects = []) {
return objects.map((object) => ({ ...object }));
}
function getHistorySnapshot(snapshot, fallbackSelectedObjectKey = null) {
if (Array.isArray(snapshot)) {
return {
objects: cloneMapObjects(snapshot),
selectedObjectKey: fallbackSelectedObjectKey,
};
}
return {
objects: cloneMapObjects(snapshot?.objects || []),
selectedObjectKey: snapshot?.selectedObjectKey ?? null,
};
}
function createHistorySnapshot(objects, selectedObjectKey) {
return {
objects: cloneMapObjects(objects),
selectedObjectKey: selectedObjectKey ?? null,
};
}
function isTextEditingTarget(target) {
return Boolean(target?.closest?.("input, textarea, select, [contenteditable]"));
}
export default function useLocationMapViewControls({
filters,
future = [],
hasUnsavedChanges,
history = [],
mapSize,
mapState,
mode,
objects,
previewDraft,
saving,
selectedObjectKey,
setFuture,
setHasUnsavedChanges,
setHistory,
setLayersOpen,
setMode,
setObjects,
setPreviewDraft,
setSelectedObjectKey,
setZoom,
svgRef,
syncMapDraftFromState,
zoom,
}) {
const shortcutControlsRef = useRef({
handleRedo: () => {},
handleUndo: () => {},
mode: "view",
saving: false,
});
useEffect(() => {
if (!selectedObjectKey) return;
const selectedMapObject = objects.find((object) => getObjectKey(object) === selectedObjectKey);
if (!selectedMapObject || !filters.showZones || selectedMapObject.visible === false) {
setSelectedObjectKey(null);
}
}, [filters.showZones, objects, selectedObjectKey, setSelectedObjectKey]);
const fitMapToCanvas = useMobileMapAutoFit({
mapSize,
mapState,
mode,
setZoom,
svgRef,
zoom,
});
const handleUndo = useCallback(() => {
if (history.length === 0) return;
const priorSnapshot = getHistorySnapshot(history[history.length - 1], selectedObjectKey);
setFuture([
createHistorySnapshot(objects, selectedObjectKey),
...future.slice(0, 19),
]);
setObjects(priorSnapshot.objects);
setSelectedObjectKey(priorSnapshot.selectedObjectKey);
setHasUnsavedChanges(history.length > 1);
setHistory(history.slice(0, -1));
}, [
future, history, objects, selectedObjectKey, setFuture, setHasUnsavedChanges, setHistory,
setObjects, setSelectedObjectKey,
]);
const handleRedo = useCallback(() => {
if (future.length === 0) return;
const nextSnapshot = getHistorySnapshot(future[0], selectedObjectKey);
setHistory([
...history.slice(-19),
createHistorySnapshot(objects, selectedObjectKey),
]);
setObjects(nextSnapshot.objects);
setSelectedObjectKey(nextSnapshot.selectedObjectKey);
setHasUnsavedChanges(true);
setFuture(future.slice(1));
}, [
future, history, objects, selectedObjectKey, setFuture, setHasUnsavedChanges, setHistory,
setObjects, setSelectedObjectKey,
]);
shortcutControlsRef.current = {
handleRedo,
handleUndo,
mode,
saving,
};
useEffect(() => {
if (typeof window === "undefined") {
return undefined;
}
const handleKeyDown = (event) => {
if (
event.defaultPrevented ||
event.altKey ||
!(event.ctrlKey || event.metaKey) ||
isTextEditingTarget(event.target)
) {
return;
}
const controls = shortcutControlsRef.current;
if (controls.mode !== "edit" || controls.saving) {
return;
}
const key = event.key.toLowerCase();
const wantsUndo = key === "z" && !event.shiftKey;
const wantsRedo = key === "y" || (key === "z" && event.shiftKey);
if (wantsUndo) {
event.preventDefault();
controls.handleUndo();
return;
}
if (wantsRedo) {
event.preventDefault();
controls.handleRedo();
}
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, []);
const handlePreviewDraft = useCallback(() => {
setMode("view");
setLayersOpen(false);
setPreviewDraft(true);
setSelectedObjectKey(null);
}, [setLayersOpen, setMode, setPreviewDraft, setSelectedObjectKey]);
const handleViewMode = useCallback(() => {
setMode("view");
setLayersOpen(false);
if ((mode === "edit" || previewDraft) && (hasUnsavedChanges || mapState?.draft_map)) {
setPreviewDraft(true);
return;
}
setPreviewDraft(false);
if (mapState) {
syncMapDraftFromState(mapState, "view", false);
}
}, [
hasUnsavedChanges,
mapState,
mode,
previewDraft,
setLayersOpen,
setMode,
setPreviewDraft,
setSelectedObjectKey,
syncMapDraftFromState,
]);
const handleEditMode = useCallback(() => {
setMode("edit");
setLayersOpen(false);
setPreviewDraft(true);
if (!previewDraft && mapState && !hasUnsavedChanges) {
syncMapDraftFromState(mapState, "edit", true);
}
}, [
hasUnsavedChanges,
mapState,
previewDraft,
setLayersOpen,
setMode,
setPreviewDraft,
syncMapDraftFromState,
]);
return {
handleEditMode,
handleFitMap: fitMapToCanvas,
handlePreviewDraft,
handleRedo,
handleUndo,
handleViewMode,
};
}

View File

@ -0,0 +1,111 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { DEFAULT_MAP_SIZE, clampMapZoom } from "../lib/locationMapUtils";
const COMPACT_MAP_LAYOUT_QUERY = "(max-width: 839px)";
function getAutoFitKey(mapState, mapSize) {
return [
mapState?.location?.id || "",
mapState?.draft_map?.id || "",
mapState?.published_map?.id || "",
mapSize.width,
mapSize.height,
].join(":");
}
function getFitZoom(mapSize, scrollElement) {
const fallbackWidth = typeof window === "undefined" ? DEFAULT_MAP_SIZE.width : window.innerWidth;
const fallbackHeight = typeof window === "undefined" ? DEFAULT_MAP_SIZE.height : window.innerHeight;
const isCompactLayout = typeof window !== "undefined"
&& window.matchMedia(COMPACT_MAP_LAYOUT_QUERY).matches;
const minimumWidth = isCompactLayout ? 160 : 280;
const minimumHeight = isCompactLayout ? 120 : 220;
const availableWidth = Math.max(minimumWidth, (scrollElement?.clientWidth || fallbackWidth) - 24);
const availableHeight = Math.max(minimumHeight, (scrollElement?.clientHeight || fallbackHeight) - 24);
return Number(clampMapZoom(
Math.min(availableWidth / mapSize.width, availableHeight / mapSize.height)
).toFixed(2));
}
export default function useMobileMapAutoFit({
mapSize,
mapState,
mode,
setZoom,
svgRef,
zoom,
}) {
const lastAutoFitKeyRef = useRef(null);
const lastFittedZoomRef = useRef(null);
const latestZoomRef = useRef(zoom);
const autoFitKey = useMemo(
() => getAutoFitKey(mapState, mapSize),
[mapSize, mapState]
);
useEffect(() => {
latestZoomRef.current = zoom;
}, [zoom]);
const fitMapToCanvas = useCallback(() => {
const scrollElement = svgRef.current?.closest(".location-map-scroll");
const nextZoom = getFitZoom(mapSize, scrollElement);
lastFittedZoomRef.current = nextZoom;
setZoom(nextZoom);
scrollElement?.scrollTo({ left: 0, top: 0 });
}, [mapSize, setZoom, svgRef]);
useEffect(() => {
if (typeof window === "undefined") return undefined;
if (!mapState || mode === "setup") return undefined;
if (!window.matchMedia(COMPACT_MAP_LAYOUT_QUERY).matches) return undefined;
if (lastAutoFitKeyRef.current === autoFitKey) return undefined;
const frameId = window.requestAnimationFrame(() => {
if (!svgRef.current) return;
lastAutoFitKeyRef.current = autoFitKey;
fitMapToCanvas();
});
return () => window.cancelAnimationFrame(frameId);
}, [autoFitKey, fitMapToCanvas, mapState, mode, svgRef]);
useEffect(() => {
if (typeof window === "undefined") return undefined;
if (!mapState || mode === "setup") return undefined;
let frameId = null;
const mediaQuery = window.matchMedia(COMPACT_MAP_LAYOUT_QUERY);
const shouldPreserveManualZoom = () => {
const lastFittedZoom = lastFittedZoomRef.current;
return lastFittedZoom !== null && Math.abs(latestZoomRef.current - lastFittedZoom) > 0.01;
};
const scheduleFit = () => {
if (!mediaQuery.matches || shouldPreserveManualZoom()) return;
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
}
frameId = window.requestAnimationFrame(() => {
frameId = null;
fitMapToCanvas();
});
};
window.addEventListener("resize", scheduleFit);
window.addEventListener("orientationchange", scheduleFit);
window.visualViewport?.addEventListener("resize", scheduleFit);
scheduleFit();
return () => {
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
}
window.removeEventListener("resize", scheduleFit);
window.removeEventListener("orientationchange", scheduleFit);
window.visualViewport?.removeEventListener("resize", scheduleFit);
};
}, [fitMapToCanvas, mapState, mode]);
return fitMapToCanvas;
}

View File

@ -0,0 +1,158 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useBeforeUnload } from "react-router-dom";
export default function useUnsavedMapLeaveGuard({
shouldGuardLeave,
navigate,
performBackNavigation,
}) {
const [pendingLeaveTarget, setPendingLeaveTarget] = useState(null);
const guardedClickTargetRef = useRef(null);
const allowGuardedClickRef = useRef(false);
const confirmedLeaveRef = useRef(false);
const historyGuardActiveRef = useRef(false);
const guardedUrlRef = useRef("");
useBeforeUnload(
useCallback((event) => {
if (!shouldGuardLeave) return;
event.preventDefault();
event.returnValue = "";
}, [shouldGuardLeave]),
{ capture: true }
);
useEffect(() => {
if (!shouldGuardLeave) {
if (historyGuardActiveRef.current && guardedUrlRef.current === window.location.href) {
window.history.back();
}
setPendingLeaveTarget(null);
guardedClickTargetRef.current = null;
confirmedLeaveRef.current = false;
historyGuardActiveRef.current = false;
return undefined;
}
if (!historyGuardActiveRef.current) {
guardedUrlRef.current = window.location.href;
window.history.pushState(
{ ...(window.history.state || {}), unsavedMapGuard: true },
"",
guardedUrlRef.current
);
historyGuardActiveRef.current = true;
}
const handleDocumentClick = (event) => {
if (allowGuardedClickRef.current) return;
if (
event.defaultPrevented ||
event.button !== 0 ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
) {
return;
}
const target = event.target instanceof Element ? event.target : null;
const button = target?.closest("button");
const isSwitchingHousehold = Boolean(
button?.classList.contains("household-option") &&
!button.classList.contains("create-household-btn") &&
!button.closest(".household-option-row.active")
);
if (button?.classList.contains("user-dropdown-logout") || isSwitchingHousehold) {
event.preventDefault();
event.stopPropagation();
guardedClickTargetRef.current = button;
setPendingLeaveTarget({ type: "click" });
return;
}
const anchor = target?.closest("a[href]");
if (!anchor || (anchor.target && anchor.target !== "_self") || anchor.hasAttribute("download")) {
return;
}
const nextUrl = new URL(anchor.href, window.location.href);
if (nextUrl.origin !== window.location.origin) return;
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
if (nextPath === currentPath) return;
event.preventDefault();
setPendingLeaveTarget({ type: "path", path: nextPath });
};
const handleBrowserHistoryNavigation = () => {
if (confirmedLeaveRef.current) return;
window.history.pushState(
{ ...(window.history.state || {}), unsavedMapGuard: true },
"",
guardedUrlRef.current || window.location.href
);
setPendingLeaveTarget({ type: "history" });
};
document.addEventListener("click", handleDocumentClick, true);
window.addEventListener("popstate", handleBrowserHistoryNavigation, true);
return () => {
document.removeEventListener("click", handleDocumentClick, true);
window.removeEventListener("popstate", handleBrowserHistoryNavigation, true);
};
}, [shouldGuardLeave]);
const requestBackNavigation = useCallback(() => {
if (shouldGuardLeave) {
setPendingLeaveTarget({ type: "back" });
return;
}
performBackNavigation();
}, [performBackNavigation, shouldGuardLeave]);
const clearPendingLeave = useCallback(() => {
guardedClickTargetRef.current = null;
setPendingLeaveTarget(null);
}, []);
const confirmPendingLeave = useCallback(() => {
const target = pendingLeaveTarget;
setPendingLeaveTarget(null);
confirmedLeaveRef.current = true;
if (target?.type === "history") {
historyGuardActiveRef.current = false;
window.history.go(-2);
return;
}
if (target?.type === "click") {
const guardedClickTarget = guardedClickTargetRef.current;
guardedClickTargetRef.current = null;
if (guardedClickTarget) {
allowGuardedClickRef.current = true;
guardedClickTarget.click();
window.setTimeout(() => {
allowGuardedClickRef.current = false;
}, 0);
return;
}
}
if (target?.type === "path" && target.path) {
navigate(target.path);
return;
}
performBackNavigation();
}, [navigate, pendingLeaveTarget, performBackNavigation]);
return {
pendingLeaveTarget,
requestBackNavigation,
clearPendingLeave,
confirmPendingLeave,
};
}

View File

@ -0,0 +1,39 @@
import {
DEFAULT_MAP_SIZE,
getObjectKey,
mapForMode,
normalizeMapObject,
objectsForMode,
} from "./locationMapUtils";
export function getDefaultMapDraft() {
return {
name: "Store Map",
width: DEFAULT_MAP_SIZE.width,
height: DEFAULT_MAP_SIZE.height,
};
}
export function getMapDraftSnapshot(nextState, nextMode, nextPreviewDraft, options = {}) {
const nextMap = mapForMode(nextState, nextMode, nextPreviewDraft);
const nextObjects = objectsForMode(nextState, nextMode, nextPreviewDraft);
if (!nextMap) return null;
const normalizedObjects = nextObjects.map(normalizeMapObject);
const preserveSelectedIndex = Number.isInteger(options.preserveSelectedIndex)
? options.preserveSelectedIndex
: -1;
const preservedSelectedObject = preserveSelectedIndex >= 0
? normalizedObjects[preserveSelectedIndex]
: null;
return {
mapDraft: {
name: nextMap.name || "Store Map",
width: nextMap.width || DEFAULT_MAP_SIZE.width,
height: nextMap.height || DEFAULT_MAP_SIZE.height,
},
objects: normalizedObjects,
selectedObjectKey: preservedSelectedObject ? getObjectKey(preservedSelectedObject) : null,
};
}

View File

@ -0,0 +1,202 @@
const DEFAULT_LOCATION_NAME = "Default Location";
export const DEFAULT_MAP_SIZE = {
width: 1000,
height: 700,
};
export const MIN_MAP_ZOOM = 0.3;
export const MAX_MAP_ZOOM = 1.6;
export const DEFAULT_MAP_FILTERS = {
showZones: true,
showLabels: true,
showCounts: true,
showPins: false,
showMyItems: true,
showOtherItems: false,
showCompleted: false,
showUnmapped: false,
};
export const MAP_OBJECT_TYPES = [
"zone",
"aisle",
"entrance",
"checkout",
"shelf",
"wall",
"department",
"anchor",
"other",
];
export function getStoreName(location) {
return location?.name || "Store";
}
export function getLocationName(location) {
if (!location) return "Location";
if (!location.location_name || location.location_name === DEFAULT_LOCATION_NAME) {
return "Default";
}
return location.location_name;
}
export function getMapStatus(state, options = {}) {
if (options.hasUnsavedChanges) return "Unsaved Draft";
if (!state?.draft_map && !state?.published_map) return "No Map";
if (options.mode === "edit") return "Draft";
if (options.mode === "view" && options.previewDraft && state?.draft_map) return "Viewing Draft";
if (state?.published_map) return "Published";
if (state?.draft_map) return "Draft";
return "No Map";
}
export function objectZoneId(object) {
return object?.zone_id ? String(object.zone_id) : "";
}
export function mapForMode(mapState, mode, previewDraft) {
if (mode === "edit") {
return mapState?.draft_map || mapState?.published_map || null;
}
if (previewDraft && mapState?.draft_map) {
return mapState.draft_map;
}
return mapState?.published_map || mapState?.draft_map || null;
}
export function objectsForMode(mapState, mode, previewDraft) {
if (mode === "edit") {
return mapState?.draft_objects?.length
? mapState.draft_objects
: mapState?.published_objects || [];
}
if (previewDraft && mapState?.draft_map) {
return mapState.draft_objects || [];
}
return mapState?.published_objects?.length
? mapState.published_objects
: mapState?.draft_objects || [];
}
export function createClientObject(zone, index = 0) {
const offset = index * 24;
return {
client_id: `new-${Date.now()}-${index}`,
zone_id: zone?.id || null,
zone_name: zone?.name || null,
type: "zone",
label: zone?.name || "New Area",
x: 80 + offset,
y: 80 + offset,
width: 220,
height: 130,
rotation: 0,
locked: false,
visible: true,
sort_order: index + 1,
metadata_json: {},
};
}
export function normalizeMapObject(object, index = 0) {
const hasExplicitLabel = Object.prototype.hasOwnProperty.call(object, "label");
return {
...object,
client_id: object.client_id || `object-${object.id || Date.now()}-${index}`,
zone_id: object.zone_id ?? null,
zone_name: object.zone_name || null,
type: MAP_OBJECT_TYPES.includes(object.type) ? object.type : "zone",
label: hasExplicitLabel
? String(object.label ?? "").trim()
: object.zone_name || "Map Area",
x: Number(object.x) || 0,
y: Number(object.y) || 0,
width: Math.max(40, Number(object.width) || 160),
height: Math.max(40, Number(object.height) || 100),
rotation: Number(object.rotation) || 0,
locked: Boolean(object.locked),
visible: object.visible !== false,
sort_order: Number(object.sort_order ?? object.sortOrder ?? index + 1),
metadata_json: object.metadata_json || {},
};
}
export function getObjectKey(object) {
return String(object.id || object.client_id);
}
export function getMapObjectDisplayLabel(object, fallback = "Map Area") {
const label = String(object?.label ?? "").trim();
const zoneName = String(object?.zone_name ?? "").trim();
return label || zoneName || fallback;
}
export function prepareObjectsForSave(objects) {
return objects.map((object, index) => ({
zone_id: object.zone_id || null,
type: object.type || "zone",
label: String(object.label ?? "").trim(),
x: Number(object.x) || 0,
y: Number(object.y) || 0,
width: Math.max(40, Number(object.width) || 160),
height: Math.max(40, Number(object.height) || 100),
rotation: Number(object.rotation) || 0,
locked: Boolean(object.locked),
visible: object.visible !== false,
sort_order: index + 1,
metadata_json: object.metadata_json || {},
}));
}
export function itemMatchesMapFilters(item, filters, username) {
if (!filters.showCompleted && item.bought) return false;
const addedBy = Array.isArray(item.added_by_users) ? item.added_by_users : [];
const hasOwnershipData = addedBy.length > 0 && username;
const isMine = hasOwnershipData ? addedBy.includes(username) : true;
if (isMine && !filters.showMyItems) return false;
if (!isMine && !filters.showOtherItems) return false;
return true;
}
export function itemsForZone(items, zoneId, filters, username) {
return items.filter((item) => (
String(item.zone_id || "") === String(zoneId || "") &&
itemMatchesMapFilters(item, filters, username)
));
}
export function unmappedItems(items, filters, username) {
return items.filter((item) => !item.zone_id && itemMatchesMapFilters(item, filters, username));
}
export function clientPointToMap(event, svgElement, mapSize) {
const rect = svgElement.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * mapSize.width;
const y = ((event.clientY - rect.top) / rect.height) * mapSize.height;
return { x, y };
}
export function clampMapZoom(value) {
return Math.max(MIN_MAP_ZOOM, Math.min(MAX_MAP_ZOOM, value));
}
export function clampObjectToMap(object, mapSize) {
const width = Math.max(40, Math.min(object.width, mapSize.width));
const height = Math.max(40, Math.min(object.height, mapSize.height));
return {
...object,
width,
height,
x: Math.max(0, Math.min(object.x, mapSize.width - width)),
y: Math.max(0, Math.min(object.y, mapSize.height - height)),
};
}

View File

@ -0,0 +1,483 @@
import { useCallback, useContext, useEffect, useRef } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
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 LocationMapDialogs from "../components/maps/LocationMapDialogs";
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 useActionToast from "../hooks/useActionToast";
import useLocationMapDerivedState from "../hooks/useLocationMapDerivedState";
import useLocationMapDraftActions from "../hooks/useLocationMapDraftActions";
import useLocationMapDraftState from "../hooks/useLocationMapDraftState";
import useLocationMapBuying from "../hooks/useLocationMapBuying";
import useLocationMapObjectActions from "../hooks/useLocationMapObjectActions";
import useLocationMapViewControls from "../hooks/useLocationMapViewControls";
import useUnsavedMapLeaveGuard from "../hooks/useUnsavedMapLeaveGuard";
import { getMapStatus } from "../lib/locationMapUtils";
import "../styles/pages/LocationMapManager.css";
function isTextEditingTarget(target) {
return Boolean(target?.closest?.("input, textarea, select, [contenteditable]"));
}
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 {
beginSaving,
dragState,
endSaving,
filters,
future,
hasUnsavedChanges,
history,
layersOpen,
loadError,
loadMap,
loading,
mapDraft,
mapState,
mode,
objects,
previewDraft,
saving,
savingAction,
selectedObjectKey,
setDragState,
setFilters,
setFuture,
setHasUnsavedChanges,
setHistory,
setLayersOpen,
setMapDraft,
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 {
hiddenAreaCount,
mapItems,
mapSize,
selectedObject,
selectedZoneItems,
unmappedItemCount,
visibleAssignedItemCount,
visibleUnmappedItems,
} = useLocationMapDerivedState({
filters,
mapDraft,
mapState,
mode,
objects,
previewDraft,
selectedObjectKey,
username,
});
const {
buyModalItem,
buyModalItems,
handleMapBuyCancel,
handleMapBuyConfirm,
handleMapBuyNavigate,
setBuyModalItem,
} = useLocationMapBuying({
activeHouseholdId: activeHousehold?.id,
locationId,
selectedObject,
selectedZoneItems,
setMapState,
toast,
visibleUnmappedItems,
});
const shouldGuardLeave = canManage && hasUnsavedChanges;
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,
setMapState,
setMode,
setPreviewDraft,
syncMapDraftFromState,
toast,
});
const {
handleAddObject,
handleDeleteObject,
handleDuplicateObject,
handleObjectField,
handleShowHiddenAreas,
handleZoneLinkChange,
expandMapToFitObject,
pendingDeleteObject,
remember,
requestDeleteObject,
setPendingDeleteObject,
updateObjects,
} = useLocationMapObjectActions({
canManage,
hiddenAreaCount,
mapSize,
mapState,
objects,
selectedObject,
selectedObjectKey,
setFuture,
setHasUnsavedChanges,
setHistory,
setMapDraft,
setObjects,
setSelectedObjectKey,
toast,
});
useEffect(() => {
setPendingDeleteObject(null);
}, [activeHousehold?.id, locationId, setPendingDeleteObject]);
const {
handleEditMode,
handleFitMap,
handlePreviewDraft,
handleRedo,
handleUndo,
handleViewMode,
} = useLocationMapViewControls({
filters,
future,
hasUnsavedChanges,
history,
mapSize,
mapState,
mode,
objects,
previewDraft,
saving,
selectedObjectKey,
setFuture,
setHasUnsavedChanges,
setHistory,
setLayersOpen,
setMode,
setObjects,
setPreviewDraft,
setSelectedObjectKey,
setZoom,
svgRef,
syncMapDraftFromState,
zoom,
});
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,
});
useEffect(() => {
const hasOpenModal = Boolean(buyModalItem || pendingDeleteObject || pendingLeaveTarget);
if (!selectedObjectKey || layersOpen || hasOpenModal || typeof window === "undefined") {
return undefined;
}
const handleKeyDown = (event) => {
if (
event.defaultPrevented ||
event.key !== "Escape" ||
isTextEditingTarget(event.target)
) {
return;
}
setSelectedObjectKey(null);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [buyModalItem, layersOpen, pendingDeleteObject, pendingLeaveTarget, selectedObjectKey, setSelectedObjectKey]);
useEffect(() => {
const hasOpenModal = Boolean(buyModalItem || pendingDeleteObject || pendingLeaveTarget);
if (mode !== "edit" || !canManage || hasOpenModal || typeof window === "undefined") {
return undefined;
}
const handleKeyDown = (event) => {
const wantsSave =
(event.ctrlKey || event.metaKey) &&
!event.altKey &&
event.key.toLowerCase() === "s";
if (!wantsSave) return;
event.preventDefault();
if (saving || !hasUnsavedChanges) return;
handleSaveDraft();
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [
buyModalItem,
canManage,
handleSaveDraft,
hasUnsavedChanges,
mode,
pendingDeleteObject,
pendingLeaveTarget,
saving,
]);
useEffect(() => {
const hasOpenModal = Boolean(buyModalItem || pendingDeleteObject || pendingLeaveTarget);
if (
!selectedObjectKey ||
mode !== "edit" ||
!canManage ||
saving ||
layersOpen ||
hasOpenModal ||
typeof window === "undefined"
) {
return undefined;
}
const handleKeyDown = (event) => {
if (
event.defaultPrevented ||
!["Backspace", "Delete"].includes(event.key) ||
isTextEditingTarget(event.target)
) {
return;
}
event.preventDefault();
requestDeleteObject();
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [
buyModalItem,
canManage,
layersOpen,
mode,
pendingDeleteObject,
pendingLeaveTarget,
requestDeleteObject,
saving,
selectedObjectKey,
]);
if (!hasLoaded || householdLoading || loading) {
return (
<LocationMapMessageState
message="Loading map..."
location={location}
status="Loading"
onBack={requestBackNavigation}
/>
);
}
if (!activeHousehold) {
return (
<LocationMapMessageState
status="Select Household"
/>
);
}
if (loadError) {
return (
<LocationMapLoadErrorState
location={location}
loadError={loadError}
onBack={requestBackNavigation}
onRetry={loadMap}
/>
);
}
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 (
<div className="location-map-page">
<LocationMapTopbar location={location} status={status} onBack={requestBackNavigation} />
<main className="location-map-workspace">
{hasAnyMap && mode !== "setup" ? (
<LocationMapToolbar
mode={mode}
hasAnyMap={hasAnyMap}
canManage={canManage}
saving={saving}
history={history}
future={future}
zoom={zoom}
setZoom={setZoom}
onFit={handleFitMap}
onView={handleViewMode}
onEdit={handleEditMode}
onUndo={handleUndo}
onRedo={handleRedo}
/>
) : null}
{!hasAnyMap || (mode === "setup" && mapState?.draft_map && !mapState?.published_map) ? (
<LocationMapSetupPanel
hasAnyMap={hasVisibleOrManageableMap}
canManage={canManage}
zoneCount={mapState?.zones?.length || 0}
saving={saving}
savingAction={savingAction}
onContinue={() => {
setMode("edit");
setPreviewDraft(true);
}}
onPreview={handlePreviewDraft}
onPublish={handlePublish}
onCreateFromZones={handleCreateFromZones}
onCreateBlank={handleCreateBlank}
/>
) : (
<>
<LocationMapCanvas
mode={mode}
objects={objects}
filters={filters}
mapSize={mapSize}
zoom={zoom}
selectedObjectKey={selectedObjectKey}
mapItems={mapItems}
username={username}
svgRef={svgRef}
dragState={dragState}
editingLocked={saving}
hiddenAreaCount={hiddenAreaCount}
onAddObject={handleAddObject}
setDragState={setDragState}
setSelectedObjectKey={setSelectedObjectKey}
remember={remember}
updateObjects={updateObjects}
expandMapToFitObject={expandMapToFitObject}
onShowZones={() =>
setFilters((current) => ({
...current,
showZones: true,
}))
}
onShowHiddenAreas={handleShowHiddenAreas}
/>
<LocationMapBottomSheet
mode={mode}
canManage={canManage}
filters={filters}
setFilters={setFilters}
layersOpen={layersOpen}
setLayersOpen={setLayersOpen}
selectedObject={selectedObject}
mapObjects={objects}
selectedZoneItems={selectedZoneItems}
visibleAssignedItemCount={visibleAssignedItemCount}
visibleUnmappedItems={visibleUnmappedItems}
unmappedItemCount={unmappedItemCount}
hiddenAreaCount={hiddenAreaCount}
saving={saving}
savingAction={savingAction}
hasUnsavedChanges={hasUnsavedChanges}
mapState={mapState}
onAddObject={handleAddObject}
onSaveDraft={handleSaveDraft}
onPublish={handlePublish}
onObjectField={handleObjectField}
onZoneLinkChange={handleZoneLinkChange}
onDuplicateObject={handleDuplicateObject}
onDeleteObject={requestDeleteObject}
onShowHiddenAreas={handleShowHiddenAreas}
onClearSelection={() => setSelectedObjectKey(null)}
onSelectZoneItem={setBuyModalItem}
onSelectUnmappedItem={setBuyModalItem}
/>
</>
)}
</main>
<LocationMapDialogs
buyModalItem={buyModalItem}
buyModalItems={buyModalItems}
onBuyNavigate={handleMapBuyNavigate}
onBuyCancel={handleMapBuyCancel}
onBuyConfirm={handleMapBuyConfirm}
pendingDeleteObject={pendingDeleteObject}
onDeleteClose={() => setPendingDeleteObject(null)}
onDeleteConfirm={handleDeleteObject}
pendingLeaveTarget={pendingLeaveTarget}
onLeaveClose={clearPendingLeave}
onLeaveConfirm={confirmPendingLeave}
/>
</div>
);
}

View File

@ -52,13 +52,12 @@
}
.confirm-buy-nav-btn {
width: 35px;
height: 35px;
width: 40px;
height: 40px;
border: var(--border-width-medium) solid var(--color-primary);
border-radius: var(--border-radius-full);
background: var(--color-bg-surface);
color: var(--color-primary);
font-size: 1.8em;
font-weight: bold;
cursor: pointer;
transition: var(--transition-base);
@ -70,6 +69,18 @@
flex-shrink: 0;
}
.confirm-buy-nav-icon {
width: 22px;
height: 22px;
display: block;
fill: none;
stroke: currentColor;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
pointer-events: none;
}
.confirm-buy-nav-btn:hover:not(:disabled) {
background: var(--color-primary);
color: var(--color-text-inverse);
@ -100,8 +111,21 @@
}
.confirm-buy-image-placeholder {
font-size: 4em;
width: min(58%, 150px);
aspect-ratio: 1;
display: inline-flex;
align-items: center;
justify-content: center;
border: var(--border-width-medium) solid var(--color-border-light);
border-radius: var(--border-radius-lg);
background: var(--color-gray-100);
color: var(--color-border-medium);
font-size: 4.75em;
line-height: 1;
}
.confirm-buy-image-placeholder span {
transform: translateY(0.02em);
}
.confirm-buy-quantity-section {
@ -260,9 +284,8 @@
}
.confirm-buy-nav-btn {
width: 30px;
height: 30px;
font-size: 1.6em;
width: 40px;
height: 40px;
}
.confirm-buy-counter-btn {
@ -299,8 +322,7 @@
}
.confirm-buy-nav-btn {
width: 28px;
height: 28px;
font-size: 1.4em;
width: 40px;
height: 40px;
}
}

View File

@ -1,78 +1,170 @@
.store-tabs {
background: var(--surface-color);
border-bottom: 2px solid var(--border-color);
margin-bottom: 1.5rem;
background: transparent;
border-bottom: none;
margin-bottom: 1rem;
}
.store-tabs-container {
display: flex;
justify-content: center;
gap: 0.25rem;
overflow-x: auto;
padding: 0.5rem 1rem 0;
width: 100%;
}
.store-tabs-container::-webkit-scrollbar {
height: 4px;
}
.store-tabs-container::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 2px;
}
.store-tab {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: transparent;
border: none;
border-bottom: 3px solid transparent;
color: var(--text-secondary);
padding: 0.5rem 0 0;
width: 100%;
}
.store-selector-trigger {
width: min(100%, 420px);
min-height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--color-bg-surface);
border: 1px solid var(--color-border-light);
border-radius: 8px;
color: var(--color-text-primary);
font-size: 1rem;
font-weight: 500;
font-weight: 700;
transition: all 0.2s ease;
text-align: left;
}
.store-selector-trigger.is-interactive {
cursor: pointer;
}
.store-selector-trigger.is-interactive:hover,
.store-selector-trigger.is-interactive:focus-visible {
color: var(--color-text-primary);
background: var(--color-bg-hover);
border-color: var(--color-primary);
outline: none;
}
.store-selector-trigger:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.location-selector-trigger {
min-height: 40px;
font-size: 0.95rem;
}
.store-map-button {
width: min(100%, 420px);
min-height: 38px;
padding: 0.6rem 1rem;
border: 1px solid var(--color-border-light);
border-radius: 8px;
background: var(--color-bg-elevated);
color: var(--color-text-primary);
font: inherit;
font-weight: 700;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
text-align: center;
}
.store-tab:hover {
color: var(--text-color);
background: var(--hover-color);
.store-map-button:hover,
.store-map-button:focus-visible {
border-color: var(--color-primary);
background: var(--color-bg-hover);
outline: none;
}
.store-tab.active {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
}
.store-tab:disabled {
.store-map-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.store-name {
font-weight: 500;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.default-badge {
padding: 0.125rem 0.5rem;
background: var(--primary-color-light);
color: var(--primary-color);
font-size: 0.75rem;
font-weight: 600;
border-radius: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
.store-selector-caret {
width: 0.55rem;
height: 0.55rem;
flex: 0 0 auto;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: rotate(45deg) translateY(-2px);
opacity: 0.8;
}
.store-tabs-empty {
padding: 1rem;
text-align: center;
color: var(--text-secondary);
color: var(--color-text-secondary);
font-style: italic;
}
.store-selector-modal-overlay {
position: fixed;
inset: 0;
z-index: 1400;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 5rem 1rem 1rem;
background: rgba(3, 10, 18, 0.58);
}
.store-selector-modal {
width: min(100%, 420px);
max-height: min(70vh, 560px);
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--color-border-light);
border-radius: 8px;
background: var(--color-bg-surface);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35);
}
.store-selector-modal h2 {
margin: 0;
color: var(--color-text-primary);
font-size: 1.05rem;
}
.store-selector-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow-y: auto;
overscroll-behavior: contain;
}
.store-selector-option {
width: 100%;
min-height: 44px;
padding: 0.75rem 0.9rem;
border: 1px solid var(--color-border-light);
border-radius: 8px;
background: var(--color-bg-elevated);
color: var(--color-text-primary);
font: inherit;
font-weight: 700;
text-align: left;
cursor: pointer;
}
.store-selector-option:hover,
.store-selector-option:focus-visible {
border-color: var(--color-primary);
background: var(--color-bg-hover);
outline: none;
}
.store-selector-option.active {
border-color: var(--color-primary);
color: var(--color-primary);
}

View File

@ -39,20 +39,11 @@ body.dark-mode .manage-section {
}
.manage-section-header h2 {
margin: 0.15rem 0 0;
margin: 0;
font-size: 1.2rem;
color: var(--text-primary);
}
.manage-section-eyebrow {
margin: 0;
color: var(--primary);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.section-description {
color: var(--text-secondary);
font-size: 0.92rem;
@ -131,7 +122,7 @@ body.dark-mode .edit-name-form input {
}
.manage-household-join-policy-toggle {
margin-bottom: 0.2rem;
margin-bottom: 0;
}
.pending-requests-summary {
@ -243,15 +234,16 @@ body.dark-mode .pending-request-card {
.invite-controls {
display: grid;
grid-template-columns: repeat(2, minmax(140px, 180px)) auto;
gap: 0.8rem;
align-items: end;
grid-template-columns: 1fr;
gap: 0.45rem;
align-items: stretch;
}
.invite-controls label {
display: flex;
flex-direction: column;
gap: 0.35rem;
display: grid;
grid-template-columns: 4.5rem minmax(0, 1fr);
gap: 0.75rem;
align-items: center;
color: var(--text-primary);
font-size: 0.9rem;
}
@ -265,14 +257,19 @@ body.dark-mode .pending-request-card {
}
.invite-controls select {
min-width: 120px;
width: 100%;
min-width: 0;
border: 1px solid var(--border);
border-radius: var(--border-radius-md);
padding: 0.7rem 0.75rem;
padding: 0.62rem 0.75rem;
background: rgba(255, 255, 255, 1);
color: var(--text-primary);
}
.invite-controls .btn-primary {
margin-top: 0.2rem;
}
[data-theme="dark"] .invite-controls select,
body.dark-mode .invite-controls select {
background: rgba(12, 19, 30, 0.92);
@ -377,20 +374,30 @@ body.dark-mode .invite-status-badge.is-used {
.members-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
gap: 0.85rem;
gap: 0.4rem;
}
.member-card {
display: flex;
flex-direction: column;
gap: 0.7rem;
padding: 0.85rem 1rem;
gap: 0.3rem;
width: 100%;
min-height: 44px;
padding: 0.55rem 0.8rem;
background: rgba(255, 255, 255, 1);
border: 1px solid var(--border);
border-radius: var(--border-radius-lg);
border-radius: var(--border-radius-md);
color: inherit;
font: inherit;
text-align: left;
transition: all 0.2s;
}
.member-card-button {
appearance: none;
cursor: pointer;
}
[data-theme="dark"] .member-card,
body.dark-mode .member-card {
background: rgba(12, 19, 30, 0.9);
@ -408,6 +415,11 @@ body.dark-mode .member-card:hover {
background: rgba(20, 32, 48, 0.98);
}
.member-card-button:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 3px;
}
.member-main {
min-width: 0;
}
@ -415,7 +427,7 @@ body.dark-mode .member-card:hover {
.member-info {
display: flex;
align-items: center;
gap: 0.45rem;
gap: 0.35rem;
min-width: 0;
max-width: 100%;
white-space: nowrap;
@ -427,15 +439,15 @@ body.dark-mode .member-card:hover {
text-overflow: ellipsis;
font-weight: 700;
color: var(--text-primary);
font-size: 1rem;
font-size: 0.95rem;
}
.member-role {
display: inline-flex;
align-items: center;
gap: 0.35rem;
gap: 0.28rem;
flex: 0 0 auto;
font-size: 0.88rem;
font-size: 0.82rem;
text-transform: capitalize;
font-weight: 700;
}
@ -466,16 +478,6 @@ body.dark-mode .member-card:hover {
font-weight: 700;
}
.member-actions {
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
justify-content: flex-start;
align-items: center;
padding-top: 0.65rem;
border-top: 1px solid color-mix(in srgb, var(--color-border-light) 82%, transparent);
}
.member-owner-action {
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
}
@ -506,11 +508,6 @@ body.dark-mode .member-role-action:hover:not(:disabled) {
color: #f3f9ff;
}
[data-theme="dark"] .member-actions,
body.dark-mode .member-actions {
border-top-color: color-mix(in srgb, var(--color-border-medium) 88%, transparent);
}
/* Danger Zone */
.danger-zone {
border-color: color-mix(in srgb, var(--danger) 30%, transparent);
@ -527,8 +524,7 @@ body.dark-mode .danger-zone {
border-color: color-mix(in srgb, var(--danger) 42%, transparent);
}
.danger-zone h2,
.danger-zone .manage-section-eyebrow {
.danger-zone h2 {
color: var(--danger);
}
@ -559,16 +555,88 @@ body.dark-mode .danger-zone {
cursor: not-allowed;
}
.member-actions-modal-overlay {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md);
background: var(--modal-backdrop-bg);
}
.member-actions-modal {
width: min(420px, calc(100vw - (2 * var(--spacing-md))));
max-height: calc(100vh - (2 * var(--spacing-md)));
overflow: auto;
padding: 1.2rem;
border: 1px solid var(--color-border-light);
border-radius: var(--border-radius-lg);
background: var(--modal-bg);
box-shadow: var(--shadow-xl);
}
.member-actions-modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.member-actions-modal-copy {
min-width: 0;
}
.member-actions-modal-copy h3 {
margin: 0.15rem 0 0.45rem;
color: var(--text-primary);
font-size: 1.25rem;
overflow-wrap: anywhere;
}
.member-actions-modal-close {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 40px;
height: 40px;
border: 1px solid var(--border);
border-radius: var(--border-radius-full);
background: var(--button-ghost-bg);
color: var(--text-primary);
cursor: pointer;
font-size: 1.3rem;
line-height: 1;
}
.member-actions-modal-close:hover,
.member-actions-modal-close:focus-visible {
border-color: var(--primary);
color: var(--primary);
outline: none;
}
.member-actions-modal-actions {
display: flex;
flex-direction: column;
gap: 0.65rem;
margin-top: 1.1rem;
}
.member-actions-modal-actions button {
width: 100%;
}
.member-actions-modal-empty {
margin: 1rem 0 0;
color: var(--text-secondary);
font-size: 0.92rem;
}
/* Responsive */
@media (max-width: 900px) {
.invite-controls {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.invite-controls .btn-primary {
grid-column: 1 / -1;
}
.invite-link-card {
grid-template-columns: 1fr;
}
@ -616,12 +684,6 @@ body.dark-mode .danger-zone {
grid-template-columns: 1fr;
}
.member-actions {
width: 100%;
justify-content: flex-start;
}
.member-actions button,
.pending-request-actions button {
flex: 1 1 100%;
}

View File

@ -22,6 +22,14 @@
font-size: 1.3rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.manage-stores-topline {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(240px, 360px);
gap: 1rem;
align-items: center;
margin-bottom: 1rem;
}
@ -50,11 +58,11 @@
background: var(--background);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.25rem;
padding: 1rem;
transition: all 0.2s;
display: flex;
flex-direction: column;
gap: 1rem;
gap: 0.75rem;
}
.store-card:hover {
@ -62,16 +70,23 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.store-card-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
}
.store-info h3 {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.5rem 0;
margin: 0;
}
.store-location {
color: var(--text-secondary);
font-size: 0.9rem;
font-size: 0.85rem;
margin: 0;
}
@ -87,44 +102,65 @@
margin-top: 0.5rem;
}
.store-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.store-location-list {
display: flex;
flex-direction: column;
gap: 1rem;
gap: 0.65rem;
}
.store-location-row {
display: flex;
flex-direction: column;
gap: 0.55rem;
padding-top: 0.65rem;
border-top: 1px solid var(--border);
}
.store-location-row:first-child {
padding-top: 0;
border-top: 0;
}
.store-location-copy {
display: flex;
flex-direction: column;
gap: 0.12rem;
}
.store-location-copy strong {
color: var(--text-primary);
font-size: 0.94rem;
}
.store-location-controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
.store-location-manager-trigger,
.store-zone-manager-trigger {
width: 100%;
}
.store-card-header > .store-location-manager-trigger {
width: auto;
}
.store-items-modal-toolbar.store-management-create-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--card-bg);
}
.store-location-row > .store-zones-panel,
.store-location-row > .store-available-items-trigger {
grid-column: 1 / -1;
}
.add-location-panel,
.store-zone-create-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
}
.add-location-panel input,
.add-store-panel input,
.store-zone-create-row input {
.store-items-modal-toolbar.store-location-create-row {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
}
.add-store-inline input,
.store-management-create-row input,
.store-settings-form input {
width: 100%;
box-sizing: border-box;
padding: 0.75rem;
@ -134,99 +170,128 @@
color: var(--text-primary);
}
.store-zones-panel {
display: flex;
flex-direction: column;
gap: 0.75rem;
.store-management-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.store-zones-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--background);
.store-management-row-with-order {
grid-template-columns: auto minmax(0, 1fr);
}
.store-zone-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
.store-management-row-with-order.is-delete-selectable {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.store-zone-row {
display: grid;
grid-template-columns: 2rem minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
.store-management-row-with-drag {
grid-template-columns: auto auto minmax(0, 1fr);
}
.store-zone-order {
.store-management-order {
width: 2rem;
color: var(--text-secondary);
font-size: 0.85rem;
text-align: right;
}
.store-zone-name {
.store-management-name {
min-width: 0;
color: var(--text-primary);
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-zone-actions {
.store-zone-drag-handle {
width: 2rem;
height: 2rem;
display: inline-flex;
border-radius: var(--border-radius-sm);
color: var(--text-secondary);
cursor: grab;
touch-action: none;
background-image: radial-gradient(currentColor 1.4px, transparent 1.4px);
background-position: center;
background-size: 6px 6px;
background-repeat: repeat;
}
.store-zone-drag-handle:hover,
.store-zone-drag-handle:focus-visible {
color: var(--color-primary);
background-color: var(--color-primary-light);
outline: none;
}
.store-management-row.is-dragging {
opacity: 0.55;
}
.store-management-row.is-drag-target {
border-color: var(--color-primary);
box-shadow: inset 3px 0 0 var(--color-primary);
}
.store-management-meta {
grid-column: 1 / -1;
min-width: 0;
color: var(--text-secondary);
font-size: var(--font-size-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-management-badge {
display: inline-flex;
align-items: center;
margin-left: var(--spacing-xs);
padding: 2px 6px;
border-radius: var(--border-radius-full);
background: var(--color-primary-light);
color: var(--color-primary);
font-size: var(--font-size-xs);
font-weight: 700;
}
.store-settings-modal {
width: min(520px, 100%);
}
.store-settings-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.store-settings-form label {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.store-settings-form label span {
color: var(--text-secondary);
font-size: var(--font-size-sm);
font-weight: 700;
}
.store-settings-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
/* Add Store Panel */
.add-store-panel {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
.available-stores {
.add-store-inline {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.available-store-card {
background: var(--background);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.25rem;
display: flex;
justify-content: space-between;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.5rem;
align-items: center;
gap: 1rem;
transition: all 0.2s;
}
.available-store-card:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.available-store-card .store-info {
flex: 1;
}
.available-store-card h3 {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.25rem 0;
}
.available-store-card .store-location {
color: var(--text-secondary);
font-size: 0.85rem;
margin: 0;
.add-store-inline .btn-primary {
min-width: 4.5rem;
}
/* Empty State */
@ -239,44 +304,49 @@
/* Responsive */
@media (max-width: 600px) {
.stores-list,
.available-stores {
.stores-list {
grid-template-columns: 1fr;
}
.store-actions {
width: 100%;
}
.store-actions button {
flex: 1;
}
.available-store-card {
flex-direction: column;
align-items: flex-start;
}
.available-store-card button {
width: 100%;
}
.store-location-row,
.add-location-panel,
.store-zone-create-row,
.store-zone-row {
.store-card-header,
.store-location-controls,
.manage-stores-topline,
.add-store-inline,
.store-items-modal-toolbar.store-management-create-row,
.store-items-modal-toolbar.store-location-create-row,
.store-management-row {
grid-template-columns: 1fr;
}
.store-zone-order {
.store-card-header > .store-location-manager-trigger {
width: 100%;
}
.add-store-inline .btn-primary {
width: 100%;
}
.store-management-order {
text-align: left;
}
.store-zone-actions {
.store-management-row-with-order {
grid-template-columns: auto minmax(0, 1fr);
}
.store-management-row-with-order.is-delete-selectable {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.store-management-row-with-drag {
grid-template-columns: auto auto minmax(0, 1fr);
}
.store-settings-actions {
justify-content: stretch;
}
.store-zone-actions button {
.store-settings-actions button {
flex: 1;
}
}

View File

@ -60,11 +60,35 @@
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
gap: var(--spacing-xs);
background: var(--modal-bg);
}
.store-items-modal-toolbar .btn-small {
flex: 0 0 auto;
min-height: 40px;
white-space: nowrap;
}
.store-items-bulk-toolbar {
display: flex;
align-items: center;
gap: var(--spacing-xs);
justify-content: flex-end;
}
.store-items-delete-toggle,
.store-items-delete-cancel {
min-height: 38px;
min-width: 132px;
}
.store-available-items-search {
flex: 1 1 auto;
width: 100%;
min-width: 0;
padding: var(--input-padding-y) var(--input-padding-x);
border: var(--border-width-thin) solid var(--input-border-color);
border-radius: var(--input-border-radius);
@ -92,24 +116,20 @@
gap: var(--spacing-sm);
}
.store-items-table-head,
.store-items-table-row {
display: grid;
grid-template-columns: minmax(220px, 2fr) minmax(180px, 2fr) minmax(170px, 1fr);
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--spacing-md);
align-items: center;
}
.store-items-table-head {
position: sticky;
top: 0;
padding: 0 var(--spacing-sm) var(--spacing-xs);
background: var(--modal-bg);
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
.store-items-table-row-button {
width: 100%;
appearance: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.store-items-table-body {
@ -125,6 +145,22 @@
background: var(--color-bg-surface);
}
.store-items-table-row-button:hover,
.store-items-table-row-button:focus-visible {
border-color: var(--color-primary);
background: var(--color-bg-hover);
outline: none;
}
.store-items-table-row-button.is-delete-selectable {
border-color: color-mix(in srgb, var(--color-danger) 35%, var(--color-border-light));
}
.store-items-table-row-button.is-selected {
border-color: var(--color-danger);
background: var(--color-danger-light);
}
.store-items-table-cell {
min-width: 0;
}
@ -153,8 +189,11 @@
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
font-weight: var(--font-weight-semibold);
border: var(--border-width-medium) solid var(--color-border-light);
background: var(--color-gray-100);
color: var(--color-border-medium);
font-size: 1.75rem;
line-height: 1;
}
.store-available-items-copy {
@ -171,24 +210,27 @@
white-space: nowrap;
}
.store-items-defaults-text {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.store-items-table-actions {
justify-self: end;
}
.store-available-items-actions {
display: flex;
gap: var(--spacing-xs);
flex-wrap: wrap;
justify-content: flex-end;
.store-items-delete-indicator {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border: var(--border-width-thin) solid var(--color-border-light);
border-radius: var(--border-radius-full);
color: var(--color-text-inverse);
background: var(--color-bg-surface);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
}
.store-items-mobile-label {
display: none;
.store-items-table-row-button.is-selected .store-items-delete-indicator {
border-color: var(--color-danger);
background: var(--color-danger);
}
@media (max-width: 720px) {
@ -197,37 +239,21 @@
padding: var(--spacing-md);
}
.store-items-table-head {
display: none;
}
.store-items-table-row {
display: flex;
flex-direction: column;
align-items: stretch;
grid-template-columns: minmax(0, 1fr);
gap: var(--spacing-sm);
}
.store-items-mobile-label {
display: block;
margin-bottom: 4px;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
.store-items-table-row-button.is-delete-selectable {
grid-template-columns: minmax(0, 1fr) auto;
}
.store-items-table-actions {
justify-self: stretch;
}
.store-available-items-actions {
.store-items-bulk-toolbar {
width: 100%;
justify-content: stretch;
align-items: stretch;
}
.store-available-items-actions button {
.store-items-bulk-toolbar button {
flex: 1 1 0;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -36,15 +36,24 @@ test("manage stores opens a modal to edit and delete household store items", asy
},
];
await page.route("**/stores", async (route) => {
await page.route("**/households/1/stores", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: 10, name: "Costco" }]),
body: JSON.stringify([
{
id: 10,
household_store_id: 100,
name: "Costco",
is_default: true,
zone_count: 0,
item_count: availableItems.length,
},
]),
});
});
await page.route("**/households/1/stores/10/available-items*", async (route) => {
await page.route("**/households/1/locations/10/available-items*", async (route) => {
const request = route.request();
const url = new URL(request.url());
const query = (url.searchParams.get("query") || "").toLowerCase();
@ -62,7 +71,7 @@ test("manage stores opens a modal to edit and delete household store items", asy
await route.fulfill({ status: 500 });
});
await page.route("**/households/1/stores/10/available-items/777", async (route) => {
await page.route("**/households/1/locations/10/available-items/777", async (route) => {
if (route.request().method() === "PATCH") {
availableItems = availableItems.map((item) =>
item.item_id === 777
@ -89,7 +98,7 @@ test("manage stores opens a modal to edit and delete household store items", asy
await route.fulfill({ status: 500 });
});
await page.route("**/households/1/stores/10/available-items/501", async (route) => {
await page.route("**/households/1/locations/10/available-items/501", async (route) => {
if (route.request().method() === "DELETE") {
availableItems = availableItems.filter((item) => item.item_id !== 501);
await route.fulfill({
@ -105,18 +114,46 @@ test("manage stores opens a modal to edit and delete household store items", asy
await page.goto("/manage?tab=stores");
await expect(page.getByRole("heading", { name: "Add Store" })).toHaveCount(0);
await expect(page.getByPlaceholder("Store name")).toBeVisible();
await expect(page.getByPlaceholder("Location name")).toHaveCount(0);
await expect(page.getByPlaceholder("Address or notes")).toHaveCount(0);
const storeCard = page.locator(".store-card").filter({ hasText: "Costco" });
await expect(storeCard).toBeVisible();
await expect(storeCard.getByRole("button", { name: "Manage Items" })).toBeVisible();
await expect(storeCard.getByText("Costco", { exact: true })).toHaveCount(1);
await expect(storeCard.getByText("Default location")).toHaveCount(0);
await expect(storeCard.getByText("Default shopping location")).toHaveCount(0);
await expect(storeCard.getByRole("button", { name: "Manage Items (2)" })).toBeVisible();
await storeCard.getByRole("button", { name: "Manage Items" }).click();
await storeCard.getByRole("button", { name: "Manage Items (2)" }).click();
const managerModal = page.locator(".store-items-modal");
await expect(managerModal).toBeVisible();
await expect(managerModal.getByText("milk", { exact: true })).toBeVisible();
await expect(managerModal.getByText("apples", { exact: true })).toBeVisible();
await expect(managerModal.locator(".store-available-items-thumb-placeholder").first()).toHaveText("\uD83D\uDCE6");
await expect(managerModal.getByText("Store Defaults")).toHaveCount(0);
await expect(managerModal.getByText("No store defaults set")).toHaveCount(0);
await expect(managerModal.getByText("Edit Settings", { exact: true })).toHaveCount(0);
await expect(managerModal.getByText("Delete Item", { exact: true })).toHaveCount(0);
await expect(managerModal.locator(".store-available-items-action")).toHaveCount(0);
await managerModal.locator(".store-items-table-row").filter({ hasText: "apples" }).getByRole("button", { name: "Edit Settings" }).click();
const searchBox = await managerModal.getByPlaceholder("Search household/store items").boundingBox();
const addButtonBox = await managerModal.getByRole("button", { name: "Add Item" }).boundingBox();
expect(searchBox).not.toBeNull();
expect(addButtonBox).not.toBeNull();
expect(
Math.abs(
((searchBox?.y ?? 0) + (searchBox?.height ?? 0) / 2) -
((addButtonBox?.y ?? 0) + (addButtonBox?.height ?? 0) / 2)
)
).toBeLessThan(2);
const appleRow = managerModal.getByRole("button", { name: "Edit settings for apples" });
const milkRow = managerModal.locator(".store-items-table-row").filter({ hasText: "milk" });
await appleRow.click();
const editorModal = page.locator(".available-item-editor-modal");
await expect(editorModal).toBeVisible();
await expect(editorModal.getByLabel("Item Name")).toBeDisabled();
@ -126,9 +163,22 @@ test("manage stores opens a modal to edit and delete household store items", asy
await editorModal.getByRole("button", { name: "Save Changes" }).click();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated store item");
await expect(managerModal.getByText("produce | Fruits | Produce & Fresh Vegetables")).toBeVisible();
await expect(managerModal.getByText("produce | Fruits | Produce & Fresh Vegetables")).toHaveCount(0);
await managerModal.locator(".store-items-table-row").filter({ hasText: "milk" }).getByRole("button", { name: "Delete Item" }).click();
await managerModal.getByRole("button", { name: "Delete Items" }).click();
await expect(managerModal.getByRole("button", { name: "Confirm Delete (0)" })).toBeDisabled();
await expect(managerModal.getByRole("button", { name: "Cancel" })).toBeVisible();
await milkRow.click();
await expect(managerModal.getByRole("button", { name: "Confirm Delete (1)" })).toBeEnabled();
await expect(milkRow).toHaveClass(/is-selected/);
await milkRow.click();
await expect(managerModal.getByRole("button", { name: "Confirm Delete (0)" })).toBeDisabled();
await expect(milkRow).not.toHaveClass(/is-selected/);
await milkRow.click();
await managerModal.getByRole("button", { name: "Confirm Delete (1)" }).click();
const confirmModal = page.locator(".confirm-slide-modal");
await expect(confirmModal).toBeVisible();
await expect(confirmModal.getByText("Delete milk?")).toBeVisible();
@ -141,6 +191,290 @@ test("manage stores opens a modal to edit and delete household store items", asy
await expect(managerModal.locator(".store-items-table-row").filter({ hasText: "milk" })).toHaveCount(0);
});
test("manage stores uses modal flows for locations and zones", async ({ page }) => {
await page.setViewportSize({ width: 460, height: 1000 });
await seedAuthStorage(page, { username: "store-manager", role: "owner" });
await mockConfig(page);
await mockHouseholdAndStoreShell(page, {
household: { name: "Modal House", role: "owner" },
});
let locations = [
{
id: 10,
household_store_id: 100,
name: "Costco",
location_name: "Default Location",
display_name: "Costco",
address: "",
is_default: true,
zone_count: 2,
item_count: 3,
},
{
id: 11,
household_store_id: 100,
name: "Costco",
location_name: "Fontana",
display_name: "Costco - Fontana",
address: "Sierra Ave",
is_default: false,
zone_count: 0,
item_count: 0,
},
];
let zones = [
{ id: 901, name: "Bakery", sort_order: 10 },
{ id: 902, name: "Frozen Foods", sort_order: 20 },
];
await page.route("**/households/1/stores", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
locations.map((location) =>
location.id === 10 ? { ...location, zone_count: zones.length } : location
)
),
});
});
await page.route("**/households/1/stores/100/locations", async (route) => {
const request = route.request();
if (request.method() === "POST") {
const body = request.postDataJSON() as { name?: string; address?: string };
const locationName = body.name || "New Location";
locations = [
...locations,
{
id: 12,
household_store_id: 100,
name: "Costco",
location_name: locationName,
display_name: `Costco - ${locationName}`,
address: body.address || "",
is_default: false,
zone_count: 0,
item_count: 0,
},
];
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ store: locations[locations.length - 1] }),
});
return;
}
await route.fulfill({ status: 405 });
});
await page.route("**/households/1/locations/11", async (route) => {
const request = route.request();
if (request.method() === "PATCH") {
const body = request.postDataJSON() as { name?: string; address?: string };
locations = locations.map((location) =>
location.id === 11
? {
...location,
location_name: body.name || location.location_name,
display_name: `Costco - ${body.name || location.location_name}`,
address: body.address || "",
}
: location
);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ store: locations.find((location) => location.id === 11) }),
});
return;
}
if (request.method() === "DELETE") {
locations = locations.filter((location) => location.id !== 11);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ message: "Location removed successfully" }),
});
return;
}
await route.fulfill({ status: 405 });
});
await page.route("**/households/1/locations/11/default", async (route) => {
if (route.request().method() === "PATCH") {
locations = locations.map((location) => ({
...location,
is_default: location.id === 11,
}));
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ message: "Default location updated successfully" }),
});
return;
}
await route.fulfill({ status: 405 });
});
await page.route("**/households/1/locations/10/zones", async (route) => {
const request = route.request();
if (request.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
zones: [...zones].sort((a, b) => a.sort_order - b.sort_order),
}),
});
return;
}
if (request.method() === "POST") {
const body = request.postDataJSON() as { name?: string; sort_order?: number };
zones = [
...zones,
{ id: 903, name: body.name || "New Zone", sort_order: body.sort_order || 30 },
];
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ zone: zones[zones.length - 1] }),
});
return;
}
await route.fulfill({ status: 405 });
});
await page.route("**/households/1/locations/10/zones/*", async (route) => {
const request = route.request();
const zoneId = Number(new URL(request.url()).pathname.split("/").pop());
if (request.method() === "PATCH") {
const body = request.postDataJSON() as { name?: string; sort_order?: number };
zones = zones.map((zone) =>
zone.id === zoneId
? { ...zone, name: body.name || zone.name, sort_order: body.sort_order ?? zone.sort_order }
: zone
);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ zone: zones.find((zone) => zone.id === zoneId) }),
});
return;
}
if (request.method() === "DELETE") {
zones = zones.filter((zone) => zone.id !== zoneId);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ message: "Zone removed successfully" }),
});
return;
}
await route.fulfill({ status: 405 });
});
await page.goto("/manage?tab=stores");
const storeCard = page.locator(".store-card").filter({ hasText: "Costco" });
await expect(storeCard).toBeVisible();
await expect(storeCard.getByRole("button", { name: "Set Default" })).toHaveCount(0);
await expect(storeCard.getByRole("button", { name: "Remove" })).toHaveCount(0);
await expect(storeCard.getByPlaceholder("Location name")).toHaveCount(0);
await storeCard.getByRole("button", { name: "Manage Locations (2)" }).click();
const locationsModal = page.locator(".store-items-modal").filter({
has: page.getByRole("heading", { name: "Costco Locations" }),
});
await expect(locationsModal).toBeVisible();
await expect(locationsModal.getByPlaceholder("Location name")).toBeVisible();
await expect(locationsModal.getByRole("button", { name: "Delete Locations" })).toBeVisible();
await locationsModal.getByRole("button", { name: "Edit location Costco - Fontana" }).click();
const locationSettings = page.locator(".store-items-modal").filter({
has: page.getByRole("heading", { name: "Costco - Fontana Settings" }),
});
await expect(locationSettings).toBeVisible();
await locationSettings.getByLabel("Location name").fill("Upland");
await locationSettings.getByLabel("Address or notes").fill("Mountain Ave");
await locationSettings.getByRole("button", { name: "Save Changes" }).click();
await expect(
page.locator(".action-toast.action-toast-success").filter({ hasText: "Updated location" })
).toContainText("Updated location");
await locationsModal.getByRole("button", { name: "Delete Locations" }).click();
await locationsModal.getByRole("button", { name: "Select Costco - Upland for deletion" }).click();
await expect(locationsModal.getByRole("button", { name: "Confirm Delete (1)" })).toBeEnabled();
await locationsModal.getByRole("button", { name: "Confirm Delete (1)" }).click();
await expect(page.getByRole("heading", { name: "Delete Costco - Upland?" })).toBeVisible();
await confirmSlide(page);
await expect(
page.locator(".action-toast.action-toast-success").filter({ hasText: "Removed location" })
).toContainText("Removed location");
await page.getByLabel("Close manage locations modal").click();
await expect(storeCard.getByRole("button", { name: "Manage Locations (1)" })).toBeVisible();
await storeCard.getByRole("button", { name: "Manage Zones (2)" }).click();
const zonesModal = page.locator(".store-items-modal").filter({
has: page.getByRole("heading", { name: "Costco Zones" }),
});
await expect(zonesModal).toBeVisible();
await expect(zonesModal.getByPlaceholder("New zone name")).toBeVisible();
await expect(zonesModal.getByRole("button", { name: "Up" })).toHaveCount(0);
await expect(zonesModal.getByRole("button", { name: "Down" })).toHaveCount(0);
await expect(zonesModal.getByRole("button", { name: "Remove" })).toHaveCount(0);
const bakeryZoneRow = zonesModal.getByRole("button", { name: "Edit zone Bakery" });
const frozenZoneRow = zonesModal.getByRole("button", { name: "Edit zone Frozen Foods" });
await expect(bakeryZoneRow.locator(".store-zone-drag-handle")).toBeVisible();
const bakeryOrderBox = await bakeryZoneRow.locator(".store-management-order").boundingBox();
const bakeryNameBox = await bakeryZoneRow.locator(".store-management-name").boundingBox();
expect(bakeryOrderBox).not.toBeNull();
expect(bakeryNameBox).not.toBeNull();
expect(
Math.abs(
((bakeryOrderBox?.y ?? 0) + (bakeryOrderBox?.height ?? 0) / 2) -
((bakeryNameBox?.y ?? 0) + (bakeryNameBox?.height ?? 0) / 2)
)
).toBeLessThan(3);
await bakeryZoneRow.locator(".store-zone-drag-handle").dragTo(frozenZoneRow);
await expect(
page.locator(".action-toast.action-toast-success").filter({ hasText: "Reordered zones" })
).toContainText("Reordered zones");
await expect(zonesModal.locator(".store-management-row-with-order").first()).toContainText("Frozen Foods");
await zonesModal.getByRole("button", { name: "Edit zone Bakery" }).click();
const zoneSettings = page.locator(".store-items-modal").filter({
has: page.getByRole("heading", { name: "Bakery Settings" }),
});
await expect(zoneSettings).toBeVisible();
await zoneSettings.getByLabel("Zone name").fill("Bread");
await zoneSettings.getByRole("button", { name: "Save Changes" }).click();
await expect(
page.locator(".action-toast.action-toast-success").filter({ hasText: "Updated zone" })
).toContainText("Updated zone");
await zonesModal.getByRole("button", { name: "Delete Zones" }).click();
await zonesModal.getByRole("button", { name: "Select Bread for deletion" }).click();
await zonesModal.getByRole("button", { name: "Confirm Delete (1)" }).click();
await expect(page.getByRole("heading", { name: "Delete Bread?" })).toBeVisible();
await confirmSlide(page);
await expect(
page.locator(".action-toast.action-toast-success").filter({ hasText: "Removed zone" })
).toContainText("Removed zone");
});
test("grocery page remains unchanged and does not show a store items picker", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
@ -156,7 +490,7 @@ test("grocery page remains unchanged and does not show a store items picker", as
});
});
await page.route("**/households/1/stores/10/list/recent", async (route) => {
await page.route("**/households/1/locations/10/list/recent", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
@ -164,7 +498,7 @@ test("grocery page remains unchanged and does not show a store items picker", as
});
});
await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
await page.route("**/households/1/locations/10/list/suggestions**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
@ -172,7 +506,7 @@ test("grocery page remains unchanged and does not show a store items picker", as
});
});
await page.route("**/households/1/stores/10/list/item**", async (route) => {
await page.route("**/households/1/locations/10/list/item**", async (route) => {
await route.fulfill({
status: 404,
contentType: "application/json",
@ -180,7 +514,7 @@ test("grocery page remains unchanged and does not show a store items picker", as
});
});
await page.route("**/households/1/stores/10/list", async (route) => {
await page.route("**/households/1/locations/10/list", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",

View File

@ -64,7 +64,19 @@ async function setupBuyModalRoutes(
});
});
await page.route("**/households/1/stores/10/list/recent", async (route) => {
await page.route("**/households/1/locations/10/zones", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
zones: [
{ id: 501, name: "Produce", sort_order: 10 },
],
}),
});
});
await page.route("**/households/1/locations/10/list/recent", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
@ -72,7 +84,7 @@ async function setupBuyModalRoutes(
});
});
await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
await page.route("**/households/1/locations/10/list/suggestions**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
@ -80,7 +92,7 @@ async function setupBuyModalRoutes(
});
});
await page.route("**/households/1/stores/10/list/classification**", async (route) => {
await page.route("**/households/1/locations/10/list/classification**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
@ -88,7 +100,7 @@ async function setupBuyModalRoutes(
});
});
await page.route("**/households/1/stores/10/list/item**", async (route) => {
await page.route("**/households/1/locations/10/list/item**", async (route) => {
const request = route.request();
if (request.method() === "PATCH") {
@ -156,7 +168,7 @@ async function setupBuyModalRoutes(
});
});
await page.route("**/households/1/stores/10/list", async (route) => {
await page.route("**/households/1/locations/10/list", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
@ -183,12 +195,11 @@ test("buying an item advances to the next one in the current sort order", async
]);
await page.goto("/");
await page.locator(".glist-sort").selectOption("qty-high");
await openBuyModal(page, "bread");
await page.getByRole("button", { name: "Mark as Bought" }).click();
await expect(page.locator(".confirm-buy-item-name")).toHaveText("apples");
await expect(page.locator(".confirm-buy-item-name")).toHaveText("milk");
});
test("buying the last item in the current order wraps to the first remaining item", async ({ page }) => {
@ -201,7 +212,6 @@ test("buying the last item in the current order wraps to the first remaining ite
]);
await page.goto("/");
await page.locator(".glist-sort").selectOption("az");
await openBuyModal(page, "milk");
await page.getByRole("button", { name: "Mark as Bought" }).click();
@ -219,7 +229,6 @@ test("partial buy keeps the item on the list and advances past it", async ({ pag
]);
await page.goto("/");
await page.locator(".glist-sort").selectOption("qty-low");
await openBuyModal(page, "bravo");
await page.locator(".confirm-buy-counter-btn").nth(0).click();

View File

@ -16,8 +16,12 @@ type HouseholdSeed = {
type StoreSeed = {
id?: number;
household_store_id?: number;
household_id?: number;
name?: string;
location?: string;
location_name?: string;
display_name?: string;
is_default?: boolean;
};
@ -57,9 +61,21 @@ export async function mockHouseholdAndStoreShell(
invite_code: "ABCD1234",
...options.household,
};
const stores = options.stores || [
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
];
const stores = (options.stores || [
{ id: 10, household_store_id: 100, name: "Costco", location_name: "Warehouse", is_default: true },
]).map((store, index) => {
const locationName = store.location_name || store.location || "Default Location";
return {
household_id: household.id,
household_store_id: store.household_store_id ?? store.id ?? index + 1,
id: store.id ?? index + 10,
name: store.name || "Costco",
location_name: locationName,
display_name: store.display_name || `${store.name || "Costco"} - ${locationName}`,
is_default: store.is_default ?? index === 0,
...store,
};
});
await page.route("**/households", async (route) => {
await route.fulfill({
@ -76,6 +92,14 @@ export async function mockHouseholdAndStoreShell(
body: JSON.stringify(stores),
});
});
await page.route(`**/households/${household.id}/stores`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(stores),
});
});
}
export async function confirmSlide(page: Page) {

View File

@ -176,7 +176,9 @@ test("household management shows pending invite approvals and can approve them",
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Approved join request");
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Pending Pal");
await expect(page.getByText("No pending join requests right now.")).toBeVisible();
await expect(page.getByText("No pending join requests right now.")).toHaveCount(0);
await expect(page.locator(".pending-requests-summary")).toContainText("0");
await expect(page.locator(".manage-section-eyebrow")).toHaveCount(0);
await expect(page.getByText("Members (2)")).toBeVisible();
});
@ -231,7 +233,11 @@ test("household member removal opens slide confirmation instead of browser dialo
await page.goto("/manage?tab=household");
const memberCard = page.locator(".member-card").filter({ hasText: "remove-me" });
await memberCard.getByRole("button", { name: "Remove" }).click();
await expect(memberCard.getByRole("button", { name: "Remove" })).toHaveCount(0);
await memberCard.click();
const memberActionsDialog = page.getByRole("dialog", { name: "remove-me" });
await expect(memberActionsDialog).toBeVisible();
await memberActionsDialog.getByRole("button", { name: "Remove" }).click();
await expect(page.getByRole("heading", { name: "Remove remove-me?" })).toBeVisible();
await expect(page.getByText("Slide to confirm. They will lose access to this household.")).toBeVisible();
@ -266,11 +272,21 @@ test("household owner can transfer ownership from household settings", async ({
});
});
await page.route("**/stores/household/1", async (route) => {
await page.route("**/households/1/stores", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: 10, name: "Costco", location: "Warehouse", is_default: true }]),
body: JSON.stringify([
{ id: 10, household_store_id: 100, name: "Costco", location: "Warehouse", is_default: true },
]),
});
});
await page.route("**/households/1/locations/10/zones", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ zones: [] }),
});
});
@ -346,9 +362,13 @@ test("household owner can transfer ownership from household settings", async ({
await page.goto("/manage?tab=household");
await expect(page.getByRole("button", { name: "Make Owner" })).toBeVisible();
await expect(page.getByRole("button", { name: "Make Owner" })).toHaveCount(0);
await page.getByRole("button", { name: "Make Owner" }).click();
const memberCard = page.locator(".member-card").filter({ hasText: "nico-admin" });
await memberCard.click();
const memberActionsDialog = page.getByRole("dialog", { name: "nico-admin" });
await expect(memberActionsDialog).toBeVisible();
await memberActionsDialog.getByRole("button", { name: "Make Owner" }).click();
await expect(page.getByText("Transfer ownership to nico-admin?")).toBeVisible();
await confirmSlide(page);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,228 @@
import { expect, test, type Page } from "@playwright/test";
import { mockConfig, seedAuthStorage } from "./helpers/e2e";
const household = { id: 1, name: "Selector House", role: "owner", invite_code: "ABCD1234" };
const user = { id: 1, username: "selector-user", role: "owner" };
async function mockGroceryShell(page: Page, stores: Array<Record<string, unknown>>) {
await seedAuthStorage(page, { username: "selector-user", role: "owner" });
await mockConfig(page);
await page.route("**/households", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([household]),
});
});
await page.route("**/households/1/stores", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(stores),
});
});
await page.route("**/households/1/members", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([user]),
});
});
await page.route("**/households/1/locations/*/zones", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ zones: [] }),
});
});
await page.route("**/households/1/locations/*/map", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: stores.find((store) => String(store.id) === route.request().url().match(/locations\/(\d+)\/map/)?.[1]) || stores[0],
map: null,
draft_map: null,
published_map: null,
objects: [],
draft_objects: [],
published_objects: [],
zones: [],
items: [],
unmapped_count: 0,
can_manage: true,
}),
});
});
await page.route("**/households/1/locations/*/list/recent", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/households/1/locations/*/list/suggestions**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/households/1/locations/*/list/item**", async (route) => {
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ message: "Item not found" }),
});
});
await page.route("**/households/1/locations/*/list", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [] }),
});
});
}
test("grocery store selector shows one selected store and picks from a modal", async ({ page }) => {
await page.setViewportSize({ width: 473, height: 1000 });
const storeContextMessages: string[] = [];
page.on("console", (message) => {
const text = message.text();
if (text.includes("[StoreContext]")) {
storeContextMessages.push(text);
}
});
await mockGroceryShell(page, [
{
id: 10,
household_store_id: 100,
name: "Costco",
location_name: "Eastvale",
display_name: "Costco - Eastvale",
is_default: true,
},
{
id: 13,
household_store_id: 100,
name: "Costco",
location_name: "Fontana",
display_name: "Costco - Fontana",
is_default: false,
},
{
id: 11,
household_store_id: 200,
name: "99 Ranch",
location_name: "Eastvale",
display_name: "99 Ranch - Eastvale",
is_default: false,
},
{
id: 12,
household_store_id: 300,
name: "Stater Bros",
location_name: "Ontario Ranch",
display_name: "Stater Bros - Ontario Ranch",
is_default: false,
},
]);
await page.goto("/");
const storeSelector = page.locator(".store-selector-trigger").first();
const locationSelector = page.locator(".location-selector-trigger");
await expect(storeSelector).toBeVisible();
await expect(storeSelector).toContainText("Costco");
await expect(locationSelector).toBeVisible();
await expect(locationSelector).toContainText("Eastvale");
await expect(page.getByRole("button", { name: "Manage Map" })).toBeVisible();
await expect(page.locator(".store-tabs")).not.toContainText("Costco - Eastvale");
await expect(page.locator(".store-tabs")).not.toContainText("99 Ranch");
await expect(page.locator(".store-tabs")).not.toContainText("Stater Bros");
await storeSelector.click();
const modal = page.getByRole("dialog", { name: "Select store" });
await expect(modal).toBeVisible();
await expect(modal.getByRole("button", { name: "Costco" })).toBeVisible();
await expect(modal.getByRole("button", { name: "99 Ranch" })).toBeVisible();
await expect(modal.getByRole("button", { name: "Stater Bros" })).toBeVisible();
await expect(modal).not.toContainText("Eastvale");
await expect(modal).not.toContainText("Ontario Ranch");
await modal.getByRole("button", { name: "99 Ranch" }).click();
await expect(modal).toHaveCount(0);
await expect(storeSelector).toContainText("99 Ranch");
await expect(locationSelector).toContainText("Eastvale");
await expect.poll(() => page.evaluate(() => localStorage.getItem("activeHouseholdStoreId_1"))).toBe("200");
await expect.poll(() => page.evaluate(() => localStorage.getItem("activeStoreLocationId_1_200"))).toBe("11");
await storeSelector.click();
await expect(modal).toBeVisible();
await page.locator(".store-selector-modal-overlay").click({ position: { x: 4, y: 4 } });
await expect(modal).toHaveCount(0);
await storeSelector.click();
await modal.getByRole("button", { name: "Costco" }).click();
await locationSelector.click();
const locationModal = page.getByRole("dialog", { name: "Select location" });
await expect(locationModal).toBeVisible();
await expect(locationModal.getByRole("button", { name: "Eastvale" })).toBeVisible();
await expect(locationModal.getByRole("button", { name: "Fontana" })).toBeVisible();
await expect(locationModal).not.toContainText("Costco - Eastvale");
await locationModal.getByRole("button", { name: "Fontana" }).click();
await expect(locationModal).toHaveCount(0);
await expect(storeSelector).toContainText("Costco");
await expect(locationSelector).toContainText("Fontana");
await expect.poll(() => page.evaluate(() => localStorage.getItem("activeHouseholdStoreId_1"))).toBe("100");
await expect.poll(() => page.evaluate(() => localStorage.getItem("activeStoreLocationId_1_100"))).toBe("13");
await page.getByRole("button", { name: "Manage Map" }).click();
await expect(page).toHaveURL(/\/stores\/100\/locations\/13\/map$/);
await expect(page.getByRole("heading", { name: "Costco" })).toBeVisible();
await expect(page.getByText("Fontana")).toBeVisible();
await expect(page.getByRole("heading", { name: "No Map" })).toBeVisible();
await page.getByRole("button", { name: "Back" }).click();
await expect(page).toHaveURL(/\/$/);
await expect(storeSelector).toContainText("Costco");
await expect(locationSelector).toContainText("Fontana");
await expect(page.getByRole("button", { name: "Manage Map" })).toBeVisible();
expect(storeContextMessages).toEqual([]);
});
test("single grocery store selector click does not open picker modal", async ({ page }) => {
await mockGroceryShell(page, [
{
id: 10,
household_store_id: 100,
name: "Costco",
location_name: "Default Location",
display_name: "Costco",
is_default: true,
},
]);
await page.goto("/");
const storeSelector = page.locator(".store-selector-trigger").first();
const locationSelector = page.locator(".location-selector-trigger");
await expect(storeSelector).toBeVisible();
await expect(storeSelector).toContainText("Costco");
await expect(locationSelector).toBeVisible();
await expect(locationSelector).toContainText("Default");
await storeSelector.click();
await expect(page.getByRole("dialog", { name: "Select store" })).toHaveCount(0);
await locationSelector.click();
await expect(page.getByRole("dialog", { name: "Select location" })).toHaveCount(0);
});

View File

@ -47,10 +47,15 @@ test("manage stores add success shows success toast", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
let linkedStoreIds = [10];
const allStores = [
{ id: 10, name: "Costco North", location: "North", is_default: true },
{ id: 11, name: "Costco South", location: "South", is_default: false },
let stores = [
{
id: 10,
household_store_id: 100,
name: "Costco",
location_name: "Default Location",
display_name: "Costco",
is_default: true,
},
];
await page.route("**/households", async (route) => {
@ -61,65 +66,63 @@ test("manage stores add success shows success toast", async ({ page }) => {
});
});
await page.route("**/stores/household/1", async (route) => {
await page.route("**/households/1/stores", async (route) => {
const request = route.request();
if (request.method() === "GET") {
const payload = linkedStoreIds.map((id, index) => {
const store = allStores.find((candidate) => candidate.id === id);
return {
...store,
is_default: index === 0,
};
});
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(payload),
body: JSON.stringify(stores),
});
return;
}
if (request.method() === "POST") {
const body = request.postDataJSON() as { storeId?: number };
if (body.storeId && !linkedStoreIds.includes(body.storeId)) {
linkedStoreIds = [...linkedStoreIds, body.storeId];
}
const body = request.postDataJSON() as { name?: string };
const name = body.name || "New Store";
stores = [
...stores,
{
id: 11,
household_store_id: 101,
name,
location_name: "Default Location",
display_name: name,
is_default: false,
},
];
await route.fulfill({
status: 200,
status: 201,
contentType: "application/json",
body: JSON.stringify({ ok: true }),
body: JSON.stringify({ store: stores[stores.length - 1] }),
});
return;
}
await route.fallback();
await route.fulfill({ status: 405 });
});
await page.route("**/stores", async (route) => {
await page.route("**/households/1/locations/10/zones", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(allStores),
body: JSON.stringify({ zones: [] }),
});
});
await page.goto("/manage?tab=stores");
await page.getByRole("button", { name: "+ Add Store" }).click();
await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click();
const addStoreForm = page.locator(".add-store-inline");
await addStoreForm.getByLabel("Store name").fill("Stater Bros");
await addStoreForm.getByRole("button", { name: "Add" }).click();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Added store");
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Costco South");
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Created store");
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Stater Bros");
});
test("manage stores add failure shows normalized error toast", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
const allStores = [
{ id: 10, name: "Costco North", location: "North", is_default: true },
{ id: 11, name: "Costco South", location: "South", is_default: false },
];
await page.route("**/households", async (route) => {
await route.fulfill({
status: 200,
@ -128,13 +131,22 @@ test("manage stores add failure shows normalized error toast", async ({ page })
});
});
await page.route("**/stores/household/1", async (route) => {
await page.route("**/households/1/stores", async (route) => {
const request = route.request();
if (request.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: 10, name: "Costco North", location: "North", is_default: true }]),
body: JSON.stringify([
{
id: 10,
household_store_id: 100,
name: "Costco",
location_name: "Default Location",
display_name: "Costco",
is_default: true,
},
]),
});
return;
}
@ -150,22 +162,23 @@ test("manage stores add failure shows normalized error toast", async ({ page })
return;
}
await route.fallback();
await route.fulfill({ status: 405 });
});
await page.route("**/stores", async (route) => {
await page.route("**/households/1/locations/10/zones", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(allStores),
body: JSON.stringify({ zones: [] }),
});
});
await page.goto("/manage?tab=stores");
await page.getByRole("button", { name: "+ Add Store" }).click();
await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click();
const addStoreForm = page.locator(".add-store-inline");
await addStoreForm.getByLabel("Store name").fill("Costco");
await addStoreForm.getByRole("button", { name: "Add" }).click();
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Add store failed");
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Create store failed");
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Store already linked to household");
});

View File

@ -2,9 +2,12 @@ import react from '@vitejs/plugin-react';
import { defineConfig, loadEnv } from 'vite';
const env = loadEnv('', process.cwd());
const allowedHosts = env.VITE_ALLOWED_HOSTS
const configuredAllowedHosts = env.VITE_ALLOWED_HOSTS
? env.VITE_ALLOWED_HOSTS.split(',').map((host) => host.trim())
: [];
const allowedHosts = Array.from(
new Set(['localhost', '127.0.0.1', '192.168.7.45', ...configuredAllowedHosts].filter(Boolean))
);
export default defineConfig({
plugins: [react()],

View File

@ -0,0 +1,66 @@
-- Location-specific store maps for the mobile map manager.
-- Maps are scoped to a household-owned physical store location.
CREATE TABLE IF NOT EXISTS location_maps (
id BIGSERIAL PRIMARY KEY,
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
store_location_id INTEGER NOT NULL REFERENCES store_locations(id) ON DELETE CASCADE,
name VARCHAR(120) NOT NULL DEFAULT 'Store Map',
width INTEGER NOT NULL DEFAULT 1000 CHECK (width > 0),
height INTEGER NOT NULL DEFAULT 700 CHECK (height > 0),
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'published', 'archived')),
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0),
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_location_maps_location_status
ON location_maps(household_id, store_location_id, status, updated_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS uq_location_maps_draft_location
ON location_maps(store_location_id)
WHERE status = 'draft';
CREATE UNIQUE INDEX IF NOT EXISTS uq_location_maps_published_location
ON location_maps(store_location_id)
WHERE status = 'published';
CREATE TABLE IF NOT EXISTS location_map_objects (
id BIGSERIAL PRIMARY KEY,
location_map_id BIGINT NOT NULL REFERENCES location_maps(id) ON DELETE CASCADE,
zone_id INTEGER REFERENCES store_location_zones(id) ON DELETE SET NULL,
type VARCHAR(30) NOT NULL DEFAULT 'zone'
CHECK (type IN (
'zone',
'aisle',
'entrance',
'checkout',
'shelf',
'wall',
'department',
'anchor',
'other'
)),
label VARCHAR(160) NOT NULL DEFAULT '',
x NUMERIC(10, 2) NOT NULL DEFAULT 0,
y NUMERIC(10, 2) NOT NULL DEFAULT 0,
width NUMERIC(10, 2) NOT NULL DEFAULT 160 CHECK (width > 0),
height NUMERIC(10, 2) NOT NULL DEFAULT 100 CHECK (height > 0),
rotation NUMERIC(7, 2) NOT NULL DEFAULT 0,
locked BOOLEAN NOT NULL DEFAULT FALSE,
visible BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER NOT NULL DEFAULT 0,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_location_map_objects_map_order
ON location_map_objects(location_map_id, sort_order, id);
CREATE INDEX IF NOT EXISTS idx_location_map_objects_zone
ON location_map_objects(zone_id);