354 lines
13 KiB
TypeScript
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, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
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 };
|
|
}
|