Files
Artista-Filmcamp/src/lib/email-templates.ts
T
Nick SzaunigandClaude Opus 5.5 7745d5862d
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 4m31s
feat: Kontaktformular für ein Erstgespräch
Neuer Abschnitt nach den Fragen, im Menü als Erstgespräch. Anfrage geht als E-Mail an die Veranstalter-Adresse (Admin) bzw. ADMIN_NOTIFY_EMAIL, sofort zugestellt, sonst über den Worker wiederholt. Gleicher Schutz wie die Buchung: nur JSON von der eigenen Seite, Rate Limit (5/h je IP), Prüfung auf dem Server, Honeypot gegen Bots, doppeltes Absenden ergibt eine Mail. Keine automatische Mail an die angegebene Adresse (sonst Missbrauch für Spam an Dritte).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 11:37:27 +02:00

184 lines
8.8 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.
/**
* Texte der Buchungs-E-Mails. Jede Mail nennt nur den tatsaechlich
* bestaetigten Status — eine Teilzahlung heisst nie "vollstaendig bezahlt".
*/
import type { Booking, Installment } from "@/generated/prisma/client";
import type { ContactRequest } from "./contact";
import { escapeHtml, type EmailDraft } from "./email";
import { appUrl } from "./env";
import { formatEuro } from "./money";
import { PLAN_LABEL } from "./payment-plan";
import { PHASE_LABEL } from "./pricing";
import { formatDate, formatDateTime } from "./time";
type BookingWithInstallments = Booking & { installments: Installment[] };
const BRAND = "ARTISTA MODELCAMP 2027";
function statusLink(token: string): string {
return appUrl(`/buchung/status/${token}`);
}
function planLines(booking: BookingWithInstallments): string[] {
return booking.installments
.slice()
.sort((a, b) => a.seq - b.seq)
.map((i) => {
const due = i.dueAt ? `fällig am ${formatDate(i.dueAt)}` : "bei Buchung";
const state =
i.status === "PAID" ? "bezahlt" : i.status === "CANCELED" ? "storniert" : i.status === "PROCESSING" ? "in Bearbeitung" : "offen";
return `Rate ${i.seq}: ${formatEuro(i.amountCents)}, ${due}, ${state}`;
});
}
function wrap(subject: string, paragraphs: string[], link?: { href: string; label: string }): { text: string; html: string } {
const text = [...paragraphs, ...(link ? [`${link.label}: ${link.href}`] : []), "", `${BRAND}`].join("\n\n");
const html = `<!doctype html><html lang="de"><body style="font-family:Georgia,serif;color:#1f1d1b;background:#f6f1ea;padding:24px">
<div style="max-width:560px;margin:0 auto;background:#fffdf9;padding:32px;border:1px solid #e6ddd0">
<p style="letter-spacing:.2em;font-size:12px;text-transform:uppercase;color:#8a5a3b">${BRAND}</p>
<h1 style="font-weight:normal;font-size:24px">${escapeHtml(subject)}</h1>
${paragraphs.map((p) => `<p style="font-family:Arial,sans-serif;font-size:15px;line-height:1.6;white-space:pre-line">${escapeHtml(p)}</p>`).join("\n")}
${link ? `<p><a href="${escapeHtml(link.href)}" style="display:inline-block;background:#1f1d1b;color:#fffdf9;padding:12px 20px;text-decoration:none;font-family:Arial,sans-serif">${escapeHtml(link.label)}</a></p>` : ""}
</div></body></html>`;
return { text, html };
}
function draft(key: string, booking: Booking, subject: string, body: { text: string; html: string }): EmailDraft {
return { dedupeKey: key, to: booking.email, subject: `${subject} | ${BRAND}`, bookingId: booking.id, ...body };
}
export function bookingConfirmedEmail(booking: BookingWithInstallments, token: string): EmailDraft {
const fullyPaid = booking.paidCents >= booking.totalCents;
const subject = fullyPaid ? "Deine Buchung ist bestätigt und bezahlt" : "Deine Buchung ist bestätigt, erste Rate erhalten";
const lines = [
`Hallo ${booking.firstName},`,
`vielen Dank! Deine Buchung ${booking.reference} ist bestätigt.`,
`Gesamtpreis: ${formatEuro(booking.totalCents)} (${PHASE_LABEL[booking.pricePhase]}${booking.voucherDiscountCents > 0 ? ", inkl. Gutschein" : ""})\nZahlungsweise: ${PLAN_LABEL[booking.planType]}\nBezahlt: ${formatEuro(booking.paidCents)}\nOffen: ${formatEuro(Math.max(0, booking.totalCents - booking.paidCents))}`,
planLines(booking).join("\n"),
];
if (!fullyPaid) {
lines.push("Die weiteren Raten werden zu den genannten Terminen über die hinterlegte Zahlungsmethode eingezogen. Du erhältst jeweils eine Bestätigung.");
}
lines.push("Alle Details und den aktuellen Zahlungsstand findest du jederzeit auf deiner Buchungsseite.");
return draft(`booking-confirmed:${booking.id}`, booking, subject, wrap(subject, lines, { href: statusLink(token), label: "Buchung ansehen" }));
}
export function paymentProcessingEmail(booking: Booking, token: string): EmailDraft {
const subject = "Deine Zahlung wird verarbeitet";
return draft(
`payment-processing:${booking.id}`,
booking,
subject,
wrap(
subject,
[
`Hallo ${booking.firstName},`,
`wir haben deine Buchung ${booking.reference} erhalten. Deine Zahlungsart braucht einige Tage, bis die Zahlung bestätigt ist. Dein Platz bleibt so lange reserviert.`,
"Sobald die Zahlung eingegangen ist, bekommst du eine Bestätigung. Erst dann ist die Buchung verbindlich bestätigt.",
],
{ href: statusLink(token), label: "Status ansehen" },
),
);
}
export function paymentFailedEmail(booking: Booking): EmailDraft {
const subject = "Deine Zahlung ist fehlgeschlagen";
return draft(
`payment-failed:${booking.id}`,
booking,
subject,
wrap(subject, [
`Hallo ${booking.firstName},`,
`leider konnte die Zahlung für deine Buchung ${booking.reference} nicht abgeschlossen werden. Die Reservierung wurde deshalb aufgehoben.`,
"Du kannst jederzeit eine neue Buchung starten.",
]),
);
}
export function installmentPaidEmail(booking: BookingWithInstallments, installment: Installment, token: string): EmailDraft {
const fullyPaid = booking.paidCents >= booking.totalCents;
const subject = fullyPaid ? "Vollständig bezahlt. Danke!" : `Rate ${installment.seq} erhalten`;
return draft(
`installment-paid:${installment.id}`,
booking,
subject,
wrap(
subject,
[
`Hallo ${booking.firstName},`,
`wir haben Rate ${installment.seq} über ${formatEuro(installment.amountCents)} für deine Buchung ${booking.reference} erhalten.`,
fullyPaid
? "Damit ist dein Platz vollständig bezahlt. Weitere Abbuchungen finden nicht statt."
: `Bezahlt: ${formatEuro(booking.paidCents)} · Offen: ${formatEuro(booking.totalCents - booking.paidCents)}`,
planLines(booking).join("\n"),
],
{ href: statusLink(token), label: "Buchung ansehen" },
),
);
}
export function installmentActionEmail(booking: Booking, installment: Installment, token: string, requiresAction: boolean): EmailDraft {
const subject = requiresAction ? `Bitte bestätige Rate ${installment.seq}` : `Rate ${installment.seq} konnte nicht eingezogen werden`;
return draft(
`installment-action:${installment.id}:${installment.attemptCount}:${requiresAction ? "auth" : "fail"}`,
booking,
subject,
wrap(
subject,
[
`Hallo ${booking.firstName},`,
requiresAction
? `deine Bank möchte die Zahlung von Rate ${installment.seq} (${formatEuro(installment.amountCents)}) zusätzlich bestätigen lassen.`
: `der Einzug von Rate ${installment.seq} (${formatEuro(installment.amountCents)}) ist leider fehlgeschlagen.`,
"Du kannst die Rate über den folgenden Link selbst begleichen, auch mit einer anderen Zahlungsmethode.",
],
{ href: statusLink(token), label: "Rate jetzt bezahlen" },
),
);
}
export function adminReviewEmail(to: string, booking: Booking, reason: string): EmailDraft {
const subject = `Prüfung nötig: Buchung ${booking.reference}`;
const body = wrap(subject, [
`Die Buchung ${booking.reference} benötigt eine manuelle Entscheidung.`,
`Grund: ${reason}`,
"Details stehen im Adminbereich unter Buchungen.",
], { href: appUrl(`/admin/buchungen/${booking.id}`), label: "Im Adminbereich öffnen" });
return { dedupeKey: `admin-review:${booking.id}:${reason}`, to, subject, bookingId: booking.id, ...body };
}
export function reviewCustomerEmail(booking: Booking, token: string): EmailDraft {
const subject = "Deine Zahlung ist eingegangen, wir melden uns";
return draft(
`review-customer:${booking.id}`,
booking,
subject,
wrap(
subject,
[
`Hallo ${booking.firstName},`,
`deine Zahlung für die Buchung ${booking.reference} ist bei uns eingegangen. Bevor wir die Buchung bestätigen können, müssen wir sie kurz manuell prüfen.`,
"Wir melden uns umgehend bei dir. Solltest du keinen Platz erhalten, wird dir der gezahlte Betrag vollständig erstattet.",
],
{ href: statusLink(token), label: "Status ansehen" },
),
);
}
/** Anfrage fuer ein Erstgespraech an den Veranstalter (src/lib/contact.ts). */
export function contactInquiryEmail(to: string, inquiry: ContactRequest, dedupeKey: string, receivedAt: Date): EmailDraft {
const subject = `Anfrage Erstgespräch: ${inquiry.name}`;
const lines = [
`Über die Webseite kam eine Anfrage für ein Erstgespräch (${formatDateTime(receivedAt)}).`,
[
`Name: ${inquiry.name}`,
`E-Mail: ${inquiry.email}`,
`Telefon: ${inquiry.phone ?? "–"}`,
`Erreichbar: ${inquiry.availability ?? "–"}`,
].join("\n"),
inquiry.message ? `Nachricht:\n${inquiry.message}` : "Keine Nachricht angegeben.",
];
const reply = `mailto:${encodeURIComponent(inquiry.email)}?subject=${encodeURIComponent(`Dein Erstgespräch – ${BRAND}`)}`;
return { dedupeKey, to, subject: `${subject} | ${BRAND}`, ...wrap(subject, lines, { href: reply, label: "Antworten" }) };
}