From b4abcf3380067304b29eb6d77f461ded6c1903b6 Mon Sep 17 00:00:00 2001 From: Nico Date: Tue, 2 Jun 2026 00:27:46 -0700 Subject: [PATCH] feat: add grocery store selector modal --- frontend/src/components/store/StoreTabs.jsx | 105 +++++++++++-- frontend/src/context/StoreContext.jsx | 62 ++++++-- frontend/src/styles/components/StoreTabs.css | 146 ++++++++++++------ frontend/tests/store-selector.spec.ts | 153 +++++++++++++++++++ 4 files changed, 400 insertions(+), 66 deletions(-) create mode 100644 frontend/tests/store-selector.spec.ts diff --git a/frontend/src/components/store/StoreTabs.jsx b/frontend/src/components/store/StoreTabs.jsx index 59c6691..ff65582 100644 --- a/frontend/src/components/store/StoreTabs.jsx +++ b/frontend/src/components/store/StoreTabs.jsx @@ -1,11 +1,55 @@ -import { useContext } from 'react'; +import { useContext, useMemo, useState } from 'react'; import { StoreContext } from '../../context/StoreContext'; import '../../styles/components/StoreTabs.css'; +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 chooseStoreLocation(locations) { + if (!locations || locations.length === 0) return null; + return ( + locations.find((store) => store.location_name === "Default Location" || store.display_name === store.name) || + locations.find((store) => store.is_default) || + locations[0] + ); +} + +function buildStoreOptions(stores) { + const grouped = new Map(); + + for (const store of stores) { + const key = getStoreKey(store); + if (!grouped.has(key)) { + grouped.set(key, { + key, + name: getStoreName(store), + locations: [], + }); + } + grouped.get(key).locations.push(store); + } + + return Array.from(grouped.values()).map((group) => ({ + ...group, + store: chooseStoreLocation(group.locations), + })); +} + export default function StoreTabs() { const { stores, activeStore, setActiveStore, loading } = useContext(StoreContext); + const [isPickerOpen, setIsPickerOpen] = useState(false); + const storeOptions = useMemo(() => buildStoreOptions(stores || []), [stores]); + const activeStoreKey = getStoreKey(activeStore); + const selectedStoreOption = + storeOptions.find((option) => option.key === activeStoreKey) || storeOptions[0]; + const canPickStore = storeOptions.length > 1 && !loading; - if (!stores || stores.length === 0) { + if (!stores || stores.length === 0 || storeOptions.length === 0) { return (
@@ -15,20 +59,57 @@ export default function StoreTabs() { ); } + const handleTriggerClick = () => { + if (!canPickStore) return; + setIsPickerOpen(true); + }; + + const handleStoreSelect = (store) => { + setActiveStore(store); + setIsPickerOpen(false); + }; + return (
- {stores.map(store => ( - - ))} +
+ + {isPickerOpen ? ( +
setIsPickerOpen(false)}> +
event.stopPropagation()} + > +

Stores

+
+ {storeOptions.map((option) => ( + + ))} +
+
+
+ ) : null}
); } diff --git a/frontend/src/context/StoreContext.jsx b/frontend/src/context/StoreContext.jsx index 26664e7..458b4de 100644 --- a/frontend/src/context/StoreContext.jsx +++ b/frontend/src/context/StoreContext.jsx @@ -4,6 +4,32 @@ import { getHouseholdStores, getLocationZones } from '../api/stores'; import { AuthContext } from './AuthContext'; import { HouseholdContext } from './HouseholdContext'; +const DEFAULT_LOCATION_NAME = "Default Location"; + +function getHouseholdStoreKey(store) { + return String(store?.household_store_id ?? store?.store_id ?? store?.id ?? ""); +} + +function isDefaultLocationName(store) { + return store?.location_name === DEFAULT_LOCATION_NAME || store?.display_name === store?.name; +} + +function chooseStoreLocation(locations) { + if (!locations || locations.length === 0) return null; + return ( + locations.find(isDefaultLocationName) || + locations.find((store) => store.is_default) || + locations[0] + ); +} + +function chooseStoreByHouseholdStoreId(stores, householdStoreId) { + const matchingLocations = stores.filter( + (store) => getHouseholdStoreKey(store) === String(householdStoreId) + ); + return chooseStoreLocation(matchingLocations); +} + export const StoreContext = createContext({ stores: [], activeStore: null, @@ -43,23 +69,39 @@ export const StoreProvider = ({ children }) => { if (!activeHousehold || stores.length === 0) return; console.log('[StoreContext] Setting active store from:', stores); - const storageKey = `activeStoreId_${activeHousehold.id}`; - const savedStoreId = localStorage.getItem(storageKey); + const householdStoreStorageKey = `activeHouseholdStoreId_${activeHousehold.id}`; + const legacyLocationStorageKey = `activeStoreId_${activeHousehold.id}`; + const savedHouseholdStoreId = localStorage.getItem(householdStoreStorageKey); - if (savedStoreId) { - const store = stores.find(s => String(s.id) === String(savedStoreId)); + if (savedHouseholdStoreId) { + const store = chooseStoreByHouseholdStoreId(stores, savedHouseholdStoreId); if (store) { - console.log('[StoreContext] Found saved store:', store); + console.log('[StoreContext] Found saved household store:', store); setActiveStoreState(store); return; } } - // No saved store or not found, use default or first one - const defaultStore = stores.find(s => s.is_default) || stores[0]; + const savedLocationId = localStorage.getItem(legacyLocationStorageKey); + if (savedLocationId) { + const savedLocation = stores.find(s => String(s.id) === String(savedLocationId)); + const store = chooseStoreByHouseholdStoreId(stores, getHouseholdStoreKey(savedLocation)); + if (store) { + console.log('[StoreContext] Migrated saved location to store:', store); + setActiveStoreState(store); + localStorage.setItem(householdStoreStorageKey, getHouseholdStoreKey(store)); + 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]) + ); console.log('[StoreContext] Using store:', defaultStore); setActiveStoreState(defaultStore); - localStorage.setItem(storageKey, defaultStore.id); + localStorage.setItem(householdStoreStorageKey, getHouseholdStoreKey(defaultStore)); }, [stores, activeHousehold]); useEffect(() => { @@ -99,8 +141,8 @@ export const StoreProvider = ({ children }) => { const setActiveStore = (store) => { setActiveStoreState(store); if (store && activeHousehold) { - const storageKey = `activeStoreId_${activeHousehold.id}`; - localStorage.setItem(storageKey, String(store.id)); + const storageKey = `activeHouseholdStoreId_${activeHousehold.id}`; + localStorage.setItem(storageKey, getHouseholdStoreKey(store)); } }; diff --git a/frontend/src/styles/components/StoreTabs.css b/frontend/src/styles/components/StoreTabs.css index 588a18a..c7072f8 100644 --- a/frontend/src/styles/components/StoreTabs.css +++ b/frontend/src/styles/components/StoreTabs.css @@ -1,78 +1,136 @@ .store-tabs { - background: var(--surface-color); - border-bottom: 2px solid var(--border-color); - margin-bottom: 1.5rem; + background: transparent; + border-bottom: none; + margin-bottom: 1rem; } .store-tabs-container { display: flex; justify-content: center; - gap: 0.25rem; - overflow-x: auto; - padding: 0.5rem 1rem 0; + padding: 0.5rem 0 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 { +.store-selector-trigger { + width: min(100%, 420px); + min-height: 44px; display: flex; align-items: center; - justify-content: center; - gap: 0.5rem; - padding: 0.75rem 1.5rem; - background: transparent; - border: none; - border-bottom: 3px solid transparent; - color: var(--text-secondary); + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: var(--color-bg-surface); + border: 1px solid var(--color-border-light); + border-radius: 8px; + color: var(--color-text-primary); font-size: 1rem; - font-weight: 500; - cursor: pointer; + font-weight: 700; transition: all 0.2s ease; - white-space: nowrap; - text-align: center; + text-align: left; } -.store-tab:hover { - color: var(--text-color); - background: var(--hover-color); +.store-selector-trigger.is-interactive { + cursor: pointer; } -.store-tab.active { - color: var(--primary-color); - border-bottom-color: var(--primary-color); +.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-tab:disabled { +.store-selector-trigger:disabled { opacity: 0.6; cursor: not-allowed; } .store-name { font-weight: 500; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.default-badge { - padding: 0.125rem 0.5rem; - background: var(--primary-color-light); - color: var(--primary-color); - font-size: 0.75rem; - font-weight: 600; - border-radius: 12px; - text-transform: uppercase; - letter-spacing: 0.5px; +.store-selector-caret { + width: 0.55rem; + height: 0.55rem; + flex: 0 0 auto; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: rotate(45deg) translateY(-2px); + opacity: 0.8; } .store-tabs-empty { padding: 1rem; text-align: center; - color: var(--text-secondary); + color: var(--color-text-secondary); font-style: italic; } + +.store-selector-modal-overlay { + position: fixed; + inset: 0; + z-index: 1400; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 5rem 1rem 1rem; + background: rgba(3, 10, 18, 0.58); +} + +.store-selector-modal { + width: min(100%, 420px); + max-height: min(70vh, 560px); + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem; + border: 1px solid var(--color-border-light); + border-radius: 8px; + background: var(--color-bg-surface); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35); +} + +.store-selector-modal h2 { + margin: 0; + color: var(--color-text-primary); + font-size: 1.05rem; +} + +.store-selector-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + overflow-y: auto; + overscroll-behavior: contain; +} + +.store-selector-option { + width: 100%; + min-height: 44px; + padding: 0.75rem 0.9rem; + border: 1px solid var(--color-border-light); + border-radius: 8px; + background: var(--color-bg-elevated); + color: var(--color-text-primary); + font: inherit; + font-weight: 700; + text-align: left; + cursor: pointer; +} + +.store-selector-option:hover, +.store-selector-option:focus-visible { + border-color: var(--color-primary); + background: var(--color-bg-hover); + outline: none; +} + +.store-selector-option.active { + border-color: var(--color-primary); + color: var(--color-primary); +} diff --git a/frontend/tests/store-selector.spec.ts b/frontend/tests/store-selector.spec.ts new file mode 100644 index 0000000..e9c8e4f --- /dev/null +++ b/frontend/tests/store-selector.spec.ts @@ -0,0 +1,153 @@ +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>) { + 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/*/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 }); + await mockGroceryShell(page, [ + { + id: 10, + household_store_id: 100, + name: "Costco", + location_name: "Eastvale", + display_name: "Costco - Eastvale", + is_default: true, + }, + { + 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 selector = page.locator(".store-selector-trigger"); + await expect(selector).toBeVisible(); + await expect(selector).toContainText("Costco"); + await expect(page.locator(".store-tabs")).not.toContainText("Eastvale"); + await expect(page.locator(".store-tabs")).not.toContainText("99 Ranch"); + await expect(page.locator(".store-tabs")).not.toContainText("Stater Bros"); + + await selector.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(selector).toContainText("99 Ranch"); + await expect.poll(() => page.evaluate(() => localStorage.getItem("activeHouseholdStoreId_1"))).toBe("200"); + + await selector.click(); + await expect(modal).toBeVisible(); + await page.locator(".store-selector-modal-overlay").click({ position: { x: 4, y: 4 } }); + await expect(modal).toHaveCount(0); +}); + +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 selector = page.locator(".store-selector-trigger"); + await expect(selector).toBeVisible(); + await expect(selector).toContainText("Costco"); + await selector.click(); + await expect(page.getByRole("dialog", { name: "Select store" })).toHaveCount(0); +});