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();