Skip to content

Commit 579f790

Browse files
committed
Add Emails preview page with dummy-data rendering
1 parent 64e10f1 commit 579f790

8 files changed

Lines changed: 122 additions & 31 deletions

File tree

application/AppGateway/appsettings.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,15 @@
156156
"HostnameKey": "App"
157157
}
158158
},
159+
"account-emails-assets": {
160+
"ClusterId": "account-api",
161+
"Match": {
162+
"Path": "/emails/assets/{**catch-all}"
163+
},
164+
"Metadata": {
165+
"HostnameKey": "App"
166+
}
167+
},
159168
"account-federation": {
160169
"ClusterId": "account-static",
161170
"Match": {

application/account/WebApp/routes/components/-components/ComponentPreview.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { useEffect, useState } from "react";
33
import { AlertsBadgesPreview } from "./AlertsBadgesPreview";
44
import { ButtonsPreview } from "./ButtonsPreview";
55
import { ControlsPreview } from "./ControlsPreview";
6-
import { EmailsPreview } from "./EmailsPreview";
76
import { MediaTab } from "./MediaTab";
87
import { NavigationPreview } from "./NavigationPreview";
98
import { OverlaysPreview } from "./OverlaysPreview";
@@ -22,8 +21,7 @@ const sections: Record<string, React.ReactNode> = {
2221
resizable: <ResizablePreview />,
2322
sidebar: <SidebarPreview />,
2423
tabs: <TabsPreview />,
25-
media: <MediaTab />,
26-
emails: <EmailsPreview />
24+
media: <MediaTab />
2725
};
2826

2927
export function ComponentPreview() {

application/account/WebApp/routes/components/-components/ComponentsSideMenu.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,14 @@ import { BlocksIcon, LayersIcon } from "lucide-react";
1919
import { CollapsibleMenu, useHash } from "./ComponentsCollapsibleMenu";
2020
import { PreviewAvatarMenu } from "./PreviewAvatarMenu";
2121
import { PreviewMobileMenu } from "./PreviewMobileMenu";
22-
import { chartsIcon as ChartsIcon, chartsLabel, componentsSections, examplesSections } from "./previewSections";
22+
import {
23+
chartsIcon as ChartsIcon,
24+
chartsLabel,
25+
componentsSections,
26+
emailsIcon as EmailsIcon,
27+
emailsLabel,
28+
examplesSections
29+
} from "./previewSections";
2330

2431
const normalizePath = (path: string): string => path.replace(/\/$/, "") || "/";
2532

@@ -29,6 +36,7 @@ export function ComponentsSideMenu() {
2936
const isComponentsPage = currentPath === "/components";
3037
const isExamplesPage = currentPath === "/components/examples";
3138
const isChartsPage = currentPath === "/components/charts";
39+
const isEmailsPage = currentPath === "/components/emails";
3240

3341
const componentsHash = useHash("controls");
3442
const examplesHash = useHash("dialogs");
@@ -75,6 +83,14 @@ export function ComponentsSideMenu() {
7583
</RouterLink>
7684
</SidebarMenuButton>
7785
</SidebarMenuItem>
86+
<SidebarMenuItem>
87+
<SidebarMenuButton asChild={true} isActive={isEmailsPage} tooltip={t`Emails`}>
88+
<RouterLink to="/components/emails">
89+
<EmailsIcon />
90+
<span>{emailsLabel}</span>
91+
</RouterLink>
92+
</SidebarMenuButton>
93+
</SidebarMenuItem>
7894
</SidebarMenu>
7995
</SidebarGroupContent>
8096
</SidebarGroup>

application/account/WebApp/routes/components/-components/EmailsPreview.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export function EmailsPreview() {
1212
const [template, setTemplate] = useState<Template>("StartSignup");
1313
const [locale, setLocale] = useState<Locale>("en-US");
1414

15-
const iframeSrc = `/emails/assets/${template}.${locale}.html`;
15+
const iframeSrc = `/emails/assets/${template}.${locale}.preview.html`;
1616

1717
return (
1818
<div className="flex flex-col gap-4">

application/account/WebApp/routes/components/-components/PreviewHeader.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { Link } from "@repo/ui/components/Link";
1010
import { useEffect, useState } from "react";
1111

1212
type PreviewHeaderProps = Readonly<{
13-
currentPage: "components" | "examples" | "charts";
13+
currentPage: "components" | "examples" | "charts" | "emails";
1414
tabLabels: Record<string, React.ReactNode>;
1515
defaultTab: string;
1616
rightContent?: React.ReactNode;
@@ -19,7 +19,8 @@ type PreviewHeaderProps = Readonly<{
1919
const sectionConfig = {
2020
components: { href: "/components", label: <Trans>Components</Trans> },
2121
examples: { href: "/components/examples", label: <Trans>Examples</Trans> },
22-
charts: { href: "/components/charts", label: <Trans>Charts</Trans> }
22+
charts: { href: "/components/charts", label: <Trans>Charts</Trans> },
23+
emails: { href: "/components/emails", label: <Trans>Emails</Trans> }
2324
} as const;
2425

2526
export function PreviewHeader({ currentPage, tabLabels, defaultTab, rightContent }: PreviewHeaderProps) {

application/account/WebApp/routes/components/-components/previewSections.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,7 @@ export const componentsSections: readonly PreviewSection[] = [
3434
{ hash: "resizable", label: <Trans>Resizable panels</Trans>, icon: LayoutDashboardIcon },
3535
{ hash: "sidebar", label: <Trans>Sidebar</Trans>, icon: PanelLeftIcon },
3636
{ hash: "tabs", label: <Trans>Tabs</Trans>, icon: SquareMousePointerIcon },
37-
{ hash: "media", label: <Trans>Media</Trans>, icon: ImageIcon },
38-
{ hash: "emails", label: <Trans>Emails</Trans>, icon: MailIcon }
37+
{ hash: "media", label: <Trans>Media</Trans>, icon: ImageIcon }
3938
];
4039

4140
export const examplesSections: readonly PreviewSection[] = [
@@ -49,6 +48,9 @@ export const examplesSections: readonly PreviewSection[] = [
4948
export const chartsIcon = BarChart3Icon;
5049
export const chartsLabel = <Trans>Charts</Trans>;
5150

51+
export const emailsIcon = MailIcon;
52+
export const emailsLabel = <Trans>Emails</Trans>;
53+
5254
export function findSectionLabel(sections: readonly PreviewSection[], hash: string): React.ReactNode {
5355
return sections.find((section) => section.hash === hash)?.label ?? sections[0].label;
5456
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { t } from "@lingui/core/macro";
2+
import { Trans } from "@lingui/react/macro";
3+
import { AppLayout } from "@repo/ui/components/AppLayout";
4+
import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar";
5+
import { createFileRoute } from "@tanstack/react-router";
6+
7+
import { ComponentsSideMenu } from "./-components/ComponentsSideMenu";
8+
import { EmailsPreview } from "./-components/EmailsPreview";
9+
import { PreviewHeader } from "./-components/PreviewHeader";
10+
11+
export const Route = createFileRoute("/components/emails")({
12+
staticData: { trackingTitle: "Emails" },
13+
component: EmailsPage
14+
});
15+
16+
function EmailsPage() {
17+
return (
18+
<SidebarProvider>
19+
<ComponentsSideMenu />
20+
<SidebarInset>
21+
<AppLayout
22+
variant="full"
23+
browserTitle={t`Emails`}
24+
title={<Trans>Emails</Trans>}
25+
beforeHeader={<PreviewHeader currentPage="emails" defaultTab="" tabLabels={{ "": <Trans>Emails</Trans> }} />}
26+
>
27+
<EmailsPreview />
28+
</AppLayout>
29+
</SidebarInset>
30+
</SidebarProvider>
31+
);
32+
}

application/shared-webapp/emails/build/build.ts

Lines changed: 55 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,25 @@ const applicationRoot = resolve(sharedEmailsRoot, "..", "..");
2020
const SYSTEMS = ["account", "main"] as const;
2121
const LOCALES = Object.keys(i18nConfig);
2222

23+
// Sample values used to substitute hand-written Scriban placeholders ({{ Name }}) that cannot go
24+
// through the JSX helpers — typically those that live inside href attributes or Lingui <Trans>
25+
// strings (see emails.md "Exception — HTML attributes and Trans strings"). Applied only to the
26+
// preview render output, never to the production .html/.txt artifacts.
27+
const PREVIEW_PLACEHOLDER_VALUES: Record<string, string> = {
28+
SignupUrl: "https://app.platformplatform.net/signup",
29+
LoginUrl: "https://app.platformplatform.net/login",
30+
TenantName: "Acme Corp"
31+
};
32+
33+
function substitutePreviewPlaceholders(input: string): string {
34+
let result = input;
35+
for (const [name, value] of Object.entries(PREVIEW_PLACEHOLDER_VALUES)) {
36+
// Scriban accepts both `{{Name}}` and `{{ Name }}` — replace both forms.
37+
result = result.replaceAll(`{{${name}}}`, value).replaceAll(`{{ ${name} }}`, value);
38+
}
39+
return result;
40+
}
41+
2342
type BuildTarget = {
2443
label: string;
2544
// Folder containing lingui.config.ts and the translations/ directory.
@@ -111,28 +130,42 @@ async function renderTemplate(target: BuildTarget, templatePath: string, name: s
111130

112131
const wrapped = createElement(I18nProvider, { i18n }, createElement(Template, { locale }));
113132

114-
// eslint-disable-next-line no-await-in-loop
115-
const html = await render(wrapped, { pretty: false });
116-
// eslint-disable-next-line no-await-in-loop
117-
const text = await render(wrapped, {
118-
plainText: true,
119-
// The default html-to-text formatter uppercases <h1>/<h2> content; that mangles Scriban
120-
// expressions like {{ firstName }} into {{ FIRSTNAME }}, breaking runtime substitution. Pin
121-
// every heading level to its original casing so the .txt output preserves the template variables.
122-
htmlToTextOptions: {
123-
selectors: [
124-
{ selector: "h1", options: { uppercase: false } },
125-
{ selector: "h2", options: { uppercase: false } },
126-
{ selector: "h3", options: { uppercase: false } },
127-
{ selector: "h4", options: { uppercase: false } }
128-
]
129-
}
130-
});
131-
132-
writeFileSync(join(target.distDir, `${name}.${locale}.html`), html, "utf8");
133-
writeFileSync(join(target.distDir, `${name}.${locale}.txt`), text, "utf8");
134-
135-
console.log(`[emails] wrote ${name}.${locale}.{html,txt}`);
133+
// Two render passes per template/locale:
134+
// "build" → helpers emit Scriban placeholders ({{ Var }}). The backend renders these at
135+
// runtime against the real model and sends the final email.
136+
// "preview" → helpers substitute their `sample` props with realistic dummy values. The in-app
137+
// preview page (BackOffice → Components → Emails) iframes these files so designers
138+
// can visually inspect the templates without sending live emails.
139+
// Both outputs share the same .po catalogs, so translations only need to be entered once.
140+
for (const mode of ["build", "preview"] as const) {
141+
process.env.EMAIL_RENDER_MODE = mode;
142+
143+
// eslint-disable-next-line no-await-in-loop
144+
const html = await render(wrapped, { pretty: false });
145+
// eslint-disable-next-line no-await-in-loop
146+
const text = await render(wrapped, {
147+
plainText: true,
148+
// The default html-to-text formatter uppercases <h1>/<h2> content; that mangles Scriban
149+
// expressions like {{ firstName }} into {{ FIRSTNAME }}, breaking runtime substitution. Pin
150+
// every heading level to its original casing so the .txt output preserves the template variables.
151+
htmlToTextOptions: {
152+
selectors: [
153+
{ selector: "h1", options: { uppercase: false } },
154+
{ selector: "h2", options: { uppercase: false } },
155+
{ selector: "h3", options: { uppercase: false } },
156+
{ selector: "h4", options: { uppercase: false } }
157+
]
158+
}
159+
});
160+
161+
const suffix = mode === "build" ? "" : ".preview";
162+
const finalHtml = mode === "preview" ? substitutePreviewPlaceholders(html) : html;
163+
const finalText = mode === "preview" ? substitutePreviewPlaceholders(text) : text;
164+
writeFileSync(join(target.distDir, `${name}.${locale}${suffix}.html`), finalHtml, "utf8");
165+
writeFileSync(join(target.distDir, `${name}.${locale}${suffix}.txt`), finalText, "utf8");
166+
167+
console.log(`[emails] wrote ${name}.${locale}${suffix}.{html,txt}`);
168+
}
136169
}
137170
}
138171

0 commit comments

Comments
 (0)