Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Hero from "./sections/Hero";
import QuickStart from "./sections/QuickStart";
import TemplateShowcase from "./sections/TemplateShowcase";
import RenderModeDemo from "./sections/RenderModeDemo";
import NewsletterArchiveDemo from "./sections/NewsletterArchiveDemo";
import FeatureComparison from "./sections/FeatureComparison";
import ComponentGallery from "./sections/ComponentGallery";
import CTABanner from "./sections/CTABanner";
Expand Down Expand Up @@ -43,6 +44,7 @@ export default function App() {
<div className="fade-in-up"><QuickStart /></div>
<div className="fade-in-up"><TemplateShowcase /></div>
<div className="fade-in-up"><RenderModeDemo /></div>
<div className="fade-in-up"><NewsletterArchiveDemo /></div>
<div className="fade-in-up"><FeatureComparison /></div>
<div className="fade-in-up"><ComponentGallery /></div>
<div className="fade-in-up"><CTABanner /></div>
Expand Down
2 changes: 1 addition & 1 deletion packages/demo/src/hooks/useRenderTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function useRenderTemplate(templateId: string): RenderResult {
let plainText = "";

try {
const parts = renderToHtmlParts(element, { mode: "email" });
const parts = renderToHtmlParts(element, { mode: entry.mode ?? "email" });
head = parts.head;
html = parts.body;
} catch (e) {
Expand Down
97 changes: 97 additions & 0 deletions packages/demo/src/sections/NewsletterArchiveDemo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { useMemo } from "react";
import { renderToHtmlParts } from "@unlayer/react-elements";
import { newsletterTemplate, type NewsletterOutput } from "../templates/NewsletterDigest";
import SectionHeader from "../components/SectionHeader";
import DeviceFrame from "../components/DeviceFrame";
import CodeBlock from "../components/CodeBlock";

const snippet = `// One tree, two outputs — only the wrapper changes.
function newsletterTemplate(output: "email" | "web") {
const Wrapper = output === "web" ? Page : Email;
return (
<Wrapper backgroundColor="#f9f9f4" contentWidth="560px">
{/* ...masthead, featured article, article list, footer... */}
</Wrapper>
);
}

// Send this:
const email = renderToHtml(newsletterTemplate("email")); // tables, Outlook-safe
// Publish this at /archive/issue-47:
const page = renderToHtml(newsletterTemplate("web")); // div + flexbox`;

const outputs: { id: NewsletterOutput; wrapper: string; label: string; blurb: string }[] = [
{
id: "email",
wrapper: "Email",
label: "In the inbox",
blurb: "Nested tables with inlined styles — what Outlook and Gmail need.",
},
{
id: "web",
wrapper: "Page",
label: "In the browser",
blurb: "Semantic divs and flexbox — the archive page you link from “View in browser”.",
},
];

export default function NewsletterArchiveDemo() {
const rendered = useMemo(
() =>
outputs.map((output) => {
try {
const { head, body } = renderToHtmlParts(newsletterTemplate(output.id), {
mode: output.id,
});
return { ...output, head, body };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return { ...output, head: "", body: `<!-- Render error: ${message} -->` };
}
}),
[]
);

return (
<section id="newsletter-archive" className="py-28 px-4 md:px-8 relative overflow-hidden">
{/* Background */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(232,93,4,0.05),transparent_55%)]" />
<div className="absolute inset-0 noise" />

<div className="relative">
<SectionHeader
badge="Email → Web"
title="Send the email, publish the archive"
description="The Weekly Brief newsletter rendered twice from the exact same component tree. The rows, columns, and copy are identical — only the wrapper differs."
/>

<div className="max-w-[1400px] mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{rendered.map((output) => (
<div key={output.id} className="flex flex-col gap-3">
<div className="flex items-baseline gap-3 px-1">
<code className="text-sm font-mono font-semibold text-accent">
{`<${output.wrapper}>`}
</code>
<span className="text-sm font-medium text-text-primary">{output.label}</span>
</div>
<p className="text-xs text-text-tertiary px-1 leading-relaxed">{output.blurb}</p>
<DeviceFrame html={output.body} device="desktop" headContent={output.head} />
</div>
))}
</div>

<div className="mt-10 max-w-3xl mx-auto">
<CodeBlock code={snippet} language="tsx" maxHeight="420px" />
<p className="mt-4 text-sm text-text-tertiary leading-relaxed text-center">
No second template to keep in sync. Swap <code className="text-accent font-mono">{"<Email>"}</code> for{" "}
<code className="text-accent font-mono">{"<Page>"}</code> and the same content ships to both the inbox
and the web — see the <span className="text-text-secondary">Newsletter Web Archive</span> entry above
for the full source.
</p>
</div>
</div>
</div>
</section>
);
}
35 changes: 31 additions & 4 deletions packages/demo/src/templates/NewsletterDigest.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ReactElement } from "react";
import {
Email,
Page,
Row,
Column,
Paragraph,
Expand All @@ -21,9 +22,24 @@ const uiFont = {
value: "system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
};

export default function NewsletterDigest(): ReactElement {
/** Which output this newsletter is being rendered for. */
export type NewsletterOutput = "email" | "web";

/**
* The newsletter, wrapper-agnostic.
*
* Everything inside the wrapper is identical for both outputs — only the root
* component changes:
* <Email> → table-based HTML for inboxes
* <Page> → div/flexbox HTML for the browser ("view in browser" archive)
*
* See NewsletterWebArchive.tsx for the web variant.
*/
export function newsletterTemplate(output: NewsletterOutput): ReactElement {
const Wrapper = output === "web" ? Page : Email;

return (
<Email
<Wrapper
backgroundColor="#f9f9f4"
textColor="#1a1a1a"
contentAlign="center"
Expand Down Expand Up @@ -278,8 +294,14 @@ export default function NewsletterDigest(): ReactElement {
lineHeight="1.6"
fontFamily={uiFont}
/>
{/* The only content that differs between the two outputs: the inbox
copy links out to the archive, the archive links back to signup. */}
<Paragraph
html='<a href="#">Unsubscribe</a> · <a href="#">View in browser</a> · <a href="#">Update preferences</a>'
html={
output === "web"
? '<a href="#">Subscribe</a> · <a href="#">Browse the archive</a> · <a href="#">RSS</a>'
: '<a href="#">Unsubscribe</a> · <a href="#">View in browser</a> · <a href="#">Update preferences</a>'
}
fontSize="12px"
color="#999999"
textAlign="center"
Expand All @@ -288,6 +310,11 @@ export default function NewsletterDigest(): ReactElement {
/>
</Column>
</Row>
</Email>
</Wrapper>
);
}

/** The newsletter as an email — table-based HTML for inboxes. */
export default function NewsletterDigest(): ReactElement {
return newsletterTemplate("email");
}
22 changes: 22 additions & 0 deletions packages/demo/src/templates/NewsletterWebArchive.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { ReactElement } from "react";
import { newsletterTemplate } from "./NewsletterDigest";

/**
* Newsletter web archive — the "view in browser" page for the newsletter email.
*
* This is the *same component tree* as NewsletterDigest. Nothing is duplicated:
* `newsletterTemplate()` swaps only the root wrapper.
*
* newsletterTemplate("email") → <Email> → tables, inlined for Outlook/Gmail
* newsletterTemplate("web") → <Page> → div/flexbox, responsive browser HTML
*
* The rows, columns, headings, images, and buttons in between are byte-identical.
* That is the point: send the email, then publish the archive page from the same
* source — no second template to keep in sync.
*
* Rendering it is the usual one-liner:
* renderToHtml(<Page>…</Page>) // or renderToHtmlParts() to own the shell
*/
export default function NewsletterWebArchive(): ReactElement {
return newsletterTemplate("web");
}
13 changes: 13 additions & 0 deletions packages/demo/src/templates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import OrderConfirmation from "./OrderConfirmation";
import ReviewRequest from "./ReviewRequest";
import PasswordReset from "./PasswordReset";
import NewsletterDigest from "./NewsletterDigest";
import NewsletterWebArchive from "./NewsletterWebArchive";
import AbandonedCart from "./AbandonedCart";
import ShippingUpdate from "./ShippingUpdate";
import ProductLaunch from "./ProductLaunch";
Expand All @@ -21,6 +22,7 @@ import orderSource from "./OrderConfirmation.tsx?raw";
import reviewSource from "./ReviewRequest.tsx?raw";
import passwordSource from "./PasswordReset.tsx?raw";
import newsletterSource from "./NewsletterDigest.tsx?raw";
import newsletterArchiveSource from "./NewsletterWebArchive.tsx?raw";
import abandonedCartSource from "./AbandonedCart.tsx?raw";
import shippingSource from "./ShippingUpdate.tsx?raw";
import productLaunchSource from "./ProductLaunch.tsx?raw";
Expand Down Expand Up @@ -101,6 +103,17 @@ export const templates: TemplateEntry[] = [
component: NewsletterDigest,
sourceCode: newsletterSource,
},
{
id: "newsletter-web-archive",
name: "Newsletter Web Archive",
description: "The same newsletter tree rendered with <Page> as a browser archive page",
category: "newsletter",
inspiration: "Substack",
colorAccent: "#e85d04",
component: NewsletterWebArchive,
sourceCode: newsletterArchiveSource,
mode: "web",
},
{
id: "abandoned-cart",
name: "Abandoned Cart",
Expand Down
Loading