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` - `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: - 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` - `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`. - 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. - 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. - Touch: long-press affordance for item-level actions when no visible button.
- Mouse: hover affordance on interactive rows/cards. - Mouse: hover affordance on interactive rows/cards.
- Tap targets remain >= 40px on mobile. - 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. - 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). - 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. - 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= DB_NAME=
PORT=5000 PORT=5000
JWT_SECRET=change-me 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_COOKIE_NAME=sid
SESSION_TTL_DAYS=30 SESSION_TTL_DAYS=30

View File

@ -4,6 +4,7 @@ const path = require("path");
const User = require("./models/user.model"); const User = require("./models/user.model");
const requestIdMiddleware = require("./middleware/request-id"); const requestIdMiddleware = require("./middleware/request-id");
const { sendError } = require("./utils/http"); const { sendError } = require("./utils/http");
const { isAllowedCorsOrigin, parseAllowedOrigins } = require("./utils/cors-origins");
const app = express(); const app = express();
app.use(requestIdMiddleware); app.use(requestIdMiddleware);
@ -14,17 +15,11 @@ if (process.env.NODE_ENV !== "production") {
app.use("/test", express.static(path.join(__dirname, "public"))); app.use("/test", express.static(path.join(__dirname, "public")));
} }
const allowedOrigins = (process.env.ALLOWED_ORIGINS || "") const allowedOrigins = parseAllowedOrigins(process.env.ALLOWED_ORIGINS || "");
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
app.use( app.use(
cors({ cors({
origin: function (origin, callback) { origin: function (origin, callback) {
if (!origin) return callback(null, true); if (isAllowedCorsOrigin(origin, allowedOrigins)) 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);
console.error(`CORS blocked origin: ${origin}`); console.error(`CORS blocked origin: ${origin}`);
callback(new Error(`CORS blocked: ${origin}. Add this origin to ALLOWED_ORIGINS environment variable.`)); 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.address,
sl.is_default, sl.is_default,
sl.map_data, 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.created_at,
sl.updated_at, sl.updated_at,
CASE CASE
@ -142,6 +144,19 @@ exports.getHouseholdStores = async (householdId) => {
END AS display_name END AS display_name
FROM store_locations sl FROM store_locations sl
JOIN household_custom_stores hcs ON hcs.id = sl.household_store_id 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 WHERE sl.household_id = $1
ORDER BY sl.is_default DESC, hcs.name ASC, sl.name ASC`, ORDER BY sl.is_default DESC, hcs.name ASC, sl.name ASC`,
[householdId, DEFAULT_LOCATION_NAME] [householdId, DEFAULT_LOCATION_NAME]

View File

@ -3,6 +3,7 @@ const router = express.Router();
const controller = require("../controllers/households.controller"); const controller = require("../controllers/households.controller");
const listsController = require("../controllers/lists.controller.v2"); const listsController = require("../controllers/lists.controller.v2");
const availableItemsController = require("../controllers/available-items.controller"); const availableItemsController = require("../controllers/available-items.controller");
const locationMapsController = require("../controllers/location-maps.controller");
const storesController = require("../controllers/stores.controller"); const storesController = require("../controllers/stores.controller");
const auth = require("../middleware/auth"); const auth = require("../middleware/auth");
const { const {
@ -134,6 +135,46 @@ router.delete(
storesController.deleteZone 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( router.get(
"/:householdId/locations/:locationId/available-items", "/:householdId/locations/:locationId/available-items",
auth, 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(), 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", () => ({ jest.mock("../controllers/stores.controller", () => ({
addLocationToStore: jest.fn((req, res) => res.status(201).json({ message: "location" })), addLocationToStore: jest.fn((req, res) => res.status(201).json({ message: "location" })),
createHouseholdStore: jest.fn((req, res) => res.status(201).json({ message: "store" })), createHouseholdStore: jest.fn((req, res) => res.status(201).json({ message: "store" })),
@ -89,6 +97,7 @@ const express = require("express");
const request = require("supertest"); const request = require("supertest");
const router = require("../routes/households.routes"); const router = require("../routes/households.routes");
const householdsController = require("../controllers/households.controller"); const householdsController = require("../controllers/households.controller");
const locationMapsController = require("../controllers/location-maps.controller");
const storesController = require("../controllers/stores.controller"); const storesController = require("../controllers/stores.controller");
describe("store location routes", () => { describe("store location routes", () => {
@ -161,4 +170,51 @@ describe("store location routes", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(storesController.updateZone).toHaveBeenCalled(); 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
- `guides/api-documentation.md`: REST API reference. Verify against current code before changing APIs. - `guides/api-documentation.md`: REST API reference. Verify against current code before changing APIs.
- `guides/frontend-readme.md`: frontend development notes. - `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/MOBILE_RESPONSIVE_AUDIT.md`: mobile design and audit checklist.
- `guides/setup-checklist.md`: older setup checklist; prefer `DEVELOPMENT.md` for current commands. - `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 # Leave blank to infer the API host from the browser URL, e.g. http://192.168.7.45:5000.
VITE_ALLOWED_HOSTS=localhost,127.0.0.1 VITE_API_URL=
VITE_ALLOWED_HOSTS=localhost,127.0.0.1,192.168.7.45

View File

