Puts the monogram to the left of "Redefined Designs" in the site header. Inline SVG rather than an <img src="/favicon.svg"> for two reasons. The tile is drawn in currentColor so it follows the antd text token and inverts with the app's own theme switch — the favicon file inverts on prefers-color-scheme, which tracks the operating system and would disagree with the app whenever someone toggles the theme themselves. And the letters are knocked out with a mask rather than painted in the background colour, so the mark sits correctly on any surface instead of carrying a backdrop that only matches this header. The mask id is generated per instance, since two marks on one page would otherwise share an id and the second would reference the first. It is stripped to alphanumerics because React's generated ids contain colons. Marked aria-hidden: the wordmark beside it already says the name, so announcing it again would be noise. Verified by screenshotting the header in both themes rather than by reasoning about the colours.
233 lines
8.7 KiB
TypeScript
Executable File
233 lines
8.7 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;
|
|
|
|
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 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}
|
|
{failed ? (
|
|
<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={() => { setLoading(true); void load(); }}>Retry</Button>}
|
|
/>
|
|
) : needsFavoritesAuth ? (
|
|
<Empty description="Sign in to see the items you have favorited">
|
|
<Button type="primary" onClick={() => setAuthModalOpen(true)}>Sign in</Button>
|
|
<Button style={{ marginInlineStart: 8 }} onClick={clearFilters}>Browse everything</Button>
|
|
</Empty>
|
|
) : !loading && !items.length ? (
|
|
<Empty
|
|
description={
|
|
hasActiveFilters(filters)
|
|
? 'No items match these filters'
|
|
: 'No items yet — check back soon'
|
|
}
|
|
>
|
|
{hasActiveFilters(filters) ? <Button onClick={clearFilters}>Clear filters</Button> : null}
|
|
</Empty>
|
|
) : (
|
|
<Row gutter={[20, 20]}>
|
|
{items.map(item => (
|
|
<Col key={item.id} xs={24} sm={12} md={8} lg={6}>
|
|
<ItemCard item={item} onChanged={reload} />
|
|
</Col>
|
|
))}
|
|
</Row>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|