feat: add grocery location selector

This commit is contained in:
Nico 2026-06-02 01:02:25 -07:00
parent b4abcf3380
commit 23df814fb3
4 changed files with 211 additions and 24 deletions

View File

@ -1,7 +1,10 @@
import { useContext, useMemo, useState } from 'react'; import { useContext, useMemo, useState } from 'react';
import { 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) { function getStoreKey(store) {
return String(store?.household_store_id ?? store?.store_id ?? store?.id ?? ""); 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"; 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) { function chooseStoreLocation(locations) {
if (!locations || locations.length === 0) return null; if (!locations || locations.length === 0) return null;
return ( return (
@ -42,12 +54,18 @@ function buildStoreOptions(stores) {
export default function StoreTabs() { export default function StoreTabs() {
const { stores, activeStore, setActiveStore, loading } = useContext(StoreContext); 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 storeOptions = useMemo(() => buildStoreOptions(stores || []), [stores]);
const activeStoreKey = getStoreKey(activeStore); const activeStoreKey = getStoreKey(activeStore);
const selectedStoreOption = const selectedStoreOption =
storeOptions.find((option) => option.key === activeStoreKey) || storeOptions[0]; 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 canPickStore = storeOptions.length > 1 && !loading;
const canPickLocation = selectedLocations.length > 1 && !loading;
if (!stores || stores.length === 0 || storeOptions.length === 0) { if (!stores || stores.length === 0 || storeOptions.length === 0) {
return ( return (
@ -59,14 +77,29 @@ export default function StoreTabs() {
); );
} }
const handleTriggerClick = () => { const handleStoreTriggerClick = () => {
if (!canPickStore) return; if (!canPickStore) return;
setIsPickerOpen(true); setActivePicker("store");
};
const handleLocationTriggerClick = () => {
if (!canPickLocation) return;
setActivePicker("location");
}; };
const handleStoreSelect = (store) => { const handleStoreSelect = (store) => {
setActiveStore(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 ( return (
@ -75,18 +108,41 @@ export default function StoreTabs() {
<button <button
type="button" type="button"
className={`store-selector-trigger ${canPickStore ? "is-interactive" : ""}`} className={`store-selector-trigger ${canPickStore ? "is-interactive" : ""}`}
onClick={handleTriggerClick} onClick={handleStoreTriggerClick}
disabled={loading} disabled={loading}
aria-haspopup={canPickStore ? "dialog" : undefined} aria-haspopup={canPickStore ? "dialog" : undefined}
aria-expanded={canPickStore ? isPickerOpen : undefined} aria-expanded={canPickStore ? activePicker === "store" : undefined}
aria-label={`Store: ${selectedStoreOption.name}`}
> >
<span className="store-name">{selectedStoreOption.name}</span> <span className="store-name">{selectedStoreOption.name}</span>
{canPickStore ? <span className="store-selector-caret" aria-hidden="true" /> : null} {canPickStore ? <span className="store-selector-caret" aria-hidden="true" /> : null}
</button> </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>
{isPickerOpen ? ( {activePicker === "store" ? (
<div className="store-selector-modal-overlay" onClick={() => setIsPickerOpen(false)}> <div className="store-selector-modal-overlay" onClick={() => setActivePicker(null)}>
<div <div
className="store-selector-modal" className="store-selector-modal"
role="dialog" role="dialog"
@ -110,6 +166,32 @@ export default function StoreTabs() {
</div> </div>
</div> </div>
) : null} ) : 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

@ -30,6 +30,22 @@ function chooseStoreByHouseholdStoreId(stores, householdStoreId) {
return chooseStoreLocation(matchingLocations); 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,
@ -74,7 +90,9 @@ export const StoreProvider = ({ children }) => {
const savedHouseholdStoreId = localStorage.getItem(householdStoreStorageKey); const savedHouseholdStoreId = localStorage.getItem(householdStoreStorageKey);
if (savedHouseholdStoreId) { if (savedHouseholdStoreId) {
const store = chooseStoreByHouseholdStoreId(stores, savedHouseholdStoreId); const store =
chooseLocationByStoredIds(stores, activeHousehold.id, savedHouseholdStoreId) ||
chooseStoreByHouseholdStoreId(stores, savedHouseholdStoreId);
if (store) { if (store) {
console.log('[StoreContext] Found saved household store:', store); console.log('[StoreContext] Found saved household store:', store);
setActiveStoreState(store); setActiveStoreState(store);
@ -85,11 +103,16 @@ export const StoreProvider = ({ children }) => {
const savedLocationId = localStorage.getItem(legacyLocationStorageKey); const savedLocationId = localStorage.getItem(legacyLocationStorageKey);
if (savedLocationId) { if (savedLocationId) {
const savedLocation = stores.find(s => String(s.id) === String(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) { if (store) {
console.log('[StoreContext] Migrated saved location to store:', store); console.log('[StoreContext] Migrated saved location to store:', store);
const householdStoreId = getHouseholdStoreKey(store);
setActiveStoreState(store); setActiveStoreState(store);
localStorage.setItem(householdStoreStorageKey, getHouseholdStoreKey(store)); localStorage.setItem(householdStoreStorageKey, householdStoreId);
localStorage.setItem(
getLocationStorageKey(activeHousehold.id, householdStoreId),
String(store.id)
);
return; return;
} }
} }
@ -101,7 +124,12 @@ export const StoreProvider = ({ children }) => {
); );
console.log('[StoreContext] Using store:', defaultStore); console.log('[StoreContext] Using store:', defaultStore);
setActiveStoreState(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]); }, [stores, activeHousehold]);
useEffect(() => { useEffect(() => {
@ -142,7 +170,12 @@ export const StoreProvider = ({ children }) => {
setActiveStoreState(store); setActiveStoreState(store);
if (store && activeHousehold) { if (store && activeHousehold) {
const storageKey = `activeHouseholdStoreId_${activeHousehold.id}`; 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)
);
} }
}; };

View File

@ -6,7 +6,10 @@
.store-tabs-container { .store-tabs-container {
display: flex; display: flex;
flex-direction: column;
align-items: center;
justify-content: center; justify-content: center;
gap: 0.5rem;
padding: 0.5rem 0 0; padding: 0.5rem 0 0;
width: 100%; width: 100%;
} }
@ -46,6 +49,37 @@
cursor: not-allowed; 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 { .store-name {
font-weight: 500; font-weight: 500;
min-width: 0; min-width: 0;

View File

@ -84,6 +84,14 @@ test("grocery store selector shows one selected store and picks from a modal", a
display_name: "Costco - Eastvale", display_name: "Costco - Eastvale",
is_default: true, is_default: true,
}, },
{
id: 13,
household_store_id: 100,
name: "Costco",
location_name: "Fontana",
display_name: "Costco - Fontana",
is_default: false,
},
{ {
id: 11, id: 11,
household_store_id: 200, 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("/"); await page.goto("/");
const selector = page.locator(".store-selector-trigger"); const storeSelector = page.locator(".store-selector-trigger").first();
await expect(selector).toBeVisible(); const locationSelector = page.locator(".location-selector-trigger");
await expect(selector).toContainText("Costco"); await expect(storeSelector).toBeVisible();
await expect(page.locator(".store-tabs")).not.toContainText("Eastvale"); 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("99 Ranch");
await expect(page.locator(".store-tabs")).not.toContainText("Stater Bros"); await expect(page.locator(".store-tabs")).not.toContainText("Stater Bros");
await selector.click(); await storeSelector.click();
const modal = page.getByRole("dialog", { name: "Select store" }); const modal = page.getByRole("dialog", { name: "Select store" });
await expect(modal).toBeVisible(); await expect(modal).toBeVisible();
await expect(modal.getByRole("button", { name: "Costco" })).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 modal.getByRole("button", { name: "99 Ranch" }).click();
await expect(modal).toHaveCount(0); 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("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 expect(modal).toBeVisible();
await page.locator(".store-selector-modal-overlay").click({ position: { x: 4, y: 4 } }); await page.locator(".store-selector-modal-overlay").click({ position: { x: 4, y: 4 } });
await expect(modal).toHaveCount(0); 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 }) => { 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("/"); await page.goto("/");
const selector = page.locator(".store-selector-trigger"); const storeSelector = page.locator(".store-selector-trigger").first();
await expect(selector).toBeVisible(); const locationSelector = page.locator(".location-selector-trigger");
await expect(selector).toContainText("Costco"); await expect(storeSelector).toBeVisible();
await selector.click(); 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 expect(page.getByRole("dialog", { name: "Select store" })).toHaveCount(0);
await locationSelector.click();
await expect(page.getByRole("dialog", { name: "Select location" })).toHaveCount(0);
}); });