@ -9,7 +9,8 @@ import { SettingsProvider } from "./context/SettingsContext.jsx";
import { StoreProvider } from "./context/StoreContext.jsx"; import { StoreProvider } from "./context/StoreContext.jsx";
import AdminPanel from "./pages/AdminPanel.jsx"; import AdminPanel from "./pages/AdminPanel.jsx";
import GroceryList from "./pages/GroceryList.jsx"; import GroceryList from "./pages/GroceryList.jsx";
import LocationMapManager from "./pages/LocationMapManager.jsx";
import Login from "./pages/Login.jsx"; import Login from "./pages/Login.jsx";
import Manage from "./pages/Manage.jsx"; import Manage from "./pages/Manage.jsx";
import Register from "./pages/Register.jsx"; import Register from "./pages/Register.jsx";
@ -48,6 +49,7 @@ function App() {
<Route path="/" element={<GroceryList />} /> <Route path="/" element={<GroceryList />} />
<Route path="/manage" element={<Manage />} /> <Route path="/manage" element={<Manage />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/stores/:storeId/locations/:locationId/map" element={<LocationMapManager />} />
<Route <Route
path="/admin" 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 [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
const [pendingRoleChange, setPendingRoleChange] = useState(null); const [pendingRoleChange, setPendingRoleChange] = useState(null);
const [pendingMemberRemoval, setPendingMemberRemoval] = useState(null); const [pendingMemberRemoval, setPendingMemberRemoval] = useState(null);
const [selectedMember, setSelectedMember] = useState(null);
const isManager = ["owner", "admin"].includes(activeHousehold?.role); const isManager = ["owner", "admin"].includes(activeHousehold?.role);
const isOwner = activeHousehold?.role === "owner"; const isOwner = activeHousehold?.role === "owner";
@ -85,6 +86,19 @@ export default function ManageHousehold() {
} }
}, [activeHousehold?.id, isManager]); }, [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 () => { const loadMembers = async () => {
if (!activeHousehold?.id) return; if (!activeHousehold?.id) return;
setLoading(true); setLoading(true);
@ -360,13 +374,34 @@ export default function ManageHousehold() {
const managerCount = members.filter((member) => ["owner", "admin"].includes(member.role)).length; const managerCount = members.filter((member) => ["owner", "admin"].includes(member.role)).length;
const memberCount = members.filter((member) => member.role === "member").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 ( return (
<div className="manage-household"> <div className="manage-household">
<section key="household-name" className="manage-section"> <section key="household-name" className="manage-section">
<div className="manage-section-header"> <div className="manage-section-header">
<div> <div>
<p className="manage-section-eyebrow">Household</p>
<h2>Identity</h2> <h2>Identity</h2>
</div> </div>
</div> </div>
@ -411,7 +446,6 @@ export default function ManageHousehold() {
<section key="join-and-invites" className="manage-section"> <section key="join-and-invites" className="manage-section">
<div className="manage-section-header"> <div className="manage-section-header">
<div> <div>
<p className="manage-section-eyebrow">Entry Rules</p>
<h2>Invite Links</h2> <h2>Invite Links</h2>
</div> </div>
</div> </div>
@ -435,9 +469,7 @@ export default function ManageHousehold() {
{inviteLoading ? ( {inviteLoading ? (
<p>Loading invite settings...</p> <p>Loading invite settings...</p>
) : pendingRequests.length === 0 ? ( ) : pendingRequests.length > 0 ? (
<p className="section-description">No pending join requests right now.</p>
) : (
<div className="pending-requests-list"> <div className="pending-requests-list">
{pendingRequests.map((request) => { {pendingRequests.map((request) => {
const requesterLabel = getRequesterLabel(request); const requesterLabel = getRequesterLabel(request);
@ -473,7 +505,7 @@ export default function ManageHousehold() {
); );
})} })}
</div> </div>
)} ) : null}
<div className="invite-controls"> <div className="invite-controls">
<label> <label>
@ -547,7 +579,6 @@ export default function ManageHousehold() {
<section key="members" className="manage-section"> <section key="members" className="manage-section">
<div className="manage-section-header"> <div className="manage-section-header">
<div> <div>
<p className="manage-section-eyebrow">People</p>
<h2>Members ({members.length})</h2> <h2>Members ({members.length})</h2>
</div> </div>
</div> </div>
@ -560,7 +591,13 @@ export default function ManageHousehold() {
const isSelf = member.id === parseInt(userId, 10); const isSelf = member.id === parseInt(userId, 10);
return ( 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-main">
<div className="member-info"> <div className="member-info">
<span className={`member-role member-role-${member.role}`}> <span className={`member-role member-role-${member.role}`}>
@ -570,46 +607,76 @@ export default function ManageHousehold() {
{isSelf && <span className="member-self-pill">You</span>} {isSelf && <span className="member-self-pill">You</span>}
</div> </div>
</div> </div>
{isManager && !isSelf && member.role !== "owner" && ( </button>
<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> </div>
)} )}
</section> </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) && ( {(isManager || isMemberOnly) && (
<section key="danger-zone" className="manage-section danger-zone"> <section key="danger-zone" className="manage-section danger-zone">
<div className="manage-section-header"> <div className="manage-section-header">
<div> <div>
<p className="manage-section-eyebrow">Final Actions</p>
<h2>Danger Zone</h2> <h2>Danger Zone</h2>
</div> </div>
{isMemberOnly ? ( {isMemberOnly ? (

View File

@ -1,21 +1,14 @@
import { useContext, useEffect, useMemo, useState } from "react"; import { useContext, useMemo, useState } from "react";
import { import { createHouseholdStore } from "../../api/stores";
addLocationToStore,
createHouseholdStore,
createLocationZone,
deleteLocationZone,
getLocationZones,
removeLocation,
setDefaultLocation,
updateLocationZone,
} from "../../api/stores";
import StoreAvailableItemsManager from "./StoreAvailableItemsManager";
import { HouseholdContext } from "../../context/HouseholdContext"; import { HouseholdContext } from "../../context/HouseholdContext";
import { StoreContext } from "../../context/StoreContext"; import { StoreContext } from "../../context/StoreContext";
import useActionToast from "../../hooks/useActionToast"; import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage"; 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/StoreAvailableItemsManager.css";
import "../../styles/components/manage/ManageStores.css";
function groupLocationsByStore(locations) { function groupLocationsByStore(locations) {
const grouped = new Map(); const grouped = new Map();
@ -36,163 +29,8 @@ function groupLocationsByStore(locations) {
return Array.from(grouped.values()).sort((a, b) => a.name.localeCompare(b.name)); return Array.from(grouped.values()).sort((a, b) => a.name.localeCompare(b.name));
} }
function ZoneManager({ householdId, location, canManage, refreshActiveZones }) { function locationLabel(location) {
const toast = useActionToast(); return location.display_name || location.name;
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>
);
} }
export default function ManageStores() { export default function ManageStores() {
@ -204,12 +42,7 @@ export default function ManageStores() {
refreshZones, refreshZones,
} = useContext(StoreContext); } = useContext(StoreContext);
const toast = useActionToast(); const toast = useActionToast();
const [createForm, setCreateForm] = useState({ const [newStoreName, setNewStoreName] = useState("");
name: "",
location_name: "",
address: "",
});
const [locationDrafts, setLocationDrafts] = useState({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const isAdmin = ["owner", "admin"].includes(activeHousehold?.role); const isAdmin = ["owner", "admin"].includes(activeHousehold?.role);
@ -225,18 +58,17 @@ export default function ManageStores() {
const handleCreateStore = async (event) => { const handleCreateStore = async (event) => {
event.preventDefault(); event.preventDefault();
if (!createForm.name.trim()) return; const storeName = newStoreName.trim();
if (!storeName) return;
setSaving(true); setSaving(true);
try { try {
await createHouseholdStore(activeHousehold.id, { await createHouseholdStore(activeHousehold.id, {
name: createForm.name.trim(), name: storeName,
location_name: createForm.location_name.trim() || "Default Location",
address: createForm.address.trim() || null,
}); });
setCreateForm({ name: "", location_name: "", address: "" }); setNewStoreName("");
await refreshAfterStoreChange(); await refreshAfterStoreChange();
toast.success("Created store", `Created store ${createForm.name.trim()}`); toast.success("Created store", `Created store ${storeName}`);
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, "Failed to create store"); const message = getApiErrorMessage(error, "Failed to create store");
toast.error("Create store failed", `Create store failed: ${message}`); 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 ( return (
<div className="manage-stores"> <div className="manage-stores">
<section className="manage-section"> <section className="manage-section">
<h2>Store Locations ({householdStores.length})</h2> <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"> <p className="manage-stores-help">
Stores and locations are private to this household. Each location has its own zones, Stores are private to this household. Locations define map-specific zones, item
item defaults, and shopping order. placement, and shopping order.
</p> </p>
{householdStores.length === 0 ? ( {householdStores.length === 0 ? (
<p className="empty-message">No store locations added yet.</p> <p className="empty-message">No store locations added yet.</p>
@ -306,133 +107,64 @@ export default function ManageStores() {
<div className="stores-list"> <div className="stores-list">
{groupedStores.map((storeGroup) => ( {groupedStores.map((storeGroup) => (
<div key={storeGroup.household_store_id} className="store-card"> <div key={storeGroup.household_store_id} className="store-card">
<div className="store-info"> <div className="store-card-header">
<h3>{storeGroup.name}</h3> <div className="store-info">
<h3>{storeGroup.name}</h3>
</div>
<StoreLocationManager
householdId={activeHousehold.id}
storeGroup={storeGroup}
allLocationCount={householdStores.length}
canManage={isAdmin}
refreshAfterStoreChange={refreshAfterStoreChange}
/>
</div> </div>
<div className="store-location-list"> <div className="store-location-list">
{storeGroup.locations.map((location) => ( {storeGroup.locations.map((location) => {
<div key={location.id} className="store-location-row"> const label = locationLabel(location);
<div className="store-info"> const showLocationName =
<strong>{location.display_name || location.name}</strong> storeGroup.locations.length > 1 || label !== storeGroup.name;
{location.address ? (
<p className="store-location">{location.address}</p> return (
) : null} <div key={location.id} className="store-location-row">
{location.is_default ? ( <div className="store-info store-location-copy">
<p className="store-location">Default shopping location</p> {showLocationName ? <strong>{label}</strong> : null}
) : null} {location.address ? (
<p className="store-location">{location.address}</p>
) : null}
</div>
<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> </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
householdId={activeHousehold.id}
location={location}
canManage={isAdmin}
refreshActiveZones={refreshZones}
/>
<StoreAvailableItemsManager
householdId={activeHousehold.id}
store={location}
isAdmin={isAdmin}
/>
</div>
))}
</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>
))} ))}
</div> </div>
)} )}
</section> </section>
{isAdmin ? ( {!isAdmin && activeStore ? (
<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 ? (
<p className="manage-stores-note"> <p className="manage-stores-note">
Household members can manage item defaults. Only owners and admins can manage stores, Household members can manage item defaults. Only owners and admins can manage stores,
locations, zones, and item deletion. locations, zones, and item deletion.

View File

@ -20,10 +20,17 @@ function itemImageSource(item) {
return `data:${mimeType};base64,${item.item_image}`; 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 toast = useActionToast();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [displayItemCount, setDisplayItemCount] = useState(itemCount);
const [zones, setZones] = useState([]); const [zones, setZones] = useState([]);
const [catalogReady, setCatalogReady] = useState(true); const [catalogReady, setCatalogReady] = useState(true);
const [catalogMessage, setCatalogMessage] = useState(""); const [catalogMessage, setCatalogMessage] = useState("");
@ -31,7 +38,12 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [editorItem, setEditorItem] = useState(null); const [editorItem, setEditorItem] = useState(null);
const [showEditor, setShowEditor] = useState(false); 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) => { const loadItems = useCallback(async (search = query) => {
if (!householdId || !store?.id) { if (!householdId || !store?.id) {
@ -80,9 +92,15 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
loadZones(); loadZones();
}, [isOpen, query, loadItems, loadZones]); }, [isOpen, query, loadItems, loadZones]);
useEffect(() => {
setDisplayItemCount(itemCount);
}, [itemCount]);
const closeManager = () => { const closeManager = () => {
setIsOpen(false); setIsOpen(false);
setPendingDeleteItem(null); setDeleteMode(false);
setSelectedDeleteIds(new Set());
setPendingDeleteItems([]);
}; };
const handleUpdate = async (payload) => { const handleUpdate = async (payload) => {
@ -108,6 +126,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
setShowEditor(false); setShowEditor(false);
setEditorItem(null); setEditorItem(null);
await loadItems(query); await loadItems(query);
await refreshStoreCounts?.();
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, "Failed to update store item"); const message = getApiErrorMessage(error, "Failed to update store item");
toast.error("Update store item failed", `Update store item failed: ${message}`); 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 () => { const handleDeleteConfirm = async () => {
if (!pendingDeleteItem) { if (pendingDeleteItems.length === 0) {
return; return;
} }
try { try {
await deleteAvailableItem(householdId, store.id, pendingDeleteItem.item_id); await Promise.all(
toast.success("Deleted store item", `Deleted ${pendingDeleteItem.item_name} from ${store.display_name || store.name}`); pendingDeleteItems.map((item) => deleteAvailableItem(householdId, store.id, item.item_id))
setPendingDeleteItem(null); );
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 loadItems(query);
await refreshStoreCounts?.();
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, "Failed to delete store item"); 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" className="btn-secondary btn-small store-available-items-trigger"
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
> >
Manage Items Manage Items ({displayItemCount})
</button> </button>
{isOpen ? ( {isOpen ? (
@ -186,6 +251,28 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
</button> </button>
</div> </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"> <div className="store-items-modal-body">
{!catalogReady ? ( {!catalogReady ? (
<p className="empty-message">Run the latest database migrations to enable store item management.</p> <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> <p className="empty-message">No household items found for this store yet.</p>
) : ( ) : (
<div className="store-items-table"> <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"> <div className="store-items-table-body">
{items.map((item) => { {items.map((item) => {
const imageSrc = itemImageSource(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 ( 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"> <div className="store-items-table-cell store-items-table-item">
<span className="store-items-mobile-label">Item</span>
<div className="store-available-items-summary"> <div className="store-available-items-summary">
{imageSrc ? ( {imageSrc ? (
<img src={imageSrc} alt="" className="store-available-items-thumb" /> <img src={imageSrc} alt="" className="store-available-items-thumb" />
) : ( ) : (
<span className="store-available-items-thumb store-available-items-thumb-placeholder"> <span
{item.item_name?.slice(0, 1).toUpperCase() || "?"} className="store-available-items-thumb store-available-items-thumb-placeholder"
aria-hidden="true"
>
{"\uD83D\uDCE6"}
</span> </span>
)} )}
<div className="store-available-items-copy"> <div className="store-available-items-copy">
@ -223,38 +324,12 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
</div> </div>
</div> </div>
<div className="store-items-table-cell"> {deleteMode ? (
<span className="store-items-mobile-label">Store Defaults</span> <span className="store-items-delete-indicator" aria-hidden="true">
<span className="store-items-defaults-text"> {isSelectedForDelete ? "✓" : ""}
{details.join(" | ") || "No store defaults set"}
</span> </span>
</div> ) : null}
</button>
<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>
); );
})} })}
</div> </div>
@ -277,15 +352,19 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
/> />
<ConfirmSlideModal <ConfirmSlideModal
isOpen={Boolean(pendingDeleteItem)} isOpen={pendingDeleteItems.length > 0}
title={pendingDeleteItem ? `Delete ${pendingDeleteItem.item_name}?` : "Delete item?"} title={
pendingDeleteItems.length === 1
? `Delete ${pendingDeleteItems[0].item_name}?`
: `Delete ${pendingDeleteItems.length} items?`
}
description={ description={
pendingDeleteItem pendingDeleteItems.length > 0
? `Slide to confirm. This permanently deletes ${pendingDeleteItem.item_name} from ${store.display_name || store.name} for this household, including current list entries and history.` ? `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" confirmLabel={pendingDeleteItems.length === 1 ? "Delete Item" : "Delete Items"}
onClose={() => setPendingDeleteItem(null)} onClose={() => setPendingDeleteItems([])}
onConfirm={handleDeleteConfirm} 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"; 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({ export default function ConfirmBuyModal({
item, item,
onConfirm, onConfirm,
@ -11,6 +20,7 @@ export default function ConfirmBuyModal({
const [quantity, setQuantity] = useState(item.quantity); const [quantity, setQuantity] = useState(item.quantity);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const maxQuantity = item.quantity; const maxQuantity = item.quantity;
const titleId = "confirm-buy-modal-title";
useEffect(() => { useEffect(() => {
setQuantity(item.quantity); setQuantity(item.quantity);
@ -21,17 +31,17 @@ export default function ConfirmBuyModal({
const hasPrev = currentIndex > 0; const hasPrev = currentIndex > 0;
const hasNext = currentIndex < allItems.length - 1; const hasNext = currentIndex < allItems.length - 1;
const handleIncrement = () => { const handleIncrement = useCallback(() => {
if (!isSubmitting && quantity < maxQuantity) { if (isSubmitting) return;
setQuantity((prev) => prev + 1);
}
};
const handleDecrement = () => { setQuantity((prev) => Math.min(maxQuantity, prev + 1));
if (!isSubmitting && quantity > 1) { }, [isSubmitting, maxQuantity]);
setQuantity((prev) => prev - 1);
} const handleDecrement = useCallback(() => {
}; if (isSubmitting) return;
setQuantity((prev) => Math.max(1, prev - 1));
}, [isSubmitting]);
const handleConfirm = async () => { const handleConfirm = async () => {
if (isSubmitting) return; if (isSubmitting) return;
@ -44,21 +54,58 @@ export default function ConfirmBuyModal({
} }
}; };
const handlePrev = () => { const handlePrev = useCallback(() => {
if (isSubmitting) return; if (isSubmitting) return;
if (hasPrev && onNavigate) { if (hasPrev && onNavigate) {
onNavigate(allItems[currentIndex - 1]); onNavigate(allItems[currentIndex - 1]);
} }
}; }, [allItems, currentIndex, hasPrev, isSubmitting, onNavigate]);
const handleNext = () => { const handleNext = useCallback(() => {
if (isSubmitting) return; if (isSubmitting) return;
if (hasNext && onNavigate) { if (hasNext && onNavigate) {
onNavigate(allItems[currentIndex + 1]); 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 const imageUrl = item.item_image && item.image_mime_type
? `data:${item.image_mime_type};base64,${item.item_image}` ? `data:${item.image_mime_type};base64,${item.item_image}`
@ -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"> <div className="confirm-buy-header">
{item.zone && <div className="confirm-buy-zone">{item.zone}</div>} {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>
<div className="confirm-buy-image-section"> <div className="confirm-buy-image-section">
<button <button
type="button"
className="confirm-buy-nav-btn confirm-buy-nav-prev" className="confirm-buy-nav-btn confirm-buy-nav-prev"
onClick={handlePrev} onClick={handlePrev}
style={{ visibility: hasPrev ? "visible" : "hidden" }} style={{ visibility: hasPrev ? "visible" : "hidden" }}
disabled={!hasPrev || isSubmitting} disabled={!hasPrev || isSubmitting}
aria-label="Previous item"
title="Previous item"
> >
{"<"} <ChevronIcon direction="previous" />
</button> </button>
<div className="confirm-buy-image-container"> <div className="confirm-buy-image-container">
{imageUrl ? ( {imageUrl ? (
<img src={imageUrl} alt={item.item_name} className="confirm-buy-image" /> <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> </div>
<button <button
type="button"
className="confirm-buy-nav-btn confirm-buy-nav-next" className="confirm-buy-nav-btn confirm-buy-nav-next"
onClick={handleNext} onClick={handleNext}
style={{ visibility: hasNext ? "visible" : "hidden" }} style={{ visibility: hasNext ? "visible" : "hidden" }}
disabled={!hasNext || isSubmitting} disabled={!hasNext || isSubmitting}
aria-label="Next item"
title="Next item"
> >
{">"} <ChevronIcon direction="next" />
</button> </button>
</div> </div>
<div className="confirm-buy-quantity-section"> <div className="confirm-buy-quantity-section">
<div className="confirm-buy-counter"> <div className="confirm-buy-counter">
<button <button
type="button"
onClick={handleDecrement} onClick={handleDecrement}
className="confirm-buy-counter-btn" className="confirm-buy-counter-btn"
disabled={quantity <= 1 || isSubmitting} disabled={quantity <= 1 || isSubmitting}
aria-label="Decrease quantity"
> >
- -
</button> </button>
@ -121,11 +184,14 @@ export default function ConfirmBuyModal({
value={quantity} value={quantity}
readOnly readOnly
className="confirm-buy-counter-display" className="confirm-buy-counter-display"
aria-label="Quantity to mark bought"
/> />
<button <button
type="button"
onClick={handleIncrement} onClick={handleIncrement}
className="confirm-buy-counter-btn" className="confirm-buy-counter-btn"
disabled={quantity >= maxQuantity || isSubmitting} disabled={quantity >= maxQuantity || isSubmitting}
aria-label="Increase quantity"
> >
+ +
</button> </button>
@ -133,10 +199,10 @@ export default function ConfirmBuyModal({
</div> </div>
<div className="confirm-buy-actions"> <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 Cancel
</button> </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"} {isSubmitting ? "Saving..." : "Mark as Bought"}
</button> </button>
</div> </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 { StoreContext } from '../../context/StoreContext';
import '../../styles/components/StoreTabs.css'; 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() { export default function StoreTabs() {
const { stores, activeStore, setActiveStore, loading } = useContext(StoreContext); 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 ( return (
<div className="store-tabs"> <div className="store-tabs">
<div className="store-tabs-empty"> <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 ( return (
<div className="store-tabs"> <div className="store-tabs">
<div className="store-tabs-container"> <div className="store-tabs-container">
{stores.map(store => ( <button
<button type="button"
key={store.id} className={`store-selector-trigger ${canPickStore ? "is-interactive" : ""}`}
className={`store-tab ${store.id === activeStore?.id ? 'active' : ''}`} onClick={handleStoreTriggerClick}
onClick={() => setActiveStore(store)} disabled={loading}
disabled={loading} aria-haspopup={canPickStore ? "dialog" : undefined}
> aria-expanded={canPickStore ? activePicker === "store" : undefined}
<span className="store-name">{store.display_name || store.name}</span> aria-label={`Store: ${selectedStoreOption.name}`}
</button> >
))} <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> </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> </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 { AuthContext } from './AuthContext';
import { HouseholdContext } from './HouseholdContext'; 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({ export const StoreContext = createContext({
stores: [], stores: [],
activeStore: null, activeStore: null,
@ -42,24 +84,48 @@ export const StoreProvider = ({ children }) => {
useEffect(() => { useEffect(() => {
if (!activeHousehold || stores.length === 0) return; if (!activeHousehold || stores.length === 0) return;
console.log('[StoreContext] Setting active store from:', stores); const householdStoreStorageKey = `activeHouseholdStoreId_${activeHousehold.id}`;
const storageKey = `activeStoreId_${activeHousehold.id}`; const legacyLocationStorageKey = `activeStoreId_${activeHousehold.id}`;
const savedStoreId = localStorage.getItem(storageKey); const savedHouseholdStoreId = localStorage.getItem(householdStoreStorageKey);
if (savedStoreId) { if (savedHouseholdStoreId) {
const store = stores.find(s => String(s.id) === String(savedStoreId)); const store =
chooseLocationByStoredIds(stores, activeHousehold.id, savedHouseholdStoreId) ||
chooseStoreByHouseholdStoreId(stores, savedHouseholdStoreId);
if (store) { if (store) {
console.log('[StoreContext] Found saved store:', store);
setActiveStoreState(store); setActiveStoreState(store);
return; return;
} }
} }
// No saved store or not found, use default or first one const savedLocationId = localStorage.getItem(legacyLocationStorageKey);
const defaultStore = stores.find(s => s.is_default) || stores[0]; if (savedLocationId) {
console.log('[StoreContext] Using store:', defaultStore); 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); 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]); }, [stores, activeHousehold]);
useEffect(() => { useEffect(() => {
@ -76,9 +142,7 @@ export const StoreProvider = ({ children }) => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
console.log('[StoreContext] Loading stores for household:', activeHousehold.id);
const response = await getHouseholdStores(activeHousehold.id); const response = await getHouseholdStores(activeHousehold.id);
console.log('[StoreContext] Loaded stores:', response.data);
setStores(response.data); setStores(response.data);
} catch (err) { } catch (err) {
console.error('[StoreContext] Failed to load stores:', err); console.error('[StoreContext] Failed to load stores:', err);
@ -99,8 +163,13 @@ export const StoreProvider = ({ children }) => {
const setActiveStore = (store) => { const setActiveStore = (store) => {
setActiveStoreState(store); setActiveStoreState(store);
if (store && activeHousehold) { if (store && activeHousehold) {
const storageKey = `activeStoreId_${activeHousehold.id}`; const storageKey = `activeHouseholdStoreId_${activeHousehold.id}`;
localStorage.setItem(storageKey, String(store.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 { .confirm-buy-nav-btn {
width: 35px; width: 40px;
height: 35px; height: 40px;
border: var(--border-width-medium) solid var(--color-primary); border: var(--border-width-medium) solid var(--color-primary);
border-radius: var(--border-radius-full); border-radius: var(--border-radius-full);
background: var(--color-bg-surface); background: var(--color-bg-surface);
color: var(--color-primary); color: var(--color-primary);
font-size: 1.8em;
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
transition: var(--transition-base); transition: var(--transition-base);
@ -70,6 +69,18 @@
flex-shrink: 0; 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) { .confirm-buy-nav-btn:hover:not(:disabled) {
background: var(--color-primary); background: var(--color-primary);
color: var(--color-text-inverse); color: var(--color-text-inverse);
@ -100,8 +111,21 @@
} }
.confirm-buy-image-placeholder { .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); 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 { .confirm-buy-quantity-section {
@ -260,9 +284,8 @@
} }
.confirm-buy-nav-btn { .confirm-buy-nav-btn {
width: 30px; width: 40px;
height: 30px; height: 40px;
font-size: 1.6em;
} }
.confirm-buy-counter-btn { .confirm-buy-counter-btn {
@ -299,8 +322,7 @@
} }
.confirm-buy-nav-btn { .confirm-buy-nav-btn {
width: 28px; width: 40px;
height: 28px; height: 40px;
font-size: 1.4em;
} }
} }

View File

@ -1,78 +1,170 @@
.store-tabs { .store-tabs {
background: var(--surface-color); background: transparent;
border-bottom: 2px solid var(--border-color); border-bottom: none;
margin-bottom: 1.5rem; margin-bottom: 1rem;
} }
.store-tabs-container { .store-tabs-container {
display: flex; display: flex;
justify-content: center; flex-direction: column;
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;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.75rem 1.5rem; padding: 0.5rem 0 0;
background: transparent; width: 100%;
border: none; }
border-bottom: 3px solid transparent;
color: var(--text-secondary); .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-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; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
white-space: nowrap;
text-align: center;
} }
.store-tab:hover { .store-map-button:hover,
color: var(--text-color); .store-map-button:focus-visible {
background: var(--hover-color); border-color: var(--color-primary);
background: var(--color-bg-hover);
outline: none;
} }
.store-tab.active { .store-map-button:disabled {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
}
.store-tab:disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
.store-name { .store-name {
font-weight: 500; font-weight: 500;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.default-badge { .store-selector-caret {
padding: 0.125rem 0.5rem; width: 0.55rem;
background: var(--primary-color-light); height: 0.55rem;
color: var(--primary-color); flex: 0 0 auto;
font-size: 0.75rem; border-right: 2px solid currentColor;
font-weight: 600; border-bottom: 2px solid currentColor;
border-radius: 12px; transform: rotate(45deg) translateY(-2px);
text-transform: uppercase; opacity: 0.8;
letter-spacing: 0.5px;
} }
.store-tabs-empty { .store-tabs-empty {
padding: 1rem; padding: 1rem;
text-align: center; text-align: center;
color: var(--text-secondary); color: var(--color-text-secondary);
font-style: italic; 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 { .manage-section-header h2 {
margin: 0.15rem 0 0; margin: 0;
font-size: 1.2rem; font-size: 1.2rem;
color: var(--text-primary); 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 { .section-description {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.92rem; font-size: 0.92rem;
@ -131,7 +122,7 @@ body.dark-mode .edit-name-form input {
} }
.manage-household-join-policy-toggle { .manage-household-join-policy-toggle {
margin-bottom: 0.2rem; margin-bottom: 0;
} }
.pending-requests-summary { .pending-requests-summary {
@ -243,15 +234,16 @@ body.dark-mode .pending-request-card {
.invite-controls { .invite-controls {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(140px, 180px)) auto; grid-template-columns: 1fr;
gap: 0.8rem; gap: 0.45rem;
align-items: end; align-items: stretch;
} }
.invite-controls label { .invite-controls label {
display: flex; display: grid;
flex-direction: column; grid-template-columns: 4.5rem minmax(0, 1fr);
gap: 0.35rem; gap: 0.75rem;
align-items: center;
color: var(--text-primary); color: var(--text-primary);
font-size: 0.9rem; font-size: 0.9rem;
} }
@ -265,14 +257,19 @@ body.dark-mode .pending-request-card {
} }
.invite-controls select { .invite-controls select {
min-width: 120px; width: 100%;
min-width: 0;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);
padding: 0.7rem 0.75rem; padding: 0.62rem 0.75rem;
background: rgba(255, 255, 255, 1); background: rgba(255, 255, 255, 1);
color: var(--text-primary); color: var(--text-primary);
} }
.invite-controls .btn-primary {
margin-top: 0.2rem;
}
[data-theme="dark"] .invite-controls select, [data-theme="dark"] .invite-controls select,
body.dark-mode .invite-controls select { body.dark-mode .invite-controls select {
background: rgba(12, 19, 30, 0.92); background: rgba(12, 19, 30, 0.92);
@ -377,20 +374,30 @@ body.dark-mode .invite-status-badge.is-used {
.members-list { .members-list {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(270px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
gap: 0.85rem; gap: 0.4rem;
} }
.member-card { .member-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.7rem; gap: 0.3rem;
padding: 0.85rem 1rem; width: 100%;
min-height: 44px;
padding: 0.55rem 0.8rem;
background: rgba(255, 255, 255, 1); background: rgba(255, 255, 255, 1);
border: 1px solid var(--border); 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; transition: all 0.2s;
} }
.member-card-button {
appearance: none;
cursor: pointer;
}
[data-theme="dark"] .member-card, [data-theme="dark"] .member-card,
body.dark-mode .member-card { body.dark-mode .member-card {
background: rgba(12, 19, 30, 0.9); background: rgba(12, 19, 30, 0.9);
@ -408,6 +415,11 @@ body.dark-mode .member-card:hover {
background: rgba(20, 32, 48, 0.98); background: rgba(20, 32, 48, 0.98);
} }
.member-card-button:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 3px;
}
.member-main { .member-main {
min-width: 0; min-width: 0;
} }
@ -415,7 +427,7 @@ body.dark-mode .member-card:hover {
.member-info { .member-info {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.45rem; gap: 0.35rem;
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;
white-space: nowrap; white-space: nowrap;
@ -427,15 +439,15 @@ body.dark-mode .member-card:hover {
text-overflow: ellipsis; text-overflow: ellipsis;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
font-size: 1rem; font-size: 0.95rem;
} }
.member-role { .member-role {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.28rem;
flex: 0 0 auto; flex: 0 0 auto;
font-size: 0.88rem; font-size: 0.82rem;
text-transform: capitalize; text-transform: capitalize;
font-weight: 700; font-weight: 700;
} }
@ -466,16 +478,6 @@ body.dark-mode .member-card:hover {
font-weight: 700; 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 { .member-owner-action {
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12); 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; 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 */
.danger-zone { .danger-zone {
border-color: color-mix(in srgb, var(--danger) 30%, transparent); 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); border-color: color-mix(in srgb, var(--danger) 42%, transparent);
} }
.danger-zone h2, .danger-zone h2 {
.danger-zone .manage-section-eyebrow {
color: var(--danger); color: var(--danger);
} }
@ -559,16 +555,88 @@ body.dark-mode .danger-zone {
cursor: not-allowed; 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 */ /* Responsive */
@media (max-width: 900px) { @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 { .invite-link-card {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@ -616,12 +684,6 @@ body.dark-mode .danger-zone {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.member-actions {
width: 100%;
justify-content: flex-start;
}
.member-actions button,
.pending-request-actions button { .pending-request-actions button {
flex: 1 1 100%; flex: 1 1 100%;
} }

View File

@ -22,6 +22,14 @@
font-size: 1.3rem; font-size: 1.3rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary); 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; margin-bottom: 1rem;
} }
@ -50,11 +58,11 @@
background: var(--background); background: var(--background);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
padding: 1.25rem; padding: 1rem;
transition: all 0.2s; transition: all 0.2s;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 0.75rem;
} }
.store-card:hover { .store-card:hover {
@ -62,16 +70,23 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 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 { .store-info h3 {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
margin: 0 0 0.5rem 0; margin: 0;
} }
.store-location { .store-location {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.9rem; font-size: 0.85rem;
margin: 0; margin: 0;
} }
@ -87,44 +102,65 @@
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.store-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.store-location-list { .store-location-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 0.65rem;
} }
.store-location-row { .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; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem; 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; align-items: center;
} }
.add-location-panel input, .store-items-modal-toolbar.store-location-create-row {
.add-store-panel input, grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
.store-zone-create-row input { }
.add-store-inline input,
.store-management-create-row input,
.store-settings-form input {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
padding: 0.75rem; padding: 0.75rem;
@ -134,99 +170,128 @@
color: var(--text-primary); color: var(--text-primary);
} }
.store-zones-panel { .store-management-row {
display: flex; grid-template-columns: minmax(0, 1fr) auto;
flex-direction: column;
gap: 0.75rem;
} }
.store-zones-content { .store-management-row-with-order {
display: flex; grid-template-columns: auto minmax(0, 1fr);
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--background);
} }
.store-zone-list { .store-management-row-with-order.is-delete-selectable {
display: flex; grid-template-columns: auto minmax(0, 1fr) auto;
flex-direction: column;
gap: 0.5rem;
} }
.store-zone-row { .store-management-row-with-drag {
display: grid; grid-template-columns: auto auto minmax(0, 1fr);
grid-template-columns: 2rem minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
} }
.store-zone-order { .store-management-order {
width: 2rem;
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.85rem; font-size: 0.85rem;
text-align: right; text-align: right;
} }
.store-zone-name { .store-management-name {
min-width: 0; min-width: 0;
color: var(--text-primary); 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; display: flex;
gap: 0.5rem; gap: 0.5rem;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
} }
/* Add Store Panel */ .add-store-inline {
.add-store-panel {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
.available-stores {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: minmax(0, 1fr) auto;
gap: 1rem; gap: 0.5rem;
}
.available-store-card {
background: var(--background);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.25rem;
display: flex;
justify-content: space-between;
align-items: center; align-items: center;
gap: 1rem;
transition: all 0.2s;
} }
.available-store-card:hover { .add-store-inline .btn-primary {
border-color: var(--primary); min-width: 4.5rem;
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;
} }
/* Empty State */ /* Empty State */
@ -239,44 +304,49 @@
/* Responsive */ /* Responsive */
@media (max-width: 600px) { @media (max-width: 600px) {
.stores-list, .stores-list {
.available-stores {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.store-actions { .store-card-header,
width: 100%; .store-location-controls,
} .manage-stores-topline,
.add-store-inline,
.store-actions button { .store-items-modal-toolbar.store-management-create-row,
flex: 1; .store-items-modal-toolbar.store-location-create-row,
} .store-management-row {
.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 {
grid-template-columns: 1fr; 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; 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; justify-content: stretch;
} }
.store-zone-actions button { .store-settings-actions button {
flex: 1; flex: 1;
} }
} }

View File

@ -60,11 +60,35 @@
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 1; z-index: 1;
display: flex;
align-items: center;
gap: var(--spacing-xs);
background: var(--modal-bg); 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 { .store-available-items-search {
flex: 1 1 auto;
width: 100%; width: 100%;
min-width: 0;
padding: var(--input-padding-y) var(--input-padding-x); padding: var(--input-padding-y) var(--input-padding-x);
border: var(--border-width-thin) solid var(--input-border-color); border: var(--border-width-thin) solid var(--input-border-color);
border-radius: var(--input-border-radius); border-radius: var(--input-border-radius);
@ -92,24 +116,20 @@
gap: var(--spacing-sm); gap: var(--spacing-sm);
} }
.store-items-table-head,
.store-items-table-row { .store-items-table-row {
display: grid; 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); gap: var(--spacing-md);
align-items: center; align-items: center;
} }
.store-items-table-head { .store-items-table-row-button {
position: sticky; width: 100%;
top: 0; appearance: none;
padding: 0 var(--spacing-sm) var(--spacing-xs); color: inherit;
background: var(--modal-bg); font: inherit;
color: var(--color-text-secondary); text-align: left;
font-size: var(--font-size-xs); cursor: pointer;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
} }
.store-items-table-body { .store-items-table-body {
@ -125,6 +145,22 @@
background: var(--color-bg-surface); 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 { .store-items-table-cell {
min-width: 0; min-width: 0;
} }
@ -153,8 +189,11 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--color-text-secondary); border: var(--border-width-medium) solid var(--color-border-light);
font-weight: var(--font-weight-semibold); background: var(--color-gray-100);
color: var(--color-border-medium);
font-size: 1.75rem;
line-height: 1;
} }
.store-available-items-copy { .store-available-items-copy {
@ -171,24 +210,27 @@
white-space: nowrap; white-space: nowrap;
} }
.store-items-defaults-text {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.store-items-table-actions { .store-items-table-actions {
justify-self: end; justify-self: end;
} }
.store-available-items-actions { .store-items-delete-indicator {
display: flex; width: 28px;
gap: var(--spacing-xs); height: 28px;
flex-wrap: wrap; display: inline-flex;
justify-content: flex-end; 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 { .store-items-table-row-button.is-selected .store-items-delete-indicator {
display: none; border-color: var(--color-danger);
background: var(--color-danger);
} }
@media (max-width: 720px) { @media (max-width: 720px) {
@ -197,37 +239,21 @@
padding: var(--spacing-md); padding: var(--spacing-md);
} }
.store-items-table-head {
display: none;
}
.store-items-table-row { .store-items-table-row {
display: flex; grid-template-columns: minmax(0, 1fr);
flex-direction: column;
align-items: stretch;
gap: var(--spacing-sm); gap: var(--spacing-sm);
} }
.store-items-mobile-label { .store-items-table-row-button.is-delete-selectable {
display: block; grid-template-columns: minmax(0, 1fr) auto;
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-actions { .store-items-bulk-toolbar {
justify-self: stretch;
}
.store-available-items-actions {
width: 100%; width: 100%;
justify-content: stretch; align-items: stretch;
} }
.store-available-items-actions button { .store-items-bulk-toolbar button {
flex: 1 1 0; 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({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", 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 request = route.request();
const url = new URL(request.url()); const url = new URL(request.url());
const query = (url.searchParams.get("query") || "").toLowerCase(); 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 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") { if (route.request().method() === "PATCH") {
availableItems = availableItems.map((item) => availableItems = availableItems.map((item) =>
item.item_id === 777 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 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") { if (route.request().method() === "DELETE") {
availableItems = availableItems.filter((item) => item.item_id !== 501); availableItems = availableItems.filter((item) => item.item_id !== 501);
await route.fulfill({ 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 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" }); const storeCard = page.locator(".store-card").filter({ hasText: "Costco" });
await expect(storeCard).toBeVisible(); 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"); const managerModal = page.locator(".store-items-modal");
await expect(managerModal).toBeVisible(); await expect(managerModal).toBeVisible();
await expect(managerModal.getByText("milk", { exact: true })).toBeVisible(); await expect(managerModal.getByText("milk", { exact: true })).toBeVisible();
await expect(managerModal.getByText("apples", { 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"); const editorModal = page.locator(".available-item-editor-modal");
await expect(editorModal).toBeVisible(); await expect(editorModal).toBeVisible();
await expect(editorModal.getByLabel("Item Name")).toBeDisabled(); 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 editorModal.getByRole("button", { name: "Save Changes" }).click();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated store item"); 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"); const confirmModal = page.locator(".confirm-slide-modal");
await expect(confirmModal).toBeVisible(); await expect(confirmModal).toBeVisible();
await expect(confirmModal.getByText("Delete milk?")).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); 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 }) => { test("grocery page remains unchanged and does not show a store items picker", async ({ page }) => {
await seedAuthStorage(page); await seedAuthStorage(page);
await mockConfig(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({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", 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({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", 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({ await route.fulfill({
status: 404, status: 404,
contentType: "application/json", 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({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", 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({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", 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({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", 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({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", 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(); const request = route.request();
if (request.method() === "PATCH") { 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({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", 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.goto("/");
await page.locator(".glist-sort").selectOption("qty-high");
await openBuyModal(page, "bread"); await openBuyModal(page, "bread");
await page.getByRole("button", { name: "Mark as Bought" }).click(); 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 }) => { 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.goto("/");
await page.locator(".glist-sort").selectOption("az");
await openBuyModal(page, "milk"); await openBuyModal(page, "milk");
await page.getByRole("button", { name: "Mark as Bought" }).click(); 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.goto("/");
await page.locator(".glist-sort").selectOption("qty-low");
await openBuyModal(page, "bravo"); await openBuyModal(page, "bravo");
await page.locator(".confirm-buy-counter-btn").nth(0).click(); await page.locator(".confirm-buy-counter-btn").nth(0).click();

View File

@ -16,8 +16,12 @@ type HouseholdSeed = {
type StoreSeed = { type StoreSeed = {
id?: number; id?: number;
household_store_id?: number;
household_id?: number;
name?: string; name?: string;
location?: string; location?: string;
location_name?: string;
display_name?: string;
is_default?: boolean; is_default?: boolean;
}; };
@ -57,9 +61,21 @@ export async function mockHouseholdAndStoreShell(
invite_code: "ABCD1234", invite_code: "ABCD1234",
...options.household, ...options.household,
}; };
const stores = options.stores || [ const stores = (options.stores || [
{ id: 10, name: "Costco", location: "Warehouse", is_default: true }, { 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 page.route("**/households", async (route) => {
await route.fulfill({ await route.fulfill({
@ -76,6 +92,14 @@ export async function mockHouseholdAndStoreShell(
body: JSON.stringify(stores), 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) { 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("Approved join request");
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Pending Pal"); 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(); 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"); await page.goto("/manage?tab=household");
const memberCard = page.locator(".member-card").filter({ hasText: "remove-me" }); 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.getByRole("heading", { name: "Remove remove-me?" })).toBeVisible();
await expect(page.getByText("Slide to confirm. They will lose access to this household.")).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({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", 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 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 expect(page.getByText("Transfer ownership to nico-admin?")).toBeVisible();
await confirmSlide(page); 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 seedAuthStorage(page);
await mockConfig(page); await mockConfig(page);
let linkedStoreIds = [10]; let stores = [
const allStores = [ {
{ id: 10, name: "Costco North", location: "North", is_default: true }, id: 10,
{ id: 11, name: "Costco South", location: "South", is_default: false }, household_store_id: 100,
name: "Costco",
location_name: "Default Location",
display_name: "Costco",
is_default: true,
},
]; ];
await page.route("**/households", async (route) => { 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(); const request = route.request();
if (request.method() === "GET") { 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({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify(payload), body: JSON.stringify(stores),
}); });
return; return;
} }
if (request.method() === "POST") { if (request.method() === "POST") {
const body = request.postDataJSON() as { storeId?: number }; const body = request.postDataJSON() as { name?: string };
if (body.storeId && !linkedStoreIds.includes(body.storeId)) { const name = body.name || "New Store";
linkedStoreIds = [...linkedStoreIds, body.storeId]; stores = [
} ...stores,
{
id: 11,
household_store_id: 101,
name,
location_name: "Default Location",
display_name: name,
is_default: false,
},
];
await route.fulfill({ await route.fulfill({
status: 200, status: 201,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ ok: true }), body: JSON.stringify({ store: stores[stores.length - 1] }),
}); });
return; 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({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify(allStores), body: JSON.stringify({ zones: [] }),
}); });
}); });
await page.goto("/manage?tab=stores"); await page.goto("/manage?tab=stores");
await page.getByRole("button", { name: "+ Add Store" }).click(); const addStoreForm = page.locator(".add-store-inline");
await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click(); 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("Created store");
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Costco South"); await expect(page.locator(".action-toast.action-toast-success")).toContainText("Stater Bros");
}); });
test("manage stores add failure shows normalized error toast", async ({ page }) => { test("manage stores add failure shows normalized error toast", async ({ page }) => {
await seedAuthStorage(page); await seedAuthStorage(page);
await mockConfig(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 page.route("**/households", async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, 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(); const request = route.request();
if (request.method() === "GET") { if (request.method() === "GET") {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", 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; return;
} }
@ -150,22 +162,23 @@ test("manage stores add failure shows normalized error toast", async ({ page })
return; 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({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify(allStores), body: JSON.stringify({ zones: [] }),
}); });
}); });
await page.goto("/manage?tab=stores"); await page.goto("/manage?tab=stores");
await page.getByRole("button", { name: "+ Add Store" }).click(); const addStoreForm = page.locator(".add-store-inline");
await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click(); 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"); 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'; import { defineConfig, loadEnv } from 'vite';
const env = loadEnv('', process.cwd()); 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()) ? 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({ export default defineConfig({
plugins: [react()], 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);