// ── 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(), } }