refactor(storefront): extract the catalogue machine from App.tsx as useCatalogue (#98) #153

Merged
bermudalamb merged 2 commits from feature/98-use-catalogue into main 2026-08-24 09:05:16 -05:00
2 changed files with 132 additions and 69 deletions
+8 -69
View File
@@ -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}
+124
View File
@@ -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 };
}