feat(admin): give the customer emails a tab of their own (#135)
The six email templates lived at the bottom of the Settings tab, under the cart-expiry card and inside a 720px wrapper. Finding them took knowing they were there — "Settings" reads as app configuration and the only thing visible on that tab was a 480px card about cart expiry. Reaching them, the editor was then crushed: EmailTemplateEditor splits a markdown pane and a rendered preview side by side, and 720px left each half under 350px, so the preview showed the email at a width nothing like how it will be read and the markdown toolbar wrapped. Emails is now its own tab, between Customers and Settings, with no width cap. Within it the email types are a left vertical rail rather than a strip across the top: six labels wrapped on narrower displays, and stacking them is what leaves the editor the width the split needs. Settings keeps the cart-expiry card and nothing else. The Default/Customised tag comes off the labels. Six antd tags stacked down a rail stop it being scannable, so a customised template gets a dot and the state in full moves into the editor beside the Restore default button that acts on it. The dot carries aria-label="Customised" so the word stays in the tab's accessible name and the state is not conveyed by a mark alone. Emails owns the fetch it inherited from Settings, and adds a Spin over it. templates starts empty, so the gap before the request lands would otherwise render an empty rail that reads as "there are no emails to edit". Closes #135
This commit is contained in:
@@ -29,6 +29,7 @@ import {
|
|||||||
} from '../api';
|
} from '../api';
|
||||||
import { useThemeMode } from '../theme/ThemeContext';
|
import { useThemeMode } from '../theme/ThemeContext';
|
||||||
import Customers from './Customers';
|
import Customers from './Customers';
|
||||||
|
import Emails from './Emails';
|
||||||
import Settings from './Settings';
|
import Settings from './Settings';
|
||||||
import Categories from './Categories';
|
import Categories from './Categories';
|
||||||
import Tags from './Tags';
|
import Tags from './Tags';
|
||||||
@@ -387,6 +388,7 @@ export default function Admin() {
|
|||||||
{ key: 'categories', label: 'Categories', children: <Categories /> },
|
{ key: 'categories', label: 'Categories', children: <Categories /> },
|
||||||
{ key: 'tags', label: 'Tags', children: <Tags /> },
|
{ key: 'tags', label: 'Tags', children: <Tags /> },
|
||||||
{ key: 'customers', label: 'Customers', children: <Customers /> },
|
{ key: 'customers', label: 'Customers', children: <Customers /> },
|
||||||
|
{ key: 'emails', label: 'Emails', children: <Emails /> },
|
||||||
{ key: 'settings', label: 'Settings', children: <Settings /> }
|
{ key: 'settings', label: 'Settings', children: <Settings /> }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function EmailTemplateEditor({ template, onChanged }: Props) {
|
|||||||
const { mode } = useThemeMode();
|
const { mode } = useThemeMode();
|
||||||
// Falls back to the default so the editor starts from the real copy rather
|
// 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",
|
// 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 [subject, setSubject] = useState(template.subject ?? template.defaultSubject);
|
||||||
const [body, setBody] = useState(template.body ?? template.defaultBody);
|
const [body, setBody] = useState(template.body ?? template.defaultBody);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -139,6 +139,8 @@ export default function EmailTemplateEditor({ template, onChanged }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* The rail marks a customised template with a dot only. The state in
|
||||||
|
full sits here, beside the button that acts on it. */}
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" loading={saving} onClick={handleSave}>
|
<Button type="primary" loading={saving} onClick={handleSave}>
|
||||||
Save
|
Save
|
||||||
@@ -150,6 +152,7 @@ export default function EmailTemplateEditor({ template, onChanged }: Props) {
|
|||||||
Restore default
|
Restore default
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{customised ? <Tag color="blue">Customised</Tag> : <Tag>Default</Tag>}
|
||||||
</Space>
|
</Space>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
|
|||||||
@@ -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<EmailTemplate[]>([]);
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<Text type="secondary">
|
||||||
|
The wording customers receive. Leave one alone and it sends the built-in copy.
|
||||||
|
</Text>
|
||||||
|
{/* 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 ? (
|
||||||
|
<div style={{ padding: 48, textAlign: 'center' }}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* 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. */
|
||||||
|
<Tabs
|
||||||
|
tabPosition="left"
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
items={templates.map(template => ({
|
||||||
|
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) && (
|
||||||
|
<span aria-label="Customised" title="Customised" style={{ marginLeft: 6 }}>
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<EmailTemplateEditor template={template} onChanged={handleTemplateChanged} />
|
||||||
|
)
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,19 +5,13 @@ import Button from 'antd/es/button';
|
|||||||
import Typography from 'antd/es/typography';
|
import Typography from 'antd/es/typography';
|
||||||
import message from 'antd/es/message';
|
import message from 'antd/es/message';
|
||||||
import Card from 'antd/es/card';
|
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 { fetchAdminSettings, updateAdminSettings } from './adminSettingsApi';
|
||||||
import { EmailTemplate, fetchEmailTemplates } from './emailTemplatesApi';
|
|
||||||
import EmailTemplateEditor from './EmailTemplateEditor';
|
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAdminSettings()
|
fetchAdminSettings()
|
||||||
@@ -29,22 +23,6 @@ export default function Settings() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [form]);
|
}, [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() {
|
async function handleSave() {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
await updateAdminSettings(values);
|
await updateAdminSettings(values);
|
||||||
@@ -52,7 +30,6 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 720 }}>
|
|
||||||
<Card style={{ maxWidth: 480 }}>
|
<Card style={{ maxWidth: 480 }}>
|
||||||
<Title level={4}>Cart Settings</Title>
|
<Title level={4}>Cart Settings</Title>
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
@@ -65,33 +42,5 @@ export default function Settings() {
|
|||||||
<Button type="primary" onClick={handleSave} loading={loading}>Save</Button>
|
<Button type="primary" onClick={handleSave} loading={loading}>Save</Button>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</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>
|
|
||||||
{/* 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. */}
|
|
||||||
<Tabs
|
|
||||||
style={{ marginTop: 16 }}
|
|
||||||
items={templates.map(template => ({
|
|
||||||
key: template.key,
|
|
||||||
label: (
|
|
||||||
<Space size={4}>
|
|
||||||
{template.label}
|
|
||||||
{template.subject !== null || template.body !== null
|
|
||||||
? <Tag color="blue">Customised</Tag>
|
|
||||||
: <Tag>Default</Tag>}
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
children: (
|
|
||||||
<EmailTemplateEditor template={template} onChanged={handleTemplateChanged} />
|
|
||||||
)
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ async function restore(page: import('@playwright/test').Page, key: string) {
|
|||||||
await page.request.delete(`/api/admin/email-templates/${key}`);
|
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.goto('/admin');
|
||||||
await page.getByRole('tab', { name: 'Settings' }).click();
|
await page.getByRole('tab', { name: 'Emails' }).click();
|
||||||
await expect(page.getByRole('heading', { name: 'Customer emails' })).toBeVisible();
|
// 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
|
// 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 }) => {
|
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 [
|
for (const label of [
|
||||||
'Email verification',
|
'Email verification',
|
||||||
@@ -49,14 +51,15 @@ test.describe('Editing the customer emails', () => {
|
|||||||
await expect(page.getByRole('tab', { name: new RegExp(label) })).toBeVisible();
|
await expect(page.getByRole('tab', { name: new RegExp(label) })).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The badge lives on the tab now, so which templates have been changed is
|
// Only a customised template is marked, so which ones have been changed is
|
||||||
// visible without opening each one.
|
// visible without opening each one. An untouched template carries nothing.
|
||||||
await expect(page.getByRole('tab', { name: /Password reset.*Default/ })).toBeVisible();
|
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 }) => {
|
test('saves a replacement subject and body', async ({ page }) => {
|
||||||
const subject = `Reset ${suffix()}`;
|
const subject = `Reset ${suffix()}`;
|
||||||
await openSettings(page);
|
await openEmails(page);
|
||||||
await openTemplate(page, 'Password reset');
|
await openTemplate(page, 'Password reset');
|
||||||
|
|
||||||
await page.getByLabel('Password reset subject').fill(subject);
|
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
|
// 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.
|
// 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 }) => {
|
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 openTemplate(page, 'Password reset');
|
||||||
|
|
||||||
await page.getByLabel('Password reset body').fill('Just click the thing in your email.');
|
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}}).' }
|
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 openTemplate(page, 'Password reset');
|
||||||
await page.getByRole('button', { name: 'Restore default' }).click();
|
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 }) => {
|
test('shows the draft being edited, not the stored copy', async ({ page }) => {
|
||||||
const wording = `Wording ${suffix()}`;
|
const wording = `Wording ${suffix()}`;
|
||||||
await openSettings(page);
|
await openEmails(page);
|
||||||
await openTemplate(page, 'Password reset');
|
await openTemplate(page, 'Password reset');
|
||||||
|
|
||||||
await page
|
await page
|
||||||
@@ -132,7 +138,7 @@ test.describe('Previewing the customer emails', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('substitutes sample values rather than showing raw placeholders', async ({ page }) => {
|
test('substitutes sample values rather than showing raw placeholders', async ({ page }) => {
|
||||||
await openSettings(page);
|
await openEmails(page);
|
||||||
await openTemplate(page, 'Password reset');
|
await openTemplate(page, 'Password reset');
|
||||||
|
|
||||||
const frame = previewFrame(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
|
// 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.
|
// thing the mailer emits, or it would be reassuring about the wrong output.
|
||||||
test('escapes raw HTML exactly as the mailer does', async ({ page }) => {
|
test('escapes raw HTML exactly as the mailer does', async ({ page }) => {
|
||||||
await openSettings(page);
|
await openEmails(page);
|
||||||
await openTemplate(page, 'Password reset');
|
await openTemplate(page, 'Password reset');
|
||||||
|
|
||||||
await page
|
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
|
// 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.
|
// 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 }) => {
|
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 openTemplate(page, 'Favorited item sold');
|
||||||
await expect(previewFrame(page, 'Favorited item sold').getByText(/account page/)).toBeVisible();
|
await expect(previewFrame(page, 'Favorited item sold').getByText(/account page/)).toBeVisible();
|
||||||
|
|||||||
Reference in New Issue
Block a user