@@ -15,20 +77,121 @@ 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 = () => {
+ const locationQuery = selectedLocation?.id ? `&locationId=${selectedLocation.id}` : "";
+ navigate(`/manage?tab=stores${locationQuery}`);
+ };
+
return (
- {stores.map(store => (
-
- ))}
+
+
+
+
+
+
+ {activePicker === "store" ? (
+
setActivePicker(null)}>
+
event.stopPropagation()}
+ >
+
Stores
+
+ {storeOptions.map((option) => (
+
+ ))}
+
+
+
+ ) : 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 26664e7..7a7f22f 100644
--- a/frontend/src/context/StoreContext.jsx
+++ b/frontend/src/context/StoreContext.jsx
@@ -4,6 +4,48 @@ import { getHouseholdStores, getLocationZones } from '../api/stores';
import { AuthContext } from './AuthContext';
import { HouseholdContext } from './HouseholdContext';
+const DEFAULT_LOCATION_NAME = "Default Location";
+
+function getHouseholdStoreKey(store) {
+ return String(store?.household_store_id ?? store?.store_id ?? store?.id ?? "");
+}
+
+function isDefaultLocationName(store) {
+ return store?.location_name === DEFAULT_LOCATION_NAME || store?.display_name === store?.name;
+}
+
+function chooseStoreLocation(locations) {
+ if (!locations || locations.length === 0) return null;
+ return (
+ locations.find(isDefaultLocationName) ||
+ locations.find((store) => store.is_default) ||
+ locations[0]
+ );
+}
+
+function chooseStoreByHouseholdStoreId(stores, householdStoreId) {
+ const matchingLocations = stores.filter(
+ (store) => getHouseholdStoreKey(store) === String(householdStoreId)
+ );
+ return chooseStoreLocation(matchingLocations);
+}
+
+function getLocationStorageKey(householdId, householdStoreId) {
+ return `activeStoreLocationId_${householdId}_${householdStoreId}`;
+}
+
+function chooseLocationByStoredIds(stores, householdId, householdStoreId) {
+ const storageKey = getLocationStorageKey(householdId, householdStoreId);
+ const savedLocationId = localStorage.getItem(storageKey);
+ if (!savedLocationId) return null;
+
+ return stores.find(
+ (store) =>
+ String(store.id) === String(savedLocationId) &&
+ getHouseholdStoreKey(store) === String(householdStoreId)
+ );
+}
+
export const StoreContext = createContext({
stores: [],
activeStore: null,
@@ -43,23 +85,51 @@ 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 =
+ chooseLocationByStoredIds(stores, activeHousehold.id, savedHouseholdStoreId) ||
+ 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 = 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, 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])
+ );
console.log('[StoreContext] Using store:', 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]);
useEffect(() => {
@@ -99,8 +169,13 @@ export const StoreProvider = ({ children }) => {
const setActiveStore = (store) => {
setActiveStoreState(store);
if (store && activeHousehold) {
- const storageKey = `activeStoreId_${activeHousehold.id}`;
- localStorage.setItem(storageKey, String(store.id));
+ const storageKey = `activeHouseholdStoreId_${activeHousehold.id}`;
+ const householdStoreId = getHouseholdStoreKey(store);
+ localStorage.setItem(storageKey, householdStoreId);
+ localStorage.setItem(
+ getLocationStorageKey(activeHousehold.id, householdStoreId),
+ String(store.id)
+ );
}
};
diff --git a/frontend/src/styles/components/StoreTabs.css b/frontend/src/styles/components/StoreTabs.css
index 588a18a..97cf777 100644
--- a/frontend/src/styles/components/StoreTabs.css
+++ b/frontend/src/styles/components/StoreTabs.css
@@ -1,78 +1,170 @@
.store-tabs {
- background: var(--surface-color);
- border-bottom: 2px solid var(--border-color);
- margin-bottom: 1.5rem;
+ background: transparent;
+ border-bottom: none;
+ margin-bottom: 1rem;
}
.store-tabs-container {
display: flex;
- justify-content: center;
- gap: 0.25rem;
- overflow-x: auto;
- padding: 0.5rem 1rem 0;
- width: 100%;
-}
-
-.store-tabs-container::-webkit-scrollbar {
- height: 4px;
-}
-
-.store-tabs-container::-webkit-scrollbar-thumb {
- background: var(--border-color);
- border-radius: 2px;
-}
-
-.store-tab {
- display: flex;
+ flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
- padding: 0.75rem 1.5rem;
- background: transparent;
- border: none;
- border-bottom: 3px solid transparent;
- color: var(--text-secondary);
+ padding: 0.5rem 0 0;
+ width: 100%;
+}
+
+.store-selector-trigger {
+ width: min(100%, 420px);
+ min-height: 44px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ background: var(--color-bg-surface);
+ border: 1px solid var(--color-border-light);
+ border-radius: 8px;
+ color: var(--color-text-primary);
font-size: 1rem;
- font-weight: 500;
+ font-weight: 700;
+ transition: all 0.2s ease;
+ text-align: left;
+}
+
+.store-selector-trigger.is-interactive {
+ cursor: pointer;
+}
+
+.store-selector-trigger.is-interactive:hover,
+.store-selector-trigger.is-interactive:focus-visible {
+ color: var(--color-text-primary);
+ background: var(--color-bg-hover);
+ border-color: var(--color-primary);
+ outline: none;
+}
+
+.store-selector-trigger:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.location-selector-trigger {
+ min-height: 40px;
+ font-size: 0.95rem;
+}
+
+.store-map-button {
+ width: min(100%, 420px);
+ min-height: 38px;
+ padding: 0.6rem 1rem;
+ border: 1px solid var(--color-border-light);
+ border-radius: 8px;
+ background: var(--color-bg-elevated);
+ color: var(--color-text-primary);
+ font: inherit;
+ font-weight: 700;
cursor: pointer;
transition: all 0.2s ease;
- white-space: nowrap;
- text-align: center;
}
-.store-tab:hover {
- color: var(--text-color);
- background: var(--hover-color);
+.store-map-button:hover,
+.store-map-button:focus-visible {
+ border-color: var(--color-primary);
+ background: var(--color-bg-hover);
+ outline: none;
}
-.store-tab.active {
- color: var(--primary-color);
- border-bottom-color: var(--primary-color);
-}
-
-.store-tab:disabled {
+.store-map-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.store-name {
font-weight: 500;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-.default-badge {
- padding: 0.125rem 0.5rem;
- background: var(--primary-color-light);
- color: var(--primary-color);
- font-size: 0.75rem;
- font-weight: 600;
- border-radius: 12px;
- text-transform: uppercase;
- letter-spacing: 0.5px;
+.store-selector-caret {
+ width: 0.55rem;
+ height: 0.55rem;
+ flex: 0 0 auto;
+ border-right: 2px solid currentColor;
+ border-bottom: 2px solid currentColor;
+ transform: rotate(45deg) translateY(-2px);
+ opacity: 0.8;
}
.store-tabs-empty {
padding: 1rem;
text-align: center;
- color: var(--text-secondary);
+ color: var(--color-text-secondary);
font-style: italic;
}
+
+.store-selector-modal-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 1400;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding: 5rem 1rem 1rem;
+ background: rgba(3, 10, 18, 0.58);
+}
+
+.store-selector-modal {
+ width: min(100%, 420px);
+ max-height: min(70vh, 560px);
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ padding: 1rem;
+ border: 1px solid var(--color-border-light);
+ border-radius: 8px;
+ background: var(--color-bg-surface);
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35);
+}
+
+.store-selector-modal h2 {
+ margin: 0;
+ color: var(--color-text-primary);
+ font-size: 1.05rem;
+}
+
+.store-selector-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+}
+
+.store-selector-option {
+ width: 100%;
+ min-height: 44px;
+ padding: 0.75rem 0.9rem;
+ border: 1px solid var(--color-border-light);
+ border-radius: 8px;
+ background: var(--color-bg-elevated);
+ color: var(--color-text-primary);
+ font: inherit;
+ font-weight: 700;
+ text-align: left;
+ cursor: pointer;
+}
+
+.store-selector-option:hover,
+.store-selector-option:focus-visible {
+ border-color: var(--color-primary);
+ background: var(--color-bg-hover);
+ outline: none;
+}
+
+.store-selector-option.active {
+ border-color: var(--color-primary);
+ color: var(--color-primary);
+}
diff --git a/frontend/tests/store-selector.spec.ts b/frontend/tests/store-selector.spec.ts
new file mode 100644
index 0000000..f5102fb
--- /dev/null
+++ b/frontend/tests/store-selector.spec.ts
@@ -0,0 +1,191 @@
+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: 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(/\/manage\?tab=stores&locationId=13$/);
+});
+
+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);
+});