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
Showing only changes of commit 4bad40868c - Show all commits
+271 -167
View File
@@ -9,53 +9,260 @@ import {
const { Title, Text } = Typography; const { Title, Text } = Typography;
// Module-level rather than nested in Customers: a function's cognitive // The two halves of the disable/re-enable confirm, as components rather than
// complexity includes everything declared inside it, and this confirm — with a // branches inside the handler: every piece of copy differs between them, so
// branch on `disabling` for each of the title, body, button label and both // one decision up front reads better than the same condition asked five times.
// outcome messages — was most of the component's score on its own. 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( function confirmToggleDisabled(
customer: CustomerSummary, customer: CustomerSummary,
setTogglingId: (id: number | null) => void, setTogglingId: (id: number | null) => void,
reload: () => void reload: () => void
) { ) {
const disabling = !customer.disabled_at; const disabling = !customer.disabled_at;
// Chosen once. Asking `disabling` again for each field is what put this
Modal.confirm({ // function over the complexity limit even after it was hoisted out.
title: disabling ? `Disable ${customer.email}?` : `Re-enable ${customer.email}?`, const copy = disabling
content: disabling ? ( ? {
<span> title: `Disable ${customer.email}?`,
They will be signed out everywhere immediately and told the account is disabled if they content: <DisableWarning customer={customer} />,
try to sign in. okText: 'Disable',
{customer.reserved_count > 0 && ( verb: 'disable',
<> Their {customer.reserved_count} reserved item done: 'Account disabled'
{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');
reload();
} }
}); : {
} 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[]>([]);
@@ -127,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',
@@ -191,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)
} }
]; ];
@@ -234,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
@@ -294,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>
); );