Merge pull request 'Feature/81 technical debt' (#82) from feature/81-technical-debt into main
SonarQube Analysis / sonarqube (push) Failing after 13m29s
Tests / lint (push) Successful in 1m43s
Tests / backend-unit (push) Successful in 1m7s
Tests / frontend-e2e (push) Failing after 8m41s

Reviewed-on: #82
This commit was merged in pull request #82.
This commit is contained in:
2026-08-20 15:08:18 -05:00
11 changed files with 509 additions and 262 deletions
+51 -26
View File
@@ -81,26 +81,67 @@ function parsePrice(value: unknown, name: string): number | null {
return parseNonNegativeInteger(raw, name); return parseNonNegativeInteger(raw, name);
} }
export function parseItemFilters(query: Record<string, unknown>): ItemFilters { function parseCategoryId(value: unknown): number | null {
const categoryRaw = singleValue(query.category, 'category'); const raw = singleValue(value, 'category');
const categoryId = categoryRaw === null || categoryRaw === '' ? null : parseId(categoryRaw, 'category'); if (raw === null || raw === '') {
return null;
}
return parseId(raw, 'category');
}
const tagsRaw = singleValue(query.tags, 'tags'); function parseTagIds(value: unknown): number[] {
const raw = singleValue(value, 'tags');
const tagIds: number[] = []; const tagIds: number[] = [];
if (tagsRaw) { if (!raw) {
for (const part of tagsRaw.split(',')) { return tagIds;
}
for (const part of raw.split(',')) {
const trimmed = part.trim(); const trimmed = part.trim();
if (trimmed === '') { if (trimmed === '') {
continue; continue;
} }
const id = parseId(trimmed, 'tags'); const id = parseId(trimmed, 'tags');
// Duplicates would inflate the required-match count below and make the // Duplicates would inflate the required-match count in buildItemFilterSql
// filter match nothing at all. // and make the filter match nothing at all.
if (!tagIds.includes(id)) { if (!tagIds.includes(id)) {
tagIds.push(id); tagIds.push(id);
} }
} }
return tagIds;
}
function parseStatus(value: unknown): ItemStatus | null {
const raw = singleValue(value, 'status');
if (raw === null || raw === '') {
return null;
} }
if (!ITEM_STATUSES.includes(raw)) {
throw new FilterError('invalid status');
}
return raw as ItemStatus;
}
function parseFavoritesOnly(value: unknown): boolean {
const raw = singleValue(value, 'favorites');
if (raw === null || raw === '') {
return false;
}
if (TRUE_VALUES.includes(raw)) {
return true;
}
if (FALSE_VALUES.includes(raw)) {
return false;
}
throw new FilterError('invalid favorites');
}
// The per-field parsing lives in the helpers above; what stays here is the
// order they run in and the one rule that spans two fields. Order is
// deliberate and observable: a query wrong in two ways reports the first
// field, so moving these lines around changes which error a caller sees.
export function parseItemFilters(query: Record<string, unknown>): ItemFilters {
const categoryId = parseCategoryId(query.category);
const tagIds = parseTagIds(query.tags);
const minPriceCents = parsePrice(query.min_price, 'min_price'); const minPriceCents = parsePrice(query.min_price, 'min_price');
const maxPriceCents = parsePrice(query.max_price, 'max_price'); const maxPriceCents = parsePrice(query.max_price, 'max_price');
@@ -108,24 +149,8 @@ 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');
} }
const statusRaw = singleValue(query.status, 'status'); const status = parseStatus(query.status);
let status: ItemStatus | null = null; const favoritesOnly = parseFavoritesOnly(query.favorites);
if (statusRaw !== null && statusRaw !== '') {
if (!ITEM_STATUSES.includes(statusRaw)) {
throw new FilterError('invalid status');
}
status = statusRaw as ItemStatus;
}
const favoritesRaw = singleValue(query.favorites, 'favorites');
let favoritesOnly = false;
if (favoritesRaw !== null && favoritesRaw !== '') {
if (TRUE_VALUES.includes(favoritesRaw)) {
favoritesOnly = true;
} else if (!FALSE_VALUES.includes(favoritesRaw)) {
throw new FilterError('invalid favorites');
}
}
return { categoryId, tagIds, minPriceCents, maxPriceCents, status, favoritesOnly }; return { categoryId, tagIds, minPriceCents, maxPriceCents, status, favoritesOnly };
} }
+43 -21
View File
@@ -79,6 +79,45 @@ router.post('/', asyncRoute(async (req: Request, res: Response) => {
} }
})); }));
// Works out what parent_id an update should land on. Absent means "leave it
// alone", so the current value is echoed back rather than treated as a clear.
// Returns the refusal instead of sending it, keeping the response the
// handler's business and the two ways a parent can be invalid out of its body.
type ParentResolution = { error: string } | { parent: number | null };
async function resolveParentId(
submitted: unknown,
id: number,
current: number | null
): Promise<ParentResolution> {
if (submitted === undefined) {
return { parent: current };
}
const parsed = readParentId(submitted);
if (parsed === undefined) {
return { error: 'invalid parent_id' };
}
if (parsed === null) {
return { parent: null };
}
if (!(await parentExists(parsed))) {
return { error: 'parent category does not exist' };
}
// Moving a node beneath itself or one of its own descendants would detach
// that whole branch from the tree into an unreachable cycle.
const { rows: cycle } = await pool.query(
`${SUBTREE_CTE} SELECT 1 FROM subtree WHERE id = $2`,
[id, parsed]
);
if (cycle.length) {
return { error: 'a category cannot be moved beneath itself' };
}
return { parent: parsed };
}
router.put('/:id', asyncRoute(async (req: Request, res: Response) => { router.put('/:id', asyncRoute(async (req: Request, res: Response) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const existing = await pool.query(`SELECT id, name, parent_id, sort_order FROM categories WHERE id = $1`, [id]); const existing = await pool.query(`SELECT id, name, parent_id, sort_order FROM categories WHERE id = $1`, [id]);
@@ -95,28 +134,11 @@ router.put('/:id', asyncRoute(async (req: Request, res: Response) => {
name = parsed; name = parsed;
} }
let parent = existing.rows[0].parent_id; const resolved = await resolveParentId(req.body.parent_id, id, existing.rows[0].parent_id);
if (req.body.parent_id !== undefined) { if ('error' in resolved) {
const parsed = readParentId(req.body.parent_id); return res.status(400).json({ error: resolved.error });
if (parsed === undefined) {
return res.status(400).json({ error: 'invalid parent_id' });
}
if (parsed !== null) {
if (!(await parentExists(parsed))) {
return res.status(400).json({ error: 'parent category does not exist' });
}
// Moving a node beneath itself or one of its own descendants would
// detach that whole branch from the tree into an unreachable cycle.
const { rows: cycle } = await pool.query(
`${SUBTREE_CTE} SELECT 1 FROM subtree WHERE id = $2`,
[id, parsed]
);
if (cycle.length) {
return res.status(400).json({ error: 'a category cannot be moved beneath itself' });
}
}
parent = parsed;
} }
const parent = resolved.parent;
const sortOrder = Number.isSafeInteger(req.body.sort_order) const sortOrder = Number.isSafeInteger(req.body.sort_order)
? req.body.sort_order ? req.body.sort_order
+89 -31
View File
@@ -26,6 +26,78 @@ const { Title } = Typography;
// would become its own request. // would become its own request.
const FILTER_DEBOUNCE_MS = 250; const FILTER_DEBOUNCE_MS = 250;
interface CatalogueProps {
failed: boolean;
loading: boolean;
items: Item[];
filters: ItemFilters;
needsFavoritesAuth: boolean;
onRetry: () => void;
onSignIn: () => void;
onClearFilters: () => void;
onChanged: () => void;
}
// The body of the catalogue: an outage, a sign-in prompt, an empty state, or
// the grid. Extracted from App so the four cases read as early returns rather
// than a ternary chain, and because a function's cognitive complexity counts
// everything nested inside it — leaving this inline is what put App over the
// limit.
function Catalogue({
failed,
loading,
items,
filters,
needsFavoritesAuth,
onRetry,
onSignIn,
onClearFilters,
onChanged
}: CatalogueProps) {
if (failed) {
return (
<Alert
type="error"
showIcon
message="Couldn't load items"
description="The server didn't return the catalogue. This is usually temporary."
action={<Button size="small" onClick={onRetry}>Retry</Button>}
/>
);
}
// Prompted rather than requested: see the effect in App that sets this.
if (needsFavoritesAuth) {
return (
<Empty description="Sign in to see the items you have favorited">
<Button type="primary" onClick={onSignIn}>Sign in</Button>
<Button style={{ marginInlineStart: 8 }} onClick={onClearFilters}>Browse everything</Button>
</Empty>
);
}
if (!loading && !items.length) {
// Distinguished so "no items match these filters" never reads as an empty
// shop, and so the way out is offered only when there is one.
const filtered = hasActiveFilters(filters);
return (
<Empty description={filtered ? 'No items match these filters' : 'No items yet — check back soon'}>
{filtered ? <Button onClick={onClearFilters}>Clear filters</Button> : null}
</Empty>
);
}
return (
<Row gutter={[20, 20]}>
{items.map(item => (
<Col key={item.id} xs={24} sm={12} md={8} lg={6}>
<ItemCard item={item} onChanged={onChanged} />
</Col>
))}
</Row>
);
}
export default function App() { export default function App() {
const [items, setItems] = useState<Item[]>([]); const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -108,6 +180,13 @@ export default function App() {
fetchFilterOptions().then(setOptions).catch(() => undefined); fetchFilterOptions().then(setOptions).catch(() => undefined);
}, [load]); }, [load]);
const handleRetry = useCallback(() => {
setLoading(true);
void load();
}, [load]);
const openAuthModal = useCallback(() => setAuthModalOpen(true), []);
const activeCount = activeFilterCount(filters); const activeCount = activeFilterCount(filters);
return ( return (
@@ -172,38 +251,17 @@ export default function App() {
</div> </div>
{loading && !items.length && !failed ? <Spin /> : null} {loading && !items.length && !failed ? <Spin /> : null}
{failed ? ( <Catalogue
<Alert failed={failed}
type="error" loading={loading}
showIcon items={items}
message="Couldn't load items" filters={filters}
description="The server didn't return the catalogue. This is usually temporary." needsFavoritesAuth={needsFavoritesAuth}
action={<Button size="small" onClick={() => { setLoading(true); void load(); }}>Retry</Button>} onRetry={handleRetry}
onSignIn={openAuthModal}
onClearFilters={clearFilters}
onChanged={reload}
/> />
) : needsFavoritesAuth ? (
<Empty description="Sign in to see the items you have favorited">
<Button type="primary" onClick={() => setAuthModalOpen(true)}>Sign in</Button>
<Button style={{ marginInlineStart: 8 }} onClick={clearFilters}>Browse everything</Button>
</Empty>
) : !loading && !items.length ? (
<Empty
description={
hasActiveFilters(filters)
? 'No items match these filters'
: 'No items yet — check back soon'
}
>
{hasActiveFilters(filters) ? <Button onClick={clearFilters}>Clear filters</Button> : null}
</Empty>
) : (
<Row gutter={[20, 20]}>
{items.map(item => (
<Col key={item.id} xs={24} sm={12} md={8} lg={6}>
<ItemCard item={item} onChanged={reload} />
</Col>
))}
</Row>
)}
</Content> </Content>
<Footer style={{ textAlign: 'center', background: token.colorBgContainer }}> <Footer style={{ textAlign: 'center', background: token.colorBgContainer }}>
<Link to="/privacy">Privacy Policy</Link> <Link to="/privacy">Privacy Policy</Link>
+5 -1
View File
@@ -26,6 +26,10 @@ import { ItemFilters, EMPTY_FILTERS } from '../filters';
const { Header, Content } = Layout; const { Header, Content } = Layout;
const { Title } = Typography; const { Title } = Typography;
// Anything not sold or reserved is available, so green is the default rather
// than a third entry — a new status shows up green instead of crashing.
const STATUS_TAG_COLORS: Record<string, string> = { sold: 'red', reserved: 'orange' };
function Inventory() { function Inventory() {
const [items, setItems] = useState<Item[]>([]); const [items, setItems] = useState<Item[]>([]);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
@@ -196,7 +200,7 @@ function Inventory() {
title: 'Status', title: 'Status',
dataIndex: 'status', dataIndex: 'status',
render: (status: string) => ( render: (status: string) => (
<Tag color={status === 'sold' ? 'red' : status === 'reserved' ? 'orange' : 'green'}>{status.toUpperCase()}</Tag> <Tag color={STATUS_TAG_COLORS[status] ?? 'green'}>{status.toUpperCase()}</Tag>
) )
}, },
{ {
+8 -1
View File
@@ -117,6 +117,13 @@ export default function Categories() {
} }
} }
// Pluralized in one place because the count drives both whether the clause
// appears at all and which suffix it takes.
function describeSubcategories(count: number): string {
if (count === 0) return 'no subcategories';
return `${count} subcategor${count === 1 ? 'y' : 'ies'}`;
}
function handleDelete(category: Category) { function handleDelete(category: Category) {
const node = findNode(tree, category.id); const node = findNode(tree, category.id);
const totals = node ? branchTotals(node) : { categories: 1, items: category.item_count }; const totals = node ? branchTotals(node) : { categories: 1, items: category.item_count };
@@ -126,7 +133,7 @@ export default function Categories() {
title: `Delete "${category.name}"?`, title: `Delete "${category.name}"?`,
content: ( content: (
<span> <span>
This deletes {subcategories === 0 ? 'no subcategories' : `${subcategories} subcategor${subcategories === 1 ? 'y' : 'ies'}`} This deletes {describeSubcategories(subcategories)}
{' '}and uncategorizes {totals.items} item{totals.items === 1 ? '' : 's'}. The items themselves are kept. {' '}and uncategorizes {totals.items} item{totals.items === 1 ? '' : 's'}. The items themselves are kept.
</span> </span>
), ),
+332 -216
View File
@@ -9,55 +9,11 @@ import {
const { Title, Text } = Typography; const { Title, Text } = Typography;
export default function Customers() { // The two halves of the disable/re-enable confirm, as components rather than
const [customers, setCustomers] = useState<CustomerSummary[]>([]); // branches inside the handler: every piece of copy differs between them, so
const [loading, setLoading] = useState(true); // one decision up front reads better than the same condition asked five times.
const [detail, setDetail] = useState<CustomerDetail | null>(null); function DisableWarning({ customer }: Readonly<{ customer: CustomerSummary }>) {
const [detailLoading, setDetailLoading] = useState(false); return (
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);
const [togglingId, setTogglingId] = useState<number | null>(null);
function load() {
return fetchCustomers()
.then(rows => { setCustomers(rows); setLoading(false); })
// Without this a failed load leaves the spinner up forever, with no
// indication that anything went wrong.
.catch(() => { setLoading(false); message.error('Could not load customers'); });
}
useEffect(() => { void load(); }, []);
async function openDetail(id: number) {
setDrawerOpen(true);
setDetailLoading(true);
const data = await fetchCustomerDetail(id);
setDetail(data);
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);
}
}
function handleToggleDisabled(customer: CustomerSummary) {
const disabling = !customer.disabled_at;
Modal.confirm({
title: disabling ? `Disable ${customer.email}?` : `Re-enable ${customer.email}?`,
content: disabling ? (
<span> <span>
They will be signed out everywhere immediately and told the account is disabled if they They will be signed out everywhere immediately and told the account is disabled if they
try to sign in. try to sign in.
@@ -68,163 +24,29 @@ export default function Customers() {
{' '}Self-service data export and account deletion stop working too, so any such request {' '}Self-service data export and account deletion stop working too, so any such request
has to be handled by hand. has to be handled by hand.
</span> </span>
) : ( );
}
function ReEnableWarning() {
return (
<span> <span>
They will be able to sign in again. Items released when the account was disabled are not They will be able to sign in again. Items released when the account was disabled are not
returned those may already have sold. returned those may already have sold.
</span> </span>
), );
okText: disabling ? 'Disable' : 'Re-enable', }
okButtonProps: { danger: disabling },
onOk: async () => {
setTogglingId(customer.id);
try {
await setCustomerDisabled(customer.id, disabling);
} catch (err) {
message.error(`Couldn't ${disabling ? 'disable' : 're-enable'}${(err as Error).message}`);
return;
} finally {
setTogglingId(null);
}
message.success(disabling ? 'Account disabled' : 'Account re-enabled');
void load();
}
});
}
async function handleRelease(item: ReservedItem) { // The customer drawer's body. Extracted because its states — loading, then a
if (!reservedFor) return; // description list, then either an empty order history or a table — are all
setReleasing(item.item_id); // branches, and every one of them counted toward Customers().
try { function CustomerDetailPanel({
await releaseReservedItem(reservedFor.id, item.item_id); detail,
} catch (err) { loading
message.error(`Couldn't release "${item.name}" — ${(err as Error).message}`); }: Readonly<{ detail: CustomerDetail | null; loading: boolean }>) {
return; if (loading || !detail) {
} finally { return <Spin />;
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));
void load();
}
const columns: ColumnsType<CustomerSummary> = [
{
title: 'Customer',
dataIndex: 'email',
sorter: (a, b) => a.email.localeCompare(b.email),
render: (email: string, row: CustomerSummary) => (
<div>
<div>{row.name || <span style={{ opacity: 0.5 }}>No name</span>}</div>
<div style={{ fontSize: 12, opacity: 0.65 }}>{email}</div>
</div>
)
},
{
title: 'Verified',
dataIndex: 'email_verified',
filters: [{ text: 'Verified', value: true }, { text: 'Unverified', value: false }],
onFilter: (value, row) => row.email_verified === value,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? 'Verified' : 'Unverified'}</Tag>
},
{
title: 'Subscribed',
dataIndex: 'marketing_consent',
filters: [{ text: 'Subscribed', value: true }, { text: 'Not subscribed', value: false }],
onFilter: (value, row) => row.marketing_consent === value,
render: (v: boolean) => <Tag color={v ? 'blue' : 'default'}>{v ? 'Yes' : 'No'}</Tag>
},
{
title: 'Status',
dataIndex: 'disabled_at',
render: (disabledAt: string | null) =>
disabledAt
? <Tag color="red">DISABLED</Tag>
: <Tag color="green">ACTIVE</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(); void openReserved(customer); }}
>
{count} item{Number(count) === 1 ? '' : 's'}
</Button>
) : (
<Text type="secondary">0</Text>
)
},
{
title: 'Orders',
dataIndex: 'order_count',
sorter: (a, b) => a.order_count - b.order_count,
defaultSortOrder: 'descend'
},
{
title: 'Total Spent',
dataIndex: 'total_spent_cents',
sorter: (a, b) => a.total_spent_cents - b.total_spent_cents,
render: (v: number) => `$${(v / 100).toFixed(2)}`
},
{
title: 'Last Order',
dataIndex: 'last_order_at',
sorter: (a, b) => new Date(a.last_order_at || 0).getTime() - new Date(b.last_order_at || 0).getTime(),
render: (v: string | null) => (v ? new Date(v).toLocaleDateString() : '—')
},
{
title: '',
key: 'actions',
render: (_: unknown, customer: CustomerSummary) => (
<Button
size="small"
danger={!customer.disabled_at}
loading={togglingId === customer.id}
// The row opens the detail drawer, so this must not bubble.
onClick={(event) => { event.stopPropagation(); handleToggleDisabled(customer); }}
>
{customer.disabled_at ? 'Re-enable' : 'Disable'}
</Button>
)
},
{
title: 'Joined',
dataIndex: 'created_at',
sorter: (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
render: (v: string) => new Date(v).toLocaleDateString()
}
];
return ( return (
<div>
<Title level={4}>Customers</Title>
<Table
rowKey="id"
loading={loading}
dataSource={customers}
columns={columns}
onRow={row => ({ onClick: () => void openDetail(row.id), style: { cursor: 'pointer' } })}
pagination={{ pageSize: 10 }}
/>
<Drawer
title={detail?.customer.name || detail?.customer.email || 'Customer'}
open={drawerOpen}
onClose={() => { setDrawerOpen(false); setDetail(null); }}
width={480}
>
{detailLoading || !detail ? (
<Spin />
) : (
<> <>
<Descriptions column={1} size="small" bordered> <Descriptions column={1} size="small" bordered>
<Descriptions.Item label="Email">{detail.customer.email}</Descriptions.Item> <Descriptions.Item label="Email">{detail.customer.email}</Descriptions.Item>
@@ -271,22 +93,29 @@ export default function Customers() {
/> />
)} )}
</> </>
)} );
</Drawer> }
<Modal // The reserved-items dialog body, extracted for the same reason: three
title={reservedFor ? `Items reserved by ${reservedFor.email}` : 'Reserved items'} // mutually exclusive states rendered as three separate conditionals.
open={reservedFor !== null} function ReservedItemsBody({
onCancel={() => setReservedFor(null)} loading,
footer={null} reserved,
destroyOnHidden releasing,
width={640} onRelease
> }: Readonly<{
{reservedLoading ? <Spin /> : null} loading: boolean;
{!reservedLoading && !reserved.length ? ( reserved: ReservedItem[];
<Empty description="This customer isn't holding any items" /> releasing: number | null;
) : null} onRelease: (item: ReservedItem) => void;
{!reservedLoading && reserved.length > 0 && ( }>) {
if (loading) {
return <Spin />;
}
if (!reserved.length) {
return <Empty description="This customer isn't holding any items" />;
}
return (
<Table <Table
rowKey="item_id" rowKey="item_id"
dataSource={reserved} dataSource={reserved}
@@ -311,7 +140,7 @@ export default function Customers() {
size="small" size="small"
danger danger
loading={releasing === item.item_id} loading={releasing === item.item_id}
onClick={() => handleRelease(item)} onClick={() => onRelease(item)}
> >
Release Release
</Button> </Button>
@@ -319,7 +148,294 @@ export default function Customers() {
} }
]} ]}
/> />
)} );
}
// Cell renderers live at module level for the same reason the confirm does:
// every branch inside a column's render callback counted toward Customers(),
// and a table with nine columns is mostly branches.
function NameAndEmail({ email, name }: Readonly<{ email: string; name: string | null }>) {
return (
<div>
<div>{name || <span style={{ opacity: 0.5 }}>No name</span>}</div>
<div style={{ fontSize: 12, opacity: 0.65 }}>{email}</div>
</div>
);
}
function BooleanTag({
value,
onColor,
onLabel,
offLabel
}: Readonly<{ value: boolean; onColor: string; onLabel: string; offLabel: string }>) {
return <Tag color={value ? onColor : 'default'}>{value ? onLabel : offLabel}</Tag>;
}
function ReservedCell({
count,
customer,
onOpen
}: Readonly<{ count: number; customer: CustomerSummary; onOpen: (c: CustomerSummary) => void }>) {
if (Number(count) <= 0) {
return <Text type="secondary">0</Text>;
}
return (
<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(); onOpen(customer); }}
>
{count} item{Number(count) === 1 ? '' : 's'}
</Button>
);
}
function ToggleDisabledButton({
customer,
busy,
onToggle
}: Readonly<{ customer: CustomerSummary; busy: boolean; onToggle: (c: CustomerSummary) => void }>) {
return (
<Button
size="small"
danger={!customer.disabled_at}
loading={busy}
// The row opens the detail drawer, so this must not bubble.
onClick={(event) => { event.stopPropagation(); onToggle(customer); }}
>
{customer.disabled_at ? 'Re-enable' : 'Disable'}
</Button>
);
}
// Dates arrive as ISO strings or null; an em dash reads better than "Invalid Date".
function formatDate(value: string | null): string {
return value ? new Date(value).toLocaleDateString() : '—';
}
// Module-level rather than nested in Customers, because a function's cognitive
// complexity counts everything declared inside it.
function confirmToggleDisabled(
customer: CustomerSummary,
setTogglingId: (id: number | null) => void,
reload: () => void
) {
const disabling = !customer.disabled_at;
// Chosen once. Asking `disabling` again for each field is what put this
// function over the complexity limit even after it was hoisted out.
const copy = disabling
? {
title: `Disable ${customer.email}?`,
content: <DisableWarning customer={customer} />,
okText: 'Disable',
verb: 'disable',
done: 'Account disabled'
}
: {
title: `Re-enable ${customer.email}?`,
content: <ReEnableWarning />,
okText: 'Re-enable',
verb: 're-enable',
done: 'Account re-enabled'
};
Modal.confirm({
title: copy.title,
content: copy.content,
okText: copy.okText,
okButtonProps: { danger: disabling },
onOk: async () => {
setTogglingId(customer.id);
try {
await setCustomerDisabled(customer.id, disabling);
} catch (err) {
message.error(`Couldn't ${copy.verb}${(err as Error).message}`);
return;
} finally {
setTogglingId(null);
}
message.success(copy.done);
reload();
}
});
}
export default function Customers() {
const [customers, setCustomers] = useState<CustomerSummary[]>([]);
const [loading, setLoading] = useState(true);
const [detail, setDetail] = useState<CustomerDetail | null>(null);
const [detailLoading, setDetailLoading] = 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);
const [togglingId, setTogglingId] = useState<number | null>(null);
function load() {
return fetchCustomers()
.then(rows => { setCustomers(rows); setLoading(false); })
// Without this a failed load leaves the spinner up forever, with no
// indication that anything went wrong.
.catch(() => { setLoading(false); message.error('Could not load customers'); });
}
useEffect(() => { void load(); }, []);
async function openDetail(id: number) {
setDrawerOpen(true);
setDetailLoading(true);
const data = await fetchCustomerDetail(id);
setDetail(data);
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);
}
}
function handleToggleDisabled(customer: CustomerSummary) {
confirmToggleDisabled(customer, setTogglingId, () => void load());
}
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));
void load();
}
const columns: ColumnsType<CustomerSummary> = [
{
title: 'Customer',
dataIndex: 'email',
sorter: (a, b) => a.email.localeCompare(b.email),
render: (email: string, row: CustomerSummary) => <NameAndEmail email={email} name={row.name} />
},
{
title: 'Verified',
dataIndex: 'email_verified',
filters: [{ text: 'Verified', value: true }, { text: 'Unverified', value: false }],
onFilter: (value, row) => row.email_verified === value,
render: (v: boolean) => <BooleanTag value={v} onColor="green" onLabel="Verified" offLabel="Unverified" />
},
{
title: 'Subscribed',
dataIndex: 'marketing_consent',
filters: [{ text: 'Subscribed', value: true }, { text: 'Not subscribed', value: false }],
onFilter: (value, row) => row.marketing_consent === value,
render: (v: boolean) => <BooleanTag value={v} onColor="blue" onLabel="Yes" offLabel="No" />
},
{
title: 'Status',
dataIndex: 'disabled_at',
render: (disabledAt: string | null) => (
<BooleanTag value={!disabledAt} onColor="green" onLabel="ACTIVE" offLabel="DISABLED" />
)
},
{
title: 'Reserved',
dataIndex: 'reserved_count',
render: (count: number, customer: CustomerSummary) => (
<ReservedCell count={count} customer={customer} onOpen={(c) => void openReserved(c)} />
)
},
{
title: 'Orders',
dataIndex: 'order_count',
sorter: (a, b) => a.order_count - b.order_count,
defaultSortOrder: 'descend'
},
{
title: 'Total Spent',
dataIndex: 'total_spent_cents',
sorter: (a, b) => a.total_spent_cents - b.total_spent_cents,
render: (v: number) => `$${(v / 100).toFixed(2)}`
},
{
title: 'Last Order',
dataIndex: 'last_order_at',
sorter: (a, b) => new Date(a.last_order_at || 0).getTime() - new Date(b.last_order_at || 0).getTime(),
render: (v: string | null) => formatDate(v)
},
{
title: '',
key: 'actions',
render: (_: unknown, customer: CustomerSummary) => (
<ToggleDisabledButton
customer={customer}
busy={togglingId === customer.id}
onToggle={handleToggleDisabled}
/>
)
},
{
title: 'Joined',
dataIndex: 'created_at',
sorter: (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
render: (v: string) => formatDate(v)
}
];
return (
<div>
<Title level={4}>Customers</Title>
<Table
rowKey="id"
loading={loading}
dataSource={customers}
columns={columns}
onRow={row => ({ onClick: () => void openDetail(row.id), style: { cursor: 'pointer' } })}
pagination={{ pageSize: 10 }}
/>
<Drawer
title={detail?.customer.name || detail?.customer.email || 'Customer'}
open={drawerOpen}
onClose={() => { setDrawerOpen(false); setDetail(null); }}
width={480}
>
<CustomerDetailPanel detail={detail} loading={detailLoading} />
</Drawer>
<Modal
title={reservedFor ? `Items reserved by ${reservedFor.email}` : 'Reserved items'}
open={reservedFor !== null}
onCancel={() => setReservedFor(null)}
footer={null}
destroyOnHidden
width={640}
>
<ReservedItemsBody
loading={reservedLoading}
reserved={reserved}
releasing={releasing}
onRelease={handleRelease}
/>
</Modal> </Modal>
</div> </div>
); );
+1 -1
View File
@@ -157,7 +157,7 @@ export default function Cart() {
<List <List
dataSource={items} dataSource={items}
renderItem={item => ( renderItem={item => (
<List.Item actions={[<Button danger size="small" onClick={() => handleRemove(item.item_id)}>Remove</Button>]}> <List.Item actions={[<Button key="remove" danger size="small" onClick={() => handleRemove(item.item_id)}>Remove</Button>]}>
<List.Item.Meta <List.Item.Meta
avatar={item.images[0] && <img src={item.images[0].image_path} alt="" style={{ width: 60, height: 60, objectFit: 'cover' }} />} avatar={item.images[0] && <img src={item.images[0].image_path} alt="" style={{ width: 60, height: 60, objectFit: 'cover' }} />}
title={item.name} title={item.name}
+7 -3
View File
@@ -1,4 +1,4 @@
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'; import React, { createContext, useContext, useEffect, useMemo, useState, useCallback } from 'react';
import { CartItem, fetchCart } from './cartApi'; import { CartItem, fetchCart } from './cartApi';
import { useCustomerAuth } from '../customer/CustomerAuthContext'; import { useCustomerAuth } from '../customer/CustomerAuthContext';
@@ -32,10 +32,14 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
} }
}, [customer, refresh]); }, [customer, refresh]);
const itemIds = new Set(items.map(i => i.item_id)); const itemIds = useMemo(() => new Set(items.map(i => i.item_id)), [items]);
// Memoized because this provider wraps the whole storefront: a new object
// here re-renders every consumer on any parent render, cart unchanged.
const value = useMemo(() => ({ items, itemIds, refresh }), [items, itemIds, refresh]);
return ( return (
<CartContext.Provider value={{ items, itemIds, refresh }}> <CartContext.Provider value={value}>
{children} {children}
</CartContext.Provider> </CartContext.Provider>
); );
@@ -1,4 +1,4 @@
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'; import React, { createContext, useContext, useEffect, useMemo, useState, useCallback } from 'react';
import { Customer, fetchMe, logoutCustomer } from './customerApi'; import { Customer, fetchMe, logoutCustomer } from './customerApi';
interface CustomerAuthValue { interface CustomerAuthValue {
@@ -49,8 +49,15 @@ export function CustomerAuthProvider({ children }: { children: React.ReactNode }
setLoading(false); setLoading(false);
}, []); }, []);
// Memoized because this is the outermost provider: an unmemoized value makes
// every signed-in-aware component in the tree re-render on any parent render.
const value = useMemo(
() => ({ customer, loading, refresh, logout }),
[customer, loading, refresh, logout]
);
return ( return (
<CustomerAuthContext.Provider value={{ customer, loading, refresh, logout }}> <CustomerAuthContext.Provider value={value}>
{children} {children}
</CustomerAuthContext.Provider> </CustomerAuthContext.Provider>
); );
+7 -3
View File
@@ -1,4 +1,4 @@
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'; import React, { createContext, useContext, useEffect, useMemo, useState, useCallback } from 'react';
import { Favorite, fetchFavorites } from './favoritesApi'; import { Favorite, fetchFavorites } from './favoritesApi';
import { useCustomerAuth } from './CustomerAuthContext'; import { useCustomerAuth } from './CustomerAuthContext';
@@ -39,10 +39,14 @@ export function FavoritesProvider({ children }: { children: React.ReactNode }) {
} }
}, [customer, refresh]); }, [customer, refresh]);
const itemIds = new Set(favorites.map(f => f.item_id)); const itemIds = useMemo(() => new Set(favorites.map(f => f.item_id)), [favorites]);
// See CartProvider: an unmemoized value re-renders every ItemCard in the
// catalogue whenever anything above this provider renders.
const value = useMemo(() => ({ favorites, itemIds, refresh }), [favorites, itemIds, refresh]);
return ( return (
<FavoritesContext.Provider value={{ favorites, itemIds, refresh }}> <FavoritesContext.Provider value={value}>
{children} {children}
</FavoritesContext.Provider> </FavoritesContext.Provider>
); );
+1 -1
View File
@@ -88,7 +88,7 @@ function AppRoutes() {
return ( return (
<> <>
<Routes location={backdrop as Location}> <Routes location={backdrop}>
<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 />} />