From 23df814fb3d221e8ba39b83fdebe797008ee790c Mon Sep 17 00:00:00 2001 From: Nico Date: Tue, 2 Jun 2026 01:02:25 -0700 Subject: [PATCH] feat: add grocery location selector --- frontend/src/components/store/StoreTabs.jsx | 98 ++++++++++++++++++-- frontend/src/context/StoreContext.jsx | 43 ++++++++- frontend/src/styles/components/StoreTabs.css | 34 +++++++ frontend/tests/store-selector.spec.ts | 60 +++++++++--- 4 files changed, 211 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/store/StoreTabs.jsx b/frontend/src/components/store/StoreTabs.jsx index ff65582..0884d48 100644 --- a/frontend/src/components/store/StoreTabs.jsx +++ b/frontend/src/components/store/StoreTabs.jsx @@ -1,7 +1,10 @@ import { useContext, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { StoreContext } from '../../context/StoreContext'; import '../../styles/components/StoreTabs.css'; +const DEFAULT_LOCATION_NAME = "Default Location"; + function getStoreKey(store) { return String(store?.household_store_id ?? store?.store_id ?? store?.id ?? ""); } @@ -10,6 +13,15 @@ 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 ( @@ -42,12 +54,18 @@ function buildStoreOptions(stores) { export default function StoreTabs() { const { stores, activeStore, setActiveStore, loading } = useContext(StoreContext); - const [isPickerOpen, setIsPickerOpen] = useState(false); + const navigate = useNavigate(); + 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 || storeOptions.length === 0) { return ( @@ -59,14 +77,29 @@ export default function StoreTabs() { ); } - const handleTriggerClick = () => { + const handleStoreTriggerClick = () => { if (!canPickStore) return; - setIsPickerOpen(true); + setActivePicker("store"); + }; + + const handleLocationTriggerClick = () => { + if (!canPickLocation) return; + setActivePicker("location"); }; const handleStoreSelect = (store) => { setActiveStore(store); - setIsPickerOpen(false); + setActivePicker(null); + }; + + const handleLocationSelect = (location) => { + setActiveStore(location); + setActivePicker(null); + }; + + const handleManageMap = () => { + const locationQuery = selectedLocation?.id ? `&locationId=${selectedLocation.id}` : ""; + navigate(`/manage?tab=stores${locationQuery}`); }; return ( @@ -75,18 +108,41 @@ export default function StoreTabs() { + + + + - {isPickerOpen ? ( -
setIsPickerOpen(false)}> + {activePicker === "store" ? ( +
setActivePicker(null)}>
) : null} + + {activePicker === "location" ? ( +
setActivePicker(null)}> +
event.stopPropagation()} + > +

Locations

+
+ {selectedLocations.map((location) => ( + + ))} +
+
+
+ ) : null}
); } diff --git a/frontend/src/context/StoreContext.jsx b/frontend/src/context/StoreContext.jsx index 458b4de..7a7f22f 100644 --- a/frontend/src/context/StoreContext.jsx +++ b/frontend/src/context/StoreContext.jsx @@ -30,6 +30,22 @@ function chooseStoreByHouseholdStoreId(stores, householdStoreId) { return chooseStoreLocation(matchingLocations); } +function getLocationStorageKey(householdId, householdStoreId) { + return `activeStoreLocationId_${householdId}_${householdStoreId}`; +} + +function chooseLocationByStoredIds(stores, householdId, householdStoreId) { + const storageKey = getLocationStorageKey(householdId, householdStoreId); + const savedLocationId = localStorage.getItem(storageKey); + if (!savedLocationId) return null; + + return stores.find( + (store) => + String(store.id) === String(savedLocationId) && + getHouseholdStoreKey(store) === String(householdStoreId) + ); +} + export const StoreContext = createContext({ stores: [], activeStore: null, @@ -74,7 +90,9 @@ export const StoreProvider = ({ children }) => { const savedHouseholdStoreId = localStorage.getItem(householdStoreStorageKey); if (savedHouseholdStoreId) { - const store = chooseStoreByHouseholdStoreId(stores, savedHouseholdStoreId); + const store = + chooseLocationByStoredIds(stores, activeHousehold.id, savedHouseholdStoreId) || + chooseStoreByHouseholdStoreId(stores, savedHouseholdStoreId); if (store) { console.log('[StoreContext] Found saved household store:', store); setActiveStoreState(store); @@ -85,11 +103,16 @@ export const StoreProvider = ({ children }) => { const savedLocationId = localStorage.getItem(legacyLocationStorageKey); if (savedLocationId) { const savedLocation = stores.find(s => String(s.id) === String(savedLocationId)); - const store = chooseStoreByHouseholdStoreId(stores, getHouseholdStoreKey(savedLocation)); + const store = savedLocation || chooseStoreByHouseholdStoreId(stores, getHouseholdStoreKey(savedLocation)); if (store) { console.log('[StoreContext] Migrated saved location to store:', store); + const householdStoreId = getHouseholdStoreKey(store); setActiveStoreState(store); - localStorage.setItem(householdStoreStorageKey, getHouseholdStoreKey(store)); + localStorage.setItem(householdStoreStorageKey, householdStoreId); + localStorage.setItem( + getLocationStorageKey(activeHousehold.id, householdStoreId), + String(store.id) + ); return; } } @@ -101,7 +124,12 @@ export const StoreProvider = ({ children }) => { ); console.log('[StoreContext] Using store:', defaultStore); setActiveStoreState(defaultStore); - localStorage.setItem(householdStoreStorageKey, getHouseholdStoreKey(defaultStore)); + const householdStoreId = getHouseholdStoreKey(defaultStore); + localStorage.setItem(householdStoreStorageKey, householdStoreId); + localStorage.setItem( + getLocationStorageKey(activeHousehold.id, householdStoreId), + String(defaultStore.id) + ); }, [stores, activeHousehold]); useEffect(() => { @@ -142,7 +170,12 @@ export const StoreProvider = ({ children }) => { setActiveStoreState(store); if (store && activeHousehold) { const storageKey = `activeHouseholdStoreId_${activeHousehold.id}`; - localStorage.setItem(storageKey, getHouseholdStoreKey(store)); + const householdStoreId = getHouseholdStoreKey(store); + localStorage.setItem(storageKey, householdStoreId); + localStorage.setItem( + getLocationStorageKey(activeHousehold.id, householdStoreId), + String(store.id) + ); } }; diff --git a/frontend/src/styles/components/StoreTabs.css b/frontend/src/styles/components/StoreTabs.css index c7072f8..97cf777 100644 --- a/frontend/src/styles/components/StoreTabs.css +++ b/frontend/src/styles/components/StoreTabs.css @@ -6,7 +6,10 @@ .store-tabs-container { display: flex; + flex-direction: column; + align-items: center; justify-content: center; + gap: 0.5rem; padding: 0.5rem 0 0; width: 100%; } @@ -46,6 +49,37 @@ cursor: not-allowed; } +.location-selector-trigger { + min-height: 40px; + font-size: 0.95rem; +} + +.store-map-button { + width: min(100%, 420px); + min-height: 38px; + padding: 0.6rem 1rem; + border: 1px solid var(--color-border-light); + border-radius: 8px; + background: var(--color-bg-elevated); + color: var(--color-text-primary); + font: inherit; + font-weight: 700; + cursor: pointer; + transition: all 0.2s ease; +} + +.store-map-button:hover, +.store-map-button:focus-visible { + border-color: var(--color-primary); + background: var(--color-bg-hover); + outline: none; +} + +.store-map-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + .store-name { font-weight: 500; min-width: 0; diff --git a/frontend/tests/store-selector.spec.ts b/frontend/tests/store-selector.spec.ts index e9c8e4f..f5102fb 100644 --- a/frontend/tests/store-selector.spec.ts +++ b/frontend/tests/store-selector.spec.ts @@ -84,6 +84,14 @@ test("grocery store selector shows one selected store and picks from a modal", a 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, @@ -104,14 +112,18 @@ test("grocery store selector shows one selected store and picks from a modal", a await page.goto("/"); - const selector = page.locator(".store-selector-trigger"); - await expect(selector).toBeVisible(); - await expect(selector).toContainText("Costco"); - await expect(page.locator(".store-tabs")).not.toContainText("Eastvale"); + 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 selector.click(); + await storeSelector.click(); const modal = page.getByRole("dialog", { name: "Select store" }); await expect(modal).toBeVisible(); await expect(modal.getByRole("button", { name: "Costco" })).toBeVisible(); @@ -122,13 +134,34 @@ test("grocery store selector shows one selected store and picks from a modal", a await modal.getByRole("button", { name: "99 Ranch" }).click(); await expect(modal).toHaveCount(0); - await expect(selector).toContainText("99 Ranch"); + 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 selector.click(); + 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(/\/manage\?tab=stores&locationId=13$/); }); test("single grocery store selector click does not open picker modal", async ({ page }) => { @@ -145,9 +178,14 @@ test("single grocery store selector click does not open picker modal", async ({ await page.goto("/"); - const selector = page.locator(".store-selector-trigger"); - await expect(selector).toBeVisible(); - await expect(selector).toContainText("Costco"); - await selector.click(); + 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); });