App.tsx was 361 lines, and roughly sixty of them were one cohesive concern with nothing to do with laying out a page: fetching the catalogue for the current filters, debouncing it, and negotiating with the session before it could ask. Six pieces of state, four callbacks and two effects, sharing scope with the header, footer, filter drawer and auth modal that make up the rest of the file. This is the same shape #81 dealt with once. That change took out the rendering half by extracting Catalogue and brought the file under the cognitive-complexity limit. The state half stayed. App keeps the URL as the source of filter truth, because that genuinely belongs to the page: a reload, a shared link and the back button all have to restore the same view. What moves is the request, the debounce, and the auth negotiation — and that last one is the reason this is worth doing. The rule the comments explain at length, that firing before the session resolves would 401 and show an outage banner to someone who is in fact signed in, now has somewhere to live rather than being a pair of derived booleans in a page component. The hook decides when the favorites filter needs a session; the page decides what to do about it, through an onAuthRequired callback, because the prompt is a modal the page owns. The serialise-then-reparse of the filters is kept, with the reason written down rather than left to be rediscovered. Depending on a string is what keeps `load` referentially stable while the filters are value-equal, and `load` is what the debounce effect depends on — depending on the object would give a new `load` every render, restart the debounce each time, and fire a request per keystroke. The alternatives considered were a ref written during render and threading the key through the page, and both are worse than one honest comment. filterKey is returned rather than recomputed by the caller: the page needs exactly that value to reset the catalogue's error boundary, so a crashed grid gets another chance when the filters change. App.tsx is 300 lines. No behaviour change is intended, so the bar is the end-to-end suite unchanged — the storefront listing, the filters, the favorites-requires-sign-in prompt, the failure banner and the boundary reset all pass. Also removes what the extraction left dead in App.tsx: the useEffect import, the debounce constant, and `authLoading`, which existed only to decide whether to fire the request. Refs #98
301 lines
11 KiB
TypeScript
Executable File
301 lines
11 KiB
TypeScript
Executable File
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 Segmented from 'antd/es/segmented';
|
|
import { ShoppingCartOutlined, FilterOutlined } 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 FilterDrawer from './components/FilterDrawer';
|
|
import ActiveFilterChips from './components/ActiveFilterChips';
|
|
import {
|
|
ItemFilters,
|
|
SaleState,
|
|
STOREFRONT_SALE_STATUSES,
|
|
activeFilterCount,
|
|
filtersFromSearchParams,
|
|
filtersToSearchParams,
|
|
hasActiveFilters,
|
|
saleStateFromStatuses
|
|
} 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;
|
|
|
|
interface CatalogueProps {
|
|
failed: boolean;
|
|
loading: boolean;
|
|
items: Item[];
|
|
filters: ItemFilters;
|
|
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,
|
|
filters,
|
|
needsFavoritesAuth,
|
|
onRetry,
|
|
onSignIn,
|
|
onClearFilters,
|
|
onChanged
|
|
}: CatalogueProps) {
|
|
if (failed) {
|
|
return (
|
|
<Alert
|
|
type="error"
|
|
showIcon
|
|
message="Couldn't load items"
|
|
description="The server didn't return the catalogue. This is usually temporary."
|
|
action={<Button size="small" onClick={onRetry}>Retry</Button>}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Prompted rather than requested: see the effect in App that sets this.
|
|
if (needsFavoritesAuth) {
|
|
return (
|
|
<Empty description="Sign in to see the items you have favorited">
|
|
<Button type="primary" onClick={onSignIn}>Sign in</Button>
|
|
<Button style={{ marginInlineStart: 8 }} onClick={onClearFilters}>Browse everything</Button>
|
|
</Empty>
|
|
);
|
|
}
|
|
|
|
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.
|
|
const filtered = hasActiveFilters(filters);
|
|
return (
|
|
<Empty description={filtered ? 'No items match these filters' : 'No items yet — check back soon'}>
|
|
{filtered ? <Button onClick={onClearFilters}>Clear filters</Button> : null}
|
|
</Empty>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
export default function App() {
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
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);
|
|
|
|
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]);
|
|
|
|
const activeCount = activeFilterCount(filters);
|
|
|
|
return (
|
|
<Layout style={{ minHeight: '100vh' }}>
|
|
<Header
|
|
className="site-header"
|
|
style={{
|
|
background: token.colorBgContainer,
|
|
borderBottom: `1px solid ${token.colorBorderSecondary}`
|
|
}}
|
|
>
|
|
{/* 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. */}
|
|
<div className="site-header-brand" style={{ color: token.colorText }}>
|
|
<BrandMark size={28} />
|
|
<Title level={3} className="site-header-title" style={{ color: token.colorText }}>
|
|
Redefined Designs
|
|
</Title>
|
|
</div>
|
|
<div className="site-header-actions">
|
|
<Switch checked={mode === 'dark'} onChange={toggle} checkedChildren="Dark" unCheckedChildren="Light" />
|
|
<Link to="/cart">
|
|
<Badge count={cartItems.length} size="small">
|
|
<Button icon={<ShoppingCartOutlined />} />
|
|
</Badge>
|
|
</Link>
|
|
{/* The account link names the page to render behind the modal, so
|
|
closing it comes back here — filters and all — rather than to a
|
|
default. */}
|
|
{customer ? (
|
|
<Link to="/account" state={{ background: location }}>
|
|
<Button>My Account</Button>
|
|
</Link>
|
|
) : (
|
|
<>
|
|
<Link to="/login" state={{ background: location }}>
|
|
<Button>Log in</Button>
|
|
</Link>
|
|
<Link to="/register" state={{ background: location }}>
|
|
<Button type="primary">Sign up</Button>
|
|
</Link>
|
|
</>
|
|
)}
|
|
</div>
|
|
</Header>
|
|
<Content style={{ padding: 24 }}>
|
|
<div className="filter-bar">
|
|
{/* In the bar rather than inside the drawer, deliberately. The default
|
|
now hides sold pieces, so a customer who never opens the drawer
|
|
would otherwise have no way to know sold items exist — and on a
|
|
one-of-a-kind catalogue the sold pieces are part of the story. */}
|
|
<Segmented
|
|
aria-label="Filter by availability"
|
|
// The fallback matches the server's: the favorites view defaults to
|
|
// everything, so the control must not claim Not Sold while sold
|
|
// favorites are on screen.
|
|
value={saleStateFromStatuses(
|
|
filters.status,
|
|
STOREFRONT_SALE_STATUSES,
|
|
filters.favoritesOnly ? 'all' : 'not-sold'
|
|
)}
|
|
onChange={(value) => {
|
|
const state = value as SaleState;
|
|
applyFilters({
|
|
...filters,
|
|
// Not Sold is the default, so it is stored as "no preference"
|
|
// rather than as an explicit list. That keeps it out of the URL
|
|
// and out of the Filters (N) count, where it would otherwise
|
|
// show as an active filter nobody chose.
|
|
status: state === 'not-sold' ? null : STOREFRONT_SALE_STATUSES[state]
|
|
});
|
|
}}
|
|
options={[
|
|
{ label: 'Not sold', value: 'not-sold' },
|
|
{ label: 'Sold', value: 'sold' },
|
|
{ label: 'All', value: 'all' }
|
|
]}
|
|
/>
|
|
<Button
|
|
icon={<FilterOutlined />}
|
|
onClick={() => setDrawerOpen(true)}
|
|
type={activeCount ? 'primary' : 'default'}
|
|
>
|
|
Filters{activeCount ? ` (${activeCount})` : ''}
|
|
</Button>
|
|
<ActiveFilterChips
|
|
options={options}
|
|
filters={filters}
|
|
onChange={applyFilters}
|
|
onClear={clearFilters}
|
|
/>
|
|
</div>
|
|
|
|
{loading && !items.length && !failed ? <Spin /> : null}
|
|
<ErrorBoundary
|
|
context="catalogue"
|
|
// Lets Clear filters (and any other filter change) recover the grid
|
|
// without a reload: filterKey changes whenever the filters change,
|
|
// which resets the boundary the next time it renders. `?boom=catalogue`
|
|
// is not itself a known filter, so filterKey is unaffected by it and
|
|
// the existing end-to-end assertion that the fallback renders still
|
|
// holds.
|
|
resetKey={filterKey}
|
|
fallback={(error) => (
|
|
<ErrorFallback
|
|
error={error}
|
|
title="The item list didn't load"
|
|
actions={
|
|
<Button type="primary" onClick={() => window.location.reload()}>
|
|
Reload
|
|
</Button>
|
|
}
|
|
/>
|
|
)}
|
|
>
|
|
{import.meta.env.DEV && <DevThrow scope="catalogue" />}
|
|
<Catalogue
|
|
failed={failed}
|
|
loading={loading}
|
|
items={items}
|
|
filters={filters}
|
|
needsFavoritesAuth={needsFavoritesAuth}
|
|
onRetry={retry}
|
|
onSignIn={openAuthModal}
|
|
onClearFilters={clearFilters}
|
|
onChanged={reload}
|
|
/>
|
|
</ErrorBoundary>
|
|
</Content>
|
|
<Footer style={{ textAlign: 'center', background: token.colorBgContainer }}>
|
|
<Link to="/privacy">Privacy Policy</Link>
|
|
</Footer>
|
|
|
|
<FilterDrawer
|
|
open={drawerOpen}
|
|
onClose={() => setDrawerOpen(false)}
|
|
options={options}
|
|
filters={filters}
|
|
onChange={applyFilters}
|
|
onClear={clearFilters}
|
|
resultCount={items.length}
|
|
/>
|
|
|
|
{/* 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. */}
|
|
<AuthPromptModal
|
|
open={authModalOpen}
|
|
onClose={() => setAuthModalOpen(false)}
|
|
onSuccess={() => setAuthModalOpen(false)}
|
|
/>
|
|
</Layout>
|
|
);
|
|
}
|