TypeScript's strict mode checks types and nothing else, so nothing enforced the React hook rules, the SonarJS rules, or unhandled-promise detection. Adds a flat config per workspace, a lint script in each, and a lint job in tests.yml. The rule selection is the substance of this change and is measured rather than guessed. A full-strength config reports 435 violations across 50 files, but 325 of those are the no-unsafe-* family from recommendedTypeChecked, every one downstream of pool.query() returning any rows and untyped fetch responses. Typing those boundaries is the whole of #65, so enabling the rules here would ship a linter whose output is three-quarters another issue's backlog — the reliable way to teach everyone to ignore lint output. This enables recommended plus the two type-aware rules that catch defects rather than describe type debt, which leaves 110 findings. Both configs downgrade every preset to a warning and then list the error rules explicitly at the bottom, so the CI gate is readable in one place instead of inferred from four presets' defaults. Errors are no-floating-promises, no-misused-promises, rules-of-hooks, exhaustive-deps and jsx-a11y/alt-text; everything else warns. No --max-warnings flag is needed because ESLint already exits non-zero on errors and zero on warnings. no-misused-promises runs with checksVoidReturn.attributes false, since onClick={async () => ...} is idiomatic React and safe when the handler catches its own errors — at the default it flags every antd button in the admin screens, 25 of its 28 hits, and a rule that is 89% noise gets switched off within a week. The 37 errors this surfaced were mostly not the mechanical fix they looked like. The plan assumed the 30 floating promises were fire-and-forget loaders that already handled their own failures, which was true of the one sampled when writing the design and false for most of the rest: Admin, Categories, Customers, Tags, Settings, Account and CustomerAuthContext all had no rejection handling at all, so `void` on them would have hidden real failures rather than annotated deliberate ones. Each of those loaders now catches and surfaces the failure before the call site voids it. The CustomerAuthContext one was a live bug — a rejected fetchMe left loading true forever, rendering as a permanent spinner instead of a signed-out page. Admin's load became a useCallback so its effect can name it honestly rather than suppress the dependency, Categories' drop handler was split so the function antd receives returns void as its type says, and Cart's effect now names refreshCartContext, which is a useCallback with an empty dependency list and so cannot re-run it. The only disable added is in asyncRoute, where returning a promise where Express expects void is the entire point of the wrapper and the promise cannot reject. Two of the issue's premises did not survive measurement, both recorded in the spec: exhaustive-deps flags 2 cases rather than the 10 inferred from empty dependency arrays, and the backend was already clean on the defect rules because #59 wrapped every async route. Verified: lint, build, 78 unit, 134 integration and 83 e2e all pass in both workspaces, and the CI gate was confirmed to fail by introducing a deliberate violation rather than by assuming the job is wired correctly. Closes #60
149 lines
5.4 KiB
TypeScript
Executable File
149 lines
5.4 KiB
TypeScript
Executable File
import { useEffect, useState } from 'react';
|
|
import { Typography, Switch, Button, Table, Modal, message, Space, Divider } from 'antd';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { fetchMyOrders, OrderHistoryItem, updateConsent, exportMyData, deleteMyAccount } from './customerApi';
|
|
import { setFavoriteAlerts } from './favoritesApi';
|
|
import { useCustomerAuth } from './CustomerAuthContext';
|
|
|
|
const { Title, Text } = Typography;
|
|
|
|
interface Props {
|
|
// Supplied by the route, which decides where closing lands: back to the page
|
|
// the customer came from, or to the storefront when they arrived directly.
|
|
onClose: () => void;
|
|
}
|
|
|
|
export default function Account({ onClose }: Props) {
|
|
const { customer, loading, refresh, logout } = useCustomerAuth();
|
|
const [orders, setOrders] = useState<OrderHistoryItem[]>([]);
|
|
const navigate = useNavigate();
|
|
|
|
useEffect(() => {
|
|
if (customer) void fetchMyOrders().then(setOrders).catch(() => message.error('Could not load your orders'));
|
|
}, [customer]);
|
|
|
|
useEffect(() => {
|
|
if (!loading && !customer) navigate('/login');
|
|
}, [loading, customer, navigate]);
|
|
|
|
if (!customer) return null;
|
|
|
|
async function handleFavoriteAlertsToggle(checked: boolean) {
|
|
try {
|
|
await setFavoriteAlerts(checked);
|
|
} catch (err) {
|
|
message.error((err as Error).message);
|
|
return;
|
|
}
|
|
refresh();
|
|
message.success(checked ? 'We will email you when a favorite sells' : 'Turned off');
|
|
}
|
|
|
|
async function handleConsentToggle(checked: boolean) {
|
|
await updateConsent(checked);
|
|
message.success(checked ? 'Subscribed to emails' : 'Unsubscribed from emails');
|
|
refresh();
|
|
}
|
|
|
|
async function handleLogout() {
|
|
try {
|
|
await logout();
|
|
} catch (err) {
|
|
message.error(`Couldn't log out — ${(err as Error).message}`);
|
|
return;
|
|
}
|
|
// replace, so Back doesn't return to the account page — which would only
|
|
// bounce to /login now that the session is gone.
|
|
navigate('/', { replace: true });
|
|
}
|
|
|
|
function handleDelete() {
|
|
Modal.confirm({
|
|
title: 'Delete your account?',
|
|
content: 'This permanently removes your account and personal data. Your past orders are kept for accounting purposes but disconnected from your identity. This cannot be undone.',
|
|
okText: 'Delete my account',
|
|
okButtonProps: { danger: true },
|
|
onOk: async () => {
|
|
await deleteMyAccount();
|
|
// The storefront is rendered behind this modal, so without clearing the
|
|
// session it goes on showing "My Account" and hiding Sign up for an
|
|
// account that no longer exists — visibly stale, not merely stale in
|
|
// state. replace, so Back cannot return to /account and bounce to
|
|
// /login.
|
|
refresh();
|
|
message.success('Account deleted');
|
|
navigate('/', { replace: true });
|
|
}
|
|
});
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
open
|
|
// The account view is a place a customer can be sent by an email link or
|
|
// a bookmark, so it is titled and closable rather than relying on the
|
|
// page behind it to say where they are.
|
|
title="My Account"
|
|
onCancel={onClose}
|
|
footer={null}
|
|
width={700}
|
|
// The view holds profile, two consents, order history, and the account
|
|
// controls, which is taller than a phone. Capping the body and letting it
|
|
// scroll keeps the title and close control in reach instead of pushing
|
|
// them off-screen.
|
|
style={{ maxWidth: 'calc(100vw - 32px)', top: 24 }}
|
|
styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}
|
|
destroyOnHidden
|
|
>
|
|
<div>
|
|
<Text>{customer.email}</Text>
|
|
{!customer.email_verified && (
|
|
<div style={{ marginTop: 8 }}>
|
|
<Text type="warning">Email not verified — check your inbox for a verification link.</Text>
|
|
</div>
|
|
)}
|
|
|
|
<Divider />
|
|
<Space align="center">
|
|
<Switch checked={customer.marketing_consent} onChange={handleConsentToggle} />
|
|
<Text>Receive emails about new items</Text>
|
|
</Space>
|
|
|
|
{/* A separate consent from marketing above, and shown separately so a
|
|
customer can hold one without the other. */}
|
|
<div style={{ marginTop: 12 }}>
|
|
<Space align="center">
|
|
<Switch checked={customer.favorite_alerts} onChange={handleFavoriteAlertsToggle} />
|
|
<Text>Email me when an item I favorited is sold</Text>
|
|
</Space>
|
|
</div>
|
|
|
|
<Divider />
|
|
<Title level={5}>Order History</Title>
|
|
<Table
|
|
rowKey="id"
|
|
size="small"
|
|
dataSource={orders}
|
|
pagination={false}
|
|
// Scrolls within itself rather than widening the modal past the
|
|
// viewport on a phone.
|
|
scroll={{ x: 'max-content' }}
|
|
columns={[
|
|
{ title: 'Item', dataIndex: 'item_name' },
|
|
{ title: 'Amount', dataIndex: 'amount_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` },
|
|
{ title: 'Processor', dataIndex: 'processor' },
|
|
{ title: 'Date', dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleDateString() }
|
|
]}
|
|
/>
|
|
|
|
<Divider />
|
|
<Space wrap>
|
|
<Button onClick={exportMyData}>Download my data</Button>
|
|
<Button onClick={handleLogout}>Log out</Button>
|
|
<Button danger onClick={handleDelete}>Delete my account</Button>
|
|
</Space>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|