feat: add grocery location selector
This commit is contained in:
parent
b4abcf3380
commit
23df814fb3
@ -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() {
|
||||
<button
|
||||
type="button"
|
||||
className={`store-selector-trigger ${canPickStore ? "is-interactive" : ""}`}
|
||||
onClick={handleTriggerClick}
|
||||
onClick={handleStoreTriggerClick}
|
||||
disabled={loading}
|
||||
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>
|
||||
{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>
|
||||
|
||||
{isPickerOpen ? (
|
||||
<div className="store-selector-modal-overlay" onClick={() => setIsPickerOpen(false)}>
|
||||
{activePicker === "store" ? (
|
||||
<div className="store-selector-modal-overlay" onClick={() => setActivePicker(null)}>
|
||||
<div
|
||||
className="store-selector-modal"
|
||||
role="dialog"
|
||||
@ -110,6 +166,32 @@ export default function StoreTabs() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user