Thirteen of SonarQube's fourteen code smells, with the fourteenth argued as a false positive rather than coded around. Four of these were not stylistic. The Remove button in Cart's List.Item actions array carried no key, so React could not match it across renders and rebuilt it on every cart render. The cart, customer-auth and favorites providers each passed a freshly allocated object as their context value, which re-renders every consumer whenever the provider renders, cart or session unchanged — and these three wrap the entire storefront, so the fan-out was the whole tree. Two of them also rebuilt a Set on every render for good measure. All four now memoized on the values they actually derive from. The four cognitive-complexity findings wanted real restructuring rather than suppression. parseItemFilters splits into one helper per field, leaving the function with the order those helpers run in and the single rule that spans two fields; the order is preserved exactly, because a query wrong in two ways reports the first field and rearranging the calls would change which error a caller sees. adminCategories' PUT extracts the parent resolution — the existence check and the cycle check — into a resolver that returns the refusal rather than sending it. App's four-way render chain becomes a Catalogue component, which also removes two of the nested ternaries. Customers' confirm dialog moves to module level, since a function's cognitive complexity counts everything declared inside it and that dialog branches on `disabling` five times. The rest were mechanical: two more nested ternaries — a status-colour lookup and a pluralisation helper — and one type assertion that asserted the type the expression already had. Left alone: S6478 on CategoryTreeSelect's popupRender. That is antd's render prop, called as a function and spliced in, never mounted as a component type, so the destroy-the-subtree failure the rule describes cannot happen. Marked false positive in SonarQube with that reasoning rather than contorting the component around a rule that misread it. Verified rather than assumed. Backend unit 78 pass, integration 134 pass, end-to-end 83 pass, both workspaces build clean. ESLint warnings drop from 35 to 31 with no new file warning — the same React and SonarJS rules #60 turned on are what surfaced this backlog in the first place. Refs #81 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
291 lines
10 KiB
TypeScript
Executable File
291 lines
10 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';
|
|
|
|
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}
|
|
<Catalogue
|
|
failed={failed}
|
|
loading={loading}
|
|
items={items}
|
|
filters={filters}
|
|
needsFavoritesAuth={needsFavoritesAuth}
|
|
onRetry={handleRetry}
|
|
onSignIn={openAuthModal}
|
|
onClearFilters={clearFilters}
|
|
onChanged={reload}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|