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:
@@ -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: "Maniküre",
|
||||||
});
|
});
|
||||||
|
|
||||||
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 = ["Maniküre", "Pediküre", "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: "Maniküre",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
@@ -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);
|
||||||
|
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@@ -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,
|
||||||
});
|
});
|
||||||
|
Reference in New Issue
Block a user