Three mount points, so a render error costs the smallest part of the page it can. The catalogue boundary is the one that earns its keep. The likeliest throw in this app is a component rendering data from the API, and the item grid renders the most of it per page — contained there, the header, cart badge, filters and footer all survive, so a customer can still navigate instead of being handed one dead page. The modal boundary exists because the modal-route arrangement couples two independent trees. /account, /login and the rest render as modals over the storefront as a backdrop, so without a boundary between them a throw in Account blanks the storefront behind it and a throw in the storefront takes the open modal with it. One boundary separates them in both directions. Every escape action is a hard navigation rather than a Link. This is worth stating because the obvious implementation is wrong: a boundary does not reset when the route changes, so a Link would change the URL and go on rendering the fallback, which reads as the app being permanently broken. ErrorFallback changed too, outside this change's original scope and for a reason worth recording. antd's Result renders its title as a plain div with no heading semantics, so a page whose entire content is an error message offered a screen-reader user navigating by headings nothing at all to find. The title is now wrapped in Typography.Title. The tests assert a heading role and were right to; the component was what needed fixing, not the assertion. DevThrow throws on ?boom=<scope> and is mounted only behind import.meta.env.DEV, so Rollup drops it from a production build. Checked in both directions rather than trusted: the dev server serves it, and a production bundle greps to zero occurrences of its marker. A gate that is silently always-off looks identical to one that works. Verified: 87 end-to-end tests pass, 4 of them new — each boundary catches rather than blanking, the header survives a catalogue throw, the storefront survives a modal throw, and the report is observed reaching /api/client-errors on the wire rather than assumed. Build clean, lint 0 errors and 31 warnings. Refs #62 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
310 lines
11 KiB
TypeScript
Executable File
310 lines
11 KiB
TypeScript
Executable File
import { useEffect, useState, useCallback, useMemo } from 'react';
|
|
import { Layout, Typography, Switch, Row, Col, Spin, Button, theme, Badge, Empty, Alert } from 'antd';
|
|
import { ShoppingCartOutlined, FilterOutlined } from '@ant-design/icons';
|
|
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
|
import { Item, FilterOptions, fetchItems, fetchFilterOptions } from './api';
|
|
import ItemCard from './components/ItemCard';
|
|
import BrandMark from './components/BrandMark';
|
|
import FilterDrawer from './components/FilterDrawer';
|
|
import ActiveFilterChips from './components/ActiveFilterChips';
|
|
import {
|
|
ItemFilters,
|
|
activeFilterCount,
|
|
filtersFromSearchParams,
|
|
filtersToSearchParams,
|
|
hasActiveFilters
|
|
} 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;
|
|
|
|
// 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;
|
|
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 [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 { 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();
|
|
|
|
// "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 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 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 (
|
|
<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">
|
|
<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"
|
|
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={handleRetry}
|
|
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>
|
|
);
|
|
}
|