feat(frontend): edit the customer emails from Admin → Settings (#92)
SonarQube Analysis / sonarqube (pull_request) Failing after 13m7s

A card per email under the existing settings screen: subject, body, the placeholders it understands, and which of them it cannot lose.

Each card starts from the copy that is actually in use — the stored version if there is one, the built-in default otherwise — rather than an empty box, so editing means changing words rather than writing the email from scratch. A badge distinguishes customised from default, which is why the API reports an unedited template as null rather than as its default text: the two are different states and the screen has to be able to tell them apart.

Restore default is offered only when there is something to restore, so it is never a button that looks like it did something and did not. It removes the stored rows rather than writing the defaults into them, which is what keeps the badge honest afterwards.

The server's refusal is shown verbatim. When a body drops a placeholder it needs, the message names which one, and that message is the entire value of the validation — replacing it with a generic failure would leave an admin guessing at which of five templates and which of three placeholders they broke.

A textarea rather than the markdown editor already used for item descriptions. That editor is a heavy dependency to load into the settings screen for five short bodies, and its preview would render markdown as the browser shows it rather than as the email renderer will — a preview that quietly disagrees with the output is worse than none. Worth revisiting if the copy gets longer.

Two things the end-to-end spec found rather than assumed. The refusal assertion first matched three elements, because the placeholder appears as the required marker, as an available tag, and inside the error — it now asserts the whole sentence. And the four tests raced each other: the suite runs fully parallel and they all edit one shared stored template, so one asserted a template was unset while another had just saved it. That describe block now runs serially, which is the honest fix for tests that mutate shared server state rather than making the assertions vaguer.

Verified: 99 end-to-end tests passing on a freshly created container, up from 95, with the whole suite run rather than the new spec alone — precisely because these tests write state other suites read. Build clean, lint unchanged at 27 warnings.

Refs #92
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 19:05:29 -05:00
co-authored by Claude Opus 5
parent 4ed9513ad2
commit 6baa769520
4 changed files with 298 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
import { useState } from 'react';
import Card from 'antd/es/card';
import Input from 'antd/es/input';
import Button from 'antd/es/button';
import Space from 'antd/es/space';
import Tag from 'antd/es/tag';
import Typography from 'antd/es/typography';
import message from 'antd/es/message';
import { EmailTemplate, saveEmailTemplate, resetEmailTemplate } from './emailTemplatesApi';
const { Text, Paragraph } = Typography;
type Props = Readonly<{
template: EmailTemplate;
onChanged: (updated: EmailTemplate) => void;
}>;
export default function EmailTemplateCard({ template, onChanged }: Props) {
// Falls back to the default so the editor starts from the real copy rather
// than an empty box. `subject`/`body` being null means "never customised",
// which is why the badge below can say so.
const [subject, setSubject] = useState(template.subject ?? template.defaultSubject);
const [body, setBody] = useState(template.body ?? template.defaultBody);
const [saving, setSaving] = useState(false);
const customised = template.subject !== null || template.body !== null;
async function handleSave() {
setSaving(true);
try {
const updated = await saveEmailTemplate(template.key, subject, body);
onChanged(updated);
message.success(`${template.label} saved`);
} catch (err) {
// The server's refusal names the placeholder that is missing, which is
// the only useful thing to say here — so it is shown rather than replaced
// with something generic.
message.error((err as Error).message);
} finally {
setSaving(false);
}
}
async function handleReset() {
setSaving(true);
try {
const restored = await resetEmailTemplate(template.key);
setSubject(template.defaultSubject);
setBody(template.defaultBody);
onChanged(restored);
message.success(`${template.label} restored to the default`);
} catch (err) {
message.error((err as Error).message);
} finally {
setSaving(false);
}
}
return (
<Card
style={{ marginBottom: 16 }}
title={
<Space>
{template.label}
{customised ? <Tag color="blue">Customised</Tag> : <Tag>Default</Tag>}
</Space>
}
>
<Paragraph type="secondary" style={{ marginBottom: 8 }}>
Markdown. Placeholders are replaced when the email is sent
{template.required.length > 0 && (
<>
{' '} <Text strong>{template.required.map((n) => `{{${n}}}`).join(' and ')}</Text>{' '}
{template.required.length === 1 ? 'is' : 'are'} required and cannot be removed
</>
)}
.
</Paragraph>
<Space wrap size={[4, 4]} style={{ marginBottom: 12 }}>
{template.available.map((name) => (
<Tag key={name} style={{ fontFamily: 'monospace' }}>{`{{${name}}}`}</Tag>
))}
</Space>
<Input
aria-label={`${template.label} subject`}
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Subject"
style={{ marginBottom: 8 }}
/>
<Input.TextArea
aria-label={`${template.label} body`}
value={body}
onChange={(e) => setBody(e.target.value)}
autoSize={{ minRows: 6, maxRows: 16 }}
style={{ fontFamily: 'monospace', marginBottom: 12 }}
/>
<Space>
<Button type="primary" loading={saving} onClick={handleSave}>
Save
</Button>
{/* Only offered when there is something to restore, so the button is
not a no-op that looks like it did something. */}
{customised && (
<Button loading={saving} onClick={handleReset}>
Restore default
</Button>
)}
</Space>
</Card>
);
}
+31
View File
@@ -6,12 +6,15 @@ import Typography from 'antd/es/typography';
import message from 'antd/es/message';
import Card from 'antd/es/card';
import { fetchAdminSettings, updateAdminSettings } from './adminSettingsApi';
import { EmailTemplate, fetchEmailTemplates } from './emailTemplatesApi';
import EmailTemplateCard from './EmailTemplateCard';
const { Title, Text } = Typography;
export default function Settings() {
const [form] = Form.useForm();
const [loading, setLoading] = useState(true);
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
useEffect(() => {
fetchAdminSettings()
@@ -23,6 +26,22 @@ export default function Settings() {
.finally(() => setLoading(false));
}, [form]);
useEffect(() => {
fetchEmailTemplates()
.then(setTemplates)
// Reported rather than swallowed: an empty list would otherwise read as
// "there are no templates" instead of "they could not be loaded".
.catch(() => message.error('Could not load the email templates'));
}, []);
// Replaces the one that changed so the Customised badge and the Restore
// button reflect what the server now holds, without refetching the rest.
function handleTemplateChanged(updated: EmailTemplate) {
setTemplates(current =>
current.map(t => (t.key === updated.key ? { ...t, subject: updated.subject, body: updated.body } : t))
);
}
async function handleSave() {
const values = await form.validateFields();
await updateAdminSettings(values);
@@ -30,6 +49,7 @@ export default function Settings() {
}
return (
<div style={{ maxWidth: 720 }}>
<Card style={{ maxWidth: 480 }}>
<Title level={4}>Cart Settings</Title>
<Text type="secondary">
@@ -42,5 +62,16 @@ export default function Settings() {
<Button type="primary" onClick={handleSave} loading={loading}>Save</Button>
</Form>
</Card>
<Title level={4} style={{ marginTop: 32 }}>Customer emails</Title>
<Text type="secondary">
The wording customers receive. Leave one alone and it sends the built-in copy.
</Text>
<div style={{ marginTop: 16 }}>
{templates.map(template => (
<EmailTemplateCard key={template.key} template={template} onChanged={handleTemplateChanged} />
))}
</div>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
export interface EmailTemplate {
key: string;
/** Human name for the card, so it is identifiable without reading the body. */
label: string;
/** Placeholders the body must keep. Saving without one is refused. */
required: string[];
/** Every placeholder this template understands. */
available: string[];
defaultSubject: string;
defaultBody: string;
/** Null when never customised, which is distinct from "same as the default". */
subject: string | null;
body: string | null;
}
async function json<T>(res: Response): Promise<T> {
return (await res.json()) as T;
}
// The server's refusals say which placeholder is missing, and that message is
// the whole point of the validation — so it is surfaced rather than replaced
// with something generic.
async function expectOk(res: Response, action: string): Promise<Response> {
if (res.ok) return res;
const detail = await res.json().catch(() => null);
throw new Error(detail?.error ? String(detail.error) : action);
}
export async function fetchEmailTemplates(): Promise<EmailTemplate[]> {
const res = await expectOk(await fetch('/api/admin/email-templates'), 'could not load templates');
return json<EmailTemplate[]>(res);
}
export async function saveEmailTemplate(
key: string,
subject: string,
body: string
): Promise<EmailTemplate> {
const res = await expectOk(
await fetch(`/api/admin/email-templates/${key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subject, body })
}),
'could not save the template'
);
return json<EmailTemplate>(res);
}
export async function resetEmailTemplate(key: string): Promise<EmailTemplate> {
const res = await expectOk(
await fetch(`/api/admin/email-templates/${key}`, { method: 'DELETE' }),
'could not restore the default'
);
return json<EmailTemplate>(res);
}