feat(frontend): give order history a page of its own (#121) #124

Merged
bermudalamb merged 1 commits from feature/121-orders-page-impl into main 2026-08-22 10:58:02 -05:00
5 changed files with 227 additions and 29 deletions
Showing only changes of commit 9bb3cc86b6 - Show all commits
+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>
);
}
+5
View File
@@ -19,6 +19,7 @@ import VerifyEmail from './customer/VerifyEmail';
import ForgotPassword from './customer/ForgotPassword';
import ResetPassword from './customer/ResetPassword';
import Cart from './cart/Cart';
import Orders from './customer/Orders';
import { CustomerAuthProvider } from './customer/CustomerAuthContext';
import { CartProvider } from './cart/CartContext';
import { FavoritesProvider } from './customer/FavoritesContext';
@@ -99,6 +100,10 @@ function AppRoutes() {
<Route path="/" element={<App />} />
<Route path="/admin" element={<Admin />} />
<Route path="/cart" element={<Cart />} />
{/* A page rather than a modal route, deliberately: order history is a
list you read, like the cart, not a dialog you dismiss. Adding it to
MODAL_ROUTES would put it back in the 700px box it just left. */}
<Route path="/orders" element={<Orders />} />
<Route path="/privacy" element={<PrivacyPolicy />} />
<Route path="/verify-email" element={<VerifyEmail />} />
</Routes>
+4 -2
View File
@@ -95,7 +95,9 @@ test.describe('My Account opens as a modal', () => {
const modal = accountModal(page);
await expect(modal).toContainText(email);
await expect(modal).toContainText('Order History');
// The orders table lives at /orders now. What the account view still owes
// the customer is a way to reach it.
await expect(modal.getByRole('button', { name: 'View order history' })).toBeVisible();
// Scoped to the modal: the storefront behind it has a theme switch of its
// own, so an unscoped switch locator would be ambiguous.
await expect(modal.getByRole('switch')).toHaveCount(2);
@@ -126,7 +128,7 @@ test.describe('My Account opens as a modal', () => {
// The view is taller than the viewport, so the body scrolls rather than
// pushing the title and close control off-screen.
await expect(modal.getByRole('button', { name: 'Close' })).toBeInViewport();
await expect(modal.getByText('Order History')).toBeVisible();
await expect(modal.getByRole('button', { name: 'View order history' })).toBeVisible();
await closeAccount(page);
await expect(page).toHaveURL(/\/$/);
+71
View File
@@ -0,0 +1,71 @@
import { test, expect, Page } from './fixtures';
const PASSWORD = 'supersecret123';
const uniqueEmail = () => `orders-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
// The generous wait is the same one the other account specs use: registration is
// a bcrypt round-trip rather than a render, and runs past Playwright's 5s
// default when the suite's workers all register at once.
async function registerCustomer(page: Page): Promise<string> {
const email = uniqueEmail();
await page.goto('/register');
await page.getByRole('textbox', { name: 'Email' }).fill(email);
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
await page.getByLabel('Password').fill(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
return email;
}
test.describe('Order history has a page of its own', () => {
test('a signed-out visitor is sent to sign in', async ({ page }) => {
await page.goto('/orders');
await expect(page).toHaveURL(/\/login/, { timeout: 20000 });
});
// A page, not a modal: no dialog, and the storefront is not rendered behind
// it. Putting /orders in MODAL_ROUTES would quietly undo the whole change,
// and this is what would catch it.
test('renders as a page rather than a modal over the storefront', async ({ page }) => {
await registerCustomer(page);
await page.goto('/orders');
await expect(page.getByRole('heading', { name: 'Order History' })).toBeVisible();
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeHidden();
});
test('says so when there are no orders, rather than showing an empty table', async ({ page }) => {
await registerCustomer(page);
await page.goto('/orders');
await expect(page.getByText('No orders yet')).toBeVisible();
await expect(page.getByRole('button', { name: 'Continue Shopping' })).toBeVisible();
});
test('Back to Shop returns to the storefront', async ({ page }) => {
await registerCustomer(page);
await page.goto('/orders');
await page.getByRole('button', { name: 'Back to Shop' }).click();
await expect(page).toHaveURL(/\/$/);
await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible();
});
// The account view is where a customer looks for their orders, so the route
// out of it is the part that has to keep working now the table has gone.
test('My Account links to it', async ({ page }) => {
await registerCustomer(page);
await page.goto('/account');
await page.getByRole('dialog', { name: 'My Account' })
.getByRole('button', { name: 'View order history' }).click();
await expect(page).toHaveURL(/\/orders/);
await expect(page.getByRole('heading', { name: 'Order History' })).toBeVisible();
});
});