mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +00:00
202 lines
7.1 KiB
TypeScript
202 lines
7.1 KiB
TypeScript
/**
|
||
* 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 };
|
||
}
|