diff --git a/.changeset/richer-email-editor.md b/.changeset/richer-email-editor.md new file mode 100644 index 00000000..991516f8 --- /dev/null +++ b/.changeset/richer-email-editor.md @@ -0,0 +1,9 @@ +--- +"@sendlit/email-editor": minor +"@sendlit/email-blocks": minor +"@sendlit/api-contract": minor +--- + +A redesigned email editor focused on writing. Write mode puts the subject and preview text above the email, edits text inline as markdown with a format bar, a `/` block menu and `{{` merge tags, and reorders blocks by dragging; Style mode previews desktop, mobile, sample data and a dark inbox beside a palette, typography and block panel. Text is written paragraph by paragraph: Enter starts a new paragraph or list item, Backspace joins paragraphs, markdown shortcuts (`#`, `##`, `-`, `1.`, `>`) turn a paragraph into a heading, list or quote, and merge tags show as chips; text blocks are still stored as one markdown block each. Blocks can define `quickEdit` for the compact popover shown while writing. Adds undo and redo, a "Before you send" checklist (with one-click fallbacks for merge tags), and `subject`, `onSubjectChange`, `variables`, `senderName`, `theme` and `defaultMode` props on `EmailEditor`. + +New blocks: `spacer`, `columns`, `social`, `html` and `conditional` (a section shown to contacts with or without a tag; marketing only). The API validates them on write, and rejects `conditional` blocks in transactional content with `conditional_not_allowed`. `renderEmailToHtml` takes `showConditionals` for previews. diff --git a/apps/api/docs/transactional-vs-marketing-templates.md b/apps/api/docs/transactional-vs-marketing-templates.md index 4f6beccf..ddcaedf3 100644 --- a/apps/api/docs/transactional-vs-marketing-templates.md +++ b/apps/api/docs/transactional-vs-marketing-templates.md @@ -51,7 +51,9 @@ unsubscribe footer and do not require a mailing address. 3. API callers never provide `address` or `unsubscribe_link`. 4. Marketing templates contain exactly one final `footer` block with SendLit-controlled address and unsubscribe content. -5. Transactional templates cannot contain a `footer` block. +5. Transactional templates cannot contain a `footer` block or a `conditional` + section (it reads the subscriber's tags, and a transactional send has no + subscriber). 6. Missing unguarded transactional variables return a synchronous `422`; no transactional row or queue job is created. 7. System templates are starters. They must be copied into a team-owned `tpl_` @@ -164,7 +166,8 @@ The transactional path: 5. Allows explicit optional values through a Liquid `default` filter or a guarded `{% if value %}` block. 6. Does not inject `address`, `unsubscribe_link`, or `subscriber`. -7. Rejects a `footer` block if one is submitted in transactional content. +7. Rejects a `footer` block or a `conditional` block (`footer_not_allowed`, + `conditional_not_allowed`) if one is submitted in transactional content. When a transactional template is created or updated, reject reserved marketing variables with a validation error such as: diff --git a/apps/api/src/mail/render.test.ts b/apps/api/src/mail/render.test.ts index 89ef1533..12b64370 100644 --- a/apps/api/src/mail/render.test.ts +++ b/apps/api/src/mail/render.test.ts @@ -39,6 +39,38 @@ describe("mail render helpers", () => { expect(html).toContain("Hello Ada"); }); + it("shows a conditional section only to contacts with the tag", async () => { + const content = { + ...defaultEmail, + content: [ + { blockType: "text" as const, settings: { content: "Hello" } }, + { + blockType: "conditional" as const, + settings: { tag: "customer", has: true, content: "Customers only" }, + }, + { + blockType: "conditional" as const, + settings: { tag: "customer", has: false, content: "Not yet a customer" }, + }, + ], + }; + const send = (tags: string[]) => + renderEmailContent({ content, variables: { subscriber: { tags } } }); + + const customer = await send(["beta", "customer"]); + expect(customer).toContain("Customers only"); + expect(customer).not.toContain("Not yet a customer"); + expect(customer).not.toContain("{%"); + + const other = await send(["beta"]); + expect(other).not.toContain("Customers only"); + expect(other).toContain("Not yet a customer"); + expect(other).not.toContain("{%"); + + const untagged = await send([]); + expect(untagged).not.toContain("Customers only"); + }); + it("renders the shared managed footer from server-owned context", async () => { const content = { ...defaultEmail, diff --git a/apps/api/src/mcp/tools/templates.ts b/apps/api/src/mcp/tools/templates.ts index b431939e..ef66d9ab 100644 --- a/apps/api/src/mcp/tools/templates.ts +++ b/apps/api/src/mcp/tools/templates.ts @@ -88,7 +88,7 @@ export const templateTools = (): SendLitMcpTool[] => [ title: z.string().min(1), purpose: templatePurposeSchema, content: emailContentInputSchema.describe( - "A complete SendLit email document. Include all style sections (colors, typography.header/text/link, interactives, and structure) and only text, link, image, separator, or footer blocks. Use list_system_templates and duplicate_template as the safest valid starting point.", + "A complete SendLit email document. Include all style sections (colors, typography.header/text/link, interactives, and structure) and only text, link, image, separator, spacer, columns, social, html, conditional (marketing only), or footer blocks. Use list_system_templates and duplicate_template as the safest valid starting point.", ), }), outputSchema: templateSchema, diff --git a/apps/api/src/templates/system-templates.ts b/apps/api/src/templates/system-templates.ts index 95f40acd..eb3a0f4a 100644 --- a/apps/api/src/templates/system-templates.ts +++ b/apps/api/src/templates/system-templates.ts @@ -97,18 +97,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< "A polished announcement template for launches, updates, and key news.", }, content: [ - { - blockType: "text", - settings: { - content: "JUST ANNOUNCED", - alignment: "center", - fontFamily: "Helvetica, sans-serif", - fontSize: "12px", - foregroundColor: "#db2777", - paddingTop: "6px", - paddingBottom: "4px", - }, - }, { blockType: "image", settings: { @@ -126,7 +114,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< settings: { content: "# Make your next announcement feel premium", alignment: "left", - fontFamily: "Helvetica, sans-serif", fontSize: "26px", foregroundColor: "#111827", paddingTop: "8px", @@ -139,7 +126,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< content: "This layout is designed for launches, waitlist openings, seasonal updates, and event drops where the headline and CTA need to carry the message clearly.", alignment: "left", - fontFamily: "Helvetica, sans-serif", fontSize: "17px", lineHeight: "1.7", foregroundColor: "#475569", @@ -182,7 +168,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< content: "## Why this template works\n\n- Fast visual hierarchy.\n- Clean body copy.\n- Strong CTA treatment.\n- Enough polish to feel current without being overdesigned.", alignment: "left", - fontFamily: "Helvetica, sans-serif", fontSize: "16px", lineHeight: "1.7", foregroundColor: "#111827", @@ -201,7 +186,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< }, }, createFooterEmailBlock({ - fontFamily: "Helvetica, sans-serif", paddingTop: "0px", paddingBottom: "0px", }), @@ -274,24 +258,11 @@ const MARKETING_SYSTEM_TEMPLATES: Array< "A friendly welcome email for onboarding new users and subscribers.", }, content: [ - { - blockType: "text", - settings: { - content: "WELCOME", - alignment: "center", - fontFamily: "Helvetica, sans-serif", - fontSize: "12px", - foregroundColor: "#2563eb", - paddingTop: "8px", - paddingBottom: "0px", - }, - }, { blockType: "text", settings: { content: "# Welcome aboard", alignment: "left", - fontFamily: "Helvetica, sans-serif", fontSize: "26px", foregroundColor: "#111827", paddingTop: "6px", @@ -304,7 +275,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< content: "Thanks for joining us. This template works well for onboarding, first-touch education, getting-started checklists, and setting expectations for what comes next.", alignment: "left", - fontFamily: "Helvetica, sans-serif", fontSize: "17px", lineHeight: "1.7", foregroundColor: "#475569", @@ -337,7 +307,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< content: "## What to do next\n\n- Complete your profile or setup.\n- Explore your dashboard or core feature.\n- Save this email for quick access later.", alignment: "left", - fontFamily: "Helvetica, sans-serif", fontSize: "16px", lineHeight: "1.7", foregroundColor: "#111827", @@ -356,7 +325,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< }, }, createFooterEmailBlock({ - fontFamily: "Helvetica, sans-serif", paddingTop: "0px", paddingBottom: "0px", }), @@ -430,25 +398,11 @@ const MARKETING_SYSTEM_TEMPLATES: Array< "A polished upsell email to spotlight related products and premium offers.", }, content: [ - { - blockType: "text", - settings: { - content: "RECOMMENDED FOR YOU", - alignment: "center", - fontFamily: "Helvetica, sans-serif", - fontSize: "12px", - foregroundColor: "#7c3aed", - backgroundColor: "#f5f3ff", - paddingTop: "8px", - paddingBottom: "8px", - }, - }, { blockType: "text", settings: { content: "# Products your customers are ready to buy next", alignment: "left", - fontFamily: "Helvetica, sans-serif", fontSize: "26px", foregroundColor: "#0f172a", paddingTop: "8px", @@ -461,7 +415,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< content: "Use this for upgrades, bundles, complementary offers, limited-time incentives, and premium add-ons that naturally follow an initial purchase.", alignment: "left", - fontFamily: "Helvetica, sans-serif", fontSize: "17px", lineHeight: "1.7", foregroundColor: "#475569", @@ -494,7 +447,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< content: "## Why this converts\n\n- Relevant add-on recommendation.\n- Clear next step.\n- A premium but simple visual treatment.\n- Plenty of room for benefits, pricing, or testimonials.", alignment: "left", - fontFamily: "Helvetica, sans-serif", fontSize: "16px", lineHeight: "1.7", foregroundColor: "#0f172a", @@ -513,7 +465,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< }, }, createFooterEmailBlock({ - fontFamily: "Helvetica, sans-serif", paddingTop: "0px", paddingBottom: "0px", }), @@ -583,24 +534,11 @@ const MARKETING_SYSTEM_TEMPLATES: Array< "A clean, editorial newsletter for recurring updates and curated stories.", }, content: [ - { - blockType: "text", - settings: { - content: "WEEKLY NEWSLETTER", - alignment: "left", - fontFamily: "Arial, sans-serif", - fontSize: "12px", - foregroundColor: "#0f766e", - paddingTop: "6px", - paddingBottom: "0px", - }, - }, { blockType: "text", settings: { content: "# What happened this week", alignment: "left", - fontFamily: "Arial, sans-serif", fontSize: "26px", foregroundColor: "#0f172a", paddingTop: "10px", @@ -613,7 +551,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< content: "Use this for curated updates, editorial roundups, key announcements, and useful links your audience will want to save.", alignment: "left", - fontFamily: "Arial, sans-serif", fontSize: "17px", lineHeight: "1.7", foregroundColor: "#475569", @@ -637,7 +574,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< content: "## Featured update\n\nShare the biggest story or insight first. A short explanation plus one strong CTA is usually enough.", alignment: "left", - fontFamily: "Arial, sans-serif", fontSize: "16px", lineHeight: "1.7", foregroundColor: "#0f172a", @@ -670,7 +606,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< content: "## Also inside\n\n- A quick tip or takeaway.\n- One resource worth sharing.\n- A subtle CTA to your offer or archive.", alignment: "left", - fontFamily: "Arial, sans-serif", fontSize: "16px", lineHeight: "1.7", foregroundColor: "#0f172a", @@ -689,7 +624,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< }, }, createFooterEmailBlock({ - fontFamily: "Arial, sans-serif", paddingTop: "0px", paddingBottom: "0px", }), @@ -763,7 +697,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< content: "# Start writing here\n\nAdd your message, links, and call to action.", alignment: "left", - fontFamily: "Helvetica, sans-serif", fontSize: "16px", foregroundColor: "#111827", paddingTop: "12px", @@ -771,7 +704,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< }, }, createFooterEmailBlock({ - fontFamily: "Helvetica, sans-serif", paddingTop: "8px", paddingBottom: "0px", }), diff --git a/apps/api/src/templates/validation.test.ts b/apps/api/src/templates/validation.test.ts index fb2b33e4..dc92a8fc 100644 --- a/apps/api/src/templates/validation.test.ts +++ b/apps/api/src/templates/validation.test.ts @@ -88,6 +88,65 @@ describe("template content validation", () => { ); }); + it("accepts the new blocks and rejects malformed ones at write time", () => { + const email = (block: Email["content"][number]): Email => ({ + ...defaultEmail, + content: [block, createFooterEmailBlock()], + }); + const valid: Email["content"] = [ + { blockType: "spacer", settings: { height: "24px" } }, + { blockType: "columns", settings: { left: "a", right: "b", ratio: "12" } }, + { + blockType: "social", + settings: { links: [{ network: "x", url: "https://x.com/sendlit" }] }, + }, + { blockType: "html", settings: { code: "

Hi

" } }, + { + blockType: "conditional", + settings: { tag: "customer", has: false, content: "Hi" }, + }, + ]; + for (const block of valid) { + expect(() => + validateTemplateInputContent(email(block), "marketing"), + ).not.toThrow(); + } + + const invalid: Email["content"] = [ + { blockType: "spacer", settings: { height: "tall" } }, + { blockType: "columns", settings: { left: "a" } }, + { + blockType: "social", + settings: { links: [{ network: "x", url: "javascript:alert(1)" }] }, + }, + { blockType: "html", settings: {} }, + { + blockType: "conditional", + settings: { tag: 'a" %}{% endif %}', content: "Hi" }, + }, + ]; + for (const block of invalid) { + expect(() => + validateTemplateInputContent(email(block), "marketing"), + ).toThrowError(expect.objectContaining({ message: "invalid_email_content" })); + } + }); + + it("keeps conditional sections out of transactional content", () => { + const transactional: Email = { + ...defaultEmail, + content: [ + { + blockType: "conditional", + settings: { tag: "customer", content: "Hi" }, + }, + ], + }; + expect(() => + validateTemplateInputContent(transactional, "transactional"), + ).toThrowError(expect.objectContaining({ message: "conditional_not_allowed" })); + }); + it("rejects footer and reserved marketing variables transactionally", () => { expect(() => validateTemplateContent(content("Hello", "marketing"), "transactional"), diff --git a/apps/api/src/templates/validation.ts b/apps/api/src/templates/validation.ts index a022efdb..b29a30f0 100644 --- a/apps/api/src/templates/validation.ts +++ b/apps/api/src/templates/validation.ts @@ -2,6 +2,8 @@ import { emailContentInputSchema, type TemplatePurpose } from "@sendlit/api-cont import type { Email, EmailBlock } from "@sendlit/email-editor"; import { discoverRequiredTemplateVariables } from "../mail/render"; +// fontFamily is still accepted so older templates save, but the footer now +// uses the email's text font and ignores it. const FOOTER_SETTINGS = new Set([ "alignment", "fontFamily", @@ -122,6 +124,11 @@ export function validateTemplateContent( if (footerIndexes.length > 0) { throw new TemplateValidationError("footer_not_allowed"); } + // Conditional sections read the subscriber's tags; transactional sends have + // no subscriber. + if (contentBlocks.some((block) => block.blockType === "conditional")) { + throw new TemplateValidationError("conditional_not_allowed"); + } const reservedVariables = findReservedMarketingVariables(content); if (reservedVariables.length > 0) { throw new TemplateValidationError( diff --git a/apps/docs/content/docs/developers/errors.mdx b/apps/docs/content/docs/developers/errors.mdx index c15dc7b0..07e0019d 100644 --- a/apps/docs/content/docs/developers/errors.mdx +++ b/apps/docs/content/docs/developers/errors.mdx @@ -14,6 +14,7 @@ on the code rather than human-readable descriptions. | `422` | `template_conversion_requires_changes` | Marketing-only references remain outside the managed footer. | | `422` | `footer_required` | Marketing content does not contain exactly one final managed footer. | | `422` | `footer_not_allowed` | Transactional content contains a managed footer. | +| `422` | `conditional_not_allowed` | Transactional content contains a conditional section. | | `422` | `marketing_variables_not_allowed` | Transactional content references marketing-owned values. | Missing and conversion errors include the affected paths: diff --git a/apps/docs/content/docs/developers/mcp.mdx b/apps/docs/content/docs/developers/mcp.mdx index e9b0e97b..b3f50f09 100644 --- a/apps/docs/content/docs/developers/mcp.mdx +++ b/apps/docs/content/docs/developers/mcp.mdx @@ -109,7 +109,7 @@ sendable templates. Use `duplicate_template` to copy or safely convert one. Template and sequence-email content is validated as a complete SendLit email document on every write. The `style` must include `colors`, `typography.header`, `typography.text`, `typography.link`, `interactives`, and -`structure`; blocks must be one of text, link, image, separator, or footer. +`structure`; blocks must be one of text, link, image, separator, spacer, columns, social, html, conditional, or footer. A conditional section shows its content only to contacts with (or without) a tag, so it is marketing-only (`conditional_not_allowed` on transactional content). Metadata supports only optional `previewText` and an optional `utm` object with `source`, `medium`, and `campaign` strings. For AI-created content, start with `list_system_templates` and diff --git a/apps/docs/content/docs/email-blocks/email-editor.mdx b/apps/docs/content/docs/email-blocks/email-editor.mdx index fd0f53a8..92acc948 100644 --- a/apps/docs/content/docs/email-blocks/email-editor.mdx +++ b/apps/docs/content/docs/email-blocks/email-editor.mdx @@ -3,8 +3,8 @@ title: EmailEditor description: WYSIWYG editor for an email's content. --- -`EmailEditor` edits an `Email`'s block content (text, separator, image, link -by default) and reports changes via `onChange`; pass `blocks` to customize +`EmailEditor` edits an `Email`'s block content (text, link, image, separator, spacer, +columns, social, html and conditional by default) and reports changes via `onChange`; pass `blocks` to customize which block types are available. If all you need is the editor and renderer — no filters, triggers, or the @@ -32,6 +32,90 @@ function Example() { } ``` +### Writing, subject and merge tags + +The editor opens in **Write** mode: the subject and preview text sit above the +email like a document title, text is written inline as markdown (select text for +a format bar, type `/` for blocks, type `{{` for merge tags), and blocks can be +dragged by their handle. **Style** mode shows the email as readers will see it +(desktop or mobile, sample data, a dark inbox) with a panel for the palette, +typography and the selected block. A "Before you send" checklist flags a missing +subject or preview text, empty buttons and images without alt text. + +Pass the optional props below to turn those on: + +```tsx + +``` + +Without `subject` the subject field is hidden, so a host that edits it elsewhere +keeps working. Saving the subject is up to the host. The editor's look is a set +of `--sle-*` CSS variables on the `.sle` element (colours, radii, fonts), so it +can be re-skinned without touching the components. Import +`@sendlit/email-editor/styles.css` for the styles. + +### Keyboard + +The editor works without a mouse. Each row is one Tab stop, named after what +it holds ("Paragraph: Thanks for joining", "Button block: Get started"). + +| Key | Does | +| ------------------------- | ----------------------------------------------------------------- | +| Enter on a row | Edits a paragraph, or opens a block's settings | +| Escape | Stops editing or closes the settings, staying on the row | +| ↑ / ↓ | Moves between paragraphs and blocks, and up to the preview text | +| Alt+Shift+↑ / ↓ | Moves the row up or down | +| `/`, `{{` | Opens the block menu or the merge tag menu | +| Ctrl/⌘+B, I, K | Bold, italic, link | +| Alt+F10 | Moves to the format toolbar while text is selected | +| Ctrl/⌘+Z, Shift+Ctrl/⌘+Z | Undo, redo | + +In Style mode, Enter on a block moves to its settings and Escape returns to the +block; the arrow keys move between blocks. + +### Choosing blocks + +`blocks` replaces the default list, so a host can add its own blocks and leave +out any of the built-in ones, including all of them. Custom blocks are stored +and rendered exactly as given. Writing paragraph by paragraph (Enter starts a +new paragraph, `#` makes a heading, `-` a list) applies to the built-in `text` +block. Without it, or with a host block registered under the name `text`, the +editor still works: blocks are inserted from the menu, selected, reordered and +edited through their settings, and the "keep writing" prompt is hidden. + +### Built-in blocks + +| Block | Settings | +| ------------- | ------------------------------------------------------------------ | +| `text` | Markdown `content`: paragraphs, headings, lists and quotes | +| `link` | A link, or a button when `isButton` is set | +| `image` | `src`, `alt`, size and alignment | +| `separator` | A divider line | +| `spacer` | Empty `height` | +| `columns` | Two markdown columns, `ratio`, optional stacking on small screens | +| `social` | `links`: `{ network, url }` buttons for your profiles | +| `html` | Raw `code`, sent as written | +| `conditional` | Markdown `content` shown only to contacts with (or without) a tag | + +A `conditional` section is rendered as a Liquid condition on +`subscriber.tags`, so it only works where a subscriber exists (marketing sends). +`renderEmailToHtml` keeps the condition by default; pass `showConditionals` for +a preview that shows every section. + ### Custom image uploader Use `blocks` to replace the default image block settings with a wrapper that @@ -117,6 +201,12 @@ compliance. Host applications can register add-on blocks with: - `placement: "any" | "first" | "last"` - an optional `renderContext` supplied by the host +- an optional `quickEdit` component (and `quickEditLayout: "row" | "column"`) + with the few fields worth editing while writing, such as a button's label and + link. Write mode shows it in a small popover under the selected block, with + Style, duplicate and delete; the full `settings` form stays in Style mode. + Without one, the popover offers just those three actions. + Mutation handlers enforce these capabilities as well as hiding unavailable controls. The render context is passed to preview and HTML rendering but is never serialized into the `Email` document. diff --git a/apps/web/app/(dashboard)/templates/page.test.tsx b/apps/web/app/(dashboard)/templates/page.test.tsx index 45c48127..a879249e 100644 --- a/apps/web/app/(dashboard)/templates/page.test.tsx +++ b/apps/web/app/(dashboard)/templates/page.test.tsx @@ -35,6 +35,11 @@ mock.module("@sendlit/email-editor/blocks", () => ({ Text: {}, Separator: {}, Link: {}, + Spacer: {}, + Columns: {}, + Social: {}, + HtmlSnippet: {}, + Conditional: {}, ImageBlock: { configure: () => ({}) }, })); mock.module("@/components/dashboard/email-image-upload-dialog", () => ({ diff --git a/apps/web/app/editor/templates/[templateId]/page.tsx b/apps/web/app/editor/templates/[templateId]/page.tsx index d9b61bdf..48964dca 100644 --- a/apps/web/app/editor/templates/[templateId]/page.tsx +++ b/apps/web/app/editor/templates/[templateId]/page.tsx @@ -7,7 +7,6 @@ import { Banner } from "@/components/dashboard/banner"; import { EmailEditorScreen } from "@/components/dashboard/email-editor-screen"; import { Loading } from "@/components/dashboard/loading"; import { Input } from "@/components/ui/codelit/input"; -import { Label } from "@/components/ui/codelit/label"; import { getTemplate, updateTemplate } from "@/lib/api"; import { ApiError } from "@/lib/api-client"; @@ -18,6 +17,7 @@ export default function TemplateEditorPage({ }) { const { templateId } = use(params); const [title, setTitle] = useState(null); + const [savedTitle, setSavedTitle] = useState(null); const [content, setContent] = useState(null); const [purpose, setPurpose] = useState(null); const [requiredVariables, setRequiredVariables] = useState([]); @@ -27,6 +27,7 @@ export default function TemplateEditorPage({ getTemplate(templateId) .then((template) => { setTitle(template.title); + setSavedTitle(template.title); setContent(template.content); setPurpose(template.purpose); setRequiredVariables(template.requiredVariables); @@ -42,30 +43,27 @@ export default function TemplateEditorPage({ return ( { const updated = await updateTemplate(templateId, { title, content: nextContent, }); setRequiredVariables(updated.requiredVariables); + setSavedTitle(title); }} - header={ -
- - setTitle(e.target.value)} - placeholder="e.g. Welcome email" - /> -
+ title={ + setTitle(e.target.value)} + placeholder="e.g. Welcome email" + /> } /> ); diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 6da18d2e..4ca95884 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -2,6 +2,7 @@ @import "tailwindcss"; @import "tw-animate-css"; @import "shadcn/tailwind.css"; +@import "../../../packages/email-editor/src/editor.css"; @source "../../../packages/email-editor/src/**/*.{ts,tsx}"; @source "../../../packages/email-blocks/src/**/*.{ts,tsx}"; diff --git a/apps/web/components/dashboard/email-editor-screen.test.tsx b/apps/web/components/dashboard/email-editor-screen.test.tsx index b6715d98..b3f3e128 100644 --- a/apps/web/components/dashboard/email-editor-screen.test.tsx +++ b/apps/web/components/dashboard/email-editor-screen.test.tsx @@ -33,6 +33,11 @@ mock.module("@sendlit/email-editor/blocks", () => ({ Text: {}, Separator: {}, Link: {}, + Spacer: {}, + Columns: {}, + Social: {}, + HtmlSnippet: {}, + Conditional: { metadata: { name: "conditional" } }, ImageBlock: { configure: () => ({}) }, })); mock.module("@/components/dashboard/email-image-upload-dialog", () => ({ @@ -178,9 +183,15 @@ describe("EmailEditorScreen", () => { block.capabilities?.deletable === false, ), ).toBe(true); - expect(screen.getByText("{{ subscriber.email }}")).toBeTruthy(); - expect(screen.getByText("{{ subscriber.tags }}")).toBeTruthy(); - expect(screen.queryByText("{{ address }}")).toBeNull(); + const tags = mocks.editorProps.variables.map((variable: any) => variable.tag); + expect(tags).toContain("{{ subscriber.email }}"); + expect(tags).toContain("{{ subscriber.tags }}"); + expect(tags).not.toContain("{{ address }}"); + expect( + mocks.editorProps.blocks.some( + (block: any) => block.metadata?.name === "conditional", + ), + ).toBe(true); }); it("keeps transactional editing footer-free and exposes required business variables", () => { @@ -198,9 +209,14 @@ describe("EmailEditorScreen", () => { mocks.editorProps.blocks.some((block: any) => block.metadata?.name === "footer"), ).toBe(false); expect(mocks.editorProps.renderContext).toBeUndefined(); - expect(screen.getByText("{{ customer.name }}")).toBeTruthy(); - expect(screen.getByText("{{ otp }}")).toBeTruthy(); - expect(screen.queryByText("{{ subscriber.email }}")).toBeNull(); + const tags = mocks.editorProps.variables.map((variable: any) => variable.tag); + expect(tags).toEqual(["{{ customer.name }}", "{{ otp }}"]); + // Conditional sections need a subscriber, which transactional sends lack. + expect( + mocks.editorProps.blocks.some( + (block: any) => block.metadata?.name === "conditional", + ), + ).toBe(false); }); it("does not expose template duplication from the editor", async () => { diff --git a/apps/web/components/dashboard/email-editor-screen.tsx b/apps/web/components/dashboard/email-editor-screen.tsx index 55fa439d..6b306043 100644 --- a/apps/web/components/dashboard/email-editor-screen.tsx +++ b/apps/web/components/dashboard/email-editor-screen.tsx @@ -9,14 +9,20 @@ import { } from "@sendlit/email-blocks/footer"; import { normalizeEmail } from "@sendlit/email-editor"; import { + Columns, + Conditional, + HtmlSnippet, ImageBlock, Link, Separator, + Social, + Spacer, Text, type UploaderProps, } from "@sendlit/email-editor/blocks"; import { Check, LogOut } from "lucide-react"; import { useRouter } from "next/navigation"; +import { useTheme } from "next-themes"; import { type ReactNode, useCallback, @@ -27,6 +33,16 @@ import { } from "react"; import { Banner } from "@/components/dashboard/banner"; import { EmailImageUploadDialog } from "@/components/dashboard/email-image-upload-dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/codelit/button"; import { IconButton } from "@/components/ui/codelit/icon-button"; @@ -39,23 +55,28 @@ const MARKETING_VARIABLES = [ { tag: "{{ subscriber.email }}", description: "The email of the subscriber", + sample: "priya@example.com", }, { tag: "{{ subscriber.name }}", description: "The name of the subscriber", + sample: "Priya", }, { tag: "{{ subscriber.tags }}", description: "The subscriber's tags", + sample: "customer, beta", }, { tag: "{{ view_in_browser_url }}", description: "Link to the web version of this email", + sample: "[link to web version]", }, { tag: "{{ web_version_url }}", description: "Public web page of this broadcast once published, else its web version", + sample: "[public page link]", }, ]; @@ -78,6 +99,10 @@ const BASE_EMAIL_EDITOR_BLOCKS = [ Separator, Link, ImageBlock.configure({ uploader: EmailImageUploader }), + Spacer, + Columns, + Social, + HtmlSnippet, ]; const SENDLIT_FOOTER_BLOCK = createFooterBlock({ @@ -99,8 +124,10 @@ const SENDLIT_FOOTER_BLOCK = createFooterBlock({ }, }); +// Conditional sections read the subscriber's tags, which only marketing sends have. export const MARKETING_EMAIL_EDITOR_BLOCKS = [ ...BASE_EMAIL_EDITOR_BLOCKS, + Conditional, SENDLIT_FOOTER_BLOCK, ]; export const TRANSACTIONAL_EMAIL_EDITOR_BLOCKS = BASE_EMAIL_EDITOR_BLOCKS; @@ -126,36 +153,38 @@ export function marketingPreviewContext({ /** * Full-screen email editing screen — the only place `EmailEditor` is rendered, * mounted under the chrome-free `/editor` layout (no dashboard sidebar). It - * locks to the viewport: the page itself never scrolls, only the variables - * panel and the editor's own canvas/settings panes do. Everywhere else shows + * locks to the viewport: the page itself never scrolls, only the editor's own + * canvas and style panes do. Everywhere else shows * an `EmailPreview` with an edit button that navigates here; exit returns to * wherever the user came from. */ export function EmailEditorScreen({ exitFallbackHref, - screenTitle = "Editing email", + title = "Editing email", initialContent, onSave, purpose, requiredVariables = [], saveLabel = "Save", - header, actions, viewInBrowser = true, extraVariables = [], + subject, + onSubjectChange, + senderName, + otherChangesUnsaved = false, }: { /** Where exit lands when there's no history to go back to (deep link). */ exitFallbackHref: string; - screenTitle?: string; + /** The left of the host bar: a label, or a field such as a template's name. */ + title?: ReactNode; initialContent: Email; - /** Persists the edited content; extra fields owned by `header` controls - * (e.g. a template's title) are the caller's to include. */ + /** Persists the edited content; extra fields owned by `title` (e.g. a + * template's name) are the caller's to include. */ onSave: (content: Email) => Promise; purpose: TemplatePurpose; requiredVariables?: string[]; saveLabel?: string; - /** Optional controls rendered in the bar above the editor canvas. */ - header?: ReactNode; /** Extra top-bar controls. `saveContent` persists the current document * (throwing on failure), e.g. before sending a test of it. */ actions?: (saveContent: () => Promise) => ReactNode; @@ -164,8 +193,17 @@ export function EmailEditorScreen({ /** More merge tags for this email, e.g. `{{ event.* }}` of the custom * event that starts its sequence. */ extraVariables?: { tag: string; description: string }[]; + /** The subject line, written above the email. Saving it is the caller's. */ + subject?: string; + onSubjectChange?: (subject: string) => void; + /** The sender shown in the inbox preview. */ + senderName?: string; + /** Edits the caller saves along with the content, such as the subject or a + * template's name, that haven't been saved yet. */ + otherChangesUnsaved?: boolean; }) { const router = useRouter(); + const { resolvedTheme } = useTheme(); const normalizedInitialContent = useMemo( () => normalizeEmail(initialContent), [initialContent], @@ -178,6 +216,13 @@ export function EmailEditorScreen({ const [error, setError] = useState(null); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); + // Edited since the last save. The editor only reports edits, never its + // first render, so any change means there's something to lose. + const contentChanged = useRef(false); + const [leaving, setLeaving] = useState(false); + const unsaved = () => contentChanged.current || otherChangesUnsaved; + const unsavedRef = useRef(unsaved); + unsavedRef.current = unsaved; const [mailingAddress, setMailingAddress] = useState( "Set your mailing address in Settings", ); @@ -219,9 +264,38 @@ export function EmailEditorScreen({ const handleContentChange = useCallback((nextContent: Email) => { contentRef.current = nextContent; + contentChanged.current = true; + }, []); + + // Closing the tab or reloading with unsaved edits asks first. + useEffect(() => { + const warn = (event: BeforeUnloadEvent) => { + if (unsavedRef.current()) event.preventDefault(); + }; + window.addEventListener("beforeunload", warn); + return () => window.removeEventListener("beforeunload", warn); + }, []); + + // Ctrl or Cmd+S saves, as in any editor, instead of saving the page. + const saveRef = useRef(save); + saveRef.current = save; + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") { + event.preventDefault(); + void saveRef.current(); + } + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); }, []); function exit() { + if (unsaved()) setLeaving(true); + else leave(); + } + + function leave() { if (window.history.length > 1) { router.back(); } else { @@ -229,15 +303,21 @@ export function EmailEditorScreen({ } } - async function save() { + /** Saves, returning whether it worked. */ + async function save(): Promise { + if (saving) return false; setSaving(true); setError(null); try { + contentChanged.current = false; await onSave(contentRef.current); setSaved(true); setTimeout(() => setSaved(false), 2000); + return true; } catch (err) { + contentChanged.current = true; setError(err instanceof ApiError ? err.message : "Failed to save email"); + return false; } finally { setSaving(false); } @@ -246,15 +326,19 @@ export function EmailEditorScreen({ return (
-
- {screenTitle} +
+ {typeof title === "string" ? ( + {title} + ) : ( + title + )} {purpose}
{actions?.(() => onSave(contentRef.current))} -