feat(frontend): give order history a page of its own (#121) #124
@@ -1,19 +1,18 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
import Typography from 'antd/es/typography';
|
import Typography from 'antd/es/typography';
|
||||||
import Switch from 'antd/es/switch';
|
import Switch from 'antd/es/switch';
|
||||||
import Button from 'antd/es/button';
|
import Button from 'antd/es/button';
|
||||||
import Table from 'antd/es/table';
|
|
||||||
import Modal from 'antd/es/modal';
|
import Modal from 'antd/es/modal';
|
||||||
import message from 'antd/es/message';
|
import message from 'antd/es/message';
|
||||||
import Space from 'antd/es/space';
|
import Space from 'antd/es/space';
|
||||||
import Divider from 'antd/es/divider';
|
import Divider from 'antd/es/divider';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { setFavoriteAlerts } from './favoritesApi';
|
||||||
import { useCustomerAuth } from './CustomerAuthContext';
|
import { useCustomerAuth } from './CustomerAuthContext';
|
||||||
import AccountDetails from './AccountDetails';
|
import AccountDetails from './AccountDetails';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
// Supplied by the route, which decides where closing lands: back to the page
|
// 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) {
|
export default function Account({ onClose }: Props) {
|
||||||
const { customer, loading, refresh, logout } = useCustomerAuth();
|
const { customer, loading, refresh, logout } = useCustomerAuth();
|
||||||
const [orders, setOrders] = useState<OrderHistoryItem[]>([]);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (customer) void fetchMyOrders().then(setOrders).catch(() => message.error('Could not load your orders'));
|
|
||||||
}, [customer]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && !customer) navigate('/login');
|
if (!loading && !customer) navigate('/login');
|
||||||
}, [loading, customer, navigate]);
|
}, [loading, customer, navigate]);
|
||||||
@@ -129,26 +123,11 @@ export default function Account({ onClose }: Props) {
|
|||||||
</Space>
|
</Space>
|
||||||
</div>
|
</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 />
|
<Divider />
|
||||||
<Space wrap>
|
<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={exportMyData}>Download my data</Button>
|
||||||
<Button onClick={handleLogout}>Log out</Button>
|
<Button onClick={handleLogout}>Log out</Button>
|
||||||
<Button danger onClick={handleDelete}>Delete my account</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 ForgotPassword from './customer/ForgotPassword';
|
||||||
import ResetPassword from './customer/ResetPassword';
|
import ResetPassword from './customer/ResetPassword';
|
||||||
import Cart from './cart/Cart';
|
import Cart from './cart/Cart';
|
||||||
|
import Orders from './customer/Orders';
|
||||||
import { CustomerAuthProvider } from './customer/CustomerAuthContext';
|
import { CustomerAuthProvider } from './customer/CustomerAuthContext';
|
||||||
import { CartProvider } from './cart/CartContext';
|
import { CartProvider } from './cart/CartContext';
|
||||||
import { FavoritesProvider } from './customer/FavoritesContext';
|
import { FavoritesProvider } from './customer/FavoritesContext';
|
||||||
@@ -99,6 +100,10 @@ function AppRoutes() {
|
|||||||
<Route path="/" element={<App />} />
|
<Route path="/" element={<App />} />
|
||||||
<Route path="/admin" element={<Admin />} />
|
<Route path="/admin" element={<Admin />} />
|
||||||
<Route path="/cart" element={<Cart />} />
|
<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="/privacy" element={<PrivacyPolicy />} />
|
||||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ test.describe('My Account opens as a modal', () => {
|
|||||||
|
|
||||||
const modal = accountModal(page);
|
const modal = accountModal(page);
|
||||||
await expect(modal).toContainText(email);
|
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
|
// Scoped to the modal: the storefront behind it has a theme switch of its
|
||||||
// own, so an unscoped switch locator would be ambiguous.
|
// own, so an unscoped switch locator would be ambiguous.
|
||||||
await expect(modal.getByRole('switch')).toHaveCount(2);
|
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
|
// The view is taller than the viewport, so the body scrolls rather than
|
||||||
// pushing the title and close control off-screen.
|
// pushing the title and close control off-screen.
|
||||||
await expect(modal.getByRole('button', { name: 'Close' })).toBeInViewport();
|
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 closeAccount(page);
|
||||||
await expect(page).toHaveURL(/\/$/);
|
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