import { useState, useCallback, useMemo } from 'react'; import Layout from 'antd/es/layout'; import Typography from 'antd/es/typography'; import Switch from 'antd/es/switch'; import Row from 'antd/es/row'; import Col from 'antd/es/col'; import Spin from 'antd/es/spin'; import Button from 'antd/es/button'; 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'; import { useCatalogue } from './useCatalogue'; 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, favoritesDimension, priceDimension, tagDimension } from './components/filters/standardDimensions'; import { ItemFilters, filtersFromSearchParams, filtersToSearchParams } from './filters'; import AuthPromptModal from './customer/AuthPromptModal'; import { useThemeMode } from './theme/ThemeContext'; import { useCustomerAuth } from './customer/CustomerAuthContext'; import { useCart } from './cart/CartContext'; import ErrorBoundary from './components/ErrorBoundary'; import ErrorFallback from './components/ErrorFallback'; import DevThrow from './components/DevThrow'; const { Header, Content, Footer } = Layout; 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; onSignIn: () => void; onClearFilters: () => void; onChanged: () => void; }>; // The body of the catalogue: an outage, a sign-in prompt, an empty state, or // the grid. Extracted from App so the four cases read as early returns rather // than a ternary chain, and because a function's cognitive complexity counts // everything nested inside it — leaving this inline is what put App over the // limit. function Catalogue({ failed, loading, items, total, page, pageSize, onPageChange, onPageSizeChange, filtered, needsFavoritesAuth, onRetry, onSignIn, onClearFilters, onChanged }: CatalogueProps) { if (failed) { return ( Retry} /> ); } // Prompted rather than requested: see the effect in App that sets this. if (needsFavoritesAuth) { return ( ); } if (!loading && !items.length) { // Distinguished so "no items match these filters" never reads as an empty // shop, and so the way out is offered only when there is one. return ( {filtered ? : null} ); } return ( <> {items.map(item => ( ))} {/* Not while there is nothing to count. A genuinely empty catalogue returns early above with the empty state, so the only way to reach here with a total of zero is mid-load — and flashing "0 items" at somebody while their catalogue is still arriving says something untrue. */} {total > 0 && ( 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'}`} /> )} ); } // Availability first and always visible, because it is the coarsest cut and // worth seeing without opening anything. Favorites next, so someone who came // for their favorites does not scroll past the catalogue controls. const STOREFRONT_DIMENSIONS = [ availabilityDimension, favoritesDimension, categoryDimension, tagDimension, priceDimension ]; // Hoisted out of the component that used to declare it inline. // // S6478 flags a function-returning-JSX in a prop as "defining a component // during render". Here it is a render prop — ErrorBoundary's `fallback` is // typed `(error: Error) => React.ReactNode` and called as // `this.props.fallback(...)` — so React only ever sees the returned elements, // never a new component type, and the subtree destruction the rule warns about // does not happen. The rule's own message offers `allowAsProps` for exactly // this shape, which cannot be set from here. // // Hoisting rather than suppressing because it costs nothing: these close over // nothing local, so at module level they are one stable function instead of a // new closure per render, which is mildly better and not a contortion. See #181. function catalogueErrorFallback(error: Error) { return ( window.location.reload()}> Reload } /> ); } export default function App() { const [authModalOpen, setAuthModalOpen] = useState(false); const [searchParams, setSearchParams] = useSearchParams(); const location = useLocation(); const { mode, toggle } = useThemeMode(); 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 openAuthModal = useCallback(() => setAuthModalOpen(true), []); 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 // under dozens of history entries. setSearchParams(filtersToSearchParams(next), { replace: true }); }, [setSearchParams] ); const clearFilters = useCallback(() => { setSearchParams(new URLSearchParams(), { replace: true }); }, [setSearchParams]); // The parent needs to know whether anything is filtering — for the empty // state's wording — but has no chip row of its own to count. Through the same // chipsFor call FilterBar's tally goes through, not a second expression over // the same dimensions: those agreed only by convention, which is the defect // #188 exists to remove. const filterContext = { filters, onChange: applyFilters, categories: options?.categories ?? [], tags: options?.tags ?? [], priceRange: options?.priceRange ?? null }; const filtered = chipsFor(STOREFRONT_DIMENSIONS, filterContext).length > 0; return (
{/* Wrapped so the mark and the wordmark travel together — the title keeps its own ellipsis behaviour when the header gets narrow, and the mark is never what gets truncated. */}
Redefined Designs
) : ( <> )}
{loading && !items.length && !failed ? : null} {import.meta.env.DEV && }
Privacy Policy
{/* The same prompt the heart button and Add to Cart use. Signing in resolves the gate above, and the filter then applies on its own — the customer never has to set it a second time. */} setAuthModalOpen(false)} onSuccess={() => setAuthModalOpen(false)} />
); }