Feature/81 technical debt #82
+59
-34
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
Reference in New Issue
Block a user