Feature/81 technical debt #82
+51
-26
@@ -81,51 +81,76 @@ function parsePrice(value: unknown, name: string): number | null {
|
||||
return parseNonNegativeInteger(raw, name);
|
||||
}
|
||||
|
||||
export function parseItemFilters(query: Record<string, unknown>): ItemFilters {
|
||||
const categoryRaw = singleValue(query.category, 'category');
|
||||
const categoryId = categoryRaw === null || categoryRaw === '' ? null : parseId(categoryRaw, 'category');
|
||||
function parseCategoryId(value: unknown): number | null {
|
||||
const raw = singleValue(value, '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[] = [];
|
||||
if (tagsRaw) {
|
||||
for (const part of tagsRaw.split(',')) {
|
||||
if (!raw) {
|
||||
return tagIds;
|
||||
}
|
||||
for (const part of raw.split(',')) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed === '') {
|
||||
continue;
|
||||
}
|
||||
const id = parseId(trimmed, 'tags');
|
||||
// Duplicates would inflate the required-match count below and make the
|
||||
// filter match nothing at all.
|
||||
// Duplicates would inflate the required-match count in buildItemFilterSql
|
||||
// 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 maxPriceCents = parsePrice(query.max_price, 'max_price');
|
||||
if (minPriceCents !== null && maxPriceCents !== null && minPriceCents > maxPriceCents) {
|
||||
throw new FilterError('min_price may not exceed max_price');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
const status = parseStatus(query.status);
|
||||
const favoritesOnly = parseFavoritesOnly(query.favorites);
|
||||
|
||||
return { categoryId, tagIds, minPriceCents, maxPriceCents, status, favoritesOnly };
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
const id = Number(req.params.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;
|
||||
}
|
||||
|
||||
let parent = existing.rows[0].parent_id;
|
||||
if (req.body.parent_id !== undefined) {
|
||||
const parsed = readParentId(req.body.parent_id);
|
||||
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 resolved = await resolveParentId(req.body.parent_id, id, existing.rows[0].parent_id);
|
||||
if ('error' in resolved) {
|
||||
return res.status(400).json({ error: resolved.error });
|
||||
}
|
||||
const parent = resolved.parent;
|
||||
|
||||
const sortOrder = Number.isSafeInteger(req.body.sort_order)
|
||||
? req.body.sort_order
|
||||
|
||||
+89
-31
@@ -26,6 +26,78 @@ const { Title } = Typography;
|
||||
// would become its own request.
|
||||
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() {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -108,6 +180,13 @@ export default function App() {
|
||||
fetchFilterOptions().then(setOptions).catch(() => undefined);
|
||||
}, [load]);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
setLoading(true);
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const openAuthModal = useCallback(() => setAuthModalOpen(true), []);
|
||||
|
||||
const activeCount = activeFilterCount(filters);
|
||||
|
||||
return (
|
||||
@@ -172,38 +251,17 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
{loading && !items.length && !failed ? <Spin /> : null}
|
||||
{failed ? (
|
||||
<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={() => { setLoading(true); void load(); }}>Retry</Button>}
|
||||
<Catalogue
|
||||
failed={failed}
|
||||
loading={loading}
|
||||
items={items}
|
||||
filters={filters}
|
||||
needsFavoritesAuth={needsFavoritesAuth}
|
||||
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>
|
||||
<Footer style={{ textAlign: 'center', background: token.colorBgContainer }}>
|
||||
<Link to="/privacy">Privacy Policy</Link>
|
||||
|
||||
@@ -26,6 +26,10 @@ import { ItemFilters, EMPTY_FILTERS } from '../filters';
|
||||
const { Header, Content } = Layout;
|
||||
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() {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -196,7 +200,7 @@ function Inventory() {
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
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>
|
||||
)
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
const node = findNode(tree, category.id);
|
||||
const totals = node ? branchTotals(node) : { categories: 1, items: category.item_count };
|
||||
@@ -126,7 +133,7 @@ export default function Categories() {
|
||||
title: `Delete "${category.name}"?`,
|
||||
content: (
|
||||
<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.
|
||||
</span>
|
||||
),
|
||||
|
||||
+331
-215
@@ -9,55 +9,11 @@ import {
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
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) {
|
||||
const disabling = !customer.disabled_at;
|
||||
|
||||
Modal.confirm({
|
||||
title: disabling ? `Disable ${customer.email}?` : `Re-enable ${customer.email}?`,
|
||||
content: disabling ? (
|
||||
// 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.
|
||||
@@ -68,163 +24,29 @@ export default function Customers() {
|
||||
{' '}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>
|
||||
),
|
||||
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) {
|
||||
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);
|
||||
// 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 />;
|
||||
}
|
||||
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 (
|
||||
<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.Item label="Email">{detail.customer.email}</Descriptions.Item>
|
||||
@@ -271,22 +93,29 @@ export default function Customers() {
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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 && (
|
||||
// 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}
|
||||
@@ -311,7 +140,7 @@ export default function Customers() {
|
||||
size="small"
|
||||
danger
|
||||
loading={releasing === item.item_id}
|
||||
onClick={() => handleRelease(item)}
|
||||
onClick={() => onRelease(item)}
|
||||
>
|
||||
Release
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -157,7 +157,7 @@ export default function Cart() {
|
||||
<List
|
||||
dataSource={items}
|
||||
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
|
||||
avatar={item.images[0] && <img src={item.images[0].image_path} alt="" style={{ width: 60, height: 60, objectFit: 'cover' }} />}
|
||||
title={item.name}
|
||||
|
||||
@@ -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 { useCustomerAuth } from '../customer/CustomerAuthContext';
|
||||
|
||||
@@ -32,10 +32,14 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, [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 (
|
||||
<CartContext.Provider value={{ items, itemIds, refresh }}>
|
||||
<CartContext.Provider value={value}>
|
||||
{children}
|
||||
</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';
|
||||
|
||||
interface CustomerAuthValue {
|
||||
@@ -49,8 +49,15 @@ export function CustomerAuthProvider({ children }: { children: React.ReactNode }
|
||||
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 (
|
||||
<CustomerAuthContext.Provider value={{ customer, loading, refresh, logout }}>
|
||||
<CustomerAuthContext.Provider value={value}>
|
||||
{children}
|
||||
</CustomerAuthContext.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 { Favorite, fetchFavorites } from './favoritesApi';
|
||||
import { useCustomerAuth } from './CustomerAuthContext';
|
||||
|
||||
@@ -39,10 +39,14 @@ export function FavoritesProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, [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 (
|
||||
<FavoritesContext.Provider value={{ favorites, itemIds, refresh }}>
|
||||
<FavoritesContext.Provider value={value}>
|
||||
{children}
|
||||
</FavoritesContext.Provider>
|
||||
);
|
||||
|
||||
@@ -88,7 +88,7 @@ function AppRoutes() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Routes location={backdrop as Location}>
|
||||
<Routes location={backdrop}>
|
||||
<Route path="/" element={<App />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
<Route path="/cart" element={<Cart />} />
|
||||
|
||||
Reference in New Issue
Block a user