mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 21:41:16 +00:00
175 lines
8.7 KiB
TypeScript
175 lines
8.7 KiB
TypeScript
import { COMPANY } from "@/modules/shared/data/company"
|
||
|
||
// Logos hängen als Inline-Bilder an jeder Mail (cid:), damit sie ohne öffentliche URL und auch lokal funktionieren.
|
||
export const LOGO_CIDS = { light: "since3-logo-light", dark: "since3-logo-dark" }
|
||
|
||
// Farben aus dem Portal (Ink-Skala, monochrom) bzw. der Rechnung.
|
||
const COLORS = {
|
||
page: "#f2f2f2",
|
||
card: "#ffffff",
|
||
border: "#e3e3e3",
|
||
ink: "#0a0a0a",
|
||
text: "#1a1a1a",
|
||
muted: "#6e6e6e",
|
||
panel: "#f7f7f7",
|
||
}
|
||
|
||
// Dark Mode wie im Portal (.dark): Hintergrund ink-950, Flächen ink-900/800, Primary-Button weiß.
|
||
const DARK = {
|
||
page: "#020202",
|
||
card: "#0c0c0c",
|
||
border: "#202020",
|
||
ink: "#ffffff",
|
||
text: "#ededed",
|
||
muted: "#949494",
|
||
panel: "#202020",
|
||
}
|
||
|
||
// Apple Mail, iOS Mail und Outlook (Mac) werten prefers-color-scheme aus, Outlook.com/-App die [data-ogsc]/[data-ogsb]-Attribute.
|
||
// Gmail ignoriert beides und invertiert selbst – dafür hat das helle Logo einen weißen Rand.
|
||
const DARK_RULES = `
|
||
.bg-page { background: ${DARK.page} !important; }
|
||
.bg-card { background: ${DARK.card} !important; border-color: ${DARK.border} !important; }
|
||
.bg-panel { background: ${DARK.panel} !important; border-color: ${DARK.border} !important; }
|
||
.text-ink { color: ${DARK.ink} !important; }
|
||
.text-body { color: ${DARK.text} !important; }
|
||
.text-muted { color: ${DARK.muted} !important; }
|
||
.btn { background: ${DARK.ink} !important; }
|
||
.btn-link { color: ${DARK.page} !important; }
|
||
.bar { background: #3a3a3a !important; background-image: linear-gradient(90deg, #3a3a3a 0%, #b9b9b9 100%) !important; }
|
||
.logo-light { display: none !important; }
|
||
.logo-dark { display: block !important; max-height: none !important; overflow: visible !important; }`
|
||
|
||
const FONT = "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif"
|
||
|
||
export function escapeHtml(value: string) {
|
||
return value
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'")
|
||
}
|
||
|
||
export type MailLayout = {
|
||
preheader: string
|
||
heading: string
|
||
// Bereits escaptes HTML (mit escapeHtml gebaut).
|
||
paragraphs: string[]
|
||
details?: { title?: string; rows: [label: string, value: string][] }
|
||
cta?: { label: string; href: string }
|
||
closing?: string[]
|
||
appUrl: string
|
||
}
|
||
|
||
function paragraph(html: string) {
|
||
return `<p class="text-body" style="margin:0 0 16px;font-size:15px;line-height:24px;color:${COLORS.text};">${html}</p>`
|
||
}
|
||
|
||
function detailsPanel({ title, rows }: NonNullable<MailLayout["details"]>) {
|
||
const rowsHtml = rows
|
||
.map(
|
||
([label, value]) => `
|
||
<tr>
|
||
<td class="text-muted" style="padding:6px 0;font-size:14px;line-height:20px;color:${COLORS.muted};">${escapeHtml(label)}</td>
|
||
<td class="text-ink" align="right" style="padding:6px 0 6px 16px;font-size:14px;line-height:20px;color:${COLORS.ink};font-weight:600;">${escapeHtml(value)}</td>
|
||
</tr>`
|
||
)
|
||
.join("")
|
||
|
||
return `
|
||
<table class="bg-panel" role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:8px 0 24px;background:${COLORS.panel};border:1px solid ${COLORS.border};border-radius:10px;">
|
||
<tr>
|
||
<td style="padding:16px 20px;">
|
||
${title ? `<p class="text-ink" style="margin:0 0 6px;font-size:13px;line-height:20px;font-weight:600;color:${COLORS.ink};">${escapeHtml(title)}</p>` : ""}
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${rowsHtml}</table>
|
||
</td>
|
||
</tr>
|
||
</table>`
|
||
}
|
||
|
||
function button({ label, href }: NonNullable<MailLayout["cta"]>) {
|
||
return `
|
||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:8px 0 24px;">
|
||
<tr>
|
||
<td class="btn" bgcolor="${COLORS.ink}" style="border-radius:8px;">
|
||
<a class="btn-link" href="${escapeHtml(href)}" target="_blank" style="display:inline-block;padding:12px 22px;font-family:${FONT};font-size:15px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">${escapeHtml(label)}</a>
|
||
</td>
|
||
</tr>
|
||
</table>`
|
||
}
|
||
|
||
export function renderMailLayout(mail: MailLayout) {
|
||
const appUrl = mail.appUrl.replace(/\/$/, "")
|
||
const closing = mail.closing ?? ["Viele Grüße", "Ihr Since3 Team"]
|
||
|
||
return `<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="color-scheme" content="light dark">
|
||
<meta name="supported-color-schemes" content="light dark">
|
||
<title>${escapeHtml(mail.heading)}</title>
|
||
<style>
|
||
:root { color-scheme: light dark; supported-color-schemes: light dark; }
|
||
@media (prefers-color-scheme: dark) {${DARK_RULES}
|
||
}
|
||
${DARK_RULES.trim().split("\n").map((rule) => `[data-ogsc] ${rule.trim()}`).join("\n ")}
|
||
@media (max-width: 600px) {
|
||
.container { padding: 16px 8px !important; }
|
||
.content { padding: 28px 22px !important; }
|
||
.header { padding: 24px 22px 0 !important; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body class="bg-page" style="margin:0;padding:0;background:${COLORS.page};font-family:${FONT};-webkit-font-smoothing:antialiased;">
|
||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">${escapeHtml(mail.preheader)}​ ​ ​ ​ ​ </div>
|
||
<table class="bg-page" role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="${COLORS.page}" style="background:${COLORS.page};">
|
||
<tr>
|
||
<td align="center" class="container" style="padding:40px 16px;">
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:560px;">
|
||
<tr>
|
||
<td class="bg-card" bgcolor="${COLORS.card}" style="background:${COLORS.card};border:1px solid ${COLORS.border};border-radius:12px;overflow:hidden;">
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||
<tr>
|
||
<td class="bar" height="5" bgcolor="#1a1a1a" style="height:5px;line-height:5px;font-size:0;background:#1a1a1a;background-image:linear-gradient(90deg,#000000 0%,#8c8c8c 100%);"> </td>
|
||
</tr>
|
||
<tr>
|
||
<td class="header" style="padding:32px 40px 0;">
|
||
<a href="${escapeHtml(appUrl)}" target="_blank" style="text-decoration:none;">
|
||
<img class="logo-light" src="cid:${LOGO_CIDS.light}" width="144" height="28" alt="Since3" style="display:block;border:0;width:144px;height:auto;">
|
||
<!--[if !mso]><!--><div class="logo-dark" style="display:none;max-height:0;overflow:hidden;mso-hide:all;"><img src="cid:${LOGO_CIDS.dark}" width="144" height="28" alt="Since3" style="display:block;border:0;width:144px;height:auto;"></div><!--<![endif]-->
|
||
</a>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="content" style="padding:32px 40px 36px;">
|
||
<h1 class="text-ink" style="margin:0 0 20px;font-size:22px;line-height:30px;font-weight:600;color:${COLORS.ink};">${escapeHtml(mail.heading)}</h1>
|
||
${mail.paragraphs.map(paragraph).join("")}
|
||
${mail.details ? detailsPanel(mail.details) : ""}
|
||
${mail.cta ? button(mail.cta) : ""}
|
||
<p class="text-body" style="margin:0;font-size:15px;line-height:24px;color:${COLORS.text};">${closing.map(escapeHtml).join("<br>")}</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="text-muted" style="padding:24px 8px 0;font-size:12px;line-height:18px;color:${COLORS.muted};text-align:center;">
|
||
Diese E-Mail wurde automatisch versendet. Bitte antworten Sie nicht auf diese E-Mail.<br>
|
||
Bei Fragen erreichen Sie uns unter
|
||
<a href="mailto:${COMPANY.email}" class="text-muted" style="color:${COLORS.muted};text-decoration:underline;">${COMPANY.email}</a>.<br><br>
|
||
${escapeHtml(COMPANY.name)} · ${escapeHtml(COMPANY.street)} · ${escapeHtml(COMPANY.city)}<br>
|
||
<a href="${escapeHtml(appUrl)}/impressum" class="text-muted" style="color:${COLORS.muted};text-decoration:underline;">Impressum</a>
|
||
·
|
||
<a href="${escapeHtml(appUrl)}/datenschutz" class="text-muted" style="color:${COLORS.muted};text-decoration:underline;">Datenschutz</a>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</body>
|
||
</html>`
|
||
}
|