patterns
EmailTemplateEditor
Block-based email template editor — designs branded HTML campaigns AND plain person-to-person emails from a single JSON contract. Same JSON drives the UI editor and the server renderer; consumer apps (Cip, Archi, future tools) integrate by pointing it at a variable schema and persisting the document.
Wrapped in a 'technical instrument' frame (rounded border + monospace chrome bar) that mirrors the RuleBuilder visual idiom. Two modes per template: `branded` renders block-based HTML on a locked 600px canvas with a dot-grid frame; `plain` renders a single rich-text document that comes through as `text/plain` + minimal HTML so it reads as a real person typing in their email client. Inline rich text inside any TipTap-backed block (heading / paragraph / footer / plain body) gets a floating bubble toolbar on selection — bold, italic, underline, strikethrough, link, plus pop-out pickers for text size (S 12px · M 14px · L 18px), font family (Sans / Serif / Mono — email-safe stacks), and text color (token-bound swatches; no free hex picker). Block-level structure (heading, paragraph, image, button, divider, spacer, footer) is our own React components with `@dnd-kit` reordering. The chrome bar exposes a `Branded / Plain` mode toggle, an `Edit / Preview` view toggle, and a viewport selector (Desktop / Tablet / Mobile) that flips the canvas into a sandboxed iframe wrapped in a stylised device-frame outline. Standalone preview routes can use the exported `<EmailPreview>`. The renderer is server-safe — no JSDOM — and turns the template JSON into Outlook-compatible table HTML for branded mode, or minimal HTML for plain. The same JSON travels through API and UI; no separate code paths.
Install
Import from the workspace package:
import { EmailTemplateEditor, EmailPreview, EmailTemplate, EmailMode, EmailBlock, EmailBlockType, PreviewViewport, VariableSchema, renderEmailToHtml, interpolateVariables, extractVariables } from "@8maverik8/design";Examples
Branded mode — invite email
Live editor with the full surface area. **Try it:** select any text in the canvas to reveal the floating bubble toolbar (bold / italic / underline / strike / link, plus pop-outs for size · family · color). Click `Preview` in the chrome bar to flip the canvas into a sandboxed iframe and switch viewports between desktop / tablet / mobile device frames. Three columns in edit mode: variable + block palette (left), dot-grid canvas with the 600px frame, and a contextual inspector that swaps between block-level and template-level props.
import {
EmailTemplateEditor,
type EmailTemplate,
type VariableSchema,
} from "@8maverik8/design";
const schema: VariableSchema = {
variables: [
{ path: "user.first_name", label: "First name", sample: "Oleg" },
{ path: "user.email", label: "Email", sample: "oleg@oapps.io" },
{ path: "org.name", label: "Organization", sample: "OAPPS" },
{ path: "invite.url", label: "Invite link", sample: "https://app.oapps.io/i/…" },
{ path: "app.unsubscribe_url", label: "Unsubscribe", sample: "https://app.oapps.io/u/…" },
],
};
const initial: EmailTemplate = {
id: "tpl_invite",
name: "Team invitation",
subject: "You're invited to {{org.name}}",
preheader: "Set up your account in 7 days",
fromName: "OAPPS",
replyTo: "support@oapps.io",
mode: "branded",
body: {
mode: "branded",
width: 600,
blocks: [
// … shape laid out per the EmailBlock variants table.
],
},
};
const [value, setValue] = useState<EmailTemplate>(initial);
<EmailTemplateEditor
schema={schema}
value={value}
onChange={setValue}
// Optional — when provided, an "Open ↗" button appears in the
// chrome bar in Preview mode, pointing at the consumer-owned
// standalone preview route. The route should render <EmailPreview>.
previewUrl={`/_preview/${value.id}`}
/>
// Standalone preview route (e.g. app/_preview/[id]/page.tsx in Cip):
// <EmailPreview value={template} viewport="desktop" schema={schema} />
// Persist value as JSON on save.
// On send: const html = renderEmailToHtml(value, { user, org, invite, app });Plain mode — re-engagement nudge
Same component, `mode: 'plain'`. The block palette disappears (no blocks in plain mode), the canvas drops the frame and dot-grid, the inspector hides block-props. The variable palette stays — variables are still interpolated. The hint at the bottom of the inspector reminds you the message goes out as `text/plain` plus minimal HTML so it reads as a personal note.
const initial: EmailTemplate = {
id: "tpl_trial_day_7",
name: "Trial — day 7 nudge",
subject: "Quick note about your {{org.name}} trial",
preheader: undefined,
fromName: "Oleg from OAPPS",
replyTo: "oleg@oapps.io",
mode: "plain",
signature: "auto",
body: {
mode: "plain",
doc: {
// ProseMirror JSON — paragraphs with bold / italic / link / variable marks.
},
},
};
<EmailTemplateEditor schema={schema} value={value} onChange={setValue} />Variants
EmailTemplate.mode
branded(default)HTML newsletter / transactional with brand identity. Block list inside a locked 600px frame on dot-grid canvas. Renders to table-based HTML with inline styles. Bubble toolbar gives bold/italic/underline/strike/link/size/family/color on every text block.plainPerson-to-person email. Single TipTap document, no frame, no fancy blocks. Same bubble toolbar applies. Renders to `text/plain` + minimal HTML so the message reads as if written manually in an email client.
Editor view (chrome bar)
edit(default)Three-column editing layout — variable / block palette · canvas · contextual inspector.previewSide rails collapse; canvas is replaced by a sandboxed iframe rendering the actual email HTML at the selected viewport (Desktop 800px · Tablet 600px · Mobile 380px) wrapped in a stylised device-frame outline. Pass `previewUrl` to surface an `Open ↗` button that pops a consumer-owned standalone preview route in a new tab.
EmailBlock.type (branded mode only)
headingTitle row. `level: 1 | 2 | 3`, `align: left | center`. TipTap inside with bubble toolbar (bold/italic/underline/strike/link/size/family/color).paragraphBody text. `align: left | center`. TipTap inside with bubble toolbar (bold/italic/underline/strike/link/size/family/color/variable).imageStatic image. `src`, `alt`, `width`, `align`. Width caps at frame width.buttonCTA. `label`, `href` (variables allowed in href), `align`. Renders as bulletproof table button in HTML.divider1px hairline at frame width. No props.spacerVertical whitespace. `height: 8 | 16 | 24 | 32 | 48`.footerLast block — small print. TipTap inside with bubble toolbar, typically with unsubscribe link via `{{app.unsubscribe_url}}`.
Anatomy
valueControlled JSON document — `EmailTemplate`. Persist as-is, hydrate on mount. Subject, preheader, fromName, replyTo, mode, signature, and a `body` discriminated by mode.schema`VariableSchema` — list of variables the consumer's data model exposes. Same shape as `RuleSchema.fields`, reused. Each variable has `path` (dotted, e.g. `user.first_name`), `label`, optional `sample` value for previewing.onChangeFires on every edit with the next `EmailTemplate`. Caller decides debounce / autosave / explicit Save.VariablePaletteLeft rail — categorised list of variables and (in branded mode) the block palette. Click a variable to insert at cursor; click a block to append at end.EmailCanvasCenter column. In `branded`, renders the dot-grid background and centers a `<EmailFrame>` locked to 600px. In `plain`, no frame — centered `max-w-prose` over plain background.EmailInspectorRight rail. Contextual: when a block is selected → block-level props (image src, button href, alignment, padding). When nothing selected → template-level props (subject, preheader, fromName, replyTo, signature for plain mode).BubbleToolbarFloating formatting menu over selected text in any TipTap-backed block. Bold / Italic / Underline / Strikethrough / Link, plus pop-outs for text size (S 12px · M 14px · L 18px), font family (Sans / Serif / Mono — email-safe stacks), and text color (6 token-bound swatches). No arbitrary px or hex input — clients render fractional sizes and free palettes inconsistently.EmailPreviewRead-only preview component for the chrome bar's Preview mode AND for consumer-owned standalone preview routes (e.g. `/_preview/<id>` in Cip). Renders the actual email HTML inside a sandboxed iframe at one of three viewports — Desktop (800px), Tablet (600px), Mobile (380px) — wrapped in a stylised device-frame outline. Auto-fills sample variable values from `schema.variables[].sample` when `vars` is omitted. Pass `bare` to drop the device chrome.renderEmailToHtml(value, vars)Server-safe renderer. Branded → table-based HTML with inline styles for Outlook compatibility. Plain → minimal HTML + `text/plain` alternative. Supports `bold`, `italic`, `underline`, `strike`, `link`, and a unified `textStyle` mark carrying `color` / `fontFamily` / `fontSize`. Variables interpolated via the second arg.
Guidelines
- Define the variable schema close to your data model and pass it in. Like RuleBuilder, the editor is dumb about your variables — that's the point. One component, every product, every email type.
- Treat the emitted JSON as the contract you persist server-side. The same JSON is what your `POST /api/templates` accepts and what the renderer consumes when sending — no UI-to-API translation layer.
- Use `mode: 'plain'` for trial-day-7 / re-engagement / personal outreach where the message should feel hand-typed. Branded HTML in those slots fights the intent.
- Use `mode: 'branded'` for transactional (invite, password reset, receipt) and broadcast announcements where brand identity matters and you control rendering across clients.
- Render an HTML editor (TipTap full kit, Slate, ProseMirror raw) directly as your editing surface.HTML-as-source-of-truth makes variable insertion fragile, breaks when you need server-side rendering for transactional sends (no JSDOM), and ties your contract to a vendor's document model. JSON blocks + targeted TipTap-for-inline is the right split.
- Use the editor as a generic page-builder.Email is its own constraint set: 600px-ish width, table-based HTML, inline styles, conservative typography. Don't extend the block list with grids / columns / accordions — those don't render reliably in Outlook 2007+, Apple Mail, Gmail clipping, etc. If you need a marketing page, build a marketing page.
- Switch modes mid-edit by remounting with a different value shape.Mode is a top-level prop (`value.mode`); the editor handles the transition with deterministic content migration (branded→plain merges all text-blocks into one TipTap doc; plain→branded wraps the doc in one paragraph block). Don't rebuild value externally.
- Bypass the renderer when sending.`renderEmailToHtml(value, vars)` is the only supported way to produce the wire HTML. It does the table conversion, inline-styles, variable interpolation, link rewriting (UTM / unsubscribe / preheader-injection). Calling it from one place keeps preview === production.