diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e82bec4..edbdf92 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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([]); - const [loading, setLoading] = useState(true); - const [failed, setFailed] = useState(false); - const [options, setOptions] = useState(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} diff --git a/frontend/src/useCatalogue.ts b/frontend/src/useCatalogue.ts new file mode 100644 index 0000000..0793b8c --- /dev/null +++ b/frontend/src/useCatalogue.ts @@ -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([]); + const [loading, setLoading] = useState(true); + const [failed, setFailed] = useState(false); + const [options, setOptions] = useState(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 }; +}