Kuma/src/lib/mail/templates/booking-confirmation.ts
2026-10-02 14:30:54 +02:00

202 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* E-Mail Template für Buchungs-Bestätigung
*
* Dieses Template wird an den Kunden gesendet, nachdem eine Kurs-Buchung
* erfolgreich bezahlt wurde (Stripe Webhook checkout.session.completed).
*/
import { escapeHtml, sanitizeUrl, singleLine } from "./utils";
import { renderButton, renderDivider, renderInfoBox, renderPriceRow } from "./components";
import { renderEmailLayout } from "./layout";
import { KUMASY_BRANDING, type EmailBranding } from "./tokens";
interface BookingConfirmationEmailParams {
courseName: string;
courseDate: Date;
timeFrom: string;
timeTo: string;
businessName: string;
businessAddress?: string;
amountPaidCents: number;
/** Anzahl gebuchter Plätze - mehr als 1 bei einer Buchung mit Begleitpersonen. */
seats?: number;
/** Gebuchte Plätze bei Kursen mit Platzwahl, z.B. ["Reformer 3", "Reformer 4"]. */
spotLabels?: string[];
/** true = mindestens ein Wunschplatz war bei Zahlungseingang vergeben und wurde ersetzt. */
spotChanged?: boolean;
/** Mit einer Karte gebucht: statt eines Betrags stehen die abgebuchten Einheiten in der Mail. */
paidWithPass?: { passName: string; creditsUsed: number; creditsRemaining: number };
myCoursesUrl: string;
coverImageUrl?: string | null;
companyName?: string;
branding?: EmailBranding;
}
function formatDate(date: Date): string {
return date.toLocaleDateString("de-DE", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
timeZone: "Europe/Berlin",
});
}
function formatPrice(cents: number): string {
return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(cents / 100);
}
/**
* Generiert HTML für die Buchungs-Bestätigung
*/
export function generateBookingConfirmationEmail({
courseName,
courseDate,
timeFrom,
timeTo,
businessName,
businessAddress,
amountPaidCents,
seats = 1,
spotLabels = [],
spotChanged = false,
paidWithPass,
myCoursesUrl,
coverImageUrl,
companyName = "Ihr Unternehmen",
branding = KUMASY_BRANDING,
}: BookingConfirmationEmailParams): { subject: string; html: string; text: string } {
// Defense gegen Markup-Injection: alle dynamischen Werte vor dem HTML-Einsetzen escapen.
const safeCourseName = escapeHtml(courseName);
const safeBusinessName = escapeHtml(businessName);
const safeBusinessAddress = businessAddress ? escapeHtml(businessAddress) : "";
const safeTimeFrom = escapeHtml(timeFrom);
const safeTimeTo = escapeHtml(timeTo);
const spotList = spotLabels.join(", ");
const safeSpotList = escapeHtml(spotList);
const spotTitle = spotLabels.length === 1 ? "Ihr Platz" : "Ihre Plätze";
const spotChangedNote = spotChanged
? spotLabels.length === 1
? "Ihr Wunschplatz war bei Zahlungseingang leider schon vergeben – wir haben Ihnen diesen Platz reserviert."
: "Ein Wunschplatz war bei Zahlungseingang leider schon vergeben – wir haben Ihnen stattdessen einen anderen freien Platz reserviert."
: "";
const safeMyCoursesUrl = sanitizeUrl(myCoursesUrl);
const safeCoverImageUrl = coverImageUrl ? sanitizeUrl(coverImageUrl) : null;
const subject = singleLine(`Buchungsbestätigung – ${courseName}`);
const formattedDate = formatDate(courseDate);
const formattedPrice = formatPrice(amountPaidCents);
const units = (n: number) => `${n} ${n === 1 ? "Einheit" : "Einheiten"}`;
const paymentIntro = paidWithPass
? `Wir haben ${units(paidWithPass.creditsUsed)} von Ihrer Karte „${paidWithPass.passName}“ abgebucht.`
: "Ihre Zahlung wurde erfolgreich verarbeitet.";
const paymentRowHtml = paidWithPass
? renderPriceRow({
label: `Von Ihrer Karte (noch ${units(paidWithPass.creditsRemaining)} frei)`,
amountFormatted: units(paidWithPass.creditsUsed),
})
: renderPriceRow({ label: "Bezahlt", amountFormatted: formattedPrice });
const paymentRowText = paidWithPass
? `Von Ihrer Karte: ${units(paidWithPass.creditsUsed)} (noch ${units(paidWithPass.creditsRemaining)} frei)`
: `Bezahlt: ${formattedPrice}`;
const bannerHtml = safeCoverImageUrl
? `
<tr>
<td style="padding: 0; line-height: 0;">
<img src="${safeCoverImageUrl}" alt="${safeCourseName}" width="600" style="display: block; width: 100%; max-width: 600px; height: auto; object-fit: cover;" />
</td>
</tr>`
: "";
const bodyHtml = `
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Hallo,
</p>
<p style="margin: 0 0 30px; color: #333333; font-size: 16px; line-height: 1.6;">
vielen Dank für Ihre Buchung. ${escapeHtml(paymentIntro)}
Hier sind die Details Ihres Kurses:
</p>
${renderInfoBox({
rows: [
{
label: "Kurs",
valueHtml: `<span style="font-size: 18px; font-weight: 600;">${safeCourseName}</span>`,
},
{ label: "Anbieter", valueHtml: safeBusinessName },
{ label: "Termin", valueHtml: `${formattedDate}<br />${safeTimeFrom} – ${safeTimeTo} Uhr` },
...(safeBusinessAddress ? [{ label: "Ort", valueHtml: safeBusinessAddress }] : []),
...(seats > 1 ? [{ label: "Plätze", valueHtml: `${seats} Plätze` }] : []),
...(safeSpotList
? [
{
label: spotTitle,
valueHtml: `<strong>${safeSpotList}</strong>${
spotChangedNote
? `<br /><span style="color: #666666; font-size: 13px;">${spotChangedNote}</span>`
: ""
}`,
},
]
: []),
],
})}
${renderDivider(paymentRowHtml)}
<p style="margin: 30px 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Wir wünschen Ihnen viel Spaß im Kurs!
</p>
${renderButton({ label: "Zu meinen Kursen", href: myCoursesUrl, branding })}
<p style="margin: 20px 0 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
Oder kopieren Sie diesen Link in Ihren Browser:
</p>
<p style="margin: 8px 0 0; color: #1E293B; font-size: 14px; word-break: break-all; text-align: center;">
${safeMyCoursesUrl}
</p>
${renderDivider(`
<p style="margin: 0; color: #999999; font-size: 13px; line-height: 1.6;">
Bei Fragen zu Ihrer Buchung wenden Sie sich bitte direkt an den Anbieter <strong>${safeBusinessName}</strong>.
</p>
`)}
`;
const html = renderEmailLayout({
title: "Buchungsbestätigung",
headerHeading: "Buchung bestätigt ✓",
bannerHtml,
bodyHtml,
branding,
});
const text = `
Buchung bestätigt – ${courseName}
Hallo,
vielen Dank für Ihre Buchung. ${paymentIntro}
Kurs: ${courseName}
Anbieter: ${businessName}
Termin: ${formattedDate}, ${timeFrom} – ${timeTo} Uhr
${businessAddress ? `Ort: ${businessAddress}\n` : ""}${seats > 1 ? `Plätze: ${seats}\n` : ""}${spotList ? `${spotTitle}: ${spotList}\n` : ""}${spotChangedNote ? `${spotChangedNote}\n` : ""}${paymentRowText}
Wir wünschen Ihnen viel Spaß im Kurs!
Zu meinen Kursen: ${safeMyCoursesUrl}
Bei Fragen zu Ihrer Buchung wenden Sie sich bitte direkt an den Anbieter ${businessName}.
${companyName}
Diese E-Mail wurde automatisch generiert.
Powered by Kumasy
`;
return { subject, html, text };
}