diff --git a/frontend/src/customer/Account.tsx b/frontend/src/customer/Account.tsx index 281c9f3..9156c7f 100755 --- a/frontend/src/customer/Account.tsx +++ b/frontend/src/customer/Account.tsx @@ -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([]); 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) { - - Order History - `$${(v / 100).toFixed(2)}` }, - { title: 'Processor', dataIndex: 'processor' }, - { title: 'Date', dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleDateString() } - ]} - /> - + {/* Order history is a page of its own now. The link stays here because + this is where a customer looks for it. */} + diff --git a/frontend/src/customer/Orders.tsx b/frontend/src/customer/Orders.tsx new file mode 100644 index 0000000..8322bbf --- /dev/null +++ b/frontend/src/customer/Orders.tsx @@ -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 = { + 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) => {v} + }, + { 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 ; + + // 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 ( + Retry} + /> + ); + } + + if (orders.length === 0) { + return ( + + + + + ); + } + + return ( +
+ ); +} + +export default function Orders() { + const { customer, loading: authLoading } = useCustomerAuth(); + const [orders, setOrders] = useState([]); + 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(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 ( + +
+ + + + Order History +
+ {/* 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. */} + + + +
+ ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 273675d..d760310 100755 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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() { } /> } /> } /> + {/* 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. */} + } /> } /> } /> diff --git a/frontend/tests/e2e/account-modal.spec.ts b/frontend/tests/e2e/account-modal.spec.ts index f490bb5..0b277cb 100644 --- a/frontend/tests/e2e/account-modal.spec.ts +++ b/frontend/tests/e2e/account-modal.spec.ts @@ -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(/\/$/); diff --git a/frontend/tests/e2e/orders.spec.ts b/frontend/tests/e2e/orders.spec.ts new file mode 100644 index 0000000..81a3b4d --- /dev/null +++ b/frontend/tests/e2e/orders.spec.ts @@ -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 { + 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(); + }); +});