Files
redefined-designs/frontend/src/App.tsx
T
bermudalambandClaude Opus 5 5b9cf91cef feat(frontend): mount error boundaries at the root, the item grid and the modals (#62)
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>
2026-08-20 18:02:45 -05:00

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>
);
}