Add grocery store and location selectors #19
@ -1,11 +1,73 @@
|
|||||||
import { useContext } 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) {
|
||||||
|
return String(store?.household_store_id ?? store?.store_id ?? store?.id ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
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() {
|
export default function StoreTabs() {
|
||||||
const { stores, activeStore, setActiveStore, loading } = useContext(StoreContext);
|
const { stores, activeStore, setActiveStore, loading } = useContext(StoreContext);
|
||||||
|
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) {
|
if (!stores || stores.length === 0 || storeOptions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="store-tabs">
|
<div className="store-tabs">
|
||||||
<div className="store-tabs-empty">
|
<div className="store-tabs-empty">
|
||||||
@ -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 (
|
return (
|
||||||
<div className="store-tabs">
|
<div className="store-tabs">
|
||||||
<div className="store-tabs-container">
|
<div className="store-tabs-container">
|
||||||
{stores.map(store => (
|
<button
|
||||||
<button
|
type="button"
|
||||||
key={store.id}
|
className={`store-selector-trigger ${canPickStore ? "is-interactive" : ""}`}
|
||||||
className={`store-tab ${store.id === activeStore?.id ? 'active' : ''}`}
|
onClick={handleStoreTriggerClick}
|
||||||
onClick={() => setActiveStore(store)}
|
disabled={loading}
|
||||||
disabled={loading}
|
aria-haspopup={canPickStore ? "dialog" : undefined}
|
||||||
>
|
aria-expanded={canPickStore ? activePicker === "store" : undefined}
|
||||||
<span className="store-name">{store.display_name || store.name}</span>
|
aria-label={`Store: ${selectedStoreOption.name}`}
|
||||||
</button>
|
>
|
||||||
))}
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{activePicker === "store" ? (
|
||||||
|
<div className="store-selector-modal-overlay" onClick={() => setActivePicker(null)}>
|
||||||
|
<div
|
||||||
|
className="store-selector-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Select store"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2>Stores</h2>
|
||||||
|
<div className="store-selector-list">
|
||||||
|
{storeOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.key}
|
||||||
|
type="button"
|
||||||
|
className={`store-selector-option ${option.key === selectedStoreOption.key ? "active" : ""}`}
|
||||||
|
onClick={() => handleStoreSelect(option.store)}
|
||||||
|
>
|
||||||
|
{option.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,48 @@ import { getHouseholdStores, getLocationZones } from '../api/stores';
|
|||||||
import { AuthContext } from './AuthContext';
|
import { AuthContext } from './AuthContext';
|
||||||
import { HouseholdContext } from './HouseholdContext';
|
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({
|
export const StoreContext = createContext({
|
||||||
stores: [],
|
stores: [],
|
||||||
activeStore: null,
|
activeStore: null,
|
||||||
@ -43,23 +85,51 @@ export const StoreProvider = ({ children }) => {
|
|||||||
if (!activeHousehold || stores.length === 0) return;
|
if (!activeHousehold || stores.length === 0) return;
|
||||||
|
|
||||||
console.log('[StoreContext] Setting active store from:', stores);
|
console.log('[StoreContext] Setting active store from:', stores);
|
||||||
const storageKey = `activeStoreId_${activeHousehold.id}`;
|
const householdStoreStorageKey = `activeHouseholdStoreId_${activeHousehold.id}`;
|
||||||
const savedStoreId = localStorage.getItem(storageKey);
|
const legacyLocationStorageKey = `activeStoreId_${activeHousehold.id}`;
|
||||||
|
const savedHouseholdStoreId = localStorage.getItem(householdStoreStorageKey);
|
||||||
|
|
||||||
if (savedStoreId) {
|
if (savedHouseholdStoreId) {
|
||||||
const store = stores.find(s => String(s.id) === String(savedStoreId));
|
const store =
|
||||||
|
chooseLocationByStoredIds(stores, activeHousehold.id, savedHouseholdStoreId) ||
|
||||||
|
chooseStoreByHouseholdStoreId(stores, savedHouseholdStoreId);
|
||||||
if (store) {
|
if (store) {
|
||||||
console.log('[StoreContext] Found saved store:', store);
|
console.log('[StoreContext] Found saved household store:', store);
|
||||||
setActiveStoreState(store);
|
setActiveStoreState(store);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No saved store or not found, use default or first one
|
const savedLocationId = localStorage.getItem(legacyLocationStorageKey);
|
||||||
const defaultStore = stores.find(s => s.is_default) || stores[0];
|
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);
|
console.log('[StoreContext] Using store:', defaultStore);
|
||||||
setActiveStoreState(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]);
|
}, [stores, activeHousehold]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -99,8 +169,13 @@ export const StoreProvider = ({ children }) => {
|
|||||||
const setActiveStore = (store) => {
|
const setActiveStore = (store) => {
|
||||||
setActiveStoreState(store);
|
setActiveStoreState(store);
|
||||||
if (store && activeHousehold) {
|
if (store && activeHousehold) {
|
||||||
const storageKey = `activeStoreId_${activeHousehold.id}`;
|
const storageKey = `activeHouseholdStoreId_${activeHousehold.id}`;
|
||||||
localStorage.setItem(storageKey, String(store.id));
|
const householdStoreId = getHouseholdStoreKey(store);
|
||||||
|
localStorage.setItem(storageKey, householdStoreId);
|
||||||
|
localStorage.setItem(
|
||||||
|
getLocationStorageKey(activeHousehold.id, householdStoreId),
|
||||||
|
String(store.id)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,78 +1,170 @@
|
|||||||
.store-tabs {
|
.store-tabs {
|
||||||
background: var(--surface-color);
|
background: transparent;
|
||||||
border-bottom: 2px solid var(--border-color);
|
border-bottom: none;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-tabs-container {
|
.store-tabs-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
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;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.5rem 0 0;
|
||||||
background: transparent;
|
width: 100%;
|
||||||
border: none;
|
}
|
||||||
border-bottom: 3px solid transparent;
|
|
||||||
color: var(--text-secondary);
|
.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-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;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
white-space: nowrap;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-tab:hover {
|
.store-map-button:hover,
|
||||||
color: var(--text-color);
|
.store-map-button:focus-visible {
|
||||||
background: var(--hover-color);
|
border-color: var(--color-primary);
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-tab.active {
|
.store-map-button:disabled {
|
||||||
color: var(--primary-color);
|
|
||||||
border-bottom-color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-tab:disabled {
|
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-name {
|
.store-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.default-badge {
|
.store-selector-caret {
|
||||||
padding: 0.125rem 0.5rem;
|
width: 0.55rem;
|
||||||
background: var(--primary-color-light);
|
height: 0.55rem;
|
||||||
color: var(--primary-color);
|
flex: 0 0 auto;
|
||||||
font-size: 0.75rem;
|
border-right: 2px solid currentColor;
|
||||||
font-weight: 600;
|
border-bottom: 2px solid currentColor;
|
||||||
border-radius: 12px;
|
transform: rotate(45deg) translateY(-2px);
|
||||||
text-transform: uppercase;
|
opacity: 0.8;
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-tabs-empty {
|
.store-tabs-empty {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-style: italic;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
191
frontend/tests/store-selector.spec.ts
Normal file
191
frontend/tests/store-selector.spec.ts
Normal file
@ -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<Record<string, unknown>>) {
|
||||||
|
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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user