Files
Calbook/lib/email/style-renderer.ts

354 lines
13 KiB
TypeScript

import {
DEFAULT_EMAIL_STYLE_ID,
getEmailStylePreset
} from "@/lib/email/style-presets";
export type StyledEmailInfoRow = {
label: string;
value: string;
};
export type StyledEmailPayload = {
styleId?: string;
subject: string;
companyName: string;
heading: string;
lead?: string;
body: string;
infoRows?: StyledEmailInfoRow[];
ctaLabel?: string;
ctaUrl?: string;
secondaryCtaLabel?: string;
secondaryCtaUrl?: string;
preheader?: string;
footerNote?: string;
};
const MEETING_BUTTON_SHORTCODES = [
"{{meetingButton}}",
"{{jitsiButton}}",
"[meeting_button]",
"[jitsi_button]"
];
const CANCEL_BUTTON_SHORTCODES = [
"{{cancelButton}}",
"{{stornoButton}}",
"{{cancellationButton}}",
"[cancel_button]"
];
function normalizeTemplateLineBreaks(value: string) {
return value
.replace(/\\r\\n/g, "\n")
.replace(/\\n/g, "\n")
.replace(/\\r/g, "\n")
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
}
function normalizeInline(value: string) {
return normalizeTemplateLineBreaks(value).replace(/\s+/g, " ").trim();
}
function escapeHtml(value: string) {
return value
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function formatTextAsHtml(value: string) {
return escapeHtml(normalizeTemplateLineBreaks(value)).replace(/\n/g, "<br/>");
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function stripCtaTokens(
value: string,
ctas: Array<{
id: string;
label: string;
url: string;
shortcodes: string[];
lineLabelPattern: string;
}>
) {
let output = normalizeTemplateLineBreaks(value);
const resolvedCtas: Array<{
id: string;
label: string;
url: string;
marker: string;
}> = [];
for (const cta of ctas) {
if (!cta.url) continue;
let triggered = false;
const marker = `__CB_CTA_${cta.id.toUpperCase()}__`;
for (const shortcode of cta.shortcodes) {
if (output.includes(shortcode)) {
triggered = true;
output = output.split(shortcode).join(marker);
}
}
if (output.includes(cta.url)) {
triggered = true;
const escapedUrl = escapeRegExp(cta.url);
output = output
.replace(
new RegExp(`^.*(?:${cta.lineLabelPattern}).*${escapedUrl}.*$`, "gim"),
marker
)
.replace(new RegExp(`^\\s*${escapedUrl}\\s*$`, "gim"), marker)
.replace(new RegExp(escapedUrl, "g"), marker);
}
if (triggered) {
output = output.replace(new RegExp(`(?:${marker})(?:\\s*${marker})+`, "g"), marker);
resolvedCtas.push({
id: cta.id,
label: cta.label,
url: cta.url,
marker
});
}
}
output = output
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
return {
body: output,
ctas: resolvedCtas
};
}
function renderInlineCtaButton(
cta: { id: string; label: string; url: string },
style: ReturnType<typeof getEmailStylePreset>
) {
const href = escapeHtml(cta.url);
const label = escapeHtml(cta.label);
const isCancel = cta.id === "cancel";
const buttonBg = isCancel ? "#dc2626" : style.buttonColor;
const buttonText = isCancel ? "#ffffff" : style.buttonTextColor;
return `
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="display:inline-table;border-collapse:separate;margin:4px 8px 4px 0;vertical-align:middle;">
<tr>
<td align="center" style="background:${buttonBg};border-radius:12px;">
<a
href="${href}"
target="_blank"
rel="noopener noreferrer"
style="display:inline-block;padding:12px 18px;border-radius:12px;color:${buttonText};text-decoration:none;font-weight:700;font-size:14px;line-height:1.2;"
>
${label}
</a>
</td>
</tr>
</table>
`.trim();
}
function renderThemeCard(styleId: string, subjectHtml: string, bodyHtml: string) {
if (styleId === "corporate") {
return `<div style="background-color:#f1f5f9;padding:32px;font-family:Arial,sans-serif;border-radius:12px;max-width:600px;margin:0 auto;">
<div style="background-color:#4f46e5;color:#ffffff;padding:20px;border-radius:12px 12px 0 0;font-weight:700;font-size:18px;">${subjectHtml}</div>
<div style="background-color:#ffffff;padding:32px;border-radius:0 0 12px 12px;white-space:normal;color:#334155;line-height:1.6;">${bodyHtml}</div>
</div>`;
}
if (styleId === "serif") {
return `<div style="background-color:#faf8f5;padding:48px;font-family:Georgia,'Times New Roman',serif;max-width:600px;margin:0 auto;color:#2b2721;border:1px solid #e7e0d5;">
<div style="text-align:center;margin-bottom:36px;">
<div style="font-size:12px;text-transform:uppercase;letter-spacing:0.24em;color:#78716c;display:inline-block;border-bottom:1px solid #d6cec0;padding-bottom:16px;">${subjectHtml}</div>
</div>
<div style="white-space:normal;line-height:1.9;text-align:center;font-size:15px;">${bodyHtml}</div>
</div>`;
}
if (styleId === "mono") {
return `<div style="background-color:#ffffff;padding:40px;font-family:'Helvetica Neue',Arial,sans-serif;max-width:600px;margin:0 auto;border:2px solid #0f172a;">
<div style="background-color:#0f172a;color:#ffffff;padding:20px 24px;font-size:17px;font-weight:800;letter-spacing:-0.01em;">${subjectHtml}</div>
<div style="padding:24px;white-space:normal;color:#1e293b;line-height:1.7;">${bodyHtml}</div>
</div>`;
}
if (styleId === "glass") {
return `<div style="background:rgba(255,255,255,0.5);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);padding:40px;font-family:Arial,sans-serif;max-width:600px;margin:0 auto;border-radius:20px;border:1px solid rgba(226,232,240,0.9);box-shadow:0 10px 40px rgba(15,23,42,0.04);">
<div style="background:rgba(255,255,255,0.3);padding:18px 22px;border-radius:14px;font-size:16px;font-weight:700;color:#0f172a;margin-bottom:24px;">${subjectHtml}</div>
<div style="background:rgba(255,255,255,0.5);padding:24px;border-radius:14px;white-space:normal;color:#334155;line-height:1.7;">${bodyHtml}</div>
</div>`;
}
if (styleId === "ink") {
return `<div style="background-color:#f0f4ff;padding:40px;font-family:'Segoe UI',Arial,sans-serif;max-width:600px;margin:0 auto;border-radius:16px;border:1px solid #cbd5e1;">
<div style="background-color:#1e3a5f;color:#e8f0fe;padding:22px 26px;border-radius:12px;font-size:18px;font-weight:700;margin-bottom:24px;">${subjectHtml}</div>
<div style="background-color:#ffffff;padding:28px;border-radius:12px;white-space:normal;color:#1e293b;line-height:1.7;">${bodyHtml}</div>
</div>`;
}
if (styleId === "warm") {
return `<div style="background-color:#fef7ed;padding:36px;font-family:Georgia,'Times New Roman',serif;max-width:600px;margin:0 auto;border-radius:20px;border:1px solid #fde68a;color:#431407;">
<div style="background-color:#b45309;color:#fff7ed;display:inline-block;padding:10px 20px;border-radius:999px;font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:0.12em;margin-bottom:24px;">${subjectHtml}</div>
<div style="background-color:#ffffff;padding:24px;border-radius:14px;white-space:normal;line-height:1.7;font-size:15px;font-weight:500;">${bodyHtml}</div>
</div>`;
}
if (styleId === "soft") {
return `<div style="background-color:#fafafa;padding:40px;font-family:Arial,sans-serif;max-width:600px;margin:0 auto;border-radius:18px;border:1px solid #e5e5e5;color:#525252;">
<div style="background-color:#f5f5f5;padding:16px 22px;border-radius:12px;font-size:15px;font-weight:600;color:#404040;margin-bottom:24px;">${subjectHtml}</div>
<div style="padding:0 8px;white-space:normal;line-height:1.7;font-size:14px;">${bodyHtml}</div>
</div>`;
}
if (styleId === "startup") {
return `<div style="background:linear-gradient(135deg,#fdfbfb 0%,#ebedee 100%);padding:40px;font-family:'Helvetica Neue',Arial,sans-serif;max-width:600px;margin:0 auto;border-radius:16px;box-shadow:0 10px 20px rgba(0,0,0,0.05);">
<div style="background:linear-gradient(to right,#6EE7B7,#3B82F6,#9333EA);-webkit-background-clip:text;color:transparent;font-size:20px;font-weight:800;text-transform:uppercase;letter-spacing:1px;margin-bottom:24px;border-bottom:2px solid #e5e7eb;padding-bottom:16px;">${subjectHtml}</div>
<div style="white-space:normal;line-height:1.7;color:#374151;font-size:15px;">${bodyHtml}</div>
</div>`;
}
return `<div style="background-color:#ffffff;padding:32px;font-family:Arial,sans-serif;border:1px solid #e2e8f0;border-radius:12px;max-width:600px;margin:0 auto;">
<div style="text-transform:uppercase;font-size:12px;font-weight:700;color:#94a3b8;border-bottom:1px solid #f1f5f9;padding-bottom:16px;margin-bottom:24px;">${subjectHtml}</div>
<div style="white-space:normal;color:#1e293b;line-height:1.6;font-weight:500;">${bodyHtml}</div>
</div>`;
}
export function renderStyledEmail(payload: StyledEmailPayload) {
const style = getEmailStylePreset(payload.styleId ?? DEFAULT_EMAIL_STYLE_ID);
const infoRows = payload.infoRows ?? [];
const normalizedLead = payload.lead ? normalizeTemplateLineBreaks(payload.lead) : "";
const normalizedHeading = payload.heading ? normalizeTemplateLineBreaks(payload.heading) : "";
const normalizedFooterNote = normalizeTemplateLineBreaks(
payload.footerNote ?? `Viele Grüße\n${payload.companyName}`
);
const normalizedSubject = normalizeInline(payload.subject);
const normalizedCtaUrl = normalizeTemplateLineBreaks(payload.ctaUrl ?? "").trim();
const normalizedCtaLabel = normalizeInline(payload.ctaLabel ?? "Meeting beitreten");
const normalizedSecondaryCtaUrl = normalizeTemplateLineBreaks(
payload.secondaryCtaUrl ?? ""
).trim();
const normalizedSecondaryCtaLabel = normalizeInline(
payload.secondaryCtaLabel ?? "Termin stornieren"
);
const ctaResolution = stripCtaTokens(payload.body, [
{
id: "meeting",
label: normalizedCtaLabel,
url: normalizedCtaUrl,
shortcodes: MEETING_BUTTON_SHORTCODES,
lineLabelPattern: "jitsi|meeting|video|raum|beitreten"
},
{
id: "cancel",
label: normalizedSecondaryCtaLabel,
url: normalizedSecondaryCtaUrl,
shortcodes: CANCEL_BUTTON_SHORTCODES,
lineLabelPattern: "absag|storn|cancel"
}
]);
const normalizedBody = ctaResolution.body;
const infoRowsText =
infoRows.length === 0
? ""
: infoRows
.map((row) => `${normalizeInline(row.label)}: ${normalizeInline(row.value)}`)
.join("\n");
const bodySectionsHtml = [
normalizedHeading && normalizeInline(normalizedHeading) !== normalizedSubject
? normalizedHeading
: "",
normalizedLead,
normalizedBody,
infoRowsText,
normalizedFooterNote
].filter(Boolean);
const rawHtmlContent = formatTextAsHtml(bodySectionsHtml.join("\n\n"));
let contentHtml = rawHtmlContent;
let contentText = bodySectionsHtml.join("\n\n");
for (const cta of ctaResolution.ctas) {
contentHtml = contentHtml.split(cta.marker).join(renderInlineCtaButton(cta, style));
contentText = contentText
.split(cta.marker)
.join(`${normalizeInline(cta.label)}: ${normalizeTemplateLineBreaks(cta.url).trim()}`);
}
const subjectHtml = escapeHtml(normalizedSubject || normalizeInline(payload.heading) || "Nachricht");
const preheaderSource =
payload.preheader ?? (normalizedLead || normalizedHeading || payload.subject);
const html = `
<!doctype html>
<html lang="de">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<meta name="supported-color-schemes" content="light" />
<title>${escapeHtml(payload.subject)}</title>
<style>
:root {
color-scheme: light !important;
supported-color-schemes: light !important;
}
.cb-body,
.cb-root,
.cb-root * {
color-scheme: light !important;
}
.cb-body {
background-color: ${style.canvasColor} !important;
}
a[x-apple-data-detectors],
u + #body a,
#MessageViewBody a {
color: inherit !important;
text-decoration: inherit !important;
font: inherit !important;
}
@media (prefers-color-scheme: dark) {
.cb-body,
.cb-root {
background-color: ${style.canvasColor} !important;
}
}
</style>
</head>
<body id="body" class="cb-body" bgcolor="${style.canvasColor}" style="margin:0;padding:20px 10px;background:${style.canvasColor};font-family:Inter,Arial,sans-serif;">
<span style="display:none;visibility:hidden;opacity:0;max-height:0;overflow:hidden;mso-hide:all;">
${escapeHtml(normalizeInline(preheaderSource))}
</span>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" class="cb-root" bgcolor="${style.canvasColor}" style="border-collapse:collapse;background:${style.canvasColor};">
<tr>
<td align="center" style="padding:0;text-align:left;">
<div style="max-width:620px;margin:0 auto;text-align:left;">
${renderThemeCard(style.id, subjectHtml, contentHtml)}
</div>
</td>
</tr>
</table>
</body>
</html>
`.trim();
const text = [normalizedSubject, contentText].filter(Boolean).join("\n\n");
return { html, text, styleId: style.id, styleName: style.name };
}