fix(admin): theme, American English, and inventory/reservation tooling (#27) #30

Merged
bermudalamb merged 1 commits from fix/admin-theme-and-filters into main 2026-08-17 15:57:00 -05:00
22 changed files with 1027 additions and 65 deletions
+24 -1
View File
@@ -9,8 +9,16 @@ export interface ItemFilters {
tagIds: number[]; tagIds: number[];
minPriceCents: number | null; minPriceCents: number | null;
maxPriceCents: number | null; maxPriceCents: number | null;
status: ItemStatus | null;
} }
export type ItemStatus = 'available' | 'reserved' | 'sold';
// Matched exactly, not case-insensitively: `items.status` only ever holds these
// lowercase values, so accepting 'Reserved' would quietly return nothing rather
// than reporting that the filter was wrong.
const ITEM_STATUSES: readonly string[] = ['available', 'reserved', 'sold'];
export interface BuiltFilter { export interface BuiltFilter {
clauses: string[]; clauses: string[];
params: unknown[]; params: unknown[];
@@ -90,7 +98,16 @@ export function parseItemFilters(query: Record<string, unknown>): ItemFilters {
throw new FilterError('min_price may not exceed max_price'); throw new FilterError('min_price may not exceed max_price');
} }
return { categoryId, tagIds, minPriceCents, maxPriceCents }; const statusRaw = singleValue(query.status, 'status');
let status: ItemStatus | null = null;
if (statusRaw !== null && statusRaw !== '') {
if (!ITEM_STATUSES.includes(statusRaw)) {
throw new FilterError('invalid status');
}
status = statusRaw as ItemStatus;
}
return { categoryId, tagIds, minPriceCents, maxPriceCents, status };
} }
// Returns WHERE fragments plus their parameters, with placeholders numbered // Returns WHERE fragments plus their parameters, with placeholders numbered
@@ -141,5 +158,11 @@ export function buildItemFilterSql(filters: ItemFilters, startIndex: number): Bu
next++; next++;
} }
if (filters.status !== null) {
params.push(filters.status);
clauses.push(`i.status = $${next}`);
next++;
}
return { clauses, params }; return { clauses, params };
} }
+20 -3
View File
@@ -5,6 +5,8 @@ import { randomUUID } from 'crypto';
import { PoolClient } from 'pg'; import { PoolClient } from 'pg';
import { pool } from '../db'; import { pool } from '../db';
import { ADMIN_ITEM_SELECT } from '../itemSelect'; import { ADMIN_ITEM_SELECT } from '../itemSelect';
import { asyncRoute } from '../asyncRoute';
import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters';
import { tagColorFor } from '../utils'; import { tagColorFor } from '../utils';
const router = Router(); const router = Router();
@@ -111,10 +113,25 @@ async function setItemTags(client: PoolClient, itemId: number, tagIds: number[])
} }
} }
router.get('/items', async (_req: Request, res: Response) => { router.get('/items', asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query(`${ADMIN_ITEM_SELECT} ORDER BY i.created_at DESC`); // Same parser and query builder as the storefront, so admin filtering cannot
// drift from what customers see. The one addition is `status`, which is how
// the Inventory tab surfaces Reserved.
let filters;
try {
filters = parseItemFilters(req.query as Record<string, unknown>);
} catch (err) {
if (err instanceof FilterError) {
return res.status(400).json({ error: err.message });
}
throw err;
}
const { clauses, params } = buildItemFilterSql(filters, 1);
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
const { rows } = await pool.query(`${ADMIN_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params);
res.json(rows); res.json(rows);
}); }));
router.post('/items', uploadImages, async (req: Request, res: Response) => { router.post('/items', uploadImages, async (req: Request, res: Response) => {
const { name, description, price } = req.body; const { name, description, price } = req.body;
+61 -5
View File
@@ -1,24 +1,80 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { pool } from '../db'; import { pool } from '../db';
import { asyncRoute } from '../asyncRoute';
const router = Router(); const router = Router();
router.get('/', async (_req: Request, res: Response) => { router.get('/', asyncRoute(async (_req: Request, res: Response) => {
const { rows } = await pool.query(` const { rows } = await pool.query(`
SELECT SELECT
c.id, c.email, c.name, c.email_verified, c.marketing_consent, c.created_at, c.id, c.email, c.name, c.email_verified, c.marketing_consent, c.created_at,
COUNT(o.id) FILTER (WHERE o.status = 'completed') AS order_count, COUNT(o.id) FILTER (WHERE o.status = 'completed') AS order_count,
COALESCE(SUM(o.amount_cents) FILTER (WHERE o.status = 'completed'), 0) AS total_spent_cents, COALESCE(SUM(o.amount_cents) FILTER (WHERE o.status = 'completed'), 0) AS total_spent_cents,
MAX(o.created_at) AS last_order_at MAX(o.created_at) AS last_order_at,
-- Counted with a subquery rather than another LEFT JOIN: joining a second
-- one-to-many relation alongside orders would multiply the rows and
-- inflate order_count and total_spent_cents.
(SELECT COUNT(*)::int
FROM cart_items ci
JOIN carts ca ON ca.id = ci.cart_id
JOIN items i ON i.id = ci.item_id
WHERE ca.customer_id = c.id AND i.status = 'reserved') AS reserved_count
FROM customers c FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id GROUP BY c.id
ORDER BY c.created_at DESC ORDER BY c.created_at DESC
`); `);
res.json(rows); res.json(rows);
}); }));
router.get('/:id', async (req: Request, res: Response) => { router.get('/:id/reserved', asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query(
`SELECT i.id AS item_id, i.name, i.price_cents, ci.added_at, ci.expires_at
FROM cart_items ci
JOIN carts ca ON ca.id = ci.cart_id
JOIN items i ON i.id = ci.item_id
WHERE ca.customer_id = $1 AND i.status = 'reserved'
ORDER BY ci.added_at`,
[req.params.id]
);
res.json(rows);
}));
// Mirrors the customer's own cart removal: drop the cart row and return the
// item to available. Deliberately no email — this is an action the customer
// did not take, and an unprompted "we removed your item" invites confusion.
router.post('/:id/reserved/:itemId/release', asyncRoute(async (req: Request, res: Response) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
`DELETE FROM cart_items ci
USING carts ca
WHERE ci.cart_id = ca.id AND ca.customer_id = $1 AND ci.item_id = $2
RETURNING ci.item_id`,
[req.params.id, req.params.itemId]
);
if (!rows.length) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'that customer is not holding this item' });
}
// Guarded on 'reserved' so releasing never resurrects a sold item.
await client.query(
`UPDATE items SET status = 'available', reserved_until = NULL
WHERE id = $1 AND status = 'reserved'`,
[req.params.itemId]
);
await client.query('COMMIT');
res.status(204).end();
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}));
router.get('/:id', asyncRoute(async (req: Request, res: Response) => {
const { rows: customerRows } = await pool.query( const { rows: customerRows } = await pool.query(
`SELECT id, email, name, email_verified, marketing_consent, marketing_consent_at, created_at `SELECT id, email, name, email_verified, marketing_consent, marketing_consent_at, created_at
FROM customers WHERE id = $1`, FROM customers WHERE id = $1`,
@@ -35,6 +91,6 @@ router.get('/:id', async (req: Request, res: Response) => {
); );
res.json({ customer: customerRows[0], orders: orderRows }); res.json({ customer: customerRows[0], orders: orderRows });
}); }));
export default router; export default router;
@@ -0,0 +1,230 @@
import request from 'supertest';
import app from '../../src/app';
import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb';
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await pool.end();
await closeDb();
});
async function createCategory(name: string, parentId: number | null = null): Promise<number> {
const res = await request(app).post('/api/admin/categories').send({ name, parent_id: parentId });
expect(res.status).toBe(201);
return res.body.id;
}
async function createTag(name: string): Promise<number> {
const res = await request(app).post('/api/admin/tags').send({ name });
expect(res.status).toBe(201);
return res.body.id;
}
async function createItem(
name: string,
priceCents: number,
options: { categoryId?: number | null; tagIds?: number[]; status?: string } = {}
): Promise<number> {
const { rows } = await pool.query(
`INSERT INTO items (name, price_cents, category_id, status) VALUES ($1, $2, $3, $4) RETURNING id`,
[name, priceCents, options.categoryId ?? null, options.status ?? 'available']
);
const itemId = rows[0].id;
for (const tagId of options.tagIds ?? []) {
await pool.query(`INSERT INTO item_tags (item_id, tag_id) VALUES ($1, $2)`, [itemId, tagId]);
}
return itemId;
}
async function registerCustomer(email: string) {
const agent = request.agent(app);
await agent.post('/api/customers/register').send({ email, password: 'supersecret123' });
const { rows } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [email]);
return { agent, id: rows[0].id as number };
}
const names = (body: { name: string }[]) => body.map(item => item.name).sort();
describe('GET /api/admin/items filtering', () => {
it('returns every item when nothing is filtered', async () => {
await createItem('A', 1000);
await createItem('B', 2000, { status: 'sold' });
const res = await request(app).get('/api/admin/items');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
});
it('filters by status, which is how Reserved is surfaced', async () => {
await createItem('Free', 1000, { status: 'available' });
await createItem('Held', 1000, { status: 'reserved' });
await createItem('Gone', 1000, { status: 'sold' });
const res = await request(app).get('/api/admin/items?status=reserved');
expect(names(res.body)).toEqual(['Held']);
});
it('matches a category and all of its descendants', async () => {
const furniture = await createCategory('Furniture');
const tables = await createCategory('Tables', furniture);
const decor = await createCategory('Decor');
await createItem('Nested', 1000, { categoryId: tables });
await createItem('Top', 1000, { categoryId: furniture });
await createItem('Elsewhere', 1000, { categoryId: decor });
const res = await request(app).get(`/api/admin/items?category=${furniture}`);
expect(names(res.body)).toEqual(['Nested', 'Top']);
});
it('requires every listed tag rather than any of them', async () => {
const vintage = await createTag('vintage');
const oak = await createTag('oak');
await createItem('Both', 1000, { tagIds: [vintage, oak] });
await createItem('One', 1000, { tagIds: [vintage] });
const res = await request(app).get(`/api/admin/items?tags=${vintage},${oak}`);
expect(names(res.body)).toEqual(['Both']);
});
it('bounds the price range inclusively', async () => {
await createItem('Under', 900);
await createItem('Edge', 1000);
await createItem('Over', 5100);
const res = await request(app).get('/api/admin/items?min_price=1000&max_price=5000');
expect(names(res.body)).toEqual(['Edge']);
});
it('combines every filter with AND', async () => {
const furniture = await createCategory('Furniture');
const tables = await createCategory('Tables', furniture);
const vintage = await createTag('vintage');
await createItem('Match', 3000, { categoryId: tables, tagIds: [vintage], status: 'reserved' });
await createItem('Wrong status', 3000, { categoryId: tables, tagIds: [vintage], status: 'available' });
await createItem('Wrong category', 3000, { tagIds: [vintage], status: 'reserved' });
await createItem('Wrong price', 9000, { categoryId: tables, tagIds: [vintage], status: 'reserved' });
const res = await request(app).get(
`/api/admin/items?category=${furniture}&tags=${vintage}&min_price=1000&max_price=5000&status=reserved`
);
expect(names(res.body)).toEqual(['Match']);
});
it('rejects an unknown status rather than returning everything', async () => {
await createItem('A', 1000);
const res = await request(app).get('/api/admin/items?status=pending');
expect(res.status).toBe(400);
});
it('still returns admin-only columns alongside the filters', async () => {
await createItem('A', 1000, { status: 'reserved' });
const res = await request(app).get('/api/admin/items?status=reserved');
expect(res.body[0]).toHaveProperty('reserved_until');
expect(res.body[0]).toHaveProperty('tags');
});
});
describe('admin customer reservations', () => {
it('reports how many items each customer is holding', async () => {
const itemId = await createItem('Held', 1000);
const { agent, id } = await registerCustomer('holder@example.com');
await agent.post(`/api/cart/items/${itemId}`);
const res = await request(app).get('/api/admin/customers');
const customer = res.body.find((c: { id: number }) => c.id === id);
expect(Number(customer.reserved_count)).toBe(1);
});
it('reports zero for a customer holding nothing', async () => {
const { id } = await registerCustomer('empty@example.com');
const res = await request(app).get('/api/admin/customers');
const customer = res.body.find((c: { id: number }) => c.id === id);
expect(Number(customer.reserved_count)).toBe(0);
});
it('lists the items a customer is holding', async () => {
const itemId = await createItem('Oak table', 34000);
const { agent, id } = await registerCustomer('lister@example.com');
await agent.post(`/api/cart/items/${itemId}`);
const res = await request(app).get(`/api/admin/customers/${id}/reserved`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].name).toBe('Oak table');
expect(res.body[0].item_id).toBe(itemId);
expect(res.body[0].price_cents).toBe(34000);
expect(res.body[0].expires_at).toBeTruthy();
});
it('returns an empty list for a customer holding nothing', async () => {
const { id } = await registerCustomer('nothing@example.com');
const res = await request(app).get(`/api/admin/customers/${id}/reserved`);
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it('releasing an item returns it to available and empties the cart row', async () => {
const itemId = await createItem('Oak table', 34000);
const { agent, id } = await registerCustomer('release@example.com');
await agent.post(`/api/cart/items/${itemId}`);
const res = await request(app).post(`/api/admin/customers/${id}/reserved/${itemId}/release`);
expect(res.status).toBe(204);
const item = await request(app).get(`/api/items/${itemId}`);
expect(item.body.status).toBe('available');
const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM cart_items WHERE item_id = $1`, [itemId]);
expect(rows[0].n).toBe(0);
});
it('a released item stops counting against the customer', async () => {
const itemId = await createItem('Oak table', 34000);
const { agent, id } = await registerCustomer('recount@example.com');
await agent.post(`/api/cart/items/${itemId}`);
await request(app).post(`/api/admin/customers/${id}/reserved/${itemId}/release`);
const res = await request(app).get('/api/admin/customers');
const customer = res.body.find((c: { id: number }) => c.id === id);
expect(Number(customer.reserved_count)).toBe(0);
});
it('a released item can be reserved again by someone else', async () => {
const itemId = await createItem('Oak table', 34000);
const { agent: first, id } = await registerCustomer('first@example.com');
await first.post(`/api/cart/items/${itemId}`);
await request(app).post(`/api/admin/customers/${id}/reserved/${itemId}/release`);
const { agent: second } = await registerCustomer('second@example.com');
const res = await second.post(`/api/cart/items/${itemId}`);
expect(res.status).toBe(201);
});
it('refuses to release an item the customer is not holding', async () => {
const itemId = await createItem('Not theirs', 1000);
const { id } = await registerCustomer('other@example.com');
const res = await request(app).post(`/api/admin/customers/${id}/reserved/${itemId}/release`);
expect(res.status).toBe(404);
});
it('does not touch a sold item when releasing', async () => {
const itemId = await createItem('Sold out', 1000);
const { agent, id } = await registerCustomer('sold@example.com');
await agent.post(`/api/cart/items/${itemId}`);
await pool.query(`UPDATE items SET status = 'sold' WHERE id = $1`, [itemId]);
await request(app).post(`/api/admin/customers/${id}/reserved/${itemId}/release`);
const { rows } = await pool.query(`SELECT status FROM items WHERE id = $1`, [itemId]);
expect(rows[0].status).toBe('sold');
});
});
+27 -1
View File
@@ -6,7 +6,8 @@ describe('parseItemFilters', () => {
categoryId: null, categoryId: null,
tagIds: [], tagIds: [],
minPriceCents: null, minPriceCents: null,
maxPriceCents: null maxPriceCents: null,
status: null
}); });
}); });
@@ -72,6 +73,25 @@ describe('parseItemFilters', () => {
it('rejects a repeated query param rather than guessing which one to use', () => { it('rejects a repeated query param rather than guessing which one to use', () => {
expect(() => parseItemFilters({ category: ['1', '2'] })).toThrow(FilterError); expect(() => parseItemFilters({ category: ['1', '2'] })).toThrow(FilterError);
}); });
it('parses each of the item statuses', () => {
expect(parseItemFilters({ status: 'available' }).status).toBe('available');
expect(parseItemFilters({ status: 'reserved' }).status).toBe('reserved');
expect(parseItemFilters({ status: 'sold' }).status).toBe('sold');
});
it('treats an absent or empty status as no status filter', () => {
expect(parseItemFilters({}).status).toBeNull();
expect(parseItemFilters({ status: '' }).status).toBeNull();
});
it('rejects a status outside the known set', () => {
expect(() => parseItemFilters({ status: 'pending' })).toThrow(FilterError);
});
it('rejects a status differing only by case, rather than silently coercing it', () => {
expect(() => parseItemFilters({ status: 'Reserved' })).toThrow(FilterError);
});
}); });
describe('buildItemFilterSql', () => { describe('buildItemFilterSql', () => {
@@ -100,6 +120,12 @@ describe('buildItemFilterSql', () => {
expect(built.clauses.join(' ')).toContain('$3'); expect(built.clauses.join(' ')).toContain('$3');
}); });
it('filters on status', () => {
const built = buildItemFilterSql(parseItemFilters({ status: 'reserved' }), 1);
expect(built.clauses.join(' ')).toContain('i.status');
expect(built.params).toEqual(['reserved']);
});
it('continues numbering across multiple filters', () => { it('continues numbering across multiple filters', () => {
const built = buildItemFilterSql( const built = buildItemFilterSql(
parseItemFilters({ category: '4', min_price: '100', max_price: '900' }), parseItemFilters({ category: '4', min_price: '100', max_price: '900' }),
+20 -2
View File
@@ -20,6 +20,8 @@ import Settings from './Settings';
import Categories from './Categories'; import Categories from './Categories';
import Tags from './Tags'; import Tags from './Tags';
import CategoryTreeSelect from './CategoryTreeSelect'; import CategoryTreeSelect from './CategoryTreeSelect';
import InventoryFilters from './InventoryFilters';
import { ItemFilters, EMPTY_FILTERS } from '../filters';
const { Header, Content } = Layout; const { Header, Content } = Layout;
const { Title } = Typography; const { Title } = Typography;
@@ -34,9 +36,10 @@ function Inventory() {
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [tags, setTags] = useState<TagRecord[]>([]); const [tags, setTags] = useState<TagRecord[]>([]);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [filters, setFilters] = useState<ItemFilters>(EMPTY_FILTERS);
const { mode } = useThemeMode(); const { mode } = useThemeMode();
const load = () => fetchAdminItems().then(setItems); const load = (active: ItemFilters = filters) => fetchAdminItems(active).then(setItems);
// The item form needs the current category tree and tag list; both change // The item form needs the current category tree and tag list; both change
// from the sibling tabs, so they're refetched whenever the modal opens. // from the sibling tabs, so they're refetched whenever the modal opens.
@@ -45,7 +48,13 @@ function Inventory() {
fetchAdminTags().then(setTags) fetchAdminTags().then(setTags)
]); ]);
useEffect(() => { load(); loadOptions(); }, []); // Refetch whenever the filters change — filtering is server-side so the
// result stays correct regardless of how many items exist.
useEffect(() => { load(filters); }, [filters]);
useEffect(() => { loadOptions(); }, []);
function applyFilters(next: ItemFilters) { setFilters(next); }
function clearFilters() { setFilters(EMPTY_FILTERS); }
function openNew() { function openNew() {
setEditingItem(null); setEditingItem(null);
@@ -193,6 +202,15 @@ function Inventory() {
<Title level={4} style={{ margin: 0 }}>Inventory</Title> <Title level={4} style={{ margin: 0 }}>Inventory</Title>
<Button type="primary" onClick={openNew}>Add Item</Button> <Button type="primary" onClick={openNew}>Add Item</Button>
</div> </div>
<InventoryFilters
categories={categories}
tags={tags}
filters={filters}
onChange={applyFilters}
onClear={clearFilters}
/>
<Table rowKey="id" dataSource={items} columns={columns} scroll={{ x: true }} /> <Table rowKey="id" dataSource={items} columns={columns} scroll={{ x: true }} />
<Modal title={editingItem ? 'Edit Item' : 'Add Item'} open={modalOpen} onOk={handleOk} confirmLoading={saving} onCancel={() => setModalOpen(false)} destroyOnHidden width={720}> <Modal title={editingItem ? 'Edit Item' : 'Add Item'} open={modalOpen} onOk={handleOk} confirmLoading={saving} onCancel={() => setModalOpen(false)} destroyOnHidden width={720}>
+10 -10
View File
@@ -1,15 +1,15 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { Key } from 'react'; import type { Key } from 'react';
import Tree from 'antd/lib/tree'; import Tree from 'antd/es/tree';
import Button from 'antd/lib/button'; import Button from 'antd/es/button';
import Input from 'antd/lib/input'; import Input from 'antd/es/input';
import Modal from 'antd/lib/modal'; import Modal from 'antd/es/modal';
import Select from 'antd/lib/select'; import Select from 'antd/es/select';
import Space from 'antd/lib/space'; import Space from 'antd/es/space';
import Typography from 'antd/lib/typography'; import Typography from 'antd/es/typography';
import Empty from 'antd/lib/empty'; import Empty from 'antd/es/empty';
import Spin from 'antd/lib/spin'; import Spin from 'antd/es/spin';
import message from 'antd/lib/message'; import message from 'antd/es/message';
import type { DataNode, TreeProps } from 'antd/es/tree'; import type { DataNode, TreeProps } from 'antd/es/tree';
import { import {
Category, Category,
+5 -5
View File
@@ -1,9 +1,9 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import TreeSelect from 'antd/lib/tree-select'; import TreeSelect from 'antd/es/tree-select';
import Input from 'antd/lib/input'; import Input from 'antd/es/input';
import Button from 'antd/lib/button'; import Button from 'antd/es/button';
import Divider from 'antd/lib/divider'; import Divider from 'antd/es/divider';
import message from 'antd/lib/message'; import message from 'antd/es/message';
import { Category, createCategory, fetchAdminCategories } from '../api'; import { Category, createCategory, fetchAdminCategories } from '../api';
import { buildCategoryTree, CategoryNode } from '../filters'; import { buildCategoryTree, CategoryNode } from '../filters';
+113 -6
View File
@@ -1,9 +1,12 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Table, Drawer, Descriptions, Tag, Typography, Spin, Empty } from 'antd'; import { Table, Drawer, Descriptions, Tag, Typography, Spin, Empty, Modal, Button, message } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { fetchCustomers, fetchCustomerDetail, CustomerSummary, CustomerDetail } from './adminCustomersApi'; import {
fetchCustomers, fetchCustomerDetail, fetchReservedItems, releaseReservedItem,
CustomerSummary, CustomerDetail, ReservedItem
} from './adminCustomersApi';
const { Title } = Typography; const { Title, Text } = Typography;
export default function Customers() { export default function Customers() {
const [customers, setCustomers] = useState<CustomerSummary[]>([]); const [customers, setCustomers] = useState<CustomerSummary[]>([]);
@@ -11,10 +14,16 @@ export default function Customers() {
const [detail, setDetail] = useState<CustomerDetail | null>(null); const [detail, setDetail] = useState<CustomerDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [reservedFor, setReservedFor] = useState<CustomerSummary | null>(null);
const [reserved, setReserved] = useState<ReservedItem[]>([]);
const [reservedLoading, setReservedLoading] = useState(false);
const [releasing, setReleasing] = useState<number | null>(null);
useEffect(() => { function load() {
fetchCustomers().then(rows => { setCustomers(rows); setLoading(false); }); return fetchCustomers().then(rows => { setCustomers(rows); setLoading(false); });
}, []); }
useEffect(() => { load(); }, []);
async function openDetail(id: number) { async function openDetail(id: number) {
setDrawerOpen(true); setDrawerOpen(true);
@@ -24,6 +33,37 @@ export default function Customers() {
setDetailLoading(false); setDetailLoading(false);
} }
async function openReserved(customer: CustomerSummary) {
setReservedFor(customer);
setReservedLoading(true);
try {
setReserved(await fetchReservedItems(customer.id));
} catch (err) {
message.error((err as Error).message);
setReserved([]);
} finally {
setReservedLoading(false);
}
}
async function handleRelease(item: ReservedItem) {
if (!reservedFor) return;
setReleasing(item.item_id);
try {
await releaseReservedItem(reservedFor.id, item.item_id);
} catch (err) {
message.error(`Couldn't release "${item.name}" — ${(err as Error).message}`);
return;
} finally {
setReleasing(null);
}
message.success(`Released "${item.name}"`);
// Refresh both the popup and the row count behind it, so the count can't
// disagree with the list it opened from.
setReserved(await fetchReservedItems(reservedFor.id));
load();
}
const columns: ColumnsType<CustomerSummary> = [ const columns: ColumnsType<CustomerSummary> = [
{ {
title: 'Customer', title: 'Customer',
@@ -50,6 +90,25 @@ export default function Customers() {
onFilter: (value, row) => row.marketing_consent === value, onFilter: (value, row) => row.marketing_consent === value,
render: (v: boolean) => <Tag color={v ? 'blue' : 'default'}>{v ? 'Yes' : 'No'}</Tag> render: (v: boolean) => <Tag color={v ? 'blue' : 'default'}>{v ? 'Yes' : 'No'}</Tag>
}, },
{
title: 'Reserved',
dataIndex: 'reserved_count',
render: (count: number, customer: CustomerSummary) =>
Number(count) > 0 ? (
<Button
type="link"
style={{ padding: 0 }}
// The whole row opens the customer drawer, so without this the
// click reaches both handlers and the drawer opens behind the
// reserved-items dialog.
onClick={(event) => { event.stopPropagation(); openReserved(customer); }}
>
{count} item{Number(count) === 1 ? '' : 's'}
</Button>
) : (
<Text type="secondary">0</Text>
)
},
{ {
title: 'Orders', title: 'Orders',
dataIndex: 'order_count', dataIndex: 'order_count',
@@ -145,6 +204,54 @@ export default function Customers() {
</> </>
)} )}
</Drawer> </Drawer>
<Modal
title={reservedFor ? `Items reserved by ${reservedFor.email}` : 'Reserved items'}
open={reservedFor !== null}
onCancel={() => setReservedFor(null)}
footer={null}
destroyOnHidden
width={640}
>
{reservedLoading ? <Spin /> : null}
{!reservedLoading && !reserved.length ? (
<Empty description="This customer isn't holding any items" />
) : null}
{!reservedLoading && reserved.length > 0 && (
<Table
rowKey="item_id"
dataSource={reserved}
pagination={false}
size="small"
columns={[
{ title: 'Item', dataIndex: 'name' },
{
title: 'Price',
dataIndex: 'price_cents',
render: (v: number) => `$${(v / 100).toFixed(2)}`
},
{
title: 'Reservation expires',
dataIndex: 'expires_at',
render: (v: string) => new Date(v).toLocaleString()
},
{
title: '',
render: (_: unknown, item: ReservedItem) => (
<Button
size="small"
danger
loading={releasing === item.item_id}
onClick={() => handleRelease(item)}
>
Release
</Button>
)
}
]}
/>
)}
</Modal>
</div> </div>
); );
} }
+106
View File
@@ -0,0 +1,106 @@
import { useMemo } from 'react';
import TreeSelect from 'antd/es/tree-select';
import Select from 'antd/es/select';
import InputNumber from 'antd/es/input-number';
import Button from 'antd/es/button';
import type { Category, Tag } from '../api';
import { ItemFilters, ItemStatus, buildCategoryTree, CategoryNode, hasActiveFilters } from '../filters';
interface CategoryTreeOption {
value: number;
title: string;
children?: CategoryTreeOption[];
}
function toTreeData(nodes: CategoryNode[]): CategoryTreeOption[] {
return nodes.map((node) => ({
value: node.id,
title: node.name,
children: node.children.length ? toTreeData(node.children) : undefined
}));
}
const STATUS_OPTIONS: { value: ItemStatus; label: string }[] = [
{ value: 'available', label: 'Available' },
{ value: 'reserved', label: 'Reserved' },
{ value: 'sold', label: 'Sold' }
];
interface Props {
categories: Category[];
tags: Tag[];
filters: ItemFilters;
onChange: (filters: ItemFilters) => void;
onClear: () => void;
}
const centsToDollars = (cents: number | null): number | null => (cents === null ? null : cents / 100);
const dollarsToCents = (dollars: number | null): number | null =>
dollars === null || Number.isNaN(dollars) ? null : Math.round(dollars * 100);
// An always-visible row rather than the storefront's drawer: this sits above a
// data table, where hiding the controls behind a click costs more than the
// space it saves, and a drawer would overlay the very rows being filtered.
export default function InventoryFilters({ categories, tags, filters, onChange, onClear }: Props) {
const treeData = useMemo(() => toTreeData(buildCategoryTree(categories)), [categories]);
return (
<div className="inventory-filters">
<TreeSelect
allowClear
showSearch
treeNodeFilterProp="title"
listHeight={256}
placeholder="Any category"
aria-label="Filter by category"
style={{ minWidth: 200 }}
treeData={treeData}
value={filters.categoryId ?? undefined}
onChange={(value) => onChange({ ...filters, categoryId: value ?? null })}
/>
<Select
allowClear
mode="multiple"
placeholder="Any tags"
aria-label="Filter by tags"
style={{ minWidth: 200 }}
value={filters.tagIds}
onChange={(value: number[]) => onChange({ ...filters, tagIds: value })}
options={tags.map((tag) => ({ value: tag.id, label: tag.name }))}
/>
<InputNumber
aria-label="Minimum price"
prefix="$"
min={0}
placeholder="Min"
style={{ width: 110 }}
value={centsToDollars(filters.minPriceCents)}
onChange={(value) => onChange({ ...filters, minPriceCents: dollarsToCents(value) })}
/>
<span style={{ opacity: 0.6 }}>to</span>
<InputNumber
aria-label="Maximum price"
prefix="$"
min={0}
placeholder="Max"
style={{ width: 110 }}
value={centsToDollars(filters.maxPriceCents)}
onChange={(value) => onChange({ ...filters, maxPriceCents: dollarsToCents(value) })}
/>
<Select
allowClear
placeholder="Any status"
aria-label="Filter by status"
style={{ minWidth: 150 }}
value={filters.status ?? undefined}
onChange={(value: ItemStatus | undefined) => onChange({ ...filters, status: value ?? null })}
options={STATUS_OPTIONS}
/>
{hasActiveFilters(filters) && <Button onClick={onClear}>Clear filters</Button>}
</div>
);
}
+12 -12
View File
@@ -1,13 +1,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Table from 'antd/lib/table'; import Table from 'antd/es/table';
import Button from 'antd/lib/button'; import Button from 'antd/es/button';
import Input from 'antd/lib/input'; import Input from 'antd/es/input';
import Modal from 'antd/lib/modal'; import Modal from 'antd/es/modal';
import Select from 'antd/lib/select'; import Select from 'antd/es/select';
import Space from 'antd/lib/space'; import Space from 'antd/es/space';
import Tag from 'antd/lib/tag'; import Tag from 'antd/es/tag';
import Typography from 'antd/lib/typography'; import Typography from 'antd/es/typography';
import message from 'antd/lib/message'; import message from 'antd/es/message';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { Tag as TagRecord, fetchAdminTags, createTag, updateTag, deleteTag } from '../api'; import { Tag as TagRecord, fetchAdminTags, createTag, updateTag, deleteTag } from '../api';
@@ -62,7 +62,7 @@ export default function Tags() {
await updateTag(editing.id, { name: trimmed, color }); await updateTag(editing.id, { name: trimmed, color });
message.success('Tag updated'); message.success('Tag updated');
} else { } else {
// New tags take the colour the server derives from the name; it can be // New tags take the color the server derives from the name; it can be
// overridden straight afterwards by editing. // overridden straight afterwards by editing.
await createTag(trimmed); await createTag(trimmed);
message.success('Tag added'); message.success('Tag added');
@@ -94,7 +94,7 @@ export default function Tags() {
dataIndex: 'name', dataIndex: 'name',
render: (_: string, tag) => <Tag color={tag.color}>{tag.name}</Tag> render: (_: string, tag) => <Tag color={tag.color}>{tag.name}</Tag>
}, },
{ title: 'Colour', dataIndex: 'color' }, { title: 'Color', dataIndex: 'color' },
{ title: 'Items', dataIndex: 'item_count' }, { title: 'Items', dataIndex: 'item_count' },
{ {
title: 'Actions', title: 'Actions',
@@ -133,7 +133,7 @@ export default function Tags() {
/> />
{editing && ( {editing && (
<> <>
<label htmlFor="tag-color">Colour</label> <label htmlFor="tag-color">Color</label>
<Select <Select
id="tag-color" id="tag-color"
style={{ width: '100%' }} style={{ width: '100%' }}
+27
View File
@@ -8,6 +8,15 @@ export interface CustomerSummary {
order_count: number; order_count: number;
total_spent_cents: number; total_spent_cents: number;
last_order_at: string | null; last_order_at: string | null;
reserved_count: number;
}
export interface ReservedItem {
item_id: number;
name: string;
price_cents: number;
added_at: string;
expires_at: string;
} }
export interface CustomerOrder { export interface CustomerOrder {
@@ -42,3 +51,21 @@ export async function fetchCustomerDetail(id: number): Promise<CustomerDetail> {
const res = await fetch(`/api/admin/customers/${id}`); const res = await fetch(`/api/admin/customers/${id}`);
return res.json(); return res.json();
} }
export async function fetchReservedItems(customerId: number): Promise<ReservedItem[]> {
const res = await fetch(`/api/admin/customers/${customerId}/reserved`);
if (!res.ok) throw new Error('failed to load reserved items');
return res.json();
}
export async function releaseReservedItem(customerId: number, itemId: number): Promise<void> {
const res = await fetch(`/api/admin/customers/${customerId}/reserved/${itemId}/release`, {
method: 'POST'
});
// Reporting success for a release that failed would leave the item held with
// nothing to indicate why.
if (!res.ok) {
const detail = await res.json().catch(() => ({}));
throw new Error(detail.error || 'failed to release item');
}
}
+6 -2
View File
@@ -77,8 +77,12 @@ async function expectOk(res: Response, action: string): Promise<Response> {
throw new Error(detail?.error ? `${action}: ${detail.error}` : action); throw new Error(detail?.error ? `${action}: ${detail.error}` : action);
} }
export async function fetchAdminItems(): Promise<Item[]> { export async function fetchAdminItems(filters?: ItemFilters): Promise<Item[]> {
const res = await expectOk(await fetch('/api/admin/items'), 'failed to load items'); const query = filters ? filtersToSearchParams(filters).toString() : '';
const res = await expectOk(
await fetch(query ? `/api/admin/items?${query}` : '/api/admin/items'),
'failed to load items'
);
return res.json(); return res.json();
} }
@@ -1,5 +1,5 @@
import Tag from 'antd/lib/tag'; import Tag from 'antd/es/tag';
import Button from 'antd/lib/button'; import Button from 'antd/es/button';
import type { FilterOptions } from '../api'; import type { FilterOptions } from '../api';
import { ItemFilters, categoryPath, formatPriceRange, hasActiveFilters } from '../filters'; import { ItemFilters, categoryPath, formatPriceRange, hasActiveFilters } from '../filters';
+8 -8
View File
@@ -1,11 +1,11 @@
import Drawer from 'antd/lib/drawer'; import Drawer from 'antd/es/drawer';
import Button from 'antd/lib/button'; import Button from 'antd/es/button';
import Tree from 'antd/lib/tree'; import Tree from 'antd/es/tree';
import Tag from 'antd/lib/tag'; import Tag from 'antd/es/tag';
import Slider from 'antd/lib/slider'; import Slider from 'antd/es/slider';
import InputNumber from 'antd/lib/input-number'; import InputNumber from 'antd/es/input-number';
import Empty from 'antd/lib/empty'; import Empty from 'antd/es/empty';
import Grid from 'antd/lib/grid'; import Grid from 'antd/es/grid';
import type { DataNode } from 'antd/es/tree'; import type { DataNode } from 'antd/es/tree';
import type { FilterOptions } from '../api'; import type { FilterOptions } from '../api';
import { ItemFilters, buildCategoryTree, CategoryNode } from '../filters'; import { ItemFilters, buildCategoryTree, CategoryNode } from '../filters';
+5 -5
View File
@@ -1,10 +1,10 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import Card from 'antd/lib/card'; import Card from 'antd/es/card';
import Typography from 'antd/lib/typography'; import Typography from 'antd/es/typography';
import Alert from 'antd/lib/alert'; import Alert from 'antd/es/alert';
import Button from 'antd/lib/button'; import Button from 'antd/es/button';
import Spin from 'antd/lib/spin'; import Spin from 'antd/es/spin';
import { verifyEmail } from './customerApi'; import { verifyEmail } from './customerApi';
import { useCustomerAuth } from './CustomerAuthContext'; import { useCustomerAuth } from './CustomerAuthContext';
+16 -2
View File
@@ -1,17 +1,23 @@
import type { Category } from './api'; import type { Category } from './api';
export type ItemStatus = 'available' | 'reserved' | 'sold';
export interface ItemFilters { export interface ItemFilters {
categoryId: number | null; categoryId: number | null;
tagIds: number[]; tagIds: number[];
minPriceCents: number | null; minPriceCents: number | null;
maxPriceCents: number | null; maxPriceCents: number | null;
// Only the admin Inventory tab sets this; the storefront leaves it null and
// shows every status, as it always has.
status: ItemStatus | null;
} }
export const EMPTY_FILTERS: ItemFilters = { export const EMPTY_FILTERS: ItemFilters = {
categoryId: null, categoryId: null,
tagIds: [], tagIds: [],
minPriceCents: null, minPriceCents: null,
maxPriceCents: null maxPriceCents: null,
status: null
}; };
// Filters live in the URL so a filtered view can be linked, bookmarked, and // Filters live in the URL so a filtered view can be linked, bookmarked, and
@@ -23,6 +29,7 @@ export function filtersToSearchParams(filters: ItemFilters): URLSearchParams {
if (filters.tagIds.length) params.set('tags', filters.tagIds.join(',')); if (filters.tagIds.length) params.set('tags', filters.tagIds.join(','));
if (filters.minPriceCents !== null) params.set('min_price', String(filters.minPriceCents)); if (filters.minPriceCents !== null) params.set('min_price', String(filters.minPriceCents));
if (filters.maxPriceCents !== null) params.set('max_price', String(filters.maxPriceCents)); if (filters.maxPriceCents !== null) params.set('max_price', String(filters.maxPriceCents));
if (filters.status !== null) params.set('status', filters.status);
return params; return params;
} }
@@ -38,11 +45,17 @@ export function filtersFromSearchParams(params: URLSearchParams): ItemFilters {
.map((part) => readInt(part)) .map((part) => readInt(part))
.filter((id): id is number => id !== null && id > 0); .filter((id): id is number => id !== null && id > 0);
const rawStatus = params.get('status');
const status = rawStatus === 'available' || rawStatus === 'reserved' || rawStatus === 'sold'
? rawStatus
: null;
return { return {
categoryId: readInt(params.get('category')), categoryId: readInt(params.get('category')),
tagIds: tags, tagIds: tags,
minPriceCents: readInt(params.get('min_price')), minPriceCents: readInt(params.get('min_price')),
maxPriceCents: readInt(params.get('max_price')) maxPriceCents: readInt(params.get('max_price')),
status
}; };
} }
@@ -53,6 +66,7 @@ export function activeFilterCount(filters: ItemFilters): number {
if (filters.categoryId !== null) count++; if (filters.categoryId !== null) count++;
count += filters.tagIds.length; count += filters.tagIds.length;
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++; if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++;
if (filters.status !== null) count++;
return count; return count;
} }
+16 -1
View File
@@ -16,6 +16,11 @@ import { CartProvider } from './cart/CartContext';
import { ThemeModeProvider, useThemeMode } from './theme/ThemeContext'; import { ThemeModeProvider, useThemeMode } from './theme/ThemeContext';
import './styles.css'; import './styles.css';
// The brand accent is monochrome, so it inverts between themes rather than
// switching to a different hue.
const LIGHT_ACCENT = '#1a1a1a';
const DARK_ACCENT = '#f0f0f0';
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'; const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
// Respects the OS-level "reduce motion" accessibility setting by turning off // Respects the OS-level "reduce motion" accessibility setting by turning off
@@ -44,7 +49,17 @@ function Root() {
<ConfigProvider <ConfigProvider
theme={{ theme={{
algorithm: mode === 'dark' ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm, algorithm: mode === 'dark' ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
token: { colorPrimary: '#1a1a1a', motion: !prefersReducedMotion } token: {
// The accent inverts with the theme rather than staying near-black.
// Held constant, it rendered the active tab label in #1a1a1a on a
// dark background — invisible.
colorPrimary: mode === 'dark' ? DARK_ACCENT : LIGHT_ACCENT,
// Text drawn *on* the accent (primary buttons, selected rows) has to
// invert with it too, or a near-white accent gets antd's default
// white label and disappears.
colorTextLightSolid: mode === 'dark' ? LIGHT_ACCENT : '#ffffff',
motion: !prefersReducedMotion
}
}} }}
> >
<BrowserRouter> <BrowserRouter>
+27
View File
@@ -115,3 +115,30 @@ body { margin: 0; }
width: 100%; width: 100%;
} }
} }
/* Admin inventory filter row — always visible above the table, wrapping onto
further lines on narrow screens rather than scrolling horizontally. */
.inventory-filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
/* Admin category tree node: name, item count, then actions pushed to the
right. Without this the three run together as one unbroken string. */
.admin-category-node {
display: inline-flex;
align-items: center;
gap: 12px;
width: 100%;
}
.admin-category-node > span:first-child {
font-weight: 500;
}
.admin-category-node .ant-space {
margin-left: auto;
}
@@ -0,0 +1,104 @@
import { test, expect, Page } from '@playwright/test';
const RUN = `v${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
const NAMES = {
category: `Filterable ${RUN}`,
tag: `filt-${RUN}`,
cheap: `Cheap item ${RUN}`,
mid: `Mid item ${RUN}`,
dear: `Dear item ${RUN}`
};
test.beforeAll(async ({ playwright }) => {
const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' });
const categoryId = (await (await api.post('/api/admin/categories', {
data: { name: NAMES.category, parent_id: null }
})).json()).id;
await api.post('/api/admin/tags', { data: { name: NAMES.tag } });
const item = (name: string, price: string, inCategory: boolean) =>
api.post('/api/admin/items', {
multipart: {
name,
description: '',
price,
category_id: inCategory ? String(categoryId) : '',
tags: JSON.stringify(inCategory ? [NAMES.tag] : [])
}
});
await item(NAMES.cheap, '50', true);
await item(NAMES.mid, '150', true);
await item(NAMES.dear, '900', true);
await api.dispose();
});
const row = (page: Page, name: string) => page.getByRole('row').filter({ hasText: name });
// The Inventory table paginates and other specs create items concurrently, so
// an unfiltered page 1 is not a reliable place to look for a fixture. Every
// assertion below therefore runs against a category filter that narrows the
// table to this spec's own items.
async function filterToOwnCategory(page: Page) {
const category = page.getByRole('combobox', { name: 'Filter by category' });
await category.click();
await category.fill(NAMES.category);
await page.getByTitle(NAMES.category, { exact: true }).click();
await expect(row(page, NAMES.cheap)).toBeVisible();
}
test.describe('Admin inventory filters', () => {
test('filters by category', async ({ page }) => {
await page.goto('/admin');
await filterToOwnCategory(page);
// All three fixtures share the category, and nothing else does.
await expect(row(page, NAMES.cheap)).toBeVisible();
await expect(row(page, NAMES.mid)).toBeVisible();
await expect(row(page, NAMES.dear)).toBeVisible();
});
test('filters by price range', async ({ page }) => {
await page.goto('/admin');
await filterToOwnCategory(page);
await page.getByLabel('Minimum price').fill('100');
await page.getByLabel('Maximum price').fill('500');
await expect(row(page, NAMES.mid)).toBeVisible();
await expect(row(page, NAMES.cheap)).toHaveCount(0);
await expect(row(page, NAMES.dear)).toHaveCount(0);
});
test('filters by status, which is how Reserved is reached', async ({ page }) => {
await page.goto('/admin');
await filterToOwnCategory(page);
await page.getByRole('combobox', { name: 'Filter by status' }).click();
await page.getByTitle('Sold', { exact: true }).click();
// Every fixture is available, so a Sold filter must exclude them all.
await expect(row(page, NAMES.cheap)).toHaveCount(0);
await expect(row(page, NAMES.mid)).toHaveCount(0);
await expect(row(page, NAMES.dear)).toHaveCount(0);
});
test('combines filters, and clearing restores them', async ({ page }) => {
await page.goto('/admin');
await filterToOwnCategory(page);
await page.getByLabel('Minimum price').fill('800');
await expect(row(page, NAMES.cheap)).toHaveCount(0);
await expect(row(page, NAMES.dear)).toBeVisible();
await page.getByRole('button', { name: 'Clear filters' }).click();
// Asserting on the controls rather than on the rows: with the filters gone
// the table is the whole paginated catalogue again, so a given fixture is
// not reliably on the first page.
await expect(page.getByRole('button', { name: 'Clear filters' })).toHaveCount(0);
await expect(page.getByLabel('Minimum price')).toHaveValue('');
});
});
@@ -0,0 +1,94 @@
import { test, expect } from '@playwright/test';
const suffix = () => `r${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
// Reserves an item by registering a customer and adding it to their cart, which
// is the only way an item legitimately reaches 'reserved'.
async function reserveItem(page: import('@playwright/test').Page, itemName: string) {
const email = `reserve-${suffix()}@example.com`;
const created = await page.request.post('/api/admin/items', {
multipart: { name: itemName, description: '', price: '75', category_id: '', tags: '[]' }
});
expect(created.ok()).toBeTruthy();
const itemId = (await created.json()).id as number;
await page.goto('/register');
await page.getByRole('textbox', { name: 'Email' }).fill(email);
await page.getByLabel('Password').fill('supersecret123');
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page).toHaveURL(/\/account/);
const added = await page.request.post(`/api/cart/items/${itemId}`);
expect(added.status()).toBe(201);
return { email, itemId };
}
test.describe('Admin reserved items', () => {
test('shows a reserved count that opens the held items', async ({ page }) => {
const itemName = `Held ${suffix()}`;
const { email } = await reserveItem(page, itemName);
await page.goto('/admin');
await page.getByRole('tab', { name: 'Customers' }).click();
const row = page.getByRole('row').filter({ hasText: email });
await expect(row).toBeVisible();
await row.getByRole('button', { name: /item/ }).click();
const dialog = page.getByRole('dialog', { name: /Items reserved by/ });
await expect(dialog.getByText(itemName)).toBeVisible();
});
test('releasing an item returns it to the storefront as available', async ({ page }) => {
const itemName = `Freed ${suffix()}`;
const { email, itemId } = await reserveItem(page, itemName);
await page.goto('/admin');
await page.getByRole('tab', { name: 'Customers' }).click();
const row = page.getByRole('row').filter({ hasText: email });
await row.getByRole('button', { name: /item/ }).click();
const dialog = page.getByRole('dialog', { name: /Items reserved by/ });
await dialog.getByRole('button', { name: 'Release' }).click();
await expect(page.getByText(`Released "${itemName}"`)).toBeVisible();
// The point of releasing is that the item becomes purchasable again.
const item = await (await page.request.get(`/api/items/${itemId}`)).json();
expect(item.status).toBe('available');
});
test('the count drops once the item is released', async ({ page }) => {
const itemName = `Recount ${suffix()}`;
const { email } = await reserveItem(page, itemName);
await page.goto('/admin');
await page.getByRole('tab', { name: 'Customers' }).click();
const row = page.getByRole('row').filter({ hasText: email });
await row.getByRole('button', { name: /item/ }).click();
const dialog = page.getByRole('dialog', { name: /Items reserved by/ });
await dialog.getByRole('button', { name: 'Release' }).click();
await expect(dialog.getByText("This customer isn't holding any items")).toBeVisible();
// The row behind the dialog must agree with the dialog it opened.
await dialog.getByRole('button', { name: 'Close' }).click();
await expect(row.getByRole('button', { name: /item/ })).toHaveCount(0);
});
test('a customer holding nothing shows no link to click', async ({ page }) => {
const email = `idle-${suffix()}@example.com`;
await page.goto('/register');
await page.getByRole('textbox', { name: 'Email' }).fill(email);
await page.getByLabel('Password').fill('supersecret123');
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page).toHaveURL(/\/account/);
await page.goto('/admin');
await page.getByRole('tab', { name: 'Customers' }).click();
const row = page.getByRole('row').filter({ hasText: email });
await expect(row).toBeVisible();
await expect(row.getByRole('button', { name: /item/ })).toHaveCount(0);
});
});
+94
View File
@@ -0,0 +1,94 @@
import { test, expect, Page } from '@playwright/test';
// Relative luminance per WCAG, used to tell "light" from "dark" without
// asserting exact hex values, which would break on any palette tweak.
function luminance(rgb: string): number {
const [r, g, b] = (rgb.match(/\d+(\.\d+)?/g) ?? ['0', '0', '0']).slice(0, 3).map(Number);
const channel = (c: number) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
};
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
}
const suffix = () => `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
// The Categories and Tags tabs render an empty state when there is nothing to
// show, and the shared dev database gets truncated by the backend integration
// suite. Seed what each test needs rather than depending on what happens to be
// there.
async function seed(page: Page) {
const run = suffix();
await page.request.post('/api/admin/categories', { data: { name: `Theme ${run}`, parent_id: null } });
await page.request.post('/api/admin/tags', { data: { name: `theme-${run}` } });
}
async function switchToDark(page: Page) {
await page.goto('/admin');
const toggle = page.getByRole('switch');
if ((await toggle.getAttribute('aria-checked')) !== 'true') {
await toggle.click();
}
await expect(page.locator('body')).toHaveAttribute('data-theme', 'dark');
}
test.describe('Admin dark mode', () => {
test('the active tab label is readable against the dark background', async ({ page }) => {
await switchToDark(page);
const label = page.locator('.ant-tabs-tab-active .ant-tabs-tab-btn').first();
// The bug: colorPrimary stayed #1a1a1a in dark mode, so the active tab was
// near-black text on a near-black background.
await expect
.poll(async () => luminance(await label.evaluate((el) => getComputedStyle(el).color)))
.toBeGreaterThan(0.5);
});
test('the Categories tab follows the dark theme', async ({ page }) => {
await seed(page);
await switchToDark(page);
await page.getByRole('tab', { name: 'Categories' }).click();
const tree = page.locator('.ant-tabs-tabpane-active .ant-tree').first();
await expect(tree).toBeVisible();
const bg = await tree.evaluate((el) => getComputedStyle(el).backgroundColor);
// The bug: deep imports from antd/lib loaded a second copy of antd that
// never saw ConfigProvider, so this rendered pure white in dark mode.
expect(luminance(bg)).toBeLessThan(0.5);
});
test('the Tags tab follows the dark theme', async ({ page }) => {
await seed(page);
await switchToDark(page);
await page.getByRole('tab', { name: 'Tags' }).click();
const table = page.locator('.ant-tabs-tabpane-active .ant-table').first();
await expect(table).toBeVisible();
const bg = await table.evaluate((el) => getComputedStyle(el).backgroundColor);
expect(luminance(bg)).toBeLessThan(0.5);
});
test('the item form category selector follows the dark theme', async ({ page }) => {
await switchToDark(page);
await page.getByRole('button', { name: 'Add Item' }).click();
await page.getByRole('dialog').getByLabel('Category', { exact: true }).click();
const popup = page.locator('.ant-select-dropdown').first();
await expect(popup).toBeVisible();
const bg = await popup.evaluate((el) => getComputedStyle(el).backgroundColor);
expect(luminance(bg)).toBeLessThan(0.5);
});
});
test.describe('Admin copy', () => {
test('uses American English spelling for color', async ({ page }) => {
await seed(page);
await page.goto('/admin');
await page.getByRole('tab', { name: 'Tags' }).click();
await expect(page.getByRole('columnheader', { name: 'Color' })).toBeVisible();
await expect(page.getByText('Colour')).toHaveCount(0);
});
});