Files
webshop/shop/lib/actions/email-templates.ts
DanielS e78fe7c4b8
All checks were successful
Staging Build / build (push) Successful in 3m9s
feat(email): implement professional responsive b2b html email template
2026-09-08 00:09:16 +02:00

490 lines
22 KiB
TypeScript

export interface OrderEmailProps {
orderNumber: string
status: 'pending' | 'pending_approval' | 'in_review' | 'approved' | 'active' | 'completed' | 'cancelled' | 'rejected'
formattedDate: string
customerCompanyName: string
billingModel?: string // e.g. "Kauf" | "Miete (monatlich)" | "Kauf / Miete"
rejectionReason?: string
siteUrl?: string
items?: any[]
partnerCompanyName?: string
partnerUserName?: string
partnerUserEmail?: string
taxRate?: number
oneTimeNet?: number
monthlyNet?: number
}
export function generateOrderEmailSubject(orderNumber: string, status: string): string {
const formattedOrderNumber = (orderNumber || '').replace(/^BE-/, 'AE-')
if (status === 'approved' || status === 'active' || status === 'completed') {
return `Auftragsbestätigung: Anfrage #${formattedOrderNumber} freigegeben`
}
if (status === 'rejected' || status === 'cancelled') {
return `Status-Update: Anfrage #${formattedOrderNumber} abgelehnt`
}
return `Eingangsbestätigung: Anfrage #${formattedOrderNumber} eingegangen`
}
export function generateOrderEmailHtml(props: OrderEmailProps): { text: string; html: string } {
const formattedOrderNumber = (props.orderNumber || '').replace(/^BE-/, 'AE-')
const siteUrl = props.siteUrl || process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'
const taxRate = props.taxRate ?? 19
const orderPortalUrl = `${siteUrl}/my-orders`
// Status Callout configuration
let calloutBg = '#f8fafc'
let calloutBorder = '#cbd5e1'
let calloutTitle = 'Status: Anfrage eingegangen und in Prüfung'
let calloutText = 'Ihre Anfrage ist erfolgreich in unserem System eingegangen und wird derzeit vom Support geprüft.'
let statusBadgeColor = '#2563eb'
if (props.status === 'approved' || props.status === 'active' || props.status === 'completed') {
calloutBg = '#f0fdf4'
calloutBorder = '#86efac'
calloutTitle = 'Status: Freigegeben / Aktiv'
calloutText = 'Ihre Auftragsbestätigung / Rechnung finden Sie als PDF im Anhang dieser E-Mail.'
statusBadgeColor = '#16a34a'
} else if (props.status === 'rejected' || props.status === 'cancelled') {
calloutBg = '#fef2f2'
calloutBorder = '#fca5a5'
calloutTitle = 'Status: Anfrage abgelehnt'
calloutText = props.rejectionReason
? `Begründung: "${props.rejectionReason}"`
: 'Ihre Anfrage wurde vom Support geprüft und konnte leider nicht freigegeben werden.'
statusBadgeColor = '#dc2626'
}
// Calculate prices if items provided
let oneTimeNet = props.oneTimeNet ?? 0
let monthlyNet = props.monthlyNet ?? 0
const items = props.items || []
if (items.length > 0 && props.oneTimeNet === undefined && props.monthlyNet === undefined) {
items.forEach((item: any) => {
if (item.billing_interval === 'monthly') {
monthlyNet += item.base_price || 0
} else {
oneTimeNet += item.base_price || 0
}
item.selected_modules?.forEach((mod: any) => {
const qty = mod.quantity || 1
const price = mod.total_price ?? (mod.price * qty)
monthlyNet += price
})
})
}
const oneTimeTax = Math.round(oneTimeNet * (taxRate / 100) * 100) / 100
const oneTimeGross = Math.round((oneTimeNet + oneTimeTax) * 100) / 100
const monthlyTax = Math.round(monthlyNet * (taxRate / 100) * 100) / 100
const monthlyGross = Math.round((monthlyNet + monthlyTax) * 100) / 100
const formatEuro = (val: number) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val)
// Determine Billing Model Label
let billingModelLabel = props.billingModel
if (!billingModelLabel) {
if (oneTimeNet > 0 && monthlyNet > 0) {
billingModelLabel = 'Kauf & Miete'
} else if (monthlyNet > 0) {
billingModelLabel = 'Miete (monatlich)'
} else {
billingModelLabel = 'Kauf (einmalig)'
}
}
// Group items by Device
const groupedItems: { [key: string]: any[] } = {}
items.forEach((item: any) => {
const devName = item.device_name || 'Kasse 1'
if (!groupedItems[devName]) {
groupedItems[devName] = []
}
groupedItems[devName].push(item)
})
// Build Text Breakdown
let itemsBreakdownText = ''
if (items.length > 0) {
itemsBreakdownText += '\nKassen-Aufstellung:\n'
Object.entries(groupedItems).forEach(([devName, devItems]) => {
itemsBreakdownText += `\n[ ${devName} ]\n`
devItems.forEach((item: any) => {
itemsBreakdownText += ` - ${item.product_name} (${item.category_name || 'Basis'}): ${formatEuro(item.base_price || 0)} ${item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}\n`
item.selected_modules?.forEach((mod: any) => {
const qty = mod.quantity || 1
const price = mod.total_price ?? (mod.price * qty)
itemsBreakdownText += ` + ${mod.module_name} ${qty > 1 ? `(x${qty})` : ''}: +${formatEuro(price)} mtl.\n`
})
})
})
}
let totalsText = ''
if (oneTimeNet > 0) {
totalsText += `\nEinmalige Beträge:\n- Netto: ${formatEuro(oneTimeNet)}\n- zzgl. ${taxRate}% MwSt: ${formatEuro(oneTimeTax)}\n- Brutto Gesamt: ${formatEuro(oneTimeGross)}\n`
}
if (monthlyNet > 0) {
totalsText += `\nMonatliche Beträge:\n- Netto: ${formatEuro(monthlyNet)} / mtl.\n- zzgl. ${taxRate}% MwSt: ${formatEuro(monthlyTax)} / mtl.\n- Brutto Gesamt: ${formatEuro(monthlyGross)} / mtl.\n`
}
let partnerPlainText = ''
if (props.partnerCompanyName || props.partnerUserName || props.partnerUserEmail) {
partnerPlainText = `\nPartner / Betreuer:\n- Firma: ${props.partnerCompanyName || '-'}\n- Ansprechpartner: ${props.partnerUserName || '-'}\n- E-Mail: ${props.partnerUserEmail || '-'}\n`
}
const text = `CASPOS B2B Portal\nAnfrage #${formattedOrderNumber}\n\n${calloutTitle}\n${calloutText}\n\nAuftragsdetails:\n- Endkunde: ${props.customerCompanyName}\n- Datum: ${props.formattedDate}\n- Abrechnung: ${billingModelLabel}\n${partnerPlainText}${itemsBreakdownText}${totalsText}\n\nAnfrage im Portal ansehen: ${orderPortalUrl}\n\nCASPOS Computerabrechnungssysteme GmbH\nAlte Bundesstraße 16 · 76846 Hauenstein\nAmtsgericht Zweibrücken HRB 12345\nAutomatische Systembenachrichtigung.`
// Build HTML Items Rows
let itemsHtmlRows = ''
if (items.length > 0) {
Object.entries(groupedItems).forEach(([devName, devItems]) => {
itemsHtmlRows += `
<tr>
<td colspan="2" style="padding: 10px 14px; background-color: #f1f5f9; font-weight: 600; font-size: 13px; color: #1e293b; border-bottom: 1px solid #e2e8f0;">
${devName === 'Zusatzleistung' ? 'Backoffice / Zusatzleistung' : `Kassengerät: ${devName}`}
</td>
</tr>
`
devItems.forEach((item: any) => {
itemsHtmlRows += `
<tr>
<td style="padding: 8px 14px; font-size: 13px; color: #334155; font-weight: 500; border-bottom: 1px solid #f1f5f9;">
${item.product_name} <span style="font-size: 11px; color: #64748b;">(${item.category_name || 'Basis'})</span>
</td>
<td style="padding: 8px 14px; text-align: right; font-size: 13px; font-weight: 600; color: #0f172a; border-bottom: 1px solid #f1f5f9;">
${formatEuro(item.base_price || 0)} <span style="font-size: 11px; color: #64748b; font-weight: normal;">${item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}</span>
</td>
</tr>
`
item.selected_modules?.forEach((mod: any) => {
const qty = mod.quantity || 1
const price = mod.total_price ?? (mod.price * qty)
itemsHtmlRows += `
<tr>
<td style="padding: 4px 14px 4px 28px; color: #64748b; font-size: 12px; border-bottom: 1px solid #f8fafc;">
+ ${mod.module_name} ${qty > 1 ? `<span style="font-size: 10px; font-weight: 600;">(x${qty})</span>` : ''}
</td>
<td style="padding: 4px 14px; text-align: right; color: #64748b; font-size: 12px; border-bottom: 1px solid #f8fafc;">
+${formatEuro(price)} <span style="font-size: 10px;">mtl.</span>
</td>
</tr>
`
})
})
})
}
// Build HTML Partner Section
let partnerHtml = ''
if (props.partnerCompanyName || props.partnerUserName || props.partnerUserEmail) {
partnerHtml = `
<tr>
<td colspan="2" style="padding: 12px 0 4px 0; border-top: 1px solid #e2e8f0; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b;">Partner & Betreuung:</td>
</tr>
${props.partnerCompanyName ? `<tr><td style="padding: 3px 0; font-size: 13px; color: #64748b;">Firma:</td><td style="padding: 3px 0; font-size: 13px; font-weight: 500; color: #0f172a; text-align: right;">${props.partnerCompanyName}</td></tr>` : ''}
${props.partnerUserName ? `<tr><td style="padding: 3px 0; font-size: 13px; color: #64748b;">Ansprechpartner:</td><td style="padding: 3px 0; font-size: 13px; color: #0f172a; text-align: right;">${props.partnerUserName}</td></tr>` : ''}
${props.partnerUserEmail ? `<tr><td style="padding: 3px 0; font-size: 13px; color: #64748b;">E-Mail:</td><td style="padding: 3px 0; font-size: 13px; color: #0f172a; text-align: right;">${props.partnerUserEmail}</td></tr>` : ''}
`
}
// Totals Section HTML
let totalsHtml = ''
if (oneTimeNet > 0 || monthlyNet > 0) {
totalsHtml = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin-top: 16px; border-top: 2px solid #e2e8f0; padding-top: 12px; font-size: 13px; color: #334155;">
${oneTimeNet > 0 ? `
<tr>
<td colspan="2" style="padding: 4px 0; font-weight: 600; font-size: 11px; text-transform: uppercase; color: #64748b; letter-spacing: 0.05em;">Einmalige Beträge:</td>
</tr>
<tr>
<td style="padding: 2px 0; color: #64748b;">Netto-Zwischensumme:</td>
<td style="padding: 2px 0; text-align: right; font-weight: 500; color: #0f172a;">${formatEuro(oneTimeNet)}</td>
</tr>
<tr>
<td style="padding: 2px 0; color: #64748b;">zzgl. ${taxRate}% MwSt:</td>
<td style="padding: 2px 0; text-align: right; color: #64748b;">${formatEuro(oneTimeTax)}</td>
</tr>
<tr>
<td style="padding: 4px 0 10px 0; font-weight: 600; color: #0f172a;">Gesamt einmalig (brutto):</td>
<td style="padding: 4px 0 10px 0; text-align: right; font-weight: 700; color: #0f172a; font-size: 14px;">${formatEuro(oneTimeGross)}</td>
</tr>
` : ''}
${monthlyNet > 0 ? `
<tr>
<td colspan="2" style="padding: ${oneTimeNet > 0 ? '10px' : '4px'} 0 4px 0; font-weight: 600; font-size: 11px; text-transform: uppercase; color: #64748b; letter-spacing: 0.05em; ${oneTimeNet > 0 ? 'border-top: 1px dashed #e2e8f0;' : ''}">Monatlich wiederkehrend:</td>
</tr>
<tr>
<td style="padding: 2px 0; color: #64748b;">Netto-Zwischensumme:</td>
<td style="padding: 2px 0; text-align: right; font-weight: 500; color: #0f172a;">${formatEuro(monthlyNet)} / mtl.</td>
</tr>
<tr>
<td style="padding: 2px 0; color: #64748b;">zzgl. ${taxRate}% MwSt:</td>
<td style="padding: 2px 0; text-align: right; color: #64748b;">${formatEuro(monthlyTax)} / mtl.</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: 600; color: #0f172a;">Gesamt monatlich (brutto):</td>
<td style="padding: 4px 0; text-align: right; font-weight: 700; color: #0f172a; font-size: 14px;">${formatEuro(monthlyGross)} / mtl.</td>
</tr>
` : ''}
</table>
`
}
const html = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CASPOS B2B Portal - #${formattedOrderNumber}</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; color: #0f172a;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: #f8fafc; padding: 24px 12px;">
<tr>
<td align="center">
<!-- Main Card (max-width 600px) -->
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="max-width: 600px; background-color: #ffffff; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);">
<!-- Header Banner -->
<tr>
<td style="background-color: #0f172a; padding: 24px; text-align: left;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td>
<div style="font-size: 18px; font-weight: 700; color: #ffffff; letter-spacing: -0.02em; text-transform: uppercase;">CASPOS B2B Portal</div>
<div style="font-size: 12px; color: #94a3b8; margin-top: 2px;">Die Kasse · Fachhandelsportal</div>
</td>
<td align="right" style="vertical-align: middle;">
<span style="font-family: monospace, Courier, monospace; font-size: 14px; font-weight: 600; color: #38bdf8; background-color: #1e293b; padding: 6px 10px; border-radius: 4px; border: 1px solid #334155;">
#${formattedOrderNumber}
</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- Body Content -->
<tr>
<td style="padding: 24px;">
<!-- Status Callout -->
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: ${calloutBg}; border-left: 4px solid ${statusBadgeColor}; border-top: 1px solid ${calloutBorder}; border-right: 1px solid ${calloutBorder}; border-bottom: 1px solid ${calloutBorder}; border-radius: 4px; margin-bottom: 24px;">
<tr>
<td style="padding: 14px 16px;">
<div style="font-weight: 700; font-size: 14px; color: #0f172a; margin-bottom: 4px;">${calloutTitle}</div>
<div style="font-size: 13px; color: #334155; line-height: 1.5;">${calloutText}</div>
</td>
</tr>
</table>
<!-- Order & Customer Meta Table -->
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin-bottom: 24px;">
<tr>
<td>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="font-size: 13px;">
<tr>
<td style="padding: 4px 0; color: #64748b; width: 140px;">Endkunde:</td>
<td style="padding: 4px 0; font-weight: 600; color: #0f172a; text-align: right;">${props.customerCompanyName}</td>
</tr>
<tr>
<td style="padding: 4px 0; color: #64748b;">Bestelldatum:</td>
<td style="padding: 4px 0; font-weight: 500; color: #0f172a; text-align: right;">${props.formattedDate}</td>
</tr>
<tr>
<td style="padding: 4px 0; color: #64748b;">Abrechnungsmodell:</td>
<td style="padding: 4px 0; font-weight: 500; color: #0f172a; text-align: right;">${billingModelLabel}</td>
</tr>
${partnerHtml}
</table>
</td>
</tr>
</table>
<!-- Hardware / Modules Breakdown -->
${items.length > 0 ? `
<div style="font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: #475569; margin-bottom: 8px;">Kassen-Aufstellung</div>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="border: 1px solid #e2e8f0; border-radius: 6px; overflow: hidden; margin-bottom: 20px;">
${itemsHtmlRows}
</table>
` : ''}
<!-- Totals -->
${totalsHtml}
<!-- Primary Action Button -->
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin-top: 32px; margin-bottom: 8px;">
<tr>
<td align="center">
<a href="${orderPortalUrl}" target="_blank" style="display: inline-block; background-color: #2563eb; color: #ffffff; text-decoration: none; font-size: 14px; font-weight: 600; padding: 12px 28px; border-radius: 6px; box-shadow: 0 1px 2px rgba(0,0,0,0.1);">
[ Anfrage im Portal ansehen ]
</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f8fafc; border-top: 1px solid #e2e8f0; padding: 20px 24px; text-align: center; font-size: 12px; color: #64748b; line-height: 1.5;">
<p style="margin: 0; font-weight: 600; color: #475569;">CASPOS Computerabrechnungssysteme GmbH</p>
<p style="margin: 2px 0 0 0;">Alte Bundesstraße 16 · 76846 Hauenstein · Amtsgericht Zweibrücken HRB 12345</p>
<p style="margin: 8px 0 0 0; font-size: 11px; color: #94a3b8;">Dies ist eine automatische Transaktions-E-Mail des CASPOS B2B Portals.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`
return { text, html }
}
// Backwards-compatible Wrappers
export function getOrderEmailTemplate(
details: {
orderNumber: string
formattedDate: string
customerCompanyName: string
totalDetailsText?: string
totalDetailsHtml?: string
itemsDetailsText?: string
itemsDetailsHtml?: string
partnerCompanyName?: string
partnerUserName?: string
partnerUserEmail?: string
items?: any[]
taxRate?: number
oneTimeNet?: number
monthlyNet?: number
billingModel?: string
},
siteUrl: string,
isUpdate: boolean = false,
_primaryColor?: string
) {
return generateOrderEmailHtml({
orderNumber: details.orderNumber,
status: isUpdate ? 'in_review' : 'pending',
formattedDate: details.formattedDate,
customerCompanyName: details.customerCompanyName,
billingModel: details.billingModel,
siteUrl,
items: details.items,
partnerCompanyName: details.partnerCompanyName,
partnerUserName: details.partnerUserName,
partnerUserEmail: details.partnerUserEmail,
taxRate: details.taxRate,
oneTimeNet: details.oneTimeNet,
monthlyNet: details.monthlyNet,
})
}
export function getStatusEmailTemplate(
orderNumber: string,
_oldLabel: string,
_newLabel: string,
statusKey?: string,
rejectionReason?: string,
extraDetails?: {
customerCompanyName?: string
formattedDate?: string
billingModel?: string
items?: any[]
partnerCompanyName?: string
partnerUserName?: string
partnerUserEmail?: string
taxRate?: number
oneTimeNet?: number
monthlyNet?: number
}
) {
const status = (statusKey || 'pending') as any
return generateOrderEmailHtml({
orderNumber,
status,
formattedDate: extraDetails?.formattedDate || new Date().toLocaleDateString('de-DE'),
customerCompanyName: extraDetails?.customerCompanyName || 'Endkunde',
billingModel: extraDetails?.billingModel,
rejectionReason,
items: extraDetails?.items,
partnerCompanyName: extraDetails?.partnerCompanyName,
partnerUserName: extraDetails?.partnerUserName,
partnerUserEmail: extraDetails?.partnerUserEmail,
taxRate: extraDetails?.taxRate,
oneTimeNet: extraDetails?.oneTimeNet,
monthlyNet: extraDetails?.monthlyNet,
})
}
export function buildEmailItemsSection(items: any[]) {
const groupedItems: { [key: string]: any[] } = {}
items.forEach((item: any) => {
const devName = item.device_name || 'Kasse 1'
if (!groupedItems[devName]) {
groupedItems[devName] = []
}
groupedItems[devName].push(item)
})
let itemsDetailsText = '\nKassen-Konfigurationen:'
let itemsDetailsHtml = `
<tr>
<td colspan="2" style="padding: 12px 0 6px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Kassen-Konfigurationen:</td>
</tr>
`
Object.entries(groupedItems).forEach(([deviceName, devItems]) => {
itemsDetailsText += `\n- Kasse: ${deviceName}`
itemsDetailsHtml += `
<tr style="background-color: #f1f5f9;">
<td colspan="2" style="padding: 6px 8px; font-weight: bold; color: #1e3a8a; font-size: 13px;">Kasse: ${deviceName}</td>
</tr>
`
devItems.forEach((item: any) => {
itemsDetailsText += `\n * ${item.product_name} (${item.category_name}): ${Number(item.base_price || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} ${item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}`
itemsDetailsHtml += `
<tr>
<td style="padding: 6px 8px; font-size: 13px; color: #334155; font-weight: 500;">
${item.product_name} <span style="font-size: 11px; color: #64748b;">(${item.category_name})</span>
</td>
<td style="padding: 6px 8px; text-align: right; font-size: 13px; font-weight: bold; color: #0f172a;">
${Number(item.base_price || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} ${item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}
</td>
</tr>
`
item.selected_modules?.forEach((mod: any) => {
const qty = mod.quantity || 1
const price = mod.total_price ?? (mod.price * qty)
itemsDetailsText += `\n + ${mod.module_name} ${qty > 1 ? `(x${qty})` : ''}: +${Number(price || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} mtl.`
itemsDetailsHtml += `
<tr>
<td style="padding: 4px 8px 4px 20px; color: #64748b; font-size: 12px;">
+ ${mod.module_name} ${qty > 1 ? `<span style="font-size: 10px; font-weight: 600;">(x${qty})</span>` : ''}
</td>
<td style="padding: 4px 8px; text-align: right; color: #64748b; font-size: 12px;">
+${Number(price || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} mtl.
</td>
</tr>
`
})
})
})
return {
text: itemsDetailsText + '\n',
html: itemsDetailsHtml
}
}