Merge pull request 'refactor(storefront): extract the catalogue machine from App.tsx as useCatalogue (#98)' (#153) from feature/98-use-catalogue into main
Reviewed-on: #153
This commit was merged in pull request #153.
This commit is contained in:
+8
-69
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import Layout from 'antd/es/layout';
|
||||
import Typography from 'antd/es/typography';
|
||||
import Switch from 'antd/es/switch';
|
||||
@@ -13,7 +13,8 @@ import Alert from 'antd/es/alert';
|
||||
import Segmented from 'antd/es/segmented';
|
||||
import { ShoppingCartOutlined, FilterOutlined } from '@ant-design/icons';
|
||||
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { Item, FilterOptions, fetchItems, fetchFilterOptions } from './api';
|
||||
import { Item } from './api';
|
||||
import { useCatalogue } from './useCatalogue';
|
||||
import ItemCard from './components/ItemCard';
|
||||
import BrandMark from './components/BrandMark';
|
||||
import FilterDrawer from './components/FilterDrawer';
|
||||
@@ -39,10 +40,6 @@ import DevThrow from './components/DevThrow';
|
||||
const { Header, Content, Footer } = Layout;
|
||||
const { Title } = Typography;
|
||||
|
||||
// Dragging the price slider fires a change per pixel; without this every one
|
||||
// would become its own request.
|
||||
const FILTER_DEBOUNCE_MS = 250;
|
||||
|
||||
interface CatalogueProps {
|
||||
failed: boolean;
|
||||
loading: boolean;
|
||||
@@ -116,29 +113,22 @@ function Catalogue({
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [options, setOptions] = useState<FilterOptions | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const { mode, toggle } = useThemeMode();
|
||||
const { customer, loading: authLoading } = useCustomerAuth();
|
||||
const { customer } = useCustomerAuth();
|
||||
const { items: cartItems } = useCart();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
// The URL is the single source of truth for filter state, so a reload, a
|
||||
// shared link, and the back button all restore the same view.
|
||||
const filters = useMemo(() => filtersFromSearchParams(searchParams), [searchParams]);
|
||||
const filterKey = filtersToSearchParams(filters).toString();
|
||||
const openAuthModal = useCallback(() => setAuthModalOpen(true), []);
|
||||
|
||||
// "Only my favorites" needs to know who is asking. Until the session has
|
||||
// resolved we hold rather than guess: firing the request early would 401 and
|
||||
// show the outage banner to someone who is in fact signed in.
|
||||
const awaitingAuth = filters.favoritesOnly && authLoading;
|
||||
const needsFavoritesAuth = filters.favoritesOnly && !authLoading && !customer;
|
||||
const { items, loading, failed, options, needsFavoritesAuth, reload, retry, filterKey } =
|
||||
useCatalogue(filters, openAuthModal);
|
||||
|
||||
const applyFilters = useCallback(
|
||||
(next: ItemFilters) => {
|
||||
@@ -153,57 +143,6 @@ export default function App() {
|
||||
setSearchParams(new URLSearchParams(), { replace: true });
|
||||
}, [setSearchParams]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
return fetchItems(filtersFromSearchParams(new URLSearchParams(filterKey)))
|
||||
.then((loaded) => {
|
||||
setItems(loaded);
|
||||
setFailed(false);
|
||||
})
|
||||
// A failed request must never fall through to the empty state: telling a
|
||||
// customer "no items yet" when the server is broken hides the outage and
|
||||
// reads as an empty shop.
|
||||
.catch(() => setFailed(true))
|
||||
.finally(() => setLoading(false));
|
||||
}, [filterKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (awaitingAuth) {
|
||||
setLoading(true);
|
||||
return;
|
||||
}
|
||||
// Prompt instead of requesting. The server would answer 401, and rendering
|
||||
// that as "no items match these filters" would tell a signed-out visitor
|
||||
// they have no favorites rather than that we do not know who they are.
|
||||
if (needsFavoritesAuth) {
|
||||
setItems([]);
|
||||
setFailed(false);
|
||||
setLoading(false);
|
||||
setAuthModalOpen(true);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const timer = setTimeout(() => void load(), FILTER_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [load, awaitingAuth, needsFavoritesAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFilterOptions().then(setOptions).catch(() => setOptions(null));
|
||||
}, []);
|
||||
|
||||
// Adding to cart flips an item to reserved, and the filter options' price
|
||||
// bounds shift as inventory changes.
|
||||
const reload = useCallback(() => {
|
||||
void load();
|
||||
fetchFilterOptions().then(setOptions).catch(() => undefined);
|
||||
}, [load]);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
setLoading(true);
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const openAuthModal = useCallback(() => setAuthModalOpen(true), []);
|
||||
|
||||
const activeCount = activeFilterCount(filters);
|
||||
|
||||
return (
|
||||
@@ -327,7 +266,7 @@ export default function App() {
|
||||
items={items}
|
||||
filters={filters}
|
||||
needsFavoritesAuth={needsFavoritesAuth}
|
||||
onRetry={handleRetry}
|
||||
onRetry={retry}
|
||||
onSignIn={openAuthModal}
|
||||
onClearFilters={clearFilters}
|
||||
onChanged={reload}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Item, FilterOptions, fetchItems, fetchFilterOptions } from './api';
|
||||
import { ItemFilters, filtersFromSearchParams, filtersToSearchParams } from './filters';
|
||||
import { useCustomerAuth } from './customer/CustomerAuthContext';
|
||||
|
||||
/**
|
||||
* Fetching the catalogue for a set of filters.
|
||||
*
|
||||
* This was sixty lines sharing scope with the page that renders a header, a
|
||||
* footer, a filter drawer and an auth modal — six pieces of state, four
|
||||
* callbacks and two effects, none of which are about laying anything out. #81
|
||||
* took the rendering half out by extracting `Catalogue`; this is the state half.
|
||||
*
|
||||
* The page keeps the URL as the source of filter truth, because that genuinely
|
||||
* belongs to it: a reload, a shared link and the back button all have to restore
|
||||
* the same view. What moves here is the request, the debounce, and the session
|
||||
* negotiation the request depends on.
|
||||
*/
|
||||
|
||||
// Dragging the price slider fires a change per pixel; without this every one
|
||||
// would become its own request.
|
||||
const FILTER_DEBOUNCE_MS = 250;
|
||||
|
||||
export interface Catalogue {
|
||||
items: Item[];
|
||||
loading: boolean;
|
||||
/** A failed request, which must never be rendered as an empty catalogue. */
|
||||
failed: boolean;
|
||||
options: FilterOptions | null;
|
||||
/** The favorites filter is set, the session has resolved, and nobody is signed in. */
|
||||
needsFavoritesAuth: boolean;
|
||||
/** Refetch both the items and the filter bounds — after a cart change, say. */
|
||||
reload: () => void;
|
||||
retry: () => void;
|
||||
/**
|
||||
* A stable identity for the current filter set.
|
||||
*
|
||||
* Returned rather than recomputed by the caller because it is already derived
|
||||
* here, and the page needs exactly this value: it resets the catalogue's error
|
||||
* boundary, so changing the filters gives a crashed grid another chance
|
||||
* without a reload.
|
||||
*/
|
||||
filterKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param onAuthRequired Called when the favorites filter is asked for by someone
|
||||
* signed out. The hook decides the moment; the page decides what to do about it,
|
||||
* because the prompt is a modal the page owns. Must be stable, or it re-triggers
|
||||
* the effect below on every render.
|
||||
*/
|
||||
export function useCatalogue(filters: ItemFilters, onAuthRequired: () => void): Catalogue {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [options, setOptions] = useState<FilterOptions | null>(null);
|
||||
|
||||
const { customer, loading: authLoading } = useCustomerAuth();
|
||||
|
||||
// "Only my favorites" needs to know who is asking. Until the session has
|
||||
// resolved we hold rather than guess: firing the request early would 401 and
|
||||
// show the outage banner to someone who is in fact signed in.
|
||||
const awaitingAuth = filters.favoritesOnly && authLoading;
|
||||
const needsFavoritesAuth = filters.favoritesOnly && !authLoading && !customer;
|
||||
|
||||
// Serialised, then parsed back inside `load`. The round trip looks redundant
|
||||
// and is not: depending on a *string* means `load` stays referentially stable
|
||||
// while the filters are value-equal, and `load` is what the debounce effect
|
||||
// below depends on. Depending on the object instead would give a new `load`
|
||||
// every render, restarting the debounce each time and firing a request per
|
||||
// keystroke.
|
||||
const filterKey = filtersToSearchParams(filters).toString();
|
||||
|
||||
const load = useCallback(() => {
|
||||
return fetchItems(filtersFromSearchParams(new URLSearchParams(filterKey)))
|
||||
.then((loaded) => {
|
||||
setItems(loaded);
|
||||
setFailed(false);
|
||||
})
|
||||
// A failed request must never fall through to the empty state: telling a
|
||||
// customer "no items yet" when the server is broken hides the outage and
|
||||
// reads as an empty shop.
|
||||
.catch(() => setFailed(true))
|
||||
.finally(() => setLoading(false));
|
||||
}, [filterKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (awaitingAuth) {
|
||||
setLoading(true);
|
||||
return;
|
||||
}
|
||||
// Prompt instead of requesting. The server would answer 401, and rendering
|
||||
// that as "no items match these filters" would tell a signed-out visitor
|
||||
// they have no favorites rather than that we do not know who they are.
|
||||
if (needsFavoritesAuth) {
|
||||
setItems([]);
|
||||
setFailed(false);
|
||||
setLoading(false);
|
||||
onAuthRequired();
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const timer = setTimeout(() => void load(), FILTER_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [load, awaitingAuth, needsFavoritesAuth, onAuthRequired]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFilterOptions().then(setOptions).catch(() => setOptions(null));
|
||||
}, []);
|
||||
|
||||
// Adding to cart flips an item to reserved, and the filter options' price
|
||||
// bounds shift as inventory changes.
|
||||
const reload = useCallback(() => {
|
||||
void load();
|
||||
fetchFilterOptions().then(setOptions).catch(() => undefined);
|
||||
}, [load]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
setLoading(true);
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return { items, loading, failed, options, needsFavoritesAuth, reload, retry, filterKey };
|
||||
}
|
||||
Reference in New Issue
Block a user