feat(storefront): page the catalogue instead of rendering all of it (#269)
The page number joins the filters in the URL, which is already the single source of truth for what the storefront is showing. That is the whole reason numbered pages were chosen over infinite scroll: a page is a place you can send someone, and a scroll position is not. The page size deliberately does not go there — it is a preference belonging to one person, and putting it in the URL would mean sharing a link to an item also imposed your page size on whoever opened it. Changing a filter returns to page one, and it does so for free: filtersToSearchParams builds a fresh URLSearchParams, so applying filters drops the page parameter while goToPage copies the existing params and keeps the filters. That is behaviour worth having rather than an accident to tidy up — landing on page seven of a two-page result is a state a customer cannot get out of without understanding the URL. The control carries the total, because showing the count was a requirement in its own right and the only count that existed before this was on the filter drawer's "Show N items" button, which is hidden whenever the drawer is closed. It is therefore shown even when everything fits on one page: hiding the control on a single page would hide the count with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+89
-8
@@ -10,6 +10,7 @@ import theme from 'antd/es/theme';
|
||||
import Badge from 'antd/es/badge';
|
||||
import Empty from 'antd/es/empty';
|
||||
import Alert from 'antd/es/alert';
|
||||
import Pagination from 'antd/es/pagination';
|
||||
import { ShoppingCartOutlined } from '@ant-design/icons';
|
||||
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { Item } from './api';
|
||||
@@ -18,6 +19,14 @@ import ItemCard from './components/ItemCard';
|
||||
import BrandMark from './components/BrandMark';
|
||||
import FilterBar from './components/filters/FilterBar';
|
||||
import { chipsFor } from './components/filters/dimension';
|
||||
import {
|
||||
PAGE_SIZE_OPTIONS,
|
||||
clampPage,
|
||||
pageFromSearchParams,
|
||||
pageSlice,
|
||||
readStoredPageSize,
|
||||
writeStoredPageSize
|
||||
} from './pagination';
|
||||
import {
|
||||
availabilityDimension,
|
||||
categoryDimension,
|
||||
@@ -40,7 +49,14 @@ const { Title } = Typography;
|
||||
type CatalogueProps = Readonly<{
|
||||
failed: boolean;
|
||||
loading: boolean;
|
||||
/** One page of items, not the whole result set — see `total`. */
|
||||
items: Item[];
|
||||
/** How many items match the filters altogether, across every page. */
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
filtered: boolean;
|
||||
needsFavoritesAuth: boolean;
|
||||
onRetry: () => void;
|
||||
@@ -58,6 +74,11 @@ function Catalogue({
|
||||
failed,
|
||||
loading,
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
filtered,
|
||||
needsFavoritesAuth,
|
||||
onRetry,
|
||||
@@ -98,13 +119,32 @@ function Catalogue({
|
||||
}
|
||||
|
||||
return (
|
||||
<Row gutter={[20, 20]}>
|
||||
{items.map(item => (
|
||||
<Col key={item.id} xs={24} sm={12} md={8} lg={6}>
|
||||
<ItemCard item={item} onChanged={onChanged} />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
<>
|
||||
<Row gutter={[20, 20]}>
|
||||
{items.map(item => (
|
||||
<Col key={item.id} xs={24} sm={12} md={8} lg={6}>
|
||||
<ItemCard item={item} onChanged={onChanged} />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
<Pagination
|
||||
style={{ marginTop: 24, textAlign: 'center' }}
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onChange={onPageChange}
|
||||
showSizeChanger
|
||||
pageSizeOptions={[...PAGE_SIZE_OPTIONS]}
|
||||
onShowSizeChange={(_current, size) => onPageSizeChange(size)}
|
||||
// Deliberately off: the jump box earns its place on a table of
|
||||
// thousands of rows, not on a catalogue somebody is browsing (#269).
|
||||
showQuickJumper={false}
|
||||
// Shown even when everything fits on one page, because the count is a
|
||||
// requirement in its own right and hiding the control would hide it.
|
||||
hideOnSinglePage={false}
|
||||
showTotal={(count) => `${count} ${count === 1 ? 'item' : 'items'}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -163,6 +203,42 @@ export default function App() {
|
||||
const { items, loading, failed, options, needsFavoritesAuth, reload, retry, filterKey } =
|
||||
useCatalogue(filters, openAuthModal);
|
||||
|
||||
/**
|
||||
* The page size is a preference, not view state, so it lives in storage
|
||||
* rather than in the URL. Putting it in the URL would mean sharing a link to
|
||||
* an item also imposed your page size on whoever opened it, which is not
|
||||
* yours to decide for them. Read through a lazy initialiser so a storage
|
||||
* that throws is not hit on every render.
|
||||
*/
|
||||
const [pageSize, setPageSize] = useState(() =>
|
||||
readStoredPageSize(typeof window === 'undefined' ? null : window.localStorage)
|
||||
);
|
||||
|
||||
const choosePageSize = useCallback((size: number) => {
|
||||
setPageSize(size);
|
||||
writeStoredPageSize(typeof window === 'undefined' ? null : window.localStorage, size);
|
||||
}, []);
|
||||
|
||||
// Clamped against what there actually is, so a shared link to a page that no
|
||||
// longer exists shows the last page rather than an empty grid.
|
||||
const page = clampPage(pageFromSearchParams(searchParams), items.length, pageSize);
|
||||
const visibleItems = useMemo(() => pageSlice(items, page, pageSize), [items, page, pageSize]);
|
||||
|
||||
const goToPage = useCallback(
|
||||
(next: number) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
// Page one is the absence of the parameter, so the plain catalogue URL
|
||||
// stays clean and two links to the same first page are the same string.
|
||||
if (next <= 1) params.delete('page');
|
||||
else params.set('page', String(next));
|
||||
// push, not replace: paging is navigation, and the back button should
|
||||
// return to the page you came from. Filters use replace for the opposite
|
||||
// reason — dragging a slider must not bury the previous view.
|
||||
setSearchParams(params);
|
||||
},
|
||||
[searchParams, setSearchParams]
|
||||
);
|
||||
|
||||
const applyFilters = useCallback(
|
||||
(next: ItemFilters) => {
|
||||
// replace, not push: dragging a slider shouldn't bury the previous page
|
||||
@@ -264,7 +340,12 @@ export default function App() {
|
||||
<Catalogue
|
||||
failed={failed}
|
||||
loading={loading}
|
||||
items={items}
|
||||
items={visibleItems}
|
||||
total={items.length}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
onPageChange={goToPage}
|
||||
onPageSizeChange={choosePageSize}
|
||||
filtered={filtered}
|
||||
needsFavoritesAuth={needsFavoritesAuth}
|
||||
onRetry={retry}
|
||||
|
||||
Reference in New Issue
Block a user