Seventeen components declared props the compiler was free to assume were mutable, and one antd prop had gone stale. Both mechanical, neither with any behaviour attached.
React never writes to props, and `Readonly<>` says so to the compiler rather than only to the reader. This finishes a pattern the codebase had already chosen rather than introducing one: AccountDetails and EmailTemplateEditor were already written as `type Props = Readonly<{…}>`, so the thirteen named prop interfaces are converted to that same shape and the four context providers, which annotate `{ children }` inline, get `Readonly<{ children: React.ReactNode }>`.
Cart.tsx was the last place passing `destroyOnClose`, deprecated in antd 5.20. Twelve other call sites across the admin screens, the filter drawer and four customer modals already use `destroyOnHidden`, so this one was simply stale. Deprecated props keep working until they do not, and the failure then arrives as an antd upgrade breaking something unrelated to the change being made.
Counted rather than assumed, which the issue specifically asks for, because a `Readonly<>` in the wrong position type-checks and fixes nothing: lint goes from 31 warnings to 13, a drop of exactly eighteen, and both rules disappear from the breakdown entirely rather than merely thinning out.
What that leaves is the point of doing it. The remaining thirteen are eleven `set-state-in-effect` and two `no-alphabetical-sort` — so the frontend's warnings are now only the ones that need a decision, which is what makes #99 tractable. It had grown from the eight in that issue's title to eleven, two of them added by #97's clock tick and lapsed-cart refetch.
No behaviour change intended, so the bar was the end-to-end suite. Full run: 121 passed, 8 failed; all eight pass in a 45/45 serial re-run, which is the shared-database and event-loop flakiness this suite has had throughout.
Closes #100
162 lines
5.9 KiB
TypeScript
Executable File
162 lines
5.9 KiB
TypeScript
Executable File
import { useEffect, useState } from 'react';
|
|
import Typography from 'antd/es/typography';
|
|
import Switch from 'antd/es/switch';
|
|
import Button from 'antd/es/button';
|
|
import Modal from 'antd/es/modal';
|
|
import message from 'antd/es/message';
|
|
import Space from 'antd/es/space';
|
|
import Divider from 'antd/es/divider';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { updateConsent, exportMyData, deleteMyAccount, resendVerificationEmail } from './customerApi';
|
|
import { setFavoriteAlerts } from './favoritesApi';
|
|
import { useCustomerAuth } from './CustomerAuthContext';
|
|
import AccountDetails from './AccountDetails';
|
|
|
|
const { Text } = Typography;
|
|
|
|
type Props = Readonly<{
|
|
// 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 navigate = useNavigate();
|
|
const [resending, setResending] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!loading && !customer) navigate('/login');
|
|
}, [loading, customer, navigate]);
|
|
|
|
if (!customer) return null;
|
|
|
|
async function handleResendVerification() {
|
|
setResending(true);
|
|
try {
|
|
await resendVerificationEmail();
|
|
message.success('Sent. Check your inbox, and your spam folder.');
|
|
} catch (err) {
|
|
// Shown as it arrives: the rate limit's message says the mail probably
|
|
// did send and where to look, which a generic failure would throw away.
|
|
message.error((err as Error).message);
|
|
} finally {
|
|
setResending(false);
|
|
}
|
|
}
|
|
|
|
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>
|
|
{/* The button only exists while there is something to verify. Offering
|
|
it on a verified account would be a control whose only outcome is a
|
|
refusal. */}
|
|
{!customer.email_verified && (
|
|
<div style={{ marginTop: 8 }}>
|
|
<Text type="warning">Email not verified — check your inbox for a verification link.</Text>
|
|
<div style={{ marginTop: 8 }}>
|
|
<Button size="small" loading={resending} onClick={handleResendVerification}>
|
|
Send it again
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<Divider />
|
|
<AccountDetails customer={customer} onChanged={refresh} />
|
|
|
|
<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 />
|
|
<Space wrap>
|
|
{/* Order history is a page of its own now. The link stays here because
|
|
this is where a customer looks for it. */}
|
|
<Button onClick={() => navigate('/orders')}>View order history</Button>
|
|
<Button onClick={exportMyData}>Download my data</Button>
|
|
<Button onClick={handleLogout}>Log out</Button>
|
|
<Button danger onClick={handleDelete}>Delete my account</Button>
|
|
</Space>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|