feat(email): implement professional responsive b2b html email template
All checks were successful
Staging Build / build (push) Successful in 3m9s

This commit is contained in:
DanielS
2026-09-08 00:09:16 +02:00
parent ec11ad8498
commit e78fe7c4b8
4 changed files with 482 additions and 222 deletions

View File

@@ -8,7 +8,7 @@ import { sendMail } from '@/utils/mail';
import { renderToBuffer } from '@react-pdf/renderer'; import { renderToBuffer } from '@react-pdf/renderer';
import React from 'react'; import React from 'react';
import { InvoicePDF } from '@/components/invoice-pdf'; import { InvoicePDF } from '@/components/invoice-pdf';
import { getOrderEmailTemplate, buildEmailItemsSection } from '@/lib/actions/email-templates'; import { generateOrderEmailHtml, generateOrderEmailSubject } from '@/lib/actions/email-templates';
function generateOrderNumber(prefix: 'BE' | 'AE' = 'AE'): string { function generateOrderNumber(prefix: 'BE' | 'AE' = 'AE'): string {
const year = new Date().getFullYear(); const year = new Date().getFullYear();
@@ -147,21 +147,22 @@ async function triggerPostProcessing(orderId: string, supabase: any, customerSna
`; `;
} }
const itemsSection = buildEmailItemsSection(items); const emailTemplate = generateOrderEmailHtml({
const emailTemplate = getOrderEmailTemplate({
orderNumber: order.order_number, orderNumber: order.order_number,
status: 'pending',
formattedDate, formattedDate,
customerCompanyName: customerSnapshot.company_name, customerCompanyName: customerSnapshot.company_name,
totalDetailsText, items,
totalDetailsHtml, taxRate,
itemsDetailsText: itemsSection.text, oneTimeNet,
itemsDetailsHtml: itemsSection.html monthlyNet,
}, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, false); });
const mailSubject = generateOrderEmailSubject(order.order_number, 'pending');
await sendMail({ await sendMail({
to: email, to: email,
subject: `Anfragebestätigung ${order.order_number}`, subject: mailSubject,
text: emailTemplate.text, text: emailTemplate.text,
html: emailTemplate.html, html: emailTemplate.html,
attachments: [ attachments: [

View File

@@ -7,7 +7,7 @@ import { createHash } from 'crypto';
import { renderToBuffer } from '@react-pdf/renderer'; import { renderToBuffer } from '@react-pdf/renderer';
import React from 'react'; import React from 'react';
import { InvoicePDF } from '@/components/invoice-pdf'; import { InvoicePDF } from '@/components/invoice-pdf';
import { getOrderEmailTemplate, buildEmailItemsSection } from '@/lib/actions/email-templates'; import { generateOrderEmailHtml, generateOrderEmailSubject } from '@/lib/actions/email-templates';
import { validateWizardSelections } from '@/lib/actions/validation'; import { validateWizardSelections } from '@/lib/actions/validation';
@@ -293,20 +293,18 @@ export async function POST(request: Request) {
`; `;
} }
const itemsSection = buildEmailItemsSection(items); const emailTemplate = generateOrderEmailHtml({
const { data: brandData } = await supabase.from('settings').select('primary_color').eq('id', 'branding').maybeSingle();
const primaryColor = brandData?.primary_color || '#2563eb';
const emailTemplate = getOrderEmailTemplate({
orderNumber: order.order_number, orderNumber: order.order_number,
status: 'pending',
formattedDate, formattedDate,
customerCompanyName: customerSnapshot.company_name, customerCompanyName: customerSnapshot.company_name,
totalDetailsText, items,
totalDetailsHtml, taxRate,
itemsDetailsText: itemsSection.text, oneTimeNet,
itemsDetailsHtml: itemsSection.html monthlyNet,
}, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, false, primaryColor); });
const mailSubject = generateOrderEmailSubject(order.order_number, 'pending');
const { data: bufferData, error: downloadError } = await supabase const { data: bufferData, error: downloadError } = await supabase
.storage .storage
@@ -325,7 +323,7 @@ export async function POST(request: Request) {
await sendMail({ await sendMail({
to: user.email, to: user.email,
subject: `Anfragebestätigung ${order.order_number}`, subject: mailSubject,
text: emailTemplate.text, text: emailTemplate.text,
html: emailTemplate.html, html: emailTemplate.html,
attachments attachments

View File

@@ -1,195 +1,429 @@
interface EmailDetails { export interface OrderEmailProps {
orderNumber: string orderNumber: string
status: 'pending' | 'pending_approval' | 'in_review' | 'approved' | 'active' | 'completed' | 'cancelled' | 'rejected'
formattedDate: string formattedDate: string
customerCompanyName: string customerCompanyName: string
totalDetailsText: string billingModel?: string // e.g. "Kauf" | "Miete (monatlich)" | "Kauf / Miete"
totalDetailsHtml: string rejectionReason?: string
itemsDetailsText?: string siteUrl?: string
itemsDetailsHtml?: string items?: any[]
partnerCompanyName?: string partnerCompanyName?: string
partnerUserName?: string partnerUserName?: string
partnerUserEmail?: string partnerUserEmail?: string
taxRate?: number
oneTimeNet?: number
monthlyNet?: number
} }
export function getOrderEmailTemplate( export function generateOrderEmailSubject(orderNumber: string, status: string): string {
details: EmailDetails, const formattedOrderNumber = (orderNumber || '').replace(/^BE-/, 'AE-')
siteUrl: string, if (status === 'approved' || status === 'active' || status === 'completed') {
isUpdate: boolean = false, return `Auftragsbestätigung: Anfrage #${formattedOrderNumber} freigegeben`
primaryColor: string = '#2563eb' }
) { if (status === 'rejected' || status === 'cancelled') {
const title = isUpdate ? 'Anfrage geändert' : 'Anfrage erhalten' return `Status-Update: Anfrage #${formattedOrderNumber} abgelehnt`
const intro = isUpdate }
? `Deine Anfrage ${details.orderNumber} wurde aktualisiert.` return `Eingangsbestätigung: Anfrage #${formattedOrderNumber} eingegangen`
: `Deine Anfrage ${details.orderNumber} ist bei uns eingegangen und wird bearbeitet.` }
let partnerText = '' export function generateOrderEmailHtml(props: OrderEmailProps): { text: string; html: string } {
let partnerHtml = '' const formattedOrderNumber = (props.orderNumber || '').replace(/^BE-/, 'AE-')
if (details.partnerCompanyName || details.partnerUserName || details.partnerUserEmail) { const siteUrl = props.siteUrl || process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'
partnerText = `\nPartner-Informationen:` const taxRate = props.taxRate ?? 19
if (details.partnerCompanyName) partnerText += `\n- Firma: ${details.partnerCompanyName}` const orderPortalUrl = `${siteUrl}/my-orders`
if (details.partnerUserName) partnerText += `\n- Benutzer: ${details.partnerUserName}`
if (details.partnerUserEmail) partnerText += `\n- E-Mail: ${details.partnerUserEmail}`
partnerText += `\n`
partnerHtml = ` // Status Callout configuration
<tr> let calloutBg = '#f8fafc'
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Partner-Informationen:</td> let calloutBorder = '#cbd5e1'
</tr> 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.'
if (details.partnerCompanyName) { let statusBadgeColor = '#2563eb'
partnerHtml += `
<tr> if (props.status === 'approved' || props.status === 'active' || props.status === 'completed') {
<td style="padding: 4px 0; padding-left: 10px; color: #475569; font-weight: bold;">Firma:</td> calloutBg = '#f0fdf4'
<td style="padding: 4px 0; color: #475569;">${details.partnerCompanyName}</td> calloutBorder = '#86efac'
</tr> calloutTitle = 'Status: Freigegeben / Aktiv'
` calloutText = 'Ihre Auftragsbestätigung / Rechnung finden Sie als PDF im Anhang dieser E-Mail.'
} statusBadgeColor = '#16a34a'
if (details.partnerUserName) { } else if (props.status === 'rejected' || props.status === 'cancelled') {
partnerHtml += ` calloutBg = '#fef2f2'
<tr> calloutBorder = '#fca5a5'
<td style="padding: 4px 0; padding-left: 10px; color: #475569; font-weight: bold;">Benutzer:</td> calloutTitle = 'Status: Anfrage abgelehnt'
<td style="padding: 4px 0; color: #475569;">${details.partnerUserName}</td> calloutText = props.rejectionReason
</tr> ? `Begründung: "${props.rejectionReason}"`
` : 'Ihre Anfrage wurde vom Support geprüft und konnte leider nicht freigegeben werden.'
} statusBadgeColor = '#dc2626'
if (details.partnerUserEmail) { }
partnerHtml += `
<tr> // Calculate prices if items provided
<td style="padding: 4px 0; padding-left: 10px; color: #475569; font-weight: bold;">E-Mail:</td> let oneTimeNet = props.oneTimeNet ?? 0
<td style="padding: 4px 0; color: #475569;">${details.partnerUserEmail}</td> let monthlyNet = props.monthlyNet ?? 0
</tr> 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)'
} }
} }
const text = `Hallo,\n\n${isUpdate ? 'Deine Anfrage wurde aktualisiert.' : 'Deine Anfrage ist bei uns eingegangen.'}\n\nDetails:\n- Nummer: ${details.orderNumber}\n- Datum: ${details.formattedDate}\n- Kunde: ${details.customerCompanyName}\n${partnerText}${details.itemsDetailsText || ''}\n${details.totalDetailsText}\n\nDeine Anfragebestätigung findest du im Anhang als PDF.\n\nPDF-Link: ${siteUrl}/api/orders/${details.orderNumber}/download\n\nViele Grüße,\nDein CASPOS Team` // 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)
})
const html = ` // Build Text Breakdown
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;"> let itemsBreakdownText = ''
<h2 style="color: ${primaryColor}; margin-bottom: 16px;">${title}</h2> if (items.length > 0) {
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Hallo,</p> itemsBreakdownText += '\nKassen-Aufstellung:\n'
<p style="color: #475569; font-size: 16px; line-height: 1.5;">${intro}</p> Object.entries(groupedItems).forEach(([devName, devItems]) => {
<div style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 24px 0;"> itemsBreakdownText += `\n[ ${devName} ]\n`
<h3 style="color: #0f172a; margin-top: 0; margin-bottom: 12px;">Details</h3> devItems.forEach((item: any) => {
<table style="width: 100%; border-collapse: collapse; font-size: 14px; color: #475569;"> 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> <tr>
<td style="padding: 4px 0; font-weight: bold; width: 140px;">Nummer:</td> <td style="padding: 8px 14px; font-size: 13px; color: #334155; font-weight: 500; border-bottom: 1px solid #f1f5f9;">
<td style="padding: 4px 0;">${details.orderNumber}</td> ${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>
<tr> <tr>
<td style="padding: 4px 0; font-weight: bold;">Datum:</td> <td style="padding: 2px 0; color: #64748b;">Netto-Zwischensumme:</td>
<td style="padding: 4px 0;">${details.formattedDate}</td> <td style="padding: 2px 0; text-align: right; font-weight: 500; color: #0f172a;">${formatEuro(oneTimeNet)}</td>
</tr> </tr>
<tr> <tr>
<td style="padding: 4px 0; font-weight: bold;">Kunde:</td> <td style="padding: 2px 0; color: #64748b;">zzgl. ${taxRate}% MwSt:</td>
<td style="padding: 4px 0;">${details.customerCompanyName}</td> <td style="padding: 2px 0; text-align: right; color: #64748b;">${formatEuro(oneTimeTax)}</td>
</tr> </tr>
${partnerHtml} <tr>
${details.itemsDetailsHtml || ''} <td style="padding: 4px 0 10px 0; font-weight: 600; color: #0f172a;">Gesamt einmalig (brutto):</td>
${details.totalDetailsHtml} <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> </table>
</div> </td>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Die Bestätigung liegt als PDF im Anhang.</p> </tr>
<p style="color: #475569; font-size: 16px; line-height: 1.5;"> </table>
<a href="${siteUrl}/api/orders/${details.orderNumber}/download" style="color: ${primaryColor}; text-decoration: underline; font-weight: 500;">Anfragebestätigung PDF herunterladen</a> </body>
</p> </html>`
<p style="color: #475569; font-size: 16px; line-height: 1.5; margin-top: 24px;">Viele Grüße,<br>Dein CASPOS Team</p>
</div>
`
return { text, 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( export function getStatusEmailTemplate(
orderNumber: string, orderNumber: string,
oldLabel: string, _oldLabel: string,
newLabel: string, _newLabel: string,
statusKey?: string, statusKey?: string,
rejectionReason?: string rejectionReason?: string,
) { extraDetails?: {
const formattedOrderNumber = orderNumber.replace(/^BE-/, 'AE-') customerCompanyName?: string
formattedDate?: string
let title = 'Statusänderung' billingModel?: string
let intro = `der Status deiner Anfrage <strong>#${formattedOrderNumber}</strong> wurde aktualisiert.` items?: any[]
let statusColor = '#f59e0b' // Amber default partnerCompanyName?: string
let showAttachmentBadge = false partnerUserName?: string
partnerUserEmail?: string
if (statusKey === 'approved' || statusKey === 'active' || statusKey === 'completed') { taxRate?: number
title = 'Freigabebestätigung' oneTimeNet?: number
intro = `Ihre Anfrage <strong>#${formattedOrderNumber}</strong> wurde erfolgreich freigegeben und ist nun aktiv. Die unterzeichnete Anfragebestätigung finden Sie im Anhang dieses Schreibens.` monthlyNet?: number
statusColor = '#10b981' // Emerald
showAttachmentBadge = true
} else if (statusKey === 'rejected') {
title = 'Anfrage abgelehnt'
intro = `Ihre Anfrage <strong>#${formattedOrderNumber}</strong> wurde vom Support geprüft und abgelehnt.`
statusColor = '#ef4444' // Red
} else if (statusKey === 'pending_approval' || statusKey === 'in_review' || statusKey === 'pending') {
title = 'Eingangsbestätigung'
intro = `Ihre Anfrage <strong>#${formattedOrderNumber}</strong> ist eingegangen und wird derzeit von unserem Support geprüft.`
statusColor = '#f59e0b'
} }
) {
const text = `Hallo,\n\n${title}\n\n${intro.replace(/<[^>]*>/g, '')}\n\nStatus: ${newLabel} (vorher: ${oldLabel})\n${rejectionReason ? `Grund: ${rejectionReason}\n` : ''}\nViele Grüße,\nDein CASPOS Team` const status = (statusKey || 'pending') as any
return generateOrderEmailHtml({
const reasonSection = rejectionReason orderNumber,
? ` status,
<div style="margin-top: 20px; padding: 12px 16px; background-color: rgba(239, 68, 68, 0.1); border-left: 4px solid #ef4444; border-radius: 4px;"> formattedDate: extraDetails?.formattedDate || new Date().toLocaleDateString('de-DE'),
<p style="margin: 0; font-size: 11px; color: #ef4444; font-weight: bold; text-transform: uppercase; tracking-spacing: 0.05em;">Begründung:</p> customerCompanyName: extraDetails?.customerCompanyName || 'Endkunde',
<p style="margin: 4px 0 0 0; font-size: 14px; color: #f8fafc; font-style: italic;">"${rejectionReason}"</p> billingModel: extraDetails?.billingModel,
</div> rejectionReason,
` items: extraDetails?.items,
: '' partnerCompanyName: extraDetails?.partnerCompanyName,
partnerUserName: extraDetails?.partnerUserName,
const footerActions = showAttachmentBadge partnerUserEmail: extraDetails?.partnerUserEmail,
? ` taxRate: extraDetails?.taxRate,
<p style="text-align: center; margin: 32px 0 0 0;"> oneTimeNet: extraDetails?.oneTimeNet,
<span style="display: inline-block; background-color: #1e293b; border: 1px solid #334155; border-radius: 6px; padding: 10px 20px; color: #3b82f6; font-size: 13px; font-weight: bold; letter-spacing: 0.025em;"> monthlyNet: extraDetails?.monthlyNet,
PDF im Anhang verfügbar })
</span>
</p>
`
: ''
const html = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 32px; border: 1px solid #1e293b; border-radius: 12px; background-color: #0b0f19; color: #f8fafc;">
<div style="text-align: center; margin-bottom: 32px;">
<h1 style="color: #3b82f6; font-size: 26px; margin: 0; font-weight: 900; letter-spacing: -0.025em; text-transform: uppercase;">CASPOS</h1>
<p style="color: #64748b; font-size: 10px; margin: 2px 0 0 0; text-transform: uppercase; letter-spacing: 0.2em; font-weight: bold;">Die Kasse</p>
</div>
<div style="background-color: #111827; border: 1px solid #1e293b; border-radius: 8px; padding: 24px; margin-bottom: 24px;">
<h2 style="color: #ffffff; font-size: 18px; margin-top: 0; margin-bottom: 12px; font-weight: 700;">${title}</h2>
<p style="color: #cbd5e1; font-size: 14px; line-height: 1.6; margin-top: 0;">Hallo,</p>
<p style="color: #cbd5e1; font-size: 14px; line-height: 1.6;">${intro}</p>
<table style="width: 100%; border-collapse: collapse; font-size: 13px; color: #cbd5e1; margin-top: 20px;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #1e293b; color: #64748b; width: 140px; font-weight: 600; text-transform: uppercase; font-size: 11px;">Anfragenummer:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #1e293b; font-family: monospace; font-weight: bold; color: #3b82f6; font-size: 14px;">#${formattedOrderNumber}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #1e293b; color: #64748b; font-weight: 600; text-transform: uppercase; font-size: 11px;">Vorher:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #1e293b; text-decoration: line-through; color: #475569;">${oldLabel}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #1e293b; color: #64748b; font-weight: 600; text-transform: uppercase; font-size: 11px;">Aktuell:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #1e293b; font-weight: bold; color: ${statusColor}; font-size: 14px;">${newLabel}</td>
</tr>
</table>
${reasonSection}
</div>
${footerActions}
<div style="text-align: center; margin-top: 32px; border-top: 1px solid #1e293b; padding-top: 20px; color: #475569; font-size: 11px; line-height: 1.5;">
<p style="margin: 0; font-weight: 600;">CASPOS Computerabrechnungssysteme GmbH · Alte Bundesstraße 16 · 76846 Hauenstein</p>
<p style="margin: 4px 0 0 0;">Dies ist eine automatisch generierte Systembenachrichtigung.</p>
</div>
</div>
`
return { text, html }
} }
export function buildEmailItemsSection(items: any[]) { export function buildEmailItemsSection(items: any[]) {
@@ -218,14 +452,14 @@ export function buildEmailItemsSection(items: any[]) {
` `
devItems.forEach((item: any) => { devItems.forEach((item: any) => {
itemsDetailsText += `\n * ${item.product_name} (${item.category_name}): ${item.base_price.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} ${item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}` 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 += ` itemsDetailsHtml += `
<tr> <tr>
<td style="padding: 6px 8px; font-size: 13px; color: #334155; font-weight: 500;"> <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> ${item.product_name} <span style="font-size: 11px; color: #64748b;">(${item.category_name})</span>
</td> </td>
<td style="padding: 6px 8px; text-align: right; font-size: 13px; font-weight: bold; color: #0f172a;"> <td style="padding: 6px 8px; text-align: right; font-size: 13px; font-weight: bold; color: #0f172a;">
${item.base_price.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} ${item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'} ${Number(item.base_price || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} ${item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}
</td> </td>
</tr> </tr>
` `
@@ -233,14 +467,14 @@ export function buildEmailItemsSection(items: any[]) {
item.selected_modules?.forEach((mod: any) => { item.selected_modules?.forEach((mod: any) => {
const qty = mod.quantity || 1 const qty = mod.quantity || 1
const price = mod.total_price ?? (mod.price * qty) const price = mod.total_price ?? (mod.price * qty)
itemsDetailsText += `\n + ${mod.module_name} ${qty > 1 ? `(x${qty})` : ''}: +${price.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} mtl.` itemsDetailsText += `\n + ${mod.module_name} ${qty > 1 ? `(x${qty})` : ''}: +${Number(price || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} mtl.`
itemsDetailsHtml += ` itemsDetailsHtml += `
<tr> <tr>
<td style="padding: 4px 8px 4px 20px; color: #64748b; font-size: 12px;"> <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>` : ''} + ${mod.module_name} ${qty > 1 ? `<span style="font-size: 10px; font-weight: 600;">(x${qty})</span>` : ''}
</td> </td>
<td style="padding: 4px 8px; text-align: right; color: #64748b; font-size: 12px;"> <td style="padding: 4px 8px; text-align: right; color: #64748b; font-size: 12px;">
+${price.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} mtl. +${Number(price || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} mtl.
</td> </td>
</tr> </tr>
` `
@@ -253,4 +487,3 @@ export function buildEmailItemsSection(items: any[]) {
html: itemsDetailsHtml html: itemsDetailsHtml
} }
} }

View File

@@ -12,7 +12,13 @@ import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transfo
import type { Category, EndCustomer, Order, Product, Profile, WizardSelections } from '@/lib/types' import type { Category, EndCustomer, Order, Product, Profile, WizardSelections } from '@/lib/types'
import { getProducts, getCategories } from '@/lib/actions/products' import { getProducts, getCategories } from '@/lib/actions/products'
import { validateWizardSelections } from '@/lib/actions/validation' import { validateWizardSelections } from '@/lib/actions/validation'
import { getOrderEmailTemplate, getStatusEmailTemplate, buildEmailItemsSection } from '@/lib/actions/email-templates' import {
generateOrderEmailHtml,
generateOrderEmailSubject,
getOrderEmailTemplate,
getStatusEmailTemplate,
buildEmailItemsSection,
} from '@/lib/actions/email-templates'
// ─── Hilfsfunktionen ───────────────────────────────────────────────────────── // ─── Hilfsfunktionen ─────────────────────────────────────────────────────────
@@ -301,24 +307,25 @@ export async function submitOrder(params: {
` `
} }
const itemsSection = buildEmailItemsSection(items) const emailTemplate = generateOrderEmailHtml({
const emailTemplate = getOrderEmailTemplate({
orderNumber, orderNumber,
status: 'pending',
formattedDate, formattedDate,
customerCompanyName: customerSnapshot.company_name, customerCompanyName: customerSnapshot.company_name,
totalDetailsText, items,
totalDetailsHtml,
itemsDetailsText: itemsSection.text,
itemsDetailsHtml: itemsSection.html,
partnerCompanyName, partnerCompanyName,
partnerUserName, partnerUserName,
partnerUserEmail partnerUserEmail,
}, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, false) taxRate,
oneTimeNet,
monthlyNet,
})
const mailSubject = generateOrderEmailSubject(orderNumber, 'pending')
await sendMail({ await sendMail({
to: user.email, to: user.email,
subject: `Anfragebestätigung ${orderNumber}`, subject: mailSubject,
text: emailTemplate.text, text: emailTemplate.text,
html: emailTemplate.html, html: emailTemplate.html,
attachments: [ attachments: [
@@ -343,7 +350,7 @@ export async function submitOrder(params: {
if (adminEmail !== user.email) { if (adminEmail !== user.email) {
await sendMail({ await sendMail({
to: adminEmail, to: adminEmail,
subject: `[Admin-Kopie] Anfragebestätigung ${orderNumber}`, subject: `[Admin-Kopie] ${mailSubject}`,
text: emailTemplate.text, text: emailTemplate.text,
html: emailTemplate.html, html: emailTemplate.html,
attachments: [ attachments: [
@@ -455,24 +462,27 @@ export async function updateOrderStatus(
if (!userError && user && user.email) { if (!userError && user && user.email) {
const orderNumber = order.order_number || order.id.slice(0, 8) const orderNumber = order.order_number || order.id.slice(0, 8)
const statusLabelMap: Record<string, string> = { const customerSnapshot = updatedOrder.customer_data || {}
pending: 'Eingegangen', const orderSnapshot = updatedOrder.order_data || {}
pending_approval: 'Wartet auf Freigabe', const formattedDate = new Date(updatedOrder.created_at || Date.now()).toLocaleDateString('de-DE')
in_review: 'In Prüfung',
approved: 'Freigegeben',
active: 'Aktiviert',
completed: 'Abgeschlossen',
cancelled: 'Storniert',
rejected: 'Abgelehnt',
}
const oldLabel = statusLabelMap[oldStatus] || oldStatus
const newLabel = statusLabelMap[newStatus] || newStatus
const statusEmail = getStatusEmailTemplate(orderNumber, oldLabel, newLabel, newStatus) const statusEmail = generateOrderEmailHtml({
orderNumber,
status: newStatus,
formattedDate,
customerCompanyName: customerSnapshot.company_name || 'Endkunde',
items: orderSnapshot.items || [],
partnerCompanyName: partnerInfo.partnerCompanyName,
partnerUserName: partnerInfo.partnerUserName,
partnerUserEmail: partnerInfo.partnerUserEmail,
taxRate: orderSnapshot.tax_rate ?? 19,
})
const mailSubject = generateOrderEmailSubject(orderNumber, newStatus)
const mailOptions: any = { const mailOptions: any = {
to: user.email, to: user.email,
subject: `Statusänderung Ihrer Anfrage ${orderNumber}`, subject: mailSubject,
text: statusEmail.text, text: statusEmail.text,
html: statusEmail.html, html: statusEmail.html,
} }
@@ -504,7 +514,7 @@ export async function updateOrderStatus(
await sendMail({ await sendMail({
...mailOptions, ...mailOptions,
to: adminEmail, to: adminEmail,
subject: `[Admin-Kopie] Statusänderung Ihrer Anfrage ${orderNumber}` subject: `[Admin-Kopie] ${mailSubject}`
}) })
} }
} }
@@ -642,11 +652,29 @@ export async function rejectOrder(
const { data: { user: orderUser }, error: userError } = await admin.auth.admin.getUserById(order.user_id) const { data: { user: orderUser }, error: userError } = await admin.auth.admin.getUserById(order.user_id)
if (!userError && orderUser && orderUser.email) { if (!userError && orderUser && orderUser.email) {
const orderNumber = order.order_number || order.id.slice(0, 8) const orderNumber = order.order_number || order.id.slice(0, 8)
const statusEmail = getStatusEmailTemplate(orderNumber, 'Wartet auf Freigabe', 'Abgelehnt', 'rejected', reason) const partnerInfo = await fetchOrderPartnerInfo(admin, updatedOrder.company_id, updatedOrder.user_id)
const customerSnapshot = updatedOrder.customer_data || {}
const orderSnapshot = updatedOrder.order_data || {}
const formattedDate = new Date(updatedOrder.created_at || Date.now()).toLocaleDateString('de-DE')
const statusEmail = generateOrderEmailHtml({
orderNumber,
status: 'rejected',
formattedDate,
customerCompanyName: customerSnapshot.company_name || 'Endkunde',
rejectionReason: reason,
items: orderSnapshot.items || [],
partnerCompanyName: partnerInfo.partnerCompanyName,
partnerUserName: partnerInfo.partnerUserName,
partnerUserEmail: partnerInfo.partnerUserEmail,
taxRate: orderSnapshot.tax_rate ?? 19,
})
const mailSubject = generateOrderEmailSubject(orderNumber, 'rejected')
await sendMail({ await sendMail({
to: orderUser.email, to: orderUser.email,
subject: `Anfrage abgelehnt: ${orderNumber}`, subject: mailSubject,
text: statusEmail.text, text: statusEmail.text,
html: statusEmail.html html: statusEmail.html
}) })
@@ -664,7 +692,7 @@ export async function rejectOrder(
if (adminEmail !== orderUser.email) { if (adminEmail !== orderUser.email) {
await sendMail({ await sendMail({
to: adminEmail, to: adminEmail,
subject: `[Admin-Kopie] Anfrage abgelehnt: ${orderNumber}`, subject: `[Admin-Kopie] ${mailSubject}`,
text: statusEmail.text, text: statusEmail.text,
html: statusEmail.html html: statusEmail.html
}) })
@@ -672,7 +700,7 @@ export async function rejectOrder(
} }
} }
} catch (adminMailError) { } catch (adminMailError) {
console.error('Failed to notify admins of order rejection:', adminMailError) console.error('Failed to notify admins of rejected order:', adminMailError)
} }
} }
} catch (mailError) { } catch (mailError) {