Files
redefined-designs/frontend/src/customer/AuthForm.tsx
T
bermudalamb 8de261538b
Linting / lint (pull_request) Successful in 2m4s
SonarQube Analysis / sonarqube (pull_request) Failing after 4m51s
refactor(frontend): declare props read-only, and drop the deprecated antd prop (#100)
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
2026-08-24 11:43:10 -05:00

151 lines
5.9 KiB
TypeScript

import { useState } from 'react';
import Form from 'antd/es/form';
import Input from 'antd/es/input';
import Button from 'antd/es/button';
import Checkbox from 'antd/es/checkbox';
import Tabs from 'antd/es/tabs';
import Alert from 'antd/es/alert';
import Typography from 'antd/es/typography';
import { registerCustomer, loginCustomer } from './customerApi';
import { useCustomerAuth } from './CustomerAuthContext';
const { Text } = Typography;
export type AuthMode = 'register' | 'login';
// Must stay identical to MARKETING_CONSENT_TEXT in backend/src/utils.ts, which
// is what gets stored verbatim against the customer's consent record. The point
// of storing it is that the record says what the customer actually saw, so a
// label that differs from the stored string defeats the whole mechanism. Before
// this was shared there were three wordings in play — this one, a shorter one in
// the cart prompt, and the string the server actually recorded — and none of
// them matched.
export const MARKETING_CONSENT_TEXT =
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
type Props = Readonly<{
mode: AuthMode;
onModeChange: (mode: AuthMode) => void;
onForgotPassword: () => void;
// Called once the session exists. What that means differs by caller: the
// route closes back to the page behind it, while the cart and favorite
// prompts resume the action the customer was interrupted doing.
onSuccess: () => void;
}>;
// The one implementation of signing in and registering. It was previously
// written twice — once as the /login and /register pages, once inside the
// prompt shown when a signed-out visitor adds to the cart — which had already
// drifted in consent wording and in which links each offered.
export default function AuthForm({ mode, onModeChange, onForgotPassword, onSuccess }: Props) {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const { refresh } = useCustomerAuth();
async function submit(action: () => Promise<unknown>) {
setLoading(true);
setError(null);
try {
await action();
refresh();
onSuccess();
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}
return (
<>
{error && <Alert type="error" showIcon message={error} style={{ marginBottom: 16 }} />}
<Tabs
activeKey={mode}
onChange={(key) => {
// A stale error from the other tab would read as though it applied to
// the form now on screen.
setError(null);
onModeChange(key as AuthMode);
}}
items={[
{
key: 'register',
label: 'Create Account',
children: (
<Form
layout="vertical"
onFinish={(values) =>
submit(() =>
registerCustomer(values.email, values.password, values.firstName, values.lastName, !!values.marketingConsent)
)
}
>
<Form.Item
name="firstName"
label="First name"
rules={[{ required: true, message: 'First name is required' }]}
>
<Input autoComplete="given-name" />
</Form.Item>
<Form.Item
name="lastName"
label="Last name"
rules={[{ required: true, message: 'Last name is required' }]}
>
<Input autoComplete="family-name" />
</Form.Item>
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
<Input autoComplete="email" />
</Form.Item>
<Form.Item
name="password"
label="Password"
rules={[{ required: true, min: 8, message: 'At least 8 characters' }]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item name="marketingConsent" valuePropName="checked" initialValue={false}>
<Checkbox>{MARKETING_CONSENT_TEXT}</Checkbox>
</Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
Create account
</Button>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 12 }}>
{/* Opens in a new tab deliberately: following it in place would
discard a part-filled signup form, and /privacy has no way
back of its own yet (#52). */}
By creating an account you agree to our{' '}
<a href="/privacy" target="_blank" rel="noopener noreferrer">Privacy Policy</a>.
</Text>
</Form>
)
},
{
key: 'login',
label: 'Log In',
children: (
<Form
layout="vertical"
onFinish={(values) => submit(() => loginCustomer(values.email, values.password))}
>
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
<Input autoComplete="email" />
</Form.Item>
<Form.Item name="password" label="Password" rules={[{ required: true }]}>
<Input.Password autoComplete="current-password" />
</Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
Log in
</Button>
<Button type="link" style={{ paddingInline: 0, marginTop: 8 }} onClick={onForgotPassword}>
Forgot password?
</Button>
</Form>
)
}
]}
/>
</>
);
}