Feature/81 technical debt #82

Merged
bermudalamb merged 2 commits from feature/81-technical-debt into main 2026-08-20 15:08:20 -05:00
11 changed files with 509 additions and 262 deletions
+59 -34
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;
const trimmed = part.trim(); }
if (trimmed === '') { for (const part of raw.split(',')) {
continue; const trimmed = part.trim();
} if (trimmed === '') {
const id = parseId(trimmed, 'tags'); continue;
// Duplicates would inflate the required-match count below and make the }
// filter match nothing at all. const id = parseId(trimmed, 'tags');
if (!tagIds.includes(id)) { // Duplicates would inflate the required-match count in buildItemFilterSql
tagIds.push(id); // and make the filter match nothing at all.
} if (!tagIds.includes(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
+90 -32
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}
) : needsFavoritesAuth ? ( onClearFilters={clearFilters}
<Empty description="Sign in to see the items you have favorited"> onChanged={reload}
<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>
), ),
+279 -163
View File
@@ -9,6 +9,261 @@ import {
const { Title, Text } = Typography; const { Title, Text } = Typography;
// The two halves of the disable/re-enable confirm, as components rather than
// branches inside the handler: every piece of copy differs between them, so
// one decision up front reads better than the same condition asked five times.
function DisableWarning({ customer }: Readonly<{ customer: CustomerSummary }>) {
return (
<span>
They will be signed out everywhere immediately and told the account is disabled if they
try to sign in.
{customer.reserved_count > 0 && (
<> Their {customer.reserved_count} reserved item
{customer.reserved_count === 1 ? '' : 's'} will be released back to the storefront.</>
)}
{' '}Self-service data export and account deletion stop working too, so any such request
has to be handled by hand.
</span>
);
}
function ReEnableWarning() {
return (
<span>
They will be able to sign in again. Items released when the account was disabled are not
returned those may already have sold.
</span>
);
}
// The customer drawer's body. Extracted because its states — loading, then a
// description list, then either an empty order history or a table — are all
// branches, and every one of them counted toward Customers().
function CustomerDetailPanel({
detail,
loading
}: Readonly<{ detail: CustomerDetail | null; loading: boolean }>) {
if (loading || !detail) {
return <Spin />;
}
return (
<>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="Email">{detail.customer.email}</Descriptions.Item>
<Descriptions.Item label="Verified">
<Tag color={detail.customer.email_verified ? 'green' : 'default'}>
{detail.customer.email_verified ? 'Verified' : 'Unverified'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Marketing consent">
<Tag color={detail.customer.marketing_consent ? 'blue' : 'default'}>
{detail.customer.marketing_consent ? 'Subscribed' : 'Not subscribed'}
</Tag>
{detail.customer.marketing_consent_at && (
<div style={{ fontSize: 12, opacity: 0.65, marginTop: 4 }}>
since {new Date(detail.customer.marketing_consent_at).toLocaleDateString()}
</div>
)}
</Descriptions.Item>
<Descriptions.Item label="Joined">
{new Date(detail.customer.created_at).toLocaleDateString()}
</Descriptions.Item>
</Descriptions>
<Title level={5} style={{ marginTop: 24 }}>Order History</Title>
{detail.orders.length === 0 ? (
<Empty description="No orders yet" />
) : (
<Table
rowKey="id"
size="small"
dataSource={detail.orders}
pagination={false}
columns={[
{ title: 'Item', dataIndex: 'item_name' },
{ title: 'Amount', dataIndex: 'amount_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` },
{
title: 'Processor',
dataIndex: 'processor',
render: (v: string) => <Tag>{v}</Tag>
},
{ title: 'Status', dataIndex: 'status' },
{ title: 'Date', dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleDateString() }
]}
/>
)}
</>
);
}
// The reserved-items dialog body, extracted for the same reason: three
// mutually exclusive states rendered as three separate conditionals.
function ReservedItemsBody({
loading,
reserved,
releasing,
onRelease
}: Readonly<{
loading: boolean;
reserved: ReservedItem[];
releasing: number | null;
onRelease: (item: ReservedItem) => void;
}>) {
if (loading) {
return <Spin />;
}
if (!reserved.length) {
return <Empty description="This customer isn't holding any items" />;
}
return (
<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={() => onRelease(item)}
>
Release
</Button>
)
}
]}
/>
);
}
// 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() { export default function Customers() {
const [customers, setCustomers] = useState<CustomerSummary[]>([]); const [customers, setCustomers] = useState<CustomerSummary[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -53,43 +308,7 @@ export default function Customers() {
} }
function handleToggleDisabled(customer: CustomerSummary) { function handleToggleDisabled(customer: CustomerSummary) {
const disabling = !customer.disabled_at; confirmToggleDisabled(customer, setTogglingId, () => void load());
Modal.confirm({
title: disabling ? `Disable ${customer.email}?` : `Re-enable ${customer.email}?`,
content: disabling ? (
<span>
They will be signed out everywhere immediately and told the account is disabled if they
try to sign in.
{customer.reserved_count > 0 && (
<> Their {customer.reserved_count} reserved item
{customer.reserved_count === 1 ? '' : 's'} will be released back to the storefront.</>
)}
{' '}Self-service data export and account deletion stop working too, so any such request
has to be handled by hand.
</span>
) : (
<span>
They will be able to sign in again. Items released when the account was disabled are not
returned those may already have sold.
</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) { async function handleRelease(item: ReservedItem) {
@@ -115,53 +334,35 @@ export default function Customers() {
title: 'Customer', title: 'Customer',
dataIndex: 'email', dataIndex: 'email',
sorter: (a, b) => a.email.localeCompare(b.email), sorter: (a, b) => a.email.localeCompare(b.email),
render: (email: string, row: CustomerSummary) => ( render: (email: string, row: CustomerSummary) => <NameAndEmail email={email} name={row.name} />
<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', title: 'Verified',
dataIndex: 'email_verified', dataIndex: 'email_verified',
filters: [{ text: 'Verified', value: true }, { text: 'Unverified', value: false }], filters: [{ text: 'Verified', value: true }, { text: 'Unverified', value: false }],
onFilter: (value, row) => row.email_verified === value, onFilter: (value, row) => row.email_verified === value,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? 'Verified' : 'Unverified'}</Tag> render: (v: boolean) => <BooleanTag value={v} onColor="green" onLabel="Verified" offLabel="Unverified" />
}, },
{ {
title: 'Subscribed', title: 'Subscribed',
dataIndex: 'marketing_consent', dataIndex: 'marketing_consent',
filters: [{ text: 'Subscribed', value: true }, { text: 'Not subscribed', value: false }], filters: [{ text: 'Subscribed', value: true }, { text: 'Not subscribed', value: false }],
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) => <BooleanTag value={v} onColor="blue" onLabel="Yes" offLabel="No" />
}, },
{ {
title: 'Status', title: 'Status',
dataIndex: 'disabled_at', dataIndex: 'disabled_at',
render: (disabledAt: string | null) => render: (disabledAt: string | null) => (
disabledAt <BooleanTag value={!disabledAt} onColor="green" onLabel="ACTIVE" offLabel="DISABLED" />
? <Tag color="red">DISABLED</Tag> )
: <Tag color="green">ACTIVE</Tag>
}, },
{ {
title: 'Reserved', title: 'Reserved',
dataIndex: 'reserved_count', dataIndex: 'reserved_count',
render: (count: number, customer: CustomerSummary) => render: (count: number, customer: CustomerSummary) => (
Number(count) > 0 ? ( <ReservedCell count={count} customer={customer} onOpen={(c) => void openReserved(c)} />
<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', title: 'Orders',
@@ -179,28 +380,24 @@ export default function Customers() {
title: 'Last Order', title: 'Last Order',
dataIndex: 'last_order_at', dataIndex: 'last_order_at',
sorter: (a, b) => new Date(a.last_order_at || 0).getTime() - new Date(b.last_order_at || 0).getTime(), 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() : '—') render: (v: string | null) => formatDate(v)
}, },
{ {
title: '', title: '',
key: 'actions', key: 'actions',
render: (_: unknown, customer: CustomerSummary) => ( render: (_: unknown, customer: CustomerSummary) => (
<Button <ToggleDisabledButton
size="small" customer={customer}
danger={!customer.disabled_at} busy={togglingId === customer.id}
loading={togglingId === customer.id} onToggle={handleToggleDisabled}
// 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', title: 'Joined',
dataIndex: 'created_at', dataIndex: 'created_at',
sorter: (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(), sorter: (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
render: (v: string) => new Date(v).toLocaleDateString() render: (v: string) => formatDate(v)
} }
]; ];
@@ -222,56 +419,7 @@ export default function Customers() {
onClose={() => { setDrawerOpen(false); setDetail(null); }} onClose={() => { setDrawerOpen(false); setDetail(null); }}
width={480} width={480}
> >
{detailLoading || !detail ? ( <CustomerDetailPanel detail={detail} loading={detailLoading} />
<Spin />
) : (
<>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="Email">{detail.customer.email}</Descriptions.Item>
<Descriptions.Item label="Verified">
<Tag color={detail.customer.email_verified ? 'green' : 'default'}>
{detail.customer.email_verified ? 'Verified' : 'Unverified'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Marketing consent">
<Tag color={detail.customer.marketing_consent ? 'blue' : 'default'}>
{detail.customer.marketing_consent ? 'Subscribed' : 'Not subscribed'}
</Tag>
{detail.customer.marketing_consent_at && (
<div style={{ fontSize: 12, opacity: 0.65, marginTop: 4 }}>
since {new Date(detail.customer.marketing_consent_at).toLocaleDateString()}
</div>
)}
</Descriptions.Item>
<Descriptions.Item label="Joined">
{new Date(detail.customer.created_at).toLocaleDateString()}
</Descriptions.Item>
</Descriptions>
<Title level={5} style={{ marginTop: 24 }}>Order History</Title>
{detail.orders.length === 0 ? (
<Empty description="No orders yet" />
) : (
<Table
rowKey="id"
size="small"
dataSource={detail.orders}
pagination={false}
columns={[
{ title: 'Item', dataIndex: 'item_name' },
{ title: 'Amount', dataIndex: 'amount_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` },
{
title: 'Processor',
dataIndex: 'processor',
render: (v: string) => <Tag>{v}</Tag>
},
{ title: 'Status', dataIndex: 'status' },
{ title: 'Date', dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleDateString() }
]}
/>
)}
</>
)}
</Drawer> </Drawer>
<Modal <Modal
@@ -282,44 +430,12 @@ export default function Customers() {
destroyOnHidden destroyOnHidden
width={640} width={640}
> >
{reservedLoading ? <Spin /> : null} <ReservedItemsBody
{!reservedLoading && !reserved.length ? ( loading={reservedLoading}
<Empty description="This customer isn't holding any items" /> reserved={reserved}
) : null} releasing={releasing}
{!reservedLoading && reserved.length > 0 && ( onRelease={handleRelease}
<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> </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 />} />