feat(engagement): the template editor, the trigger catalog and the send log (engagement Phase 5b)
Phase 5a gave templates a table, a renderer and nine seeded rows; nothing could
change one. This is the screen that lets an operator change one without being able
to break the mail the system depends on — plus the two screens Q4 promised Phase 5:
Triggers (read-only, from the registries) and the Send Log, which closes G15.
The shape follows from one fact: a mail body is rendered by the SERVER, so the
preview is too, and framed rather than redrawn in React. A client-side renderer
would be a second implementation of the one artifact that matters, agreeing with
the send path on the day it was written and drifting from the first Outlook fix on.
Settled with the org lead before any code: a shipped default is edited IN PLACE
(`protected` blocks deletion and nothing else, `customized = 1` keeps the edit);
duplicate is the only way to a new template; `renderByKey` now requires
`published`; a test send is logged under a synthetic `core.admin.test-send`; and a
template a rule points at refuses deletion with a 409 naming the rules.
Three things the plan did not know, found by building it:
- The undeclared-variable check cannot be a token scan. `email.itemList.variable`
holds a BARE name, so a digest pointed at `itmes` would have saved clean and
arrived empty. Blocks now declare `variables(props)`; the editor makes that
field a select over the trigger's list variables so the typo is unavailable.
- A duplicate that drops `seed_key` loses its variable palette, so duplicating
`notify.event` would have been refused for the tokens it was copied with — the
one action §4.6.2 offers, refusing itself. The copy inherits it; `customized`
is what the seeder actually reads.
- `validateEmailBlocks` returns `{ valid, errors }`, not an array, and the first
version tested it with `.length` — so block validation never ran at all.
Also fixes a Phase 4a defect the live walk found, with the org lead's approval: a
rule's template key was checked against a pattern with no dot in it, so no rule
could name any template that exists — §4.6.2's whole duplicate-and-point-a-rule-at-it
workflow was unreachable. Both models now read one pattern.
Verified against the running stack: real multipart mail into a mailpit catcher
including an unsaved draft, the draft/published arms both ways through the real
mailer path, every refusal, and the end-to-end duplicate → rule → 409 walk.
Server 1428 tests green, client 324.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,9 @@ import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
|
||||
import ModulesAdmin from './routes/admin/views/ModulesAdmin.jsx'
|
||||
import EngagementRules from './routes/admin/views/EngagementRules.jsx'
|
||||
import EngagementAudiences from './routes/admin/views/EngagementAudiences.jsx'
|
||||
import EngagementTemplates from './routes/admin/views/EngagementTemplates.jsx'
|
||||
import EngagementTriggers from './routes/admin/views/EngagementTriggers.jsx'
|
||||
import EngagementSendLog from './routes/admin/views/EngagementSendLog.jsx'
|
||||
import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
|
||||
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||
import Moderation from './routes/admin/views/Moderation.jsx'
|
||||
@@ -186,7 +189,7 @@ export default function App() {
|
||||
actions that publish a game-written name is applied per request
|
||||
on the server, from the caller's live role (TEAMS.md 2.9). */}
|
||||
<Route path="teams" element={<TeamsAdmin />} />
|
||||
{/* Engagement (ENGAGEMENT.md Phase 4b). Admin-only, matching the
|
||||
{/* Engagement (ENGAGEMENT.md Phases 4b and 5b). Admin-only, matching the
|
||||
server: every route under /admin/engagement re-gates to `admin`
|
||||
on top of the group's staff gate, because this is the group that
|
||||
decides who receives mail. */}
|
||||
@@ -201,6 +204,9 @@ export default function App() {
|
||||
<Route index element={<Navigate to="rules" replace />} />
|
||||
<Route path="rules" element={<EngagementRules />} />
|
||||
<Route path="audiences" element={<EngagementAudiences />} />
|
||||
<Route path="templates" element={<EngagementTemplates />} />
|
||||
<Route path="triggers" element={<EngagementTriggers />} />
|
||||
<Route path="sends" element={<EngagementSendLog />} />
|
||||
</Route>
|
||||
<Route path="account" element={<AccountAdmin />} />
|
||||
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
|
||||
|
||||
@@ -389,6 +389,32 @@ export const api = {
|
||||
return req(`/admin/engagement/audience-preview${withQs(qs.toString())}`)
|
||||
},
|
||||
|
||||
// Templates and the send log (engagement Phase 5b). `previewEngagementTemplate`
|
||||
// and `testSendEngagementTemplate` are POSTs that write nothing: both act on
|
||||
// the draft in the request, so the editor can show and send what is on screen
|
||||
// rather than what was last saved.
|
||||
listEngagementTemplates: () => req('/admin/engagement/templates'),
|
||||
getEngagementTemplate: (id) => req(`/admin/engagement/templates/${id}`),
|
||||
updateEngagementTemplate: (id, body) =>
|
||||
req(`/admin/engagement/templates/${id}`, { method: 'PUT', body }),
|
||||
duplicateEngagementTemplate: (id, body) =>
|
||||
req(`/admin/engagement/templates/${id}/duplicate`, { method: 'POST', body }),
|
||||
deleteEngagementTemplate: (id) => req(`/admin/engagement/templates/${id}`, { method: 'DELETE' }),
|
||||
previewEngagementTemplate: (id, body) =>
|
||||
req(`/admin/engagement/templates/${id}/preview`, { method: 'POST', body }),
|
||||
testSendEngagementTemplate: (id, body) =>
|
||||
req(`/admin/engagement/templates/${id}/test-send`, { method: 'POST', body }),
|
||||
listEngagementSends: ({ limit, offset, triggerId, ruleId, userId, status } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (limit) qs.set('limit', String(limit))
|
||||
if (offset) qs.set('offset', String(offset))
|
||||
if (triggerId) qs.set('triggerId', triggerId)
|
||||
if (ruleId) qs.set('ruleId', String(ruleId))
|
||||
if (userId) qs.set('userId', String(userId))
|
||||
if (status) qs.set('status', status)
|
||||
return req(`/admin/engagement/sends${withQs(qs.toString())}`)
|
||||
},
|
||||
|
||||
// Teams (docs/website/TEAMS.md §2.11). Three of these mean something
|
||||
// different depending on who calls them: for a moderator, unhide and
|
||||
// setTeamDisplayName file a request and the response says `pending: true`.
|
||||
|
||||
12
client/src/emailBlocks/index.js
Normal file
12
client/src/emailBlocks/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
// Client email-block registry entrypoint. Importing this module registers every
|
||||
// `email.*` authoring definition exactly once, then re-exports the registry API.
|
||||
// The template editor imports from HERE, never from ./registry, so the
|
||||
// definitions are loaded before anything reads the palette.
|
||||
//
|
||||
// Same shape as `blocks/index.js` — and the same reason for existing.
|
||||
|
||||
export * from './registry'
|
||||
export { VariablePalette } from './types.jsx'
|
||||
|
||||
// ── Definitions (self-register on import) ──────────────────────────────────
|
||||
import './types.jsx'
|
||||
100
client/src/emailBlocks/registry.js
Normal file
100
client/src/emailBlocks/registry.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// ── The client-side `email.*` block registry ───────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §4.6.2, Phase 5b. A sibling of `blocks/registry.js` for the same
|
||||
// reason its server counterpart is a sibling of `blocks/registry.js` on that side
|
||||
// — and with ONE structural difference that is the whole argument for the shape of
|
||||
// this screen:
|
||||
//
|
||||
// **an email block definition here has no `component`.**
|
||||
//
|
||||
// A page block carries a React renderer because a page IS React. A mail body is a
|
||||
// string this deployment's server produces, and the preview shows exactly that
|
||||
// string. Giving these entries a React renderer would mean two renderers for one
|
||||
// artifact — one drawing the editor's preview, one producing what actually lands
|
||||
// in someone's inbox — and nothing would make them agree. They would agree on the
|
||||
// day they were written and drift from the first Outlook fix onward, at which
|
||||
// point the preview becomes a confident lie about mail nobody can see.
|
||||
//
|
||||
// So the division is: **this registry owns authoring, the server owns rendering.**
|
||||
// Everything here is about the editing experience — the palette entry, the prop
|
||||
// form, the starting props — and the preview arrives from
|
||||
// `POST /admin/engagement/templates/:id/preview` as HTML that goes into a
|
||||
// sandboxed iframe.
|
||||
//
|
||||
// `type` and `version` must match the server definition in
|
||||
// `server/src/emailBlocks/types/`. That pairing is the same discipline the page
|
||||
// family already runs on, and the save is the thing that enforces it: the server
|
||||
// validates against its own registry, so a client entry that has drifted produces
|
||||
// a refused save rather than a bad row.
|
||||
|
||||
const registry = new Map()
|
||||
|
||||
// The same reserved envelope keys the server's `RESERVED_KEYS` names. Duplicated
|
||||
// rather than imported because the client cannot import from `server/`, exactly as
|
||||
// `blocks/registry.js` duplicates them — and, as there, the server is the one that
|
||||
// decides: a block this list let through is still refused at the save.
|
||||
export const RESERVED_KEYS = ['id', 'type', 'version', 'visible', 'props']
|
||||
|
||||
/**
|
||||
* Register an email block definition.
|
||||
*
|
||||
* @param {object} def
|
||||
* @param {string} def.type must match the server type, e.g. 'email.heading'
|
||||
* @param {number} def.version must match the server schema version
|
||||
* @param {string} def.label palette display name
|
||||
* @param {string} def.icon palette icon glyph
|
||||
* @param {Function} def.editor ({ props, onChange, variables }) => JSX
|
||||
* @param {Function} def.defaults starting props when the block is added
|
||||
*/
|
||||
export function registerEmailBlock(def) {
|
||||
if (!def || typeof def.type !== 'string' || !def.type.startsWith('email.')) {
|
||||
throw new Error('registerEmailBlock: a definition needs a type namespaced "email."')
|
||||
}
|
||||
if (registry.has(def.type)) {
|
||||
throw new Error(`registerEmailBlock: block type already registered: ${def.type}`)
|
||||
}
|
||||
const entry = {
|
||||
type: def.type,
|
||||
version: Number.isInteger(def.version) ? def.version : 1,
|
||||
label: def.label || def.type,
|
||||
icon: def.icon || null,
|
||||
// The one-line description under the palette button. Mail blocks are less
|
||||
// self-evident than page ones — "Item list" does not say that it repeats over
|
||||
// a variable — and the palette is where that has to be said.
|
||||
hint: def.hint || '',
|
||||
editor: def.editor || null,
|
||||
defaults: typeof def.defaults === 'function' ? def.defaults : () => ({}),
|
||||
}
|
||||
registry.set(entry.type, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
/** @returns {object|null} the definition for `type`, or null if unknown. */
|
||||
export function getEmailBlock(type) {
|
||||
return registry.get(type) || null
|
||||
}
|
||||
|
||||
/** @returns {object[]} every definition, in registration order — the palette. */
|
||||
export function listEmailBlocks() {
|
||||
return [...registry.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh block envelope of `type`, ready to push onto the array.
|
||||
*
|
||||
* The id is random rather than sequential because block ids are unique across the
|
||||
* whole document and an operator can delete block 2 and add another; a counter
|
||||
* would hand out an id that is already taken and the save would be refused for a
|
||||
* reason nothing on screen explains.
|
||||
*/
|
||||
export function newEmailBlock(type) {
|
||||
const def = getEmailBlock(type)
|
||||
if (!def) return null
|
||||
return {
|
||||
id: `b${Math.random().toString(36).slice(2, 10)}`,
|
||||
type: def.type,
|
||||
version: def.version,
|
||||
visible: true,
|
||||
props: def.defaults(),
|
||||
}
|
||||
}
|
||||
272
client/src/emailBlocks/types.jsx
Normal file
272
client/src/emailBlocks/types.jsx
Normal file
@@ -0,0 +1,272 @@
|
||||
// The six `email.*` block editors, in one file rather than one file each.
|
||||
//
|
||||
// The page family gives every block its own module because each carries a React
|
||||
// RENDERER as well as a form, and those are substantial. An email block carries
|
||||
// only a form — the rendering is the server's (see ./registry.js) — and six short
|
||||
// prop panels split across six files would be six imports of the same three
|
||||
// controls to no benefit.
|
||||
//
|
||||
// Every `type` and `version` here pairs with a definition in
|
||||
// `server/src/emailBlocks/types/`, and the field lists are the server's `onlyKeys`
|
||||
// lists. Where a server schema has a bound (`MAX_TEXT`, `MAX_LABEL`), the input
|
||||
// carries the same `maxLength` — not as the check, which is the server's, but so
|
||||
// that an operator meets the limit while typing rather than at the save.
|
||||
import { TextField, TextAreaField, SelectField, Field } from '../blocks/editorKit.jsx'
|
||||
import { registerEmailBlock } from './registry'
|
||||
|
||||
/**
|
||||
* The variable palette, rendered under whichever field is being edited.
|
||||
*
|
||||
* Clicking a variable APPENDS its token rather than inserting at the caret. That
|
||||
* is a deliberate simplification: tracking a caret across a controlled React input
|
||||
* that a parent may re-render costs a ref and a selection-restore on every change,
|
||||
* and appending is both predictable and trivially undone. §4.6.2's requirement is
|
||||
* that inserting a variable "writes a token; it is never free-text" — which this
|
||||
* satisfies — not that it lands at the cursor.
|
||||
*/
|
||||
export function VariablePalette({ variables, onInsert }) {
|
||||
if (!variables || !variables.length) return null
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
|
||||
{variables.map((v) => (
|
||||
<button
|
||||
key={v.name}
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
title={`${v.type || 'string'}${v.required ? ' · required' : ''}${v.description ? ` — ${v.description}` : ''}`}
|
||||
onClick={() => onInsert(`{{${v.name}}}`)}
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.72rem', padding: '2px 6px' }}
|
||||
>
|
||||
{v.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A text field with the palette attached — the shape four of the six blocks want. */
|
||||
function VariableTextField({ label, hint, value, onChange, variables, maxLength, area, rows }) {
|
||||
const Control = area ? TextAreaField : TextField
|
||||
return (
|
||||
<div>
|
||||
<Control
|
||||
label={label}
|
||||
hint={hint}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
maxLength={maxLength}
|
||||
rows={rows}
|
||||
/>
|
||||
<VariablePalette variables={variables} onInsert={(token) => onChange(`${value || ''}${token}`)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.heading',
|
||||
version: 1,
|
||||
label: 'Heading',
|
||||
icon: 'H',
|
||||
hint: 'A section heading, at one of three sizes.',
|
||||
defaults: () => ({ level: 'h2', text: 'Heading' }),
|
||||
editor: ({ props, onChange, variables }) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<SelectField
|
||||
label="Size"
|
||||
// Named "Size" and not "Level" for the reason the server block's header
|
||||
// gives: mail clients build no outline from a message, so this is
|
||||
// typography rather than structure, and calling it a level in the UI would
|
||||
// invite someone to use it as one.
|
||||
hint="Mail clients build no document outline, so this is a size, not a rank."
|
||||
value={props.level || 'h2'}
|
||||
onChange={(level) => onChange({ ...props, level })}
|
||||
options={[
|
||||
['h1', 'Large'],
|
||||
['h2', 'Medium'],
|
||||
['h3', 'Small'],
|
||||
]}
|
||||
/>
|
||||
<VariableTextField
|
||||
label="Text"
|
||||
value={props.text}
|
||||
maxLength={200}
|
||||
variables={variables}
|
||||
onChange={(text) => onChange({ ...props, text })}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.text',
|
||||
version: 1,
|
||||
label: 'Paragraph',
|
||||
icon: '¶',
|
||||
hint: 'A paragraph of body text.',
|
||||
defaults: () => ({ text: 'Write your message here.', muted: false }),
|
||||
editor: ({ props, onChange, variables }) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<VariableTextField
|
||||
label="Text"
|
||||
area
|
||||
rows={5}
|
||||
value={props.text}
|
||||
maxLength={4000}
|
||||
variables={variables}
|
||||
onChange={(text) => onChange({ ...props, text })}
|
||||
/>
|
||||
<Field label="Style">
|
||||
<label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(props.muted)}
|
||||
onChange={(e) => onChange({ ...props, muted: e.target.checked })}
|
||||
/>
|
||||
<span>Quieter — for footnotes and small print</span>
|
||||
</label>
|
||||
</Field>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.button',
|
||||
version: 1,
|
||||
label: 'Button / link',
|
||||
icon: '▭',
|
||||
hint: 'The call to action. Its plain-text form is a sentence plus the URL.',
|
||||
defaults: () => ({ label: 'Open', url: '/', textLead: 'Open it here:' }),
|
||||
editor: ({ props, onChange, variables }) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<TextField
|
||||
label="Button text"
|
||||
value={props.label}
|
||||
maxLength={80}
|
||||
onChange={(label) => onChange({ ...props, label })}
|
||||
/>
|
||||
<VariableTextField
|
||||
label="Link"
|
||||
hint="Usually a variable, so the link is built for each recipient."
|
||||
value={props.url}
|
||||
maxLength={600}
|
||||
variables={variables}
|
||||
onChange={(url) => onChange({ ...props, url })}
|
||||
/>
|
||||
<TextField
|
||||
label="Plain-text lead-in"
|
||||
// The server block's header is worth repeating here in one line, because
|
||||
// this field looks optional and is the difference between a bare URL and a
|
||||
// sentence in every text-only inbox.
|
||||
hint="A button is nothing in plain text. This sentence introduces the link there, e.g. “Choose a new password here:”."
|
||||
value={props.textLead}
|
||||
maxLength={200}
|
||||
onChange={(textLead) => onChange({ ...props, textLead })}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.divider',
|
||||
version: 1,
|
||||
label: 'Divider',
|
||||
icon: '—',
|
||||
hint: 'A horizontal rule.',
|
||||
defaults: () => ({}),
|
||||
editor: () => (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
|
||||
A divider has nothing to configure.
|
||||
</p>
|
||||
),
|
||||
})
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.image',
|
||||
version: 1,
|
||||
label: 'Image',
|
||||
icon: '▣',
|
||||
hint: 'An image by URL. Many clients block images until the reader allows them.',
|
||||
defaults: () => ({ url: '/brand/logo.png', alt: 'Logo' }),
|
||||
editor: ({ props, onChange, variables }) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<VariableTextField
|
||||
label="Image URL"
|
||||
value={props.url}
|
||||
maxLength={600}
|
||||
variables={variables}
|
||||
onChange={(url) => onChange({ ...props, url })}
|
||||
/>
|
||||
<TextField
|
||||
label="Alt text"
|
||||
hint="Most mail clients block images by default, so for many readers this IS the image."
|
||||
value={props.alt}
|
||||
maxLength={200}
|
||||
onChange={(alt) => onChange({ ...props, alt })}
|
||||
/>
|
||||
<Field label="Width" hint="Pixels, 16-560. Leave blank to let the image size itself.">
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min={16}
|
||||
max={560}
|
||||
value={props.width ?? ''}
|
||||
// Blank REMOVES the prop rather than setting it to 0. The server accepts
|
||||
// `width` absent or between 16 and 560, so a 0 left behind by an empty
|
||||
// field is a refused save whose message names a field the operator
|
||||
// believes they cleared.
|
||||
onChange={(e) => {
|
||||
const next = { ...props }
|
||||
const value = Number(e.target.value)
|
||||
if (!e.target.value || !Number.isFinite(value)) delete next.width
|
||||
else next.width = Math.trunc(value)
|
||||
onChange(next)
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.itemList',
|
||||
version: 1,
|
||||
label: 'Item list',
|
||||
icon: '☰',
|
||||
hint: 'Repeats over a list variable — this is how a digest lists its items.',
|
||||
defaults: () => ({ variable: '', emptyText: '' }),
|
||||
editor: ({ props, onChange, variables }) => {
|
||||
// Only LIST variables may be chosen, and the field is a select rather than a
|
||||
// text input because this prop is a bare NAME, not a token: a typo here is the
|
||||
// one variable reference a reader of the template cannot see is wrong, and it
|
||||
// renders as an empty mail rather than as a visible gap.
|
||||
const lists = (variables || []).filter((v) => v.type === 'list' || v.type === 'array')
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{lists.length ? (
|
||||
<SelectField
|
||||
label="List variable"
|
||||
hint="Each item becomes a row with its heading, excerpt and link."
|
||||
value={props.variable || ''}
|
||||
onChange={(variable) => onChange({ ...props, variable })}
|
||||
options={[['', 'Choose a list…'], ...lists.map((v) => [v.name, v.name])]}
|
||||
/>
|
||||
) : (
|
||||
<Field label="List variable">
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem', margin: 0 }}>
|
||||
This template’s trigger declares no list variable, so an item list has nothing to
|
||||
repeat over. Point the template at a trigger that declares one — a digest, typically —
|
||||
or use paragraphs instead.
|
||||
</p>
|
||||
</Field>
|
||||
)}
|
||||
<TextField
|
||||
label="When the list is empty"
|
||||
hint="Shown instead of the list. Leave blank to show nothing at all."
|
||||
value={props.emptyText}
|
||||
maxLength={200}
|
||||
onChange={(emptyText) => onChange({ ...props, emptyText })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -48,6 +48,9 @@ const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2
|
||||
const IconModules = () => <Icon><path d="M12 3l8 4.5-8 4.5-8-4.5z" /><path d="M4 12l8 4.5 8-4.5" /><path d="M4 16.5L12 21l8-4.5" /></Icon>
|
||||
const IconMail = () => <Icon><rect x="3" y="5" width="18" height="14" rx="2" /><path d="M3.5 6.5L12 13l8.5-6.5" /></Icon>
|
||||
const IconList = () => <Icon><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4" cy="6" r="1.2" /><circle cx="4" cy="12" r="1.2" /><circle cx="4" cy="18" r="1.2" /></Icon>
|
||||
const IconTemplate = () => <Icon><rect x="4" y="3" width="16" height="18" rx="2" /><path d="M8 8h8M8 12h8M8 16h4" /></Icon>
|
||||
const IconSpark = () => <Icon><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z" /><path d="M18 16l.9 2.1L21 19l-2.1.9L18 22l-.9-2.1L15 19l2.1-.9z" /></Icon>
|
||||
const IconLog = () => <Icon><path d="M4 5h16v14H4z" /><path d="M8 9h8M8 12h8M8 15h5" /></Icon>
|
||||
|
||||
// Nav is grouped into collapsible categories. A group with no `title` renders
|
||||
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
|
||||
@@ -93,15 +96,17 @@ export const NAV = [
|
||||
},
|
||||
{
|
||||
// Its own top-level group (ENGAGEMENT.md §7.1 Q4), not a section of
|
||||
// Settings. Settings is already one long page of sections, and the screens
|
||||
// that join this group in Phase 5 - Triggers, Templates and the send log -
|
||||
// are a catalog, an editor and a paged table, none of which is a settings
|
||||
// section. Email Delivery stays under Settings: configuring a transport is
|
||||
// not the same job as deciding who gets mail.
|
||||
// Settings. Settings is already one long page of sections, and these five
|
||||
// screens are two editors, a catalog and a paged table, none of which is a
|
||||
// settings section. Email Delivery stays under Settings: configuring a
|
||||
// transport is not the same job as deciding who gets mail.
|
||||
title: 'Engagement',
|
||||
items: [
|
||||
{ to: '/admin/engagement/rules', label: 'Rules', icon: IconMail, roles: ['admin'] },
|
||||
{ to: '/admin/engagement/audiences', label: 'Audiences', icon: IconList, roles: ['admin'] },
|
||||
{ to: '/admin/engagement/templates', label: 'Templates', icon: IconTemplate, roles: ['admin'] },
|
||||
{ to: '/admin/engagement/triggers', label: 'Triggers', icon: IconSpark, roles: ['admin'] },
|
||||
{ to: '/admin/engagement/sends', label: 'Send Log', icon: IconLog, roles: ['admin'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -174,6 +179,9 @@ const TITLES = {
|
||||
'/admin/account': 'Account Security',
|
||||
'/admin/engagement/rules': 'Engagement Rules',
|
||||
'/admin/engagement/audiences': 'Engagement Audiences',
|
||||
'/admin/engagement/templates': 'Message Templates',
|
||||
'/admin/engagement/triggers': 'Triggers',
|
||||
'/admin/engagement/sends': 'Send Log',
|
||||
}
|
||||
|
||||
// An installed module's admin pages are not in TITLES and cannot be — core does
|
||||
|
||||
172
client/src/routes/admin/views/EngagementSendLog.jsx
Normal file
172
client/src/routes/admin/views/EngagementSendLog.jsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Admin → Engagement → Send Log (ENGAGEMENT.md §4.5, gap G15, Phase 5b).
|
||||
//
|
||||
// G15 was stated as: "no per-message record — no send log, no delivery status, no
|
||||
// audit". The table has been filling since Phase 4a; this is the screen that reads
|
||||
// it, and the question it exists to answer is the operator's, not the engine's:
|
||||
// **did that person get that mail, and if not, why not?**
|
||||
//
|
||||
// Two things it deliberately does not show.
|
||||
//
|
||||
// • **The address.** The log stores a sha256 so a bounce can be correlated back
|
||||
// to a recipient (Phase 9) without becoming a second address book. The route
|
||||
// strips the column; this screen could not render it if it wanted to.
|
||||
// • **A name for the user.** The `user_id` is what the log holds, and joining
|
||||
// users in would make a delivery screen into a directory. The id is enough to
|
||||
// paste into Moderation, which is where a person's record belongs.
|
||||
//
|
||||
// `failed` rows are the point of the screen, so the reason is a column and not a
|
||||
// tooltip: a delivery log whose failures need a hover is a log nobody reads.
|
||||
|
||||
const STATUS_LABEL = {
|
||||
sent: 'Sent',
|
||||
failed: 'Failed',
|
||||
suppressed: 'Not sent',
|
||||
bounced: 'Bounced',
|
||||
complained: 'Marked as spam',
|
||||
}
|
||||
|
||||
const STATUS_COLOR = {
|
||||
failed: '#d98b84',
|
||||
bounced: '#d98b84',
|
||||
complained: '#d98b84',
|
||||
}
|
||||
|
||||
const PAGE = 50
|
||||
|
||||
export default function EngagementSendLog() {
|
||||
const [rows, setRows] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [status, setStatus] = useState('')
|
||||
const [testTrigger, setTestTrigger] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const load = useCallback(async (nextOffset, nextStatus) => {
|
||||
const result = await api.admin.listEngagementSends({
|
||||
limit: PAGE,
|
||||
offset: nextOffset,
|
||||
status: nextStatus || undefined,
|
||||
})
|
||||
setRows(result.sends || [])
|
||||
setTotal(result.total || 0)
|
||||
setTestTrigger(result.testSendTrigger || '')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await load(offset, status)
|
||||
if (alive) setError(null)
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message)
|
||||
} finally {
|
||||
if (alive) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { alive = false }
|
||||
}, [load, offset, status])
|
||||
|
||||
if (loading && rows.length === 0) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
const to = Math.min(offset + PAGE, total)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 560 }}>
|
||||
Every message this deployment tried to deliver, successful or not. Addresses are not kept
|
||||
here — only a one-way hash, so a bounce can be matched back without the log becoming a
|
||||
second address book.
|
||||
</p>
|
||||
<label>
|
||||
<span className="field-label">Show</span>
|
||||
<select className="select" value={status} onChange={(e) => { setOffset(0); setStatus(e.target.value) }}>
|
||||
<option value="">Everything</option>
|
||||
<option value="sent">Sent</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="suppressed">Not sent</option>
|
||||
<option value="bounced">Bounced</option>
|
||||
<option value="complained">Marked as spam</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{total === 0 ? (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
|
||||
{status ? 'Nothing matches that filter.' : 'Nothing has been sent yet.'}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">When</th>
|
||||
<th className="adm-th">What</th>
|
||||
<th className="adm-th">To</th>
|
||||
<th className="adm-th">Channel</th>
|
||||
<th className="adm-th">Result</th>
|
||||
<th className="adm-th">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="adm-td" style={{ whiteSpace: 'nowrap', fontSize: '0.8rem' }}>
|
||||
{new Date(r.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{/* The synthetic test-send id is rendered by name: it is not a
|
||||
registered trigger and will never appear in the catalog,
|
||||
so showing the raw id would send someone looking for it. */}
|
||||
{r.trigger_id === testTrigger
|
||||
? <span>Test send <span className="dim">from the template editor</span></span>
|
||||
: <code style={{ fontSize: '0.8rem' }}>{r.trigger_id}</code>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{r.user_id ? <span className="dim">user #{r.user_id}</span> : <span className="dim">—</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{r.channel}
|
||||
{r.transport && <span className="dim"> · {r.transport}</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem', color: STATUS_COLOR[r.status] || undefined }}>
|
||||
{STATUS_LABEL[r.status] || r.status}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.8rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
|
||||
{r.detail || ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
|
||||
<span className="sans dim" style={{ fontSize: '0.82rem' }}>
|
||||
{offset + 1}–{to} of {total}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
|
||||
disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE))}>
|
||||
Newer
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
|
||||
disabled={to >= total} onClick={() => setOffset(offset + PAGE)}>
|
||||
Older
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
649
client/src/routes/admin/views/EngagementTemplates.jsx
Normal file
649
client/src/routes/admin/views/EngagementTemplates.jsx
Normal file
@@ -0,0 +1,649 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { getEmailBlock, listEmailBlocks, newEmailBlock } from '../../../emailBlocks/index.js'
|
||||
|
||||
// Admin → Engagement → Templates (ENGAGEMENT.md §4.6.2, Phase 5b).
|
||||
//
|
||||
// Phase 5a moved every subject and body out of `mailer.js` into rows. This is the
|
||||
// screen that lets someone change one, and its whole shape follows from a single
|
||||
// fact about email:
|
||||
//
|
||||
// **the server renders the mail, so the server renders the preview.**
|
||||
//
|
||||
// There is no React renderer for an `email.*` block anywhere in this client. The
|
||||
// preview is HTML the server produced with the same call the send path uses,
|
||||
// dropped into a sandboxed iframe. That costs a round trip per edit — debounced
|
||||
// below — and buys the only property that matters on a screen like this: what is
|
||||
// on screen is what will arrive, not a second implementation's opinion of it.
|
||||
//
|
||||
// **The sandbox is a security boundary, not a nicety.** The preview is
|
||||
// operator-authored HTML. It renders with `sandbox` and no `allow-scripts`, from
|
||||
// `srcdoc` (an opaque origin), so it can neither run script nor reach this page's
|
||||
// cookies even if someone stores markup that gets past `sanitizeHtml`. The
|
||||
// attributes are asserted in `client/test/emailTemplates.test.js` for the same
|
||||
// reason the server's checks are asserted: this is the kind of attribute someone
|
||||
// removes while debugging and does not put back.
|
||||
//
|
||||
// What the operator can do here is deliberately bounded (settled with the org
|
||||
// lead at the start of the phase):
|
||||
//
|
||||
// • **A shipped default is edited in place.** `protected` blocks deletion and
|
||||
// nothing else; saving sets `customized = 1`, which is what stops the next
|
||||
// seed bump from taking the edit back.
|
||||
// • **Duplicate is the only way to a new template**, so every template on a
|
||||
// deployment descends from one that renders.
|
||||
|
||||
const DANGER = { color: '#d98b84', borderColor: '#5b2020' }
|
||||
|
||||
// Three widths, because a mail body has to survive all of them and the failures
|
||||
// are different: 640 is a desktop client's reading pane, 360 is a phone, and the
|
||||
// plain-text part is what a text-only client and every screen reader gets.
|
||||
const WIDTHS = [
|
||||
['desktop', 'Desktop', 640],
|
||||
['mobile', 'Mobile', 360],
|
||||
]
|
||||
|
||||
/** Short, human label for a template's channel. */
|
||||
const CHANNEL_LABEL = { email: 'Email', inapp: 'On the site', push: 'Push' }
|
||||
|
||||
// ── The preview frame ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The rendered HTML, in a sandboxed frame.
|
||||
*
|
||||
* `dark` applies a CSS inversion to the FRAME, not to the mail: it approximates
|
||||
* what Apple Mail and Outlook do to a light-only message, which is the failure
|
||||
* §4.6.2 asks this control to expose ("a light-only template renders as unreadable
|
||||
* dark-on-dark in about a third of inboxes"). It is an approximation and says so
|
||||
* on screen — the alternative, rendering a second dark palette server-side, would
|
||||
* be a preview of a mail this system does not send.
|
||||
*/
|
||||
function PreviewFrame({ html, width, dark }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: dark ? '#1b1b1b' : '#f4f4f5',
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
overflowX: 'auto',
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
// No allow-scripts, and no allow-same-origin. Both omissions are load
|
||||
// bearing; see this file's header.
|
||||
sandbox=""
|
||||
srcDoc={html || ''}
|
||||
title="Message preview"
|
||||
style={{
|
||||
width,
|
||||
maxWidth: '100%',
|
||||
height: 520,
|
||||
border: '1px solid var(--rule)',
|
||||
borderRadius: 4,
|
||||
background: '#fff',
|
||||
display: 'block',
|
||||
margin: '0 auto',
|
||||
filter: dark ? 'invert(1) hue-rotate(180deg)' : 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The editor ─────────────────────────────────────────────────────────────
|
||||
|
||||
function TemplateEditor({ template, triggers, onDone, onCancel }) {
|
||||
const [name, setName] = useState(template.name)
|
||||
const [subject, setSubject] = useState(template.subject || '')
|
||||
const [blocks, setBlocks] = useState(template.blocks || [])
|
||||
const [textBody, setTextBody] = useState(template.text_body || '')
|
||||
const [status, setStatus] = useState(template.status)
|
||||
const [triggerId, setTriggerId] = useState(template.trigger_id || '')
|
||||
const [selected, setSelected] = useState(template.blocks?.[0]?.id || null)
|
||||
|
||||
const [preview, setPreview] = useState(null)
|
||||
const [previewError, setPreviewError] = useState(null)
|
||||
const [tab, setTab] = useState('html')
|
||||
const [width, setWidth] = useState('desktop')
|
||||
const [dark, setDark] = useState(false)
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [errors, setErrors] = useState([])
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [testTo, setTestTo] = useState('')
|
||||
const [testState, setTestState] = useState(null)
|
||||
|
||||
// The variable palette. It comes from the server with the row and is refreshed
|
||||
// by every preview, because re-pointing the template at another trigger changes
|
||||
// it and the server is the one that knows what that trigger declares.
|
||||
const [variables, setVariables] = useState(template.variables || [])
|
||||
|
||||
const draft = useMemo(
|
||||
() => ({ name, subject, blocks, textBody: textBody || null, status, triggerId: triggerId || null }),
|
||||
[name, subject, blocks, textBody, status, triggerId],
|
||||
)
|
||||
|
||||
// Debounced preview. The delay is not about server load — it is one small
|
||||
// render — but about the frame: re-mounting an iframe on every keystroke makes
|
||||
// the preview flicker and steals nothing back.
|
||||
const timer = useRef(null)
|
||||
useEffect(() => {
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(async () => {
|
||||
try {
|
||||
const body = { subject: draft.subject, blocks: draft.blocks, textBody: draft.textBody, triggerId: draft.triggerId }
|
||||
const result = await api.admin.previewEngagementTemplate(template.id, body)
|
||||
setPreview(result)
|
||||
setPreviewError(null)
|
||||
if (Array.isArray(result.variables)) setVariables(result.variables)
|
||||
} catch (err) {
|
||||
// A preview failure is expected while a block is half-edited, so it is
|
||||
// shown where the preview would be rather than as a page-level error.
|
||||
setPreviewError(err.body?.errors?.join(' · ') || err.message)
|
||||
}
|
||||
}, 400)
|
||||
return () => timer.current && clearTimeout(timer.current)
|
||||
}, [draft, template.id])
|
||||
|
||||
const selectedBlock = blocks.find((b) => b.id === selected) || null
|
||||
const selectedDef = selectedBlock ? getEmailBlock(selectedBlock.type) : null
|
||||
|
||||
const updateBlock = (id, props) =>
|
||||
setBlocks((bs) => bs.map((b) => (b.id === id ? { ...b, props } : b)))
|
||||
|
||||
const addBlock = (type) => {
|
||||
const block = newEmailBlock(type)
|
||||
if (!block) return
|
||||
setBlocks((bs) => [...bs, block])
|
||||
setSelected(block.id)
|
||||
}
|
||||
|
||||
const move = (id, delta) =>
|
||||
setBlocks((bs) => {
|
||||
const i = bs.findIndex((b) => b.id === id)
|
||||
const j = i + delta
|
||||
if (i < 0 || j < 0 || j >= bs.length) return bs
|
||||
const next = [...bs]
|
||||
;[next[i], next[j]] = [next[j], next[i]]
|
||||
return next
|
||||
})
|
||||
|
||||
const removeBlock = (id) =>
|
||||
setBlocks((bs) => {
|
||||
const next = bs.filter((b) => b.id !== id)
|
||||
if (selected === id) setSelected(next[0]?.id || null)
|
||||
return next
|
||||
})
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
setErrors([])
|
||||
setSaved(false)
|
||||
try {
|
||||
await api.admin.updateEngagementTemplate(template.id, draft)
|
||||
setSaved(true)
|
||||
onDone()
|
||||
} catch (err) {
|
||||
setErrors(err.body?.errors?.length ? err.body.errors : [err.message])
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTest() {
|
||||
setTestState({ busy: true })
|
||||
try {
|
||||
const body = { ...draft, to: testTo }
|
||||
const result = await api.admin.testSendEngagementTemplate(template.id, body)
|
||||
setTestState({ ok: true, message: `Sent to ${result.to}.` })
|
||||
} catch (err) {
|
||||
setTestState({ ok: false, message: err.body?.errors?.join(' · ') || err.message })
|
||||
}
|
||||
}
|
||||
|
||||
const widthPx = WIDTHS.find(([id]) => id === width)?.[2] || 640
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, marginBottom: 16 }}>
|
||||
<div>
|
||||
<h2 className="sans" style={{ margin: '0 0 4px', fontSize: '1.05rem' }}>{template.name}</h2>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>
|
||||
<code>{template.key}</code> · {CHANNEL_LABEL[template.channel] || template.channel}
|
||||
{template.protected && ' · part of the system'}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="btn btn-sq" onClick={onCancel}>Back</button>
|
||||
<button type="button" className="btn btn-primary btn-sq" onClick={save} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errors.length > 0 && (
|
||||
<div className="panel" style={{ padding: 14, marginBottom: 16, borderColor: '#5b2020' }}>
|
||||
{errors.map((e) => (
|
||||
<p key={e} className="sans" style={{ margin: '0 0 4px', color: '#d98b84', fontSize: '0.85rem' }}>{e}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{saved && errors.length === 0 && (
|
||||
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.85rem', color: 'var(--muted)' }}>Saved.</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(280px, 1fr) minmax(320px, 1.2fr)', gap: 22, alignItems: 'start' }}>
|
||||
{/* ── Authoring ── */}
|
||||
<div>
|
||||
<div className="panel" style={{ padding: 18, marginBottom: 18 }}>
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Name</span>
|
||||
<input className="input" value={name} maxLength={160} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
{template.channel === 'email' && (
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Subject</span>
|
||||
<input className="input" value={subject} maxLength={300} onChange={(e) => setSubject(e.target.value)} />
|
||||
<VariableButtons variables={variables} onInsert={(t) => setSubject((s) => s + t)} />
|
||||
</label>
|
||||
)}
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Trigger</span>
|
||||
<select className="select" value={triggerId} onChange={(e) => setTriggerId(e.target.value)}>
|
||||
{/* "None" is the right default and not a missing value: every
|
||||
transactional template is tied to no trigger — mailer renders
|
||||
it by key with no rule involved. */}
|
||||
<option value="">None — used by key, not by a rule</option>
|
||||
{triggers.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.label} ({t.id})</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
|
||||
The trigger decides which variables this template may use.
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Status</span>
|
||||
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="draft">Draft — the shipped default is sent instead</option>
|
||||
<option value="published">Published — this is what goes out</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="panel" style={{ padding: 18, marginBottom: 18 }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Body</div>
|
||||
{blocks.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>No blocks yet. Add one below.</p>
|
||||
)}
|
||||
{blocks.map((b, i) => {
|
||||
const def = getEmailBlock(b.type)
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', marginBottom: 4,
|
||||
borderRadius: 4, cursor: 'pointer',
|
||||
background: b.id === selected ? 'var(--panel-2, rgba(255,255,255,0.05))' : 'transparent',
|
||||
border: `1px solid ${b.id === selected ? 'var(--accent)' : 'transparent'}`,
|
||||
}}
|
||||
onClick={() => setSelected(b.id)}
|
||||
>
|
||||
<span style={{ width: 18, textAlign: 'center' }}>{def?.icon || '?'}</span>
|
||||
<span className="sans" style={{ flex: 1, fontSize: '0.86rem' }}>
|
||||
{/* An unknown type is a client/server version skew, and saying
|
||||
so beats rendering a blank row the operator cannot act on. */}
|
||||
{def ? def.label : `${b.type} (not known to this client)`}
|
||||
</span>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.7rem' }} disabled={i === 0}
|
||||
onClick={(e) => { e.stopPropagation(); move(b.id, -1) }}>↑</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.7rem' }} disabled={i === blocks.length - 1}
|
||||
onClick={(e) => { e.stopPropagation(); move(b.id, 1) }}>↓</button>
|
||||
<button type="button" className="pill" style={{ ...DANGER, fontSize: '0.7rem' }}
|
||||
onClick={(e) => { e.stopPropagation(); removeBlock(b.id) }}>×</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 12 }}>
|
||||
{listEmailBlocks().map((def) => (
|
||||
<button key={def.type} type="button" className="pill" title={def.hint}
|
||||
style={{ fontSize: '0.74rem' }} onClick={() => addBlock(def.type)}>
|
||||
+ {def.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedBlock && selectedDef?.editor && (
|
||||
<div className="panel" style={{ padding: 18, marginBottom: 18 }}>
|
||||
<div className="field-label" style={{ marginBottom: 10 }}>{selectedDef.label}</div>
|
||||
<selectedDef.editor
|
||||
props={selectedBlock.props || {}}
|
||||
variables={variables}
|
||||
onChange={(props) => updateBlock(selectedBlock.id, props)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="panel" style={{ padding: 18 }}>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Plain-text part (optional override)</span>
|
||||
<textarea
|
||||
className="input" rows={5} value={textBody}
|
||||
placeholder="Leave blank to generate it from the blocks above."
|
||||
onChange={(e) => setTextBody(e.target.value)}
|
||||
style={{ resize: 'vertical', fontFamily: 'monospace', fontSize: '0.82rem' }}
|
||||
/>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
|
||||
Every message has both parts. Writing one here REPLACES the generated text entirely.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Preview ── */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 10, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem', opacity: tab === 'html' ? 1 : 0.6 }}
|
||||
onClick={() => setTab('html')}>HTML</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem', opacity: tab === 'text' ? 1 : 0.6 }}
|
||||
onClick={() => setTab('text')}>Plain text</button>
|
||||
{tab === 'html' && (
|
||||
<>
|
||||
<span style={{ width: 10 }} />
|
||||
{WIDTHS.map(([id, label]) => (
|
||||
<button key={id} type="button" className="pill"
|
||||
style={{ fontSize: '0.74rem', opacity: width === id ? 1 : 0.6 }}
|
||||
onClick={() => setWidth(id)}>{label}</button>
|
||||
))}
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem', opacity: dark ? 1 : 0.6 }}
|
||||
onClick={() => setDark((d) => !d)}>Dark mode</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewError ? (
|
||||
<div className="panel" style={{ padding: 16, borderColor: '#5b2020' }}>
|
||||
<p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{previewError}</p>
|
||||
</div>
|
||||
) : !preview ? (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>Rendering…</p>
|
||||
) : tab === 'html' ? (
|
||||
<>
|
||||
{template.channel === 'email' && (
|
||||
<p className="sans" style={{ margin: '0 0 8px', fontSize: '0.85rem' }}>
|
||||
<span className="dim">Subject: </span>{preview.subject || <em className="dim">none</em>}
|
||||
</p>
|
||||
)}
|
||||
<PreviewFrame html={preview.html} width={widthPx} dark={dark} />
|
||||
{dark && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 6 }}>
|
||||
An approximation of how a client that inverts a light-only message will show it.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<pre className="panel" style={{ padding: 16, fontSize: '0.82rem', whiteSpace: 'pre-wrap', margin: 0 }}>
|
||||
{preview.text || '(empty — a published template is refused with no text part)'}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{preview?.missing?.length > 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', marginTop: 8 }}>
|
||||
No example value for: {preview.missing.join(', ')} — these render as nothing here and
|
||||
will carry real values when the message is actually sent.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="panel" style={{ padding: 18, marginTop: 18 }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Send a test</div>
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
|
||||
Sends what is on screen, saved or not, through the configured transport.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input className="input" type="email" placeholder="you@example.com" value={testTo}
|
||||
onChange={(e) => setTestTo(e.target.value)} style={{ flex: 1 }} />
|
||||
<button type="button" className="btn btn-sq" onClick={sendTest} disabled={testState?.busy}>
|
||||
{testState?.busy ? 'Sending…' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
{testState && !testState.busy && (
|
||||
<p className="sans" style={{ margin: '8px 0 0', fontSize: '0.82rem', color: testState.ok ? 'var(--muted)' : '#d98b84' }}>
|
||||
{testState.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** The variable tokens, for the two fields that are not block props. */
|
||||
function VariableButtons({ variables, onInsert }) {
|
||||
if (!variables?.length) return null
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
|
||||
{variables.map((v) => (
|
||||
<button key={v.name} type="button" className="btn btn-ghost btn-xs"
|
||||
title={`${v.type || 'string'}${v.description ? ` — ${v.description}` : ''}`}
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.72rem', padding: '2px 6px' }}
|
||||
onClick={() => onInsert(`{{${v.name}}}`)}>
|
||||
{v.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Duplicate ──────────────────────────────────────────────────────────────
|
||||
|
||||
function DuplicateForm({ source, triggers, onDone, onCancel }) {
|
||||
const [key, setKey] = useState('')
|
||||
const [name, setName] = useState(`${source.name} (copy)`)
|
||||
const [triggerId, setTriggerId] = useState(source.trigger_id || '')
|
||||
const [errors, setErrors] = useState([])
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
setErrors([])
|
||||
try {
|
||||
const { template } = await api.admin.duplicateEngagementTemplate(source.id, { key, name, triggerId: triggerId || null })
|
||||
onDone(template)
|
||||
} catch (err) {
|
||||
setErrors(err.body?.errors?.length ? err.body.errors : [err.message])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="panel" style={{ padding: 22, marginBottom: 22 }} onSubmit={submit}>
|
||||
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.98rem' }}>Duplicate “{source.name}”</h3>
|
||||
<p className="sans dim" style={{ margin: '0 0 16px', fontSize: '0.82rem' }}>
|
||||
The copy starts as a draft, so nothing sends it until you publish it.
|
||||
</p>
|
||||
{errors.map((e) => (
|
||||
<p key={e} className="sans" style={{ margin: '0 0 8px', color: '#d98b84', fontSize: '0.85rem' }}>{e}</p>
|
||||
))}
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Key</span>
|
||||
<input className="input" value={key} maxLength={96} placeholder="notify.my-event"
|
||||
onChange={(e) => setKey(e.target.value)} />
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
|
||||
How a rule points at this template. Lowercase letters, digits, dots and dashes; it cannot be
|
||||
changed afterwards.
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Name</span>
|
||||
<input className="input" value={name} maxLength={160} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||
<span className="field-label">Trigger</span>
|
||||
<select className="select" value={triggerId} onChange={(e) => setTriggerId(e.target.value)}>
|
||||
<option value="">None — used by key, not by a rule</option>
|
||||
{triggers.map((t) => <option key={t.id} value={t.id}>{t.label} ({t.id})</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="submit" className="btn btn-primary btn-sq">Duplicate</button>
|
||||
<button type="button" className="btn btn-sq" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The list ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function EngagementTemplates() {
|
||||
const [templates, setTemplates] = useState([])
|
||||
const [triggers, setTriggers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [rowError, setRowError] = useState(null)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [duplicating, setDuplicating] = useState(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [t, tr] = await Promise.all([api.admin.listEngagementTemplates(), api.admin.engagementTriggers()])
|
||||
setTemplates(t.templates || [])
|
||||
setTriggers(tr.triggers || [])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
try {
|
||||
await load()
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message)
|
||||
} finally {
|
||||
if (alive) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { alive = false }
|
||||
}, [load])
|
||||
|
||||
async function open(row) {
|
||||
setRowError(null)
|
||||
try {
|
||||
const { template } = await api.admin.getEngagementTemplate(row.id)
|
||||
setEditing(template)
|
||||
} catch (err) {
|
||||
setRowError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row) {
|
||||
if (!window.confirm(`Delete “${row.name}”?`)) return
|
||||
setRowError(null)
|
||||
try {
|
||||
await api.admin.deleteEngagementTemplate(row.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setRowError(err.body?.errors?.join(' · ') || err.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<TemplateEditor
|
||||
template={editing}
|
||||
triggers={triggers}
|
||||
onDone={load}
|
||||
onCancel={async () => { setEditing(null); await load() }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
{duplicating && (
|
||||
<DuplicateForm
|
||||
source={duplicating}
|
||||
triggers={triggers}
|
||||
onCancel={() => setDuplicating(null)}
|
||||
onDone={async (template) => { setDuplicating(null); await load(); setEditing(template) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="sans" style={{ margin: '0 0 16px', fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 680 }}>
|
||||
Every message this deployment sends. The shipped ones are editable — your edits survive
|
||||
upgrades — and cannot be deleted, because the system breaks without them. To make a new
|
||||
template, duplicate one that already works.
|
||||
</p>
|
||||
|
||||
{rowError && (
|
||||
<p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{rowError}</p>
|
||||
)}
|
||||
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Name</th>
|
||||
<th className="adm-th">Key</th>
|
||||
<th className="adm-th">Channel</th>
|
||||
<th className="adm-th">Status</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{templates.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<td className="adm-td">
|
||||
{t.name}
|
||||
{t.protected && (
|
||||
<span className="pill" style={{ marginLeft: 8, fontSize: '0.68rem' }}>system</span>
|
||||
)}
|
||||
<Flags template={t} />
|
||||
</td>
|
||||
<td className="adm-td"><code style={{ fontSize: '0.8rem' }}>{t.key}</code></td>
|
||||
<td className="adm-td">{CHANNEL_LABEL[t.channel] || t.channel}</td>
|
||||
<td className="adm-td">{t.status === 'published' ? 'Published' : 'Draft'}</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginRight: 6 }}
|
||||
onClick={() => open(t)}>Edit</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginRight: 6 }}
|
||||
onClick={() => setDuplicating(t)}>Duplicate</button>
|
||||
<button type="button" className="pill"
|
||||
style={{ ...DANGER, fontSize: '0.72rem', opacity: t.protected ? 0.4 : 1 }}
|
||||
disabled={t.protected}
|
||||
title={t.protected ? 'Part of the system — edit it or duplicate it' : undefined}
|
||||
onClick={() => remove(t)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The three warnings a row can carry. Each is a different fact and they are worded
|
||||
* as what an operator should DO, not as the flag name: "dormant" and "behind" mean
|
||||
* nothing to someone who has not read the design document.
|
||||
*/
|
||||
function Flags({ template }) {
|
||||
const notes = []
|
||||
if (template.dormant) {
|
||||
notes.push(`No installed module declares ${template.trigger_id} — nothing will send this.`)
|
||||
}
|
||||
if (template.triggerBehind) {
|
||||
notes.push('Its trigger has changed since this was written; check the variables still exist.')
|
||||
}
|
||||
if (template.seedBehind) {
|
||||
notes.push('A newer version of the shipped default exists. Your edits were kept, so it was not applied.')
|
||||
}
|
||||
if (!notes.length) return null
|
||||
return (
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{notes.map((n) => <div key={n}>{n}</div>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
129
client/src/routes/admin/views/EngagementTriggers.jsx
Normal file
129
client/src/routes/admin/views/EngagementTriggers.jsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Admin → Engagement → Triggers (ENGAGEMENT.md §4.3, Phase 5b).
|
||||
//
|
||||
// Read-only, and structurally so: **there is no table behind this screen.** A
|
||||
// trigger is DECLARED in code by core or by an installed module, so this is
|
||||
// whatever registered on the current boot. Uninstall a module and its triggers
|
||||
// stop appearing here; nothing was deleted and nothing needs to be.
|
||||
//
|
||||
// It exists because the two things it shows are otherwise invisible and both are
|
||||
// load-bearing elsewhere:
|
||||
//
|
||||
// • **The variables** are the contract a template may reference. When a rule
|
||||
// mails nothing sensible, "which variables does this event actually carry"
|
||||
// is the first question, and the answer used to live only in a module's source.
|
||||
// • **The ceiling** is the security boundary from G24 — the widest audience a
|
||||
// rule may ever give this trigger. A rule editor that offers a narrower set
|
||||
// than an operator expects is obeying a number declared here.
|
||||
|
||||
const CEILING_NOTE = {
|
||||
owner: 'only the person the event is about',
|
||||
members: 'only members of the thing it is about',
|
||||
subscribers: 'only people who opted in',
|
||||
staff: 'only staff',
|
||||
authenticated: 'any signed-in account',
|
||||
everyone: 'anyone',
|
||||
}
|
||||
|
||||
export default function EngagementTriggers() {
|
||||
const [triggers, setTriggers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
try {
|
||||
const { triggers: list } = await api.admin.engagementTriggers()
|
||||
if (alive) setTriggers(list || [])
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message)
|
||||
} finally {
|
||||
if (alive) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
return (
|
||||
<section>
|
||||
<p className="sans" style={{ margin: '0 0 16px', fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 680 }}>
|
||||
The events a rule can be built on, declared in code by core and by installed modules. This
|
||||
list is whatever is registered right now — it is not stored anywhere, so a module that is
|
||||
uninstalled simply stops appearing.
|
||||
</p>
|
||||
|
||||
{triggers.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>Nothing is registered.</p>
|
||||
)}
|
||||
|
||||
{triggers.map((t) => (
|
||||
<div className="panel" key={t.id} style={{ padding: 18, marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<h3 className="sans" style={{ margin: '0 0 2px', fontSize: '0.98rem' }}>{t.label}</h3>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
|
||||
<code>{t.id}</code> · from {t.owner} · v{t.version}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="field-label" style={{ marginBottom: 2 }}>Can reach at most</div>
|
||||
<div className="sans" style={{ fontSize: '0.84rem' }}>
|
||||
{t.ceiling}
|
||||
<span className="dim"> — {CEILING_NOTE[t.ceiling] || 'see the design document'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{t.description && (
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.84rem', color: 'var(--muted)' }}>
|
||||
{t.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(t.variables || []).length > 0 && (
|
||||
<table className="adm-table" style={{ marginTop: 14 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Variable</th>
|
||||
<th className="adm-th">Type</th>
|
||||
<th className="adm-th">Example</th>
|
||||
<th className="adm-th">What it is</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{t.variables.map((v) => (
|
||||
<tr key={v.name}>
|
||||
{/* `nowrap`: without it the "always set" pill wraps between its
|
||||
two words on a longer variable name, orphaning "set" on a
|
||||
line of its own and making the row read as two facts. */}
|
||||
<td className="adm-td" style={{ whiteSpace: 'nowrap' }}>
|
||||
<code style={{ fontSize: '0.8rem' }}>{`{{${v.name}}}`}</code>
|
||||
{v.required && <span className="pill" style={{ marginLeft: 6, fontSize: '0.66rem' }}>always set</span>}
|
||||
</td>
|
||||
<td className="adm-td">{v.type}</td>
|
||||
<td className="adm-td" style={{ maxWidth: 260, overflowWrap: 'anywhere' }}>
|
||||
<span className="dim" style={{ fontSize: '0.8rem' }}>
|
||||
{/* A list variable's example is an array of objects; showing
|
||||
it as JSON is honest and short, and it is the shape an
|
||||
item list repeats over. */}
|
||||
{typeof v.example === 'string' ? v.example : JSON.stringify(v.example)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>{v.description || ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user