diff --git a/frontend/src/admin/Admin.tsx b/frontend/src/admin/Admin.tsx
index 50f9a96..0e95270 100755
--- a/frontend/src/admin/Admin.tsx
+++ b/frontend/src/admin/Admin.tsx
@@ -29,6 +29,7 @@ import {
} from '../api';
import { useThemeMode } from '../theme/ThemeContext';
import Customers from './Customers';
+import Emails from './Emails';
import Settings from './Settings';
import Categories from './Categories';
import Tags from './Tags';
@@ -387,6 +388,7 @@ export default function Admin() {
{ key: 'categories', label: 'Categories', children: },
{ key: 'tags', label: 'Tags', children: },
{ key: 'customers', label: 'Customers', children: },
+ { key: 'emails', label: 'Emails', children: },
{ key: 'settings', label: 'Settings', children: }
]}
/>
diff --git a/frontend/src/admin/EmailTemplateEditor.tsx b/frontend/src/admin/EmailTemplateEditor.tsx
index eb711aa..036977a 100644
--- a/frontend/src/admin/EmailTemplateEditor.tsx
+++ b/frontend/src/admin/EmailTemplateEditor.tsx
@@ -34,7 +34,7 @@ export default function EmailTemplateEditor({ template, onChanged }: Props) {
const { mode } = useThemeMode();
// 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 on the tab can say so.
+ // which is what the rail's dot and the tag below are reading.
const [subject, setSubject] = useState(template.subject ?? template.defaultSubject);
const [body, setBody] = useState(template.body ?? template.defaultBody);
const [saving, setSaving] = useState(false);
@@ -139,6 +139,8 @@ export default function EmailTemplateEditor({ template, onChanged }: Props) {
/>
+ {/* The rail marks a customised template with a dot only. The state in
+ full sits here, beside the button that acts on it. */}
)}
+ {customised ? Customised : Default}
diff --git a/frontend/src/admin/Emails.tsx b/frontend/src/admin/Emails.tsx
new file mode 100644
index 0000000..71b2f7f
--- /dev/null
+++ b/frontend/src/admin/Emails.tsx
@@ -0,0 +1,73 @@
+import { useEffect, useState } from 'react';
+import Tabs from 'antd/es/tabs';
+import Spin from 'antd/es/spin';
+import Typography from 'antd/es/typography';
+import message from 'antd/es/message';
+import { EmailTemplate, fetchEmailTemplates } from './emailTemplatesApi';
+import EmailTemplateEditor from './EmailTemplateEditor';
+
+const { Text } = Typography;
+
+export default function Emails() {
+ const [templates, setTemplates] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ 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'))
+ .finally(() => setLoading(false));
+ }, []);
+
+ // Replaces the one that changed so the customised marker 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))
+ );
+ }
+
+ return (
+
+
+ The wording customers receive. Leave one alone and it sends the built-in copy.
+
+ {/* Spinner rather than an empty rail: with templates starting empty, the
+ gap before the fetch lands reads as "there are no emails to edit". */}
+ {loading ? (
+
+
+
+ ) : (
+ /* A left rail rather than a strip across the top. Six labels wrapped on
+ narrower displays, and stacking them leaves the editor the full width
+ it needs for a markdown pane and a rendered preview side by side. */
+ ({
+ key: template.key,
+ label: (
+ <>
+ {template.label}
+ {/* A dot, not a Tag — six tags down a rail stop it being
+ scannable. aria-label keeps the word in the tab's
+ accessible name, so the state is not colour-only. */}
+ {(template.subject !== null || template.body !== null) && (
+
+ •
+
+ )}
+ >
+ ),
+ children: (
+
+ )
+ }))}
+ />
+ )}
+
+ );
+}
diff --git a/frontend/src/admin/Settings.tsx b/frontend/src/admin/Settings.tsx
index ce6a3ec..8b4c8b7 100644
--- a/frontend/src/admin/Settings.tsx
+++ b/frontend/src/admin/Settings.tsx
@@ -5,19 +5,13 @@ import Button from 'antd/es/button';
import Typography from 'antd/es/typography';
import message from 'antd/es/message';
import Card from 'antd/es/card';
-import Tabs from 'antd/es/tabs';
-import Tag from 'antd/es/tag';
-import Space from 'antd/es/space';
import { fetchAdminSettings, updateAdminSettings } from './adminSettingsApi';
-import { EmailTemplate, fetchEmailTemplates } from './emailTemplatesApi';
-import EmailTemplateEditor from './EmailTemplateEditor';
const { Title, Text } = Typography;
export default function Settings() {
const [form] = Form.useForm();
const [loading, setLoading] = useState(true);
- const [templates, setTemplates] = useState([]);
useEffect(() => {
fetchAdminSettings()
@@ -29,22 +23,6 @@ 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);
@@ -52,7 +30,6 @@ export default function Settings() {
}
return (
-
Cart Settings
@@ -65,33 +42,5 @@ export default function Settings() {
-
- Customer emails
-
- The wording customers receive. Leave one alone and it sends the built-in copy.
-
- {/* Tabs rather than a stacked column. With six templates the cart reminder
- sat below five editors, so which one you were editing was knowable only
- from a card title you had already scrolled past. The Default/Customised
- tag moves onto the tab label, so which templates have been changed is
- visible without opening each one. */}
- ({
- key: template.key,
- label: (
-
- {template.label}
- {template.subject !== null || template.body !== null
- ? Customised
- : Default}
-
- ),
- children: (
-
- )
- }))}
- />
-
);
}
diff --git a/frontend/tests/e2e/email-templates.spec.ts b/frontend/tests/e2e/email-templates.spec.ts
index 382995c..2566b6f 100644
--- a/frontend/tests/e2e/email-templates.spec.ts
+++ b/frontend/tests/e2e/email-templates.spec.ts
@@ -8,10 +8,12 @@ async function restore(page: import('@playwright/test').Page, key: string) {
await page.request.delete(`/api/admin/email-templates/${key}`);
}
-async function openSettings(page: import('@playwright/test').Page) {
+async function openEmails(page: import('@playwright/test').Page) {
await page.goto('/admin');
- await page.getByRole('tab', { name: 'Settings' }).click();
- await expect(page.getByRole('heading', { name: 'Customer emails' })).toBeVisible();
+ await page.getByRole('tab', { name: 'Emails' }).click();
+ // The rail's first entry, rather than a heading — the tab label is the
+ // heading now, so there is no second one inside the page to wait on.
+ await expect(page.getByRole('tab', { name: /Email verification/ })).toBeVisible();
}
// Opens one template's tab. Only the active tab's editor is mounted, which is
@@ -36,7 +38,7 @@ test.describe('Editing the customer emails', () => {
});
test('offers every template as a tab, marked default until it is edited', async ({ page }) => {
- await openSettings(page);
+ await openEmails(page);
for (const label of [
'Email verification',
@@ -49,14 +51,15 @@ test.describe('Editing the customer emails', () => {
await expect(page.getByRole('tab', { name: new RegExp(label) })).toBeVisible();
}
- // The badge lives on the tab now, so which templates have been changed is
- // visible without opening each one.
- await expect(page.getByRole('tab', { name: /Password reset.*Default/ })).toBeVisible();
+ // Only a customised template is marked, so which ones have been changed is
+ // visible without opening each one. An untouched template carries nothing.
+ await expect(page.getByRole('tab', { name: /Password reset/ })).toBeVisible();
+ await expect(page.getByRole('tab', { name: /Password reset.*Customised/ })).toHaveCount(0);
});
test('saves a replacement subject and body', async ({ page }) => {
const subject = `Reset ${suffix()}`;
- await openSettings(page);
+ await openEmails(page);
await openTemplate(page, 'Password reset');
await page.getByLabel('Password reset subject').fill(subject);
@@ -77,7 +80,7 @@ test.describe('Editing the customer emails', () => {
// looks fine in the log, so the save has to be refused rather than warned
// about — and the admin has to be told which placeholder is missing.
test('refuses a body that drops the required placeholder, and says which', async ({ page }) => {
- await openSettings(page);
+ await openEmails(page);
await openTemplate(page, 'Password reset');
await page.getByLabel('Password reset body').fill('Just click the thing in your email.');
@@ -96,7 +99,10 @@ test.describe('Editing the customer emails', () => {
data: { subject: 'Temporary', body: 'Temporary [link]({{resetUrl}}).' }
});
- await openSettings(page);
+ await openEmails(page);
+ // Marked in the rail before it is opened, which is the whole point of the
+ // dot — the stored template above was never touched through the UI.
+ await expect(page.getByRole('tab', { name: /Password reset.*Customised/ })).toBeVisible();
await openTemplate(page, 'Password reset');
await page.getByRole('button', { name: 'Restore default' }).click();
@@ -116,7 +122,7 @@ test.describe('Previewing the customer emails', () => {
test('shows the draft being edited, not the stored copy', async ({ page }) => {
const wording = `Wording ${suffix()}`;
- await openSettings(page);
+ await openEmails(page);
await openTemplate(page, 'Password reset');
await page
@@ -132,7 +138,7 @@ test.describe('Previewing the customer emails', () => {
});
test('substitutes sample values rather than showing raw placeholders', async ({ page }) => {
- await openSettings(page);
+ await openEmails(page);
await openTemplate(page, 'Password reset');
const frame = previewFrame(page, 'Password reset');
@@ -144,7 +150,7 @@ test.describe('Previewing the customer emails', () => {
// markdown-it's html: false on the server. The preview has to show the same
// thing the mailer emits, or it would be reassuring about the wrong output.
test('escapes raw HTML exactly as the mailer does', async ({ page }) => {
- await openSettings(page);
+ await openEmails(page);
await openTemplate(page, 'Password reset');
await page
@@ -157,7 +163,7 @@ test.describe('Previewing the customer emails', () => {
// Appended by the server and not editable, so it has to appear in the preview
// of the two templates it belongs to and nowhere else.
test('includes the consent footer on a favorite template, and not on others', async ({ page }) => {
- await openSettings(page);
+ await openEmails(page);
await openTemplate(page, 'Favorited item sold');
await expect(previewFrame(page, 'Favorited item sold').getByText(/account page/)).toBeVisible();