Files
redefined-designs/frontend/src/App.tsx
T
bermudalamb 91485b6ac1 feat(account): open My Account as a modal over the page behind it (#51)
/account had no header and no links of any kind, so once a customer opened it the only way back to the storefront was the browser's back button or editing the URL.

It is now a modal rendered over whatever the customer was looking at, while staying a real route. Opening it from the header pushes /account and names the current page as the backdrop, so closing returns there with filters intact, and the browser's Back button does the same thing as the close control. Entering /account directly — a bookmark, the link in a verification email, or the redirect after registering — has no page behind it and falls back to the storefront, so closing always lands somewhere real. Keeping it a route means the URL still works: bookmarkable, shareable, and refreshable with the view still open, which the header link and the four post-authentication redirects already depend on.

Deleting an account now clears the session as well. Previously it removed the account server-side and navigated home without touching the auth context, so the header went on offering "My Account" for an account that no longer existed until the next reload. That was always wrong, but the modal makes it visible rather than merely stale, because the storefront is rendered behind and the wrong header is on screen throughout.

The modal body is capped and scrolls, and the order history table scrolls within itself, so the view survives a phone without pushing its own title and close control off-screen.

Also scopes the account switch locator in the favorites spec to the modal, since the storefront now renders behind the account view and has a theme switch of its own, and gives the post-registration wait a realistic timeout — it waits on a bcrypt round-trip rather than a render, and the 5s default was surfacing as a flake on whichever test lost the race under parallel load.

Closes #51
2026-08-18 16:36:20 -05:00

222 lines
8.2 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 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(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(() => {
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}`
}}
>
<Title level={3} className="site-header-title" style={{ color: token.colorText }}>
Redefined Designs
</Title>
<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"><Button>Log in</Button></Link>
<Link to="/register"><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); 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>
);
}