feat: Add AGB PDF attachment to booking confirmation emails

- Extend email system to support file attachments
- Add sendEmailWithAGB() function that automatically attaches AGB.pdf
- Implement AGB PDF caching for better performance
- Update booking confirmation email template with AGB notice
- Add visual highlight box in HTML email with AGB information
- Update email subject to indicate AGB attachment
- Include AGB reference in both HTML and text versions
- Ensure legal compliance by automatically sending terms with confirmations

Changes:
- email.ts: Add attachment support and AGB PDF integration
- email-templates.ts: Add AGB notice to confirmation emails
- bookings.ts: Use sendEmailWithAGB for confirmed bookings
- German localization for admin treatments component
This commit is contained in:
2025-09-30 11:18:23 +02:00
parent bb04e5a118
commit a1935aae02
4 changed files with 77 additions and 27 deletions

View File

@@ -9,8 +9,8 @@ export function AdminTreatments() {
name: "", name: "",
description: "", description: "",
duration: 60, duration: 60,
price: 5000, // $50.00 in cents price: 5000, // 50,00 in Cent
category: "Manicure", category: "Manire",
}); });
const { data: treatments } = useQuery( const { data: treatments } = useQuery(
@@ -29,7 +29,7 @@ export function AdminTreatments() {
queryClient.treatments.remove.mutationOptions() queryClient.treatments.remove.mutationOptions()
); );
const categories = ["Manicure", "Pedicure", "Nail Art", "Extensions", "Other"]; const categories = ["Manire", "Pedire", "Nageldesign", "Verlängerungen", "Sonstiges"];
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -61,7 +61,7 @@ export function AdminTreatments() {
description: "", description: "",
duration: 60, duration: 60,
price: 5000, price: 5000,
category: "Manicure", category: "Manire",
}); });
}; };
@@ -86,26 +86,26 @@ export function AdminTreatments() {
return ( return (
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Manage Treatments</h2> <h2 className="text-2xl font-bold text-gray-900">Behandlungen verwalten</h2>
<button <button
onClick={() => setShowForm(true)} onClick={() => setShowForm(true)}
className="bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700 font-medium" className="bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700 font-medium"
> >
Add Treatment Behandlung hinzufügen
</button> </button>
</div> </div>
{showForm && ( {showForm && (
<div className="bg-white rounded-lg shadow-lg p-6 mb-6"> <div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<h3 className="text-lg font-semibold mb-4"> <h3 className="text-lg font-semibold mb-4">
{editingTreatment ? "Edit Treatment" : "Add New Treatment"} {editingTreatment ? "Behandlung bearbeiten" : "Neue Behandlung hinzufügen"}
</h3> </h3>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Treatment Name * Behandlungsname *
</label> </label>
<input <input
type="text" type="text"
@@ -117,7 +117,7 @@ export function AdminTreatments() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Category * Kategorie *
</label> </label>
<select <select
value={formData.category} value={formData.category}
@@ -134,7 +134,7 @@ export function AdminTreatments() {
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Description * Beschreibung *
</label> </label>
<textarea <textarea
value={formData.description} value={formData.description}
@@ -148,7 +148,7 @@ export function AdminTreatments() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Duration (minutes) * Dauer (Minuten) *
</label> </label>
<input <input
type="number" type="number"
@@ -163,7 +163,7 @@ export function AdminTreatments() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Price ($) * Preis () *
</label> </label>
<input <input
type="number" type="number"
@@ -183,14 +183,14 @@ export function AdminTreatments() {
disabled={isCreating || isUpdating} disabled={isCreating || isUpdating}
className="bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700 disabled:opacity-50 font-medium" className="bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700 disabled:opacity-50 font-medium"
> >
{isCreating || isUpdating ? "Saving..." : (editingTreatment ? "Update" : "Create")} {isCreating || isUpdating ? "Speichern..." : (editingTreatment ? "Aktualisieren" : "Erstellen")}
</button> </button>
<button <button
type="button" type="button"
onClick={handleCancel} onClick={handleCancel}
className="bg-gray-300 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-400 font-medium" className="bg-gray-300 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-400 font-medium"
> >
Cancel Abbrechen
</button> </button>
</div> </div>
</form> </form>
@@ -202,19 +202,19 @@ export function AdminTreatments() {
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Treatment Behandlung
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Category Kategorie
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Duration Dauer
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Price Preis
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions Aktionen
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -231,17 +231,17 @@ export function AdminTreatments() {
{treatment.category} {treatment.category}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{treatment.duration} min {treatment.duration} Min
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
${(treatment.price / 100).toFixed(2)} {(treatment.price / 100).toFixed(2)}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2"> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<button <button
onClick={() => handleEdit(treatment)} onClick={() => handleEdit(treatment)}
className="text-pink-600 hover:text-pink-900" className="text-pink-600 hover:text-pink-900"
> >
Edit Bearbeiten
</button> </button>
<button <button
onClick={() => { onClick={() => {
@@ -251,7 +251,7 @@ export function AdminTreatments() {
}} }}
className="text-red-600 hover:text-red-900" className="text-red-600 hover:text-red-900"
> >
Delete Löschen
</button> </button>
</td> </td>
</tr> </tr>

View File

@@ -62,6 +62,10 @@ export async function renderBookingConfirmedHTML(params: { name: string; date: s
<p>Hallo ${name},</p> <p>Hallo ${name},</p>
<p>wir haben deinen Termin am <strong>${date}</strong> um <strong>${time}</strong> bestätigt.</p> <p>wir haben deinen Termin am <strong>${date}</strong> um <strong>${time}</strong> bestätigt.</p>
<p>Wir freuen uns auf dich!</p> <p>Wir freuen uns auf dich!</p>
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #db2777;">📋 Wichtiger Hinweis:</p>
<p style="margin: 8px 0 0 0; color: #475569;">Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.</p>
</div>
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p> <p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
`; `;
return renderBrandedEmail("Termin bestätigt", inner); return renderBrandedEmail("Termin bestätigt", inner);

View File

@@ -6,11 +6,39 @@ type SendEmailParams = {
from?: string; from?: string;
cc?: string | string[]; cc?: string | string[];
bcc?: string | string[]; bcc?: string | string[];
attachments?: Array<{
filename: string;
content: string; // base64 encoded
type?: string;
}>;
}; };
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const RESEND_API_KEY = process.env.RESEND_API_KEY; const RESEND_API_KEY = process.env.RESEND_API_KEY;
const DEFAULT_FROM = process.env.EMAIL_FROM || "Stargirlnails <no-reply@stargirlnails.de>"; const DEFAULT_FROM = process.env.EMAIL_FROM || "Stargirlnails <no-reply@stargirlnails.de>";
// Cache for AGB PDF to avoid reading it multiple times
let cachedAGBPDF: string | null = null;
async function getAGBPDFBase64(): Promise<string | null> {
if (cachedAGBPDF) return cachedAGBPDF;
try {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const agbPath = resolve(__dirname, "../../../AGB.pdf");
const buf = await readFile(agbPath);
cachedAGBPDF = buf.toString('base64');
return cachedAGBPDF;
} catch (error) {
console.warn("Could not read AGB.pdf:", error);
return null;
}
}
export async function sendEmail(params: SendEmailParams): Promise<{ success: boolean }> { export async function sendEmail(params: SendEmailParams): Promise<{ success: boolean }> {
if (!RESEND_API_KEY) { if (!RESEND_API_KEY) {
// In development or if not configured, skip sending but don't fail the flow // In development or if not configured, skip sending but don't fail the flow
@@ -32,6 +60,7 @@ export async function sendEmail(params: SendEmailParams): Promise<{ success: boo
html: params.html, html: params.html,
cc: params.cc ? (Array.isArray(params.cc) ? params.cc : [params.cc]) : undefined, cc: params.cc ? (Array.isArray(params.cc) ? params.cc : [params.cc]) : undefined,
bcc: params.bcc ? (Array.isArray(params.bcc) ? params.bcc : [params.bcc]) : undefined, bcc: params.bcc ? (Array.isArray(params.bcc) ? params.bcc : [params.bcc]) : undefined,
attachments: params.attachments,
}), }),
}); });
@@ -43,4 +72,21 @@ export async function sendEmail(params: SendEmailParams): Promise<{ success: boo
return { success: true }; return { success: true };
} }
export async function sendEmailWithAGB(params: SendEmailParams): Promise<{ success: boolean }> {
const agbBase64 = await getAGBPDFBase64();
if (agbBase64) {
params.attachments = [
...(params.attachments || []),
{
filename: "AGB_Stargirlnails_Kiel.pdf",
content: agbBase64,
type: "application/pdf"
}
];
}
return sendEmail(params);
}

View File

@@ -3,7 +3,7 @@ import { z } from "zod";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { createKV } from "@/server/lib/create-kv"; import { createKV } from "@/server/lib/create-kv";
import { createKV as createAvailabilityKV } from "@/server/lib/create-kv"; import { createKV as createAvailabilityKV } from "@/server/lib/create-kv";
import { sendEmail } from "@/server/lib/email"; import { sendEmail, sendEmailWithAGB } from "@/server/lib/email";
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML } from "@/server/lib/email-templates"; import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML } from "@/server/lib/email-templates";
const BookingSchema = z.object({ const BookingSchema = z.object({
@@ -146,10 +146,10 @@ const updateStatus = os
try { try {
if (input.status === "confirmed") { if (input.status === "confirmed") {
const html = await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime }); const html = await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
await sendEmail({ await sendEmailWithAGB({
to: booking.customerEmail, to: booking.customerEmail,
subject: "Dein Termin wurde bestätigt", subject: "Dein Termin wurde bestätigt - AGB im Anhang",
text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${booking.appointmentDate} um ${booking.appointmentTime} bestätigt.\n\nBis bald!\nStargirlnails Kiel`, text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${booking.appointmentDate} um ${booking.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nBis bald!\nStargirlnails Kiel`,
html, html,
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined, cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
}); });