Merge pull request 'feat(frontend): give order history a page of its own (#121)' (#124) from feature/121-orders-page-impl into main
Reviewed-on: #124
This commit was merged in pull request #124.
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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(/\/$/);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user