Files
redefined-designs/frontend/src/customer/Orders.tsx
T
bermudalambandClaude Opus 5 39c82ff3a4
Linting / lint (pull_request) Successful in 2m13s
SonarQube Analysis / sonarqube (pull_request) Failing after 16m22s
fix(orders): mark a demo order in the history rather than leaving it to read as real (#205)
#195 and #203 made the cart say a demo order is a demo order. That message is an antd toast lasting about three seconds, after which the cart empties and the card unmounts. Order history is what the customer comes back to when they wonder where their item is, and it said nothing.

A demo row was a real row: item name, `$80.00`, status `completed` rendered as a neutral tag because `STATUS_COLORS` has no `completed` key, and `demo` printed raw under a heading reading "Processor". That is not an explanation — a customer has no reason to read `demo` as "this did not happen", and "processor" is not a word they have any reason to know.

Two things now say it, for the same reason the cart needed two. The `demo` cell renders as a tag reading "Demo (not charged)", which marks *which* order. A notice above the table, shown only when there is one, says what that means — a tag reading "Demo" still assumes the reader knows what a demo order is, and what they actually want to know is whether to expect a parcel.

Nothing changes on the backend: `orders.processor = 'demo'` was already written at checkout and already selected for this page. The row is a real row in a real table and stays visible, because hiding it would be its own kind of lie — the customer did do something, and it did have an effect on the catalogue.

Written test-first against a browser: the new case drives a real demo purchase through the cart, opens `/orders`, and failed on both assertions before the change.

Verified: 4 end-to-end tests in this spec pass, the 5 existing order-history tests still pass, frontend build clean, lint 0 errors (2 pre-existing warnings in `src/filters.ts`).

Closes #205

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 12:22:39 -05:00

170 lines
5.9 KiB
TypeScript

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',
// `demo` used to render as a raw column value, which is not an explanation:
// a customer has no reason to read it as "this did not happen", and the row
// was otherwise identical to a real one — real price, `completed` status,
// same neutral tag. The cart says it is a demo (#195, #203) for about three
// seconds; this is the record they come back to. See #205.
render: (v: string) => (v === 'demo' ? <Tag color="warning">Demo (not charged)</Tag> : v)
},
{ 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>}
/>
);
}
// Shown only when there is one to explain. The per-row tag says which order,
// this says what it means — a tag reading "Demo" still assumes the reader
// knows what a demo order is, and the thing they actually want to know is
// whether to expect a parcel.
const hasDemoOrder = orders.some(o => o.processor === 'demo');
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 (
<>
{hasDemoOrder && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="Some of these are demo orders"
description="A demo order is a pretend one: nothing was charged, and nothing will be shipped."
/>
)}
<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);
}
}, []);
// Load-on-mount once the session resolves, with a pending flag.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
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>
);
}