feat(frontend): give order history a page of its own (#121)
Linting / lint (pull_request) Successful in 1m37s
SonarQube Analysis / sonarqube (pull_request) Failing after 13m19s

The account modal had accumulated: a profile line, a name form, two collapsed panels for changing email and password, two consent switches, an order table and four controls. The table was the piece that fitted worst, being the only tabular data in a 700px dialog whose body is capped at 70vh. The scroll={{ x: 'max-content' }} already on it was a workaround for being in the wrong container rather than a layout choice.

It moves to /orders, an ordinary page in the same Routes block as /cart and /privacy, rather than another entry in MODAL_ROUTES. Order history is a list you read, like the cart, not a dialog you dismiss. A modal at /account/orders would have been the smaller change and was rejected: it inherits the same width and the same scroll cap, so it moves the table without giving it anything.

The page shell follows Cart.tsx, which is the established shape here: a Layout with a Header carrying Back to Shop and the title, and the same guard sending a signed-out visitor to /login. The account modal keeps a View order history button where the table used to be, because that is where a customer looks for it.

One thing changes rather than moves. The old effect caught a failed load with a toast and left orders as an empty array. The toast faded and the empty table did not, so from then on a customer whose request failed saw exactly what a customer with no orders saw, and the page asserted something false. Loading, failed and empty are now three distinct states, and the failed one carries a Retry: a transient failure would otherwise strand someone on a page that needs a full reload to recover.

OrdersBody sits at module level rather than nested inside Orders(). A function declared inside a component counts toward that component's cognitive complexity, which is what made Customers() hard to bring back under the threshold in #81.

The two assertions in account-modal.spec.ts that looked for the text "Order History" inside the modal are updated to look for the link, not deleted. They were the only coverage that the account view still offers any route to the orders, which is exactly what this change could have silently broken.

Verification, against a real backend and database: five new tests covering the signed-out redirect, the empty state, Back to Shop, the link from My Account, and that the page renders as a page rather than a modal over the storefront - that last one is what would catch /orders being added to MODAL_ROUTES and quietly undoing the change. The full suite goes from 100 to 105 passing with no new failures; the three that fail did so before this branch and fail identically on main. tsc and the production build are clean, ESLint reports no errors.

Closes #121
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 10:53:40 -05:00
parent 6aa633d109
commit 9bb3cc86b6
5 changed files with 227 additions and 29 deletions
+6 -27
View File
@@ -1,19 +1,18 @@
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import Typography from 'antd/es/typography';
import Switch from 'antd/es/switch';
import Button from 'antd/es/button';
import Table from 'antd/es/table';
import Modal from 'antd/es/modal';
import message from 'antd/es/message';
import Space from 'antd/es/space';
import Divider from 'antd/es/divider';
import { useNavigate } from 'react-router-dom';
import { fetchMyOrders, OrderHistoryItem, updateConsent, exportMyData, deleteMyAccount } from './customerApi';
import { updateConsent, exportMyData, deleteMyAccount } from './customerApi';
import { setFavoriteAlerts } from './favoritesApi';
import { useCustomerAuth } from './CustomerAuthContext';
import AccountDetails from './AccountDetails';
const { Title, Text } = Typography;
const { Text } = Typography;
interface Props {
// Supplied by the route, which decides where closing lands: back to the page
@@ -23,13 +22,8 @@ interface Props {
export default function Account({ onClose }: Props) {
const { customer, loading, refresh, logout } = useCustomerAuth();
const [orders, setOrders] = useState<OrderHistoryItem[]>([]);
const navigate = useNavigate();
useEffect(() => {
if (customer) void fetchMyOrders().then(setOrders).catch(() => message.error('Could not load your orders'));
}, [customer]);
useEffect(() => {
if (!loading && !customer) navigate('/login');
}, [loading, customer, navigate]);
@@ -129,26 +123,11 @@ export default function Account({ onClose }: Props) {
</Space>
</div>
<Divider />
<Title level={5}>Order History</Title>
<Table
rowKey="id"
size="small"
dataSource={orders}
pagination={false}
// Scrolls within itself rather than widening the modal past the
// viewport on a phone.
scroll={{ x: 'max-content' }}
columns={[
{ title: 'Item', dataIndex: 'item_name' },
{ title: 'Amount', dataIndex: 'amount_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` },
{ title: 'Processor', dataIndex: 'processor' },
{ title: 'Date', dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleDateString() }
]}
/>
<Divider />
<Space wrap>
{/* Order history is a page of its own now. The link stays here because
this is where a customer looks for it. */}
<Button onClick={() => navigate('/orders')}>View order history</Button>
<Button onClick={exportMyData}>Download my data</Button>
<Button onClick={handleLogout}>Log out</Button>
<Button danger onClick={handleDelete}>Delete my account</Button>
+141
View File
@@ -0,0 +1,141 @@
import { useCallback, useEffect, useState } from 'react';
import Layout from 'antd/es/layout';
import Typography from 'antd/es/typography';
import Table from 'antd/es/table';
import Button from 'antd/es/button';
import Empty from 'antd/es/empty';
import Alert from 'antd/es/alert';
import Spin from 'antd/es/spin';
import Space from 'antd/es/space';
import Tag from 'antd/es/tag';
import theme from 'antd/es/theme';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router-dom';
import { fetchMyOrders, OrderHistoryItem } from './customerApi';
import { useCustomerAuth } from './CustomerAuthContext';
const { Header, Content } = Layout;
const { Title } = Typography;
// Refunded is the one a customer needs to pick out of a column at a glance.
// Anything unrecognised falls through to a plain tag rather than disappearing.
const STATUS_COLORS: Record<string, string> = {
paid: 'green',
refunded: 'orange',
failed: 'red'
};
const COLUMNS = [
{ title: 'Item', dataIndex: 'item_name' },
{
title: 'Amount',
dataIndex: 'amount_cents',
align: 'right' as const,
render: (v: number) => `$${(v / 100).toFixed(2)}`
},
{
title: 'Status',
dataIndex: 'status',
render: (v: string) => <Tag color={STATUS_COLORS[v]}>{v}</Tag>
},
{ title: 'Processor', dataIndex: 'processor' },
{ title: 'Date', dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleDateString() }
];
type BodyProps = Readonly<{
loading: boolean;
error: string | null;
orders: OrderHistoryItem[];
onRetry: () => void;
}>;
// At module level rather than nested in Orders(). A function declared inside a
// component counts toward that component's cognitive complexity, which is what
// made Customers() hard to bring back under the threshold in #81.
function OrdersBody({ loading, error, orders, onRetry }: BodyProps) {
if (loading) return <Spin style={{ margin: 48 }} />;
// A retry rather than an alert alone: a transient failure would otherwise
// strand the customer on a page that needs a full reload to recover.
if (error) {
return (
<Alert
type="error"
showIcon
message="Could not load your orders"
description={error}
action={<Button onClick={onRetry}>Retry</Button>}
/>
);
}
if (orders.length === 0) {
return (
<Space direction="vertical" align="center" style={{ width: '100%', marginTop: 24 }}>
<Empty description="No orders yet" />
<Link to="/"><Button type="primary">Continue Shopping</Button></Link>
</Space>
);
}
return (
<Table
rowKey="id"
dataSource={orders}
pagination={false}
// Still correct on a phone. It is no longer compensating for being in a
// modal narrower than its own content.
scroll={{ x: 'max-content' }}
columns={COLUMNS}
/>
);
}
export default function Orders() {
const { customer, loading: authLoading } = useCustomerAuth();
const [orders, setOrders] = useState<OrderHistoryItem[]>([]);
const [loading, setLoading] = useState(true);
// Held separately from an empty list, because the two used to be
// indistinguishable: a failed load left an empty table behind a toast that
// faded, so the page went on telling the customer they had never ordered
// anything.
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const { token } = theme.useToken();
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setOrders(await fetchMyOrders());
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (customer) void load();
}, [customer, load]);
useEffect(() => {
if (!authLoading && !customer) navigate('/login');
}, [authLoading, customer, navigate]);
return (
<Layout style={{ minHeight: '100vh' }}>
<Header style={{ background: token.colorBgContainer, borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', alignItems: 'center', gap: 16 }}>
<Link to="/">
<Button icon={<ArrowLeftOutlined />}>Back to Shop</Button>
</Link>
<Title level={3} style={{ color: token.colorText, margin: 0 }}>Order History</Title>
</Header>
{/* 960 rather than the account modal's 700: four columns of which one is a
free-text item name, with room to add a fifth without another rethink. */}
<Content style={{ padding: 24, maxWidth: 960, margin: '0 auto', width: '100%' }}>
<OrdersBody loading={authLoading || loading} error={error} orders={orders} onRetry={load} />
</Content>
</Layout>
);
}