3 Commits

Author SHA1 Message Date
cceb4d4e60 Feature: Admin kann Nachrichten an Kunden senden
- Neues Email-Template für Kundennachrichten
- RPC-Funktion sendCustomerMessage für zukünftige Termine
- UI: Nachricht-Button und Modal in Admin-Buchungen
- Email mit BCC an Admin für Monitoring
- HTML-Escaping für sichere Nachrichtenanzeige
- Detailliertes Logging für Debugging
2025-10-08 11:13:59 +02:00
ca20516080 v0.1.3 2025-10-07 14:15:17 +02:00
f2963ca951 Android PWA-Installationshinweis mit direktem Install-Button hinzugefügt 2025-10-07 13:57:28 +02:00
6 changed files with 419 additions and 69 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "quests-template-basic", "name": "quests-template-basic",
"private": true, "private": true,
"version": "0.0.0", "version": "0.1.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"check:types": "tsc --noEmit", "check:types": "tsc --noEmit",

View File

@@ -7,6 +7,8 @@ export function AdminBookings() {
const [selectedPhoto, setSelectedPhoto] = useState<string>(""); const [selectedPhoto, setSelectedPhoto] = useState<string>("");
const [showPhotoModal, setShowPhotoModal] = useState(false); const [showPhotoModal, setShowPhotoModal] = useState(false);
const [showCancelConfirm, setShowCancelConfirm] = useState<string | null>(null); const [showCancelConfirm, setShowCancelConfirm] = useState<string | null>(null);
const [showMessageModal, setShowMessageModal] = useState<string | null>(null);
const [messageText, setMessageText] = useState<string>("");
const [successMsg, setSuccessMsg] = useState<string>(""); const [successMsg, setSuccessMsg] = useState<string>("");
const [errorMsg, setErrorMsg] = useState<string>(""); const [errorMsg, setErrorMsg] = useState<string>("");
@@ -49,6 +51,19 @@ export function AdminBookings() {
}) })
); );
const { mutate: sendMessage, isPending: isSendingMessage } = useMutation(
queryClient.bookings.sendCustomerMessage.mutationOptions({
onSuccess: () => {
setSuccessMsg("Nachricht wurde erfolgreich gesendet.");
setShowMessageModal(null);
setMessageText("");
},
onError: (error: any) => {
setErrorMsg(error?.message || "Fehler beim Senden der Nachricht.");
}
})
);
const getTreatmentName = (treatmentId: string) => { const getTreatmentName = (treatmentId: string) => {
return treatments?.find(t => t.id === treatmentId)?.name || "Unbekannte Behandlung"; return treatments?.find(t => t.id === treatmentId)?.name || "Unbekannte Behandlung";
}; };
@@ -83,6 +98,35 @@ export function AdminBookings() {
setSelectedPhoto(""); setSelectedPhoto("");
}; };
const openMessageModal = (bookingId: string) => {
setShowMessageModal(bookingId);
setMessageText("");
};
const closeMessageModal = () => {
setShowMessageModal(null);
setMessageText("");
};
const handleSendMessage = () => {
if (!showMessageModal || !messageText.trim()) {
setErrorMsg("Bitte gib eine Nachricht ein.");
return;
}
sendMessage({
sessionId: localStorage.getItem("sessionId") || "",
bookingId: showMessageModal,
message: messageText.trim(),
});
};
// Check if booking is in the future
const isFutureBooking = (appointmentDate: string) => {
const today = new Date().toISOString().split("T")[0];
return appointmentDate >= today;
};
const filteredBookings = bookings?.filter(booking => const filteredBookings = bookings?.filter(booking =>
selectedDate ? booking.appointmentDate === selectedDate : true selectedDate ? booking.appointmentDate === selectedDate : true
).sort((a, b) => { ).sort((a, b) => {
@@ -251,45 +295,57 @@ export function AdminBookings() {
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2"> <div className="flex flex-col space-y-2">
{booking.status === "pending" && ( <div className="flex space-x-2">
<> {booking.status === "pending" && (
<>
<button
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "confirmed" })}
className="text-green-600 hover:text-green-900"
>
Confirm
</button>
<button
onClick={() => setShowCancelConfirm(booking.id)}
className="text-red-600 hover:text-red-900"
>
Cancel
</button>
</>
)}
{booking.status === "confirmed" && (
<>
<button
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "completed" })}
className="text-blue-600 hover:text-blue-900"
>
Complete
</button>
<button
onClick={() => setShowCancelConfirm(booking.id)}
className="text-red-600 hover:text-red-900"
>
Cancel
</button>
</>
)}
{(booking.status === "cancelled" || booking.status === "completed") && (
<button <button
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "confirmed" })} onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "confirmed" })}
className="text-green-600 hover:text-green-900" className="text-green-600 hover:text-green-900"
> >
Confirm Reactivate
</button> </button>
<button )}
onClick={() => setShowCancelConfirm(booking.id)} </div>
className="text-red-600 hover:text-red-900" {/* Show message button for future bookings with email */}
> {isFutureBooking(booking.appointmentDate) && booking.customerEmail && (
Cancel
</button>
</>
)}
{booking.status === "confirmed" && (
<>
<button
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "completed" })}
className="text-blue-600 hover:text-blue-900"
>
Complete
</button>
<button
onClick={() => setShowCancelConfirm(booking.id)}
className="text-red-600 hover:text-red-900"
>
Cancel
</button>
</>
)}
{(booking.status === "cancelled" || booking.status === "completed") && (
<button <button
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "confirmed" })} onClick={() => openMessageModal(booking.id)}
className="text-green-600 hover:text-green-900" className="text-pink-600 hover:text-pink-900 text-left"
title="Nachricht an Kunden senden"
> >
Reactivate 💬 Nachricht
</button> </button>
)} )}
</div> </div>
@@ -369,6 +425,87 @@ export function AdminBookings() {
</div> </div>
</div> </div>
)} )}
{/* Message Modal */}
{showMessageModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">Nachricht an Kunden senden</h3>
<button
onClick={closeMessageModal}
className="text-gray-400 hover:text-gray-600 text-2xl"
disabled={isSendingMessage}
>
×
</button>
</div>
{(() => {
const booking = bookings?.find(b => b.id === showMessageModal);
if (!booking) return null;
return (
<div className="mb-4 bg-gray-50 p-4 rounded-md">
<p className="text-sm text-gray-700">
<strong>Kunde:</strong> {booking.customerName}
</p>
<p className="text-sm text-gray-700">
<strong>E-Mail:</strong> {booking.customerEmail}
</p>
<p className="text-sm text-gray-700">
<strong>Termin:</strong> {new Date(booking.appointmentDate).toLocaleDateString()} um {booking.appointmentTime}
</p>
<p className="text-sm text-gray-700">
<strong>Behandlung:</strong> {getTreatmentName(booking.treatmentId)}
</p>
</div>
);
})()}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Deine Nachricht
</label>
<textarea
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
placeholder="Schreibe hier deine Nachricht an den Kunden..."
rows={6}
maxLength={5000}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
disabled={isSendingMessage}
/>
<p className="text-xs text-gray-500 mt-1">
{messageText.length} / 5000 Zeichen
</p>
</div>
<div className="bg-blue-50 border-l-4 border-blue-400 p-3 mb-4">
<p className="text-sm text-blue-700">
💡 <strong>Hinweis:</strong> Der Kunde kann direkt auf diese E-Mail antworten. Die Antwort geht an die in den Einstellungen hinterlegte Admin-E-Mail-Adresse.
</p>
</div>
<div className="flex space-x-3">
<button
onClick={handleSendMessage}
disabled={isSendingMessage || !messageText.trim()}
className="flex-1 bg-pink-600 text-white py-2 px-4 rounded-md hover:bg-pink-700 transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed"
>
{isSendingMessage ? "Wird gesendet..." : "Nachricht senden"}
</button>
<button
onClick={closeMessageModal}
disabled={isSendingMessage}
className="flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-md hover:bg-gray-300 transition-colors disabled:bg-gray-100 disabled:cursor-not-allowed"
>
Abbrechen
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -1,10 +1,19 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
declare global { declare global {
interface Navigator { standalone?: boolean } interface Navigator { standalone?: boolean }
interface WindowEventMap {
beforeinstallprompt: BeforeInstallPromptEvent;
}
} }
const LAST_SHOWN_KEY = 'pwaInstallPrompt_lastShown'; const LAST_SHOWN_KEY = 'pwaInstallPrompt_lastShown';
const ANDROID_DISMISSED_KEY = 'pwaInstallPrompt_androidDismissed';
function isIOS(): boolean { function isIOS(): boolean {
if (typeof navigator === 'undefined') return false; if (typeof navigator === 'undefined') return false;
@@ -31,59 +40,144 @@ function isStandalone(): boolean {
export function PWAInstallPrompt({ hidden = false }: { hidden?: boolean }) { export function PWAInstallPrompt({ hidden = false }: { hidden?: boolean }) {
if (hidden) return null; if (hidden) return null;
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
const [promptType, setPromptType] = useState<'ios' | 'android' | null>(null);
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false);
useEffect(() => { useEffect(() => {
// Only run on client // Only run on client
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const eligible = isIOS() && isSafari() && !isStandalone(); // Check if already in standalone mode
if (!eligible) { if (isStandalone()) {
setInitialized(true); setInitialized(true);
return; return;
} }
let lastShown = 0; // Handle iOS
try { const isIOSDevice = isIOS() && isSafari();
lastShown = Number(localStorage.getItem(LAST_SHOWN_KEY) || 0); if (isIOSDevice) {
} catch {} let lastShown = 0;
const oneWeek = 7 * 24 * 60 * 60 * 1000; try {
const shouldShow = !lastShown || Date.now() - lastShown > oneWeek; lastShown = Number(localStorage.getItem(LAST_SHOWN_KEY) || 0);
} catch {}
const oneWeek = 7 * 24 * 60 * 60 * 1000;
const shouldShow = !lastShown || Date.now() - lastShown > oneWeek;
setShow(shouldShow); if (shouldShow) {
setPromptType('ios');
setShow(true);
}
setInitialized(true);
return;
}
// Handle Android (beforeinstallprompt)
const handleBeforeInstall = (e: BeforeInstallPromptEvent) => {
e.preventDefault();
// Check if user has dismissed Android prompt before
let dismissed = false;
try {
dismissed = localStorage.getItem(ANDROID_DISMISSED_KEY) === 'true';
} catch {}
if (!dismissed) {
setDeferredPrompt(e);
setPromptType('android');
setShow(true);
}
};
window.addEventListener('beforeinstallprompt', handleBeforeInstall);
setInitialized(true); setInitialized(true);
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstall);
};
}, []); }, []);
const dismiss = () => { const dismiss = () => {
try { localStorage.setItem(LAST_SHOWN_KEY, String(Date.now())); } catch {} if (promptType === 'ios') {
try { localStorage.setItem(LAST_SHOWN_KEY, String(Date.now())); } catch {}
} else if (promptType === 'android') {
try { localStorage.setItem(ANDROID_DISMISSED_KEY, 'true'); } catch {}
}
setShow(false); setShow(false);
}; };
if (!initialized || !show) return null; const handleAndroidInstall = async () => {
if (!deferredPrompt) return;
try {
await deferredPrompt.prompt();
const choiceResult = await deferredPrompt.userChoice;
if (choiceResult.outcome === 'accepted') {
console.log('PWA installation accepted');
}
setDeferredPrompt(null);
setShow(false);
} catch (error) {
console.error('Error during PWA installation:', error);
}
};
if (!initialized || !show || !promptType) return null;
return ( return (
<div <div
role="dialog" role="dialog"
aria-label="PWA Installation Anleitung" aria-label={promptType === 'android' ? 'PWA Installation' : 'PWA Installation Anleitung'}
className="fixed bottom-0 left-0 right-0 z-50 px-4 pb-4" className="fixed bottom-0 left-0 right-0 z-50 px-4 pb-4"
style={{ paddingBottom: `max(env(safe-area-inset-bottom), 1rem)` }} style={{ paddingBottom: `max(env(safe-area-inset-bottom), 1rem)` }}
> >
<div className="relative max-w-3xl mx-auto rounded-xl shadow-lg bg-gradient-to-r from-pink-500 to-purple-600 text-white p-4 sm:p-6"> <div className="relative max-w-3xl mx-auto rounded-xl shadow-lg bg-gradient-to-r from-pink-500 to-purple-600 text-white p-4 sm:p-6">
<button aria-label="Hinweis schließen" onClick={dismiss} className="absolute top-2 right-2 text-white/90 hover:text-white text-2xl leading-none">×</button> <button aria-label="Hinweis schließen" onClick={dismiss} className="absolute top-2 right-2 text-white/90 hover:text-white text-2xl leading-none">×</button>
<div className="flex items-start gap-3">
<div className="text-2xl">📱</div> {promptType === 'android' ? (
<div className="flex-1"> // Android: Direct install button
<h3 className="text-lg sm:text-xl font-bold mb-2">App installieren</h3> <div className="flex items-center gap-4">
<p className="text-white/90 mb-2">So installierst du Stargirlnails als App auf deinem iPhone/iPad:</p> <div className="text-3xl">📱</div>
<ol className="list-decimal pl-5 space-y-1 text-white/95"> <div className="flex-1">
<li>Öffne diese Seite in Safari (nicht Chrome oder andere Browser).</li> <h3 className="text-lg sm:text-xl font-bold mb-2">App installieren</h3>
<li>Tippe auf das Teilen-Symbol () unten in der Mitte.</li> <p className="text-white/90 mb-3">
<li>Scrolle nach unten und wähle Zum Home-Bildschirm".</li> Installiere Stargirlnails als App für schnellen Zugriff direkt vom Startbildschirm!
<li>Tippe auf „Hinzufügen".</li> </p>
</ol> <div className="flex gap-2">
<p className="mt-3 text-sm text-white/90"> Schneller Zugriff, keine App-Store-Installation nötig, automatische Updates.</p> <button
onClick={handleAndroidInstall}
className="bg-white text-pink-600 px-4 py-2 rounded-lg font-semibold hover:bg-pink-50 transition-colors"
>
Jetzt installieren
</button>
<button
onClick={dismiss}
className="bg-white/20 text-white px-4 py-2 rounded-lg font-semibold hover:bg-white/30 transition-colors"
>
Später
</button>
</div>
</div>
</div> </div>
</div> ) : (
// iOS: Manual instructions
<div className="flex items-start gap-3">
<div className="text-2xl">📱</div>
<div className="flex-1">
<h3 className="text-lg sm:text-xl font-bold mb-2">App installieren</h3>
<p className="text-white/90 mb-2">So installierst du Stargirlnails als App auf deinem iPhone/iPad:</p>
<ol className="list-decimal pl-5 space-y-1 text-white/95">
<li>Öffne diese Seite in Safari (nicht Chrome oder andere Browser).</li>
<li>Tippe auf das Teilen-Symbol () unten in der Mitte.</li>
<li>Scrolle nach unten und wähle Zum Home-Bildschirm".</li>
<li>Tippe auf „Hinzufügen".</li>
</ol>
<p className="mt-3 text-sm text-white/90"> Schneller Zugriff, keine App-Store-Installation nötig, automatische Updates.</p>
</div>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -347,3 +347,43 @@ export async function renderAdminRescheduleExpiredHTML(params: {
return renderBrandedEmail("Abgelaufene Terminänderungsvorschläge", inner); return renderBrandedEmail("Abgelaufene Terminänderungsvorschläge", inner);
} }
export async function renderCustomerMessageHTML(params: {
customerName: string;
message: string;
appointmentDate?: string;
appointmentTime?: string;
treatmentName?: string;
}) {
const { customerName, message, appointmentDate, appointmentTime, treatmentName } = params;
const formattedDate = appointmentDate ? formatDateGerman(appointmentDate) : null;
const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https';
const legalUrl = `${protocol}://${domain}/legal`;
const ownerName = process.env.OWNER_NAME || 'Stargirlnails Kiel';
const inner = `
<p>Hallo ${customerName},</p>
${(appointmentDate && appointmentTime && treatmentName) ? `
<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;">📅 Zu deinem Termin:</p>
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
<li><strong>Behandlung:</strong> ${treatmentName}</li>
<li><strong>Datum:</strong> ${formattedDate}</li>
<li><strong>Uhrzeit:</strong> ${appointmentTime}</li>
</ul>
</div>
` : ''}
<div style="background-color: #fef9f5; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #f59e0b;">💬 Nachricht von ${ownerName}:</p>
<div style="margin: 12px 0 0 0; color: #475569; white-space: pre-wrap; line-height: 1.6;">${message.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>
</div>
<p>Bei Fragen oder Anliegen kannst du einfach auf diese E-Mail antworten wir helfen dir gerne weiter!</p>
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>
</div>
<p>Liebe Grüße,<br/>${ownerName}</p>
`;
return renderBrandedEmail("Nachricht zu deinem Termin", inner);
}

View File

@@ -6,6 +6,7 @@ type SendEmailParams = {
from?: string; from?: string;
cc?: string | string[]; cc?: string | string[];
bcc?: string | string[]; bcc?: string | string[];
replyTo?: string | string[];
attachments?: Array<{ attachments?: Array<{
filename: string; filename: string;
content: string; // base64 encoded content: string; // base64 encoded
@@ -130,22 +131,27 @@ export async function sendEmail(params: SendEmailParams): Promise<{ success: boo
return { success: false }; return { success: false };
} }
const payload = {
from: params.from || DEFAULT_FROM,
to: Array.isArray(params.to) ? params.to : [params.to],
subject: params.subject,
text: params.text,
html: params.html,
cc: params.cc ? (Array.isArray(params.cc) ? params.cc : [params.cc]) : undefined,
bcc: params.bcc ? (Array.isArray(params.bcc) ? params.bcc : [params.bcc]) : undefined,
reply_to: params.replyTo ? (Array.isArray(params.replyTo) ? params.replyTo : [params.replyTo]) : undefined,
attachments: params.attachments,
};
console.log(`Sending email via Resend: to=${JSON.stringify(payload.to)}, subject="${params.subject}"`);
const response = await fetch("https://api.resend.com/emails", { const response = await fetch("https://api.resend.com/emails", {
method: "POST", method: "POST",
headers: { headers: {
"Authorization": `Bearer ${RESEND_API_KEY}`, "Authorization": `Bearer ${RESEND_API_KEY}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify(payload),
from: params.from || DEFAULT_FROM,
to: Array.isArray(params.to) ? params.to : [params.to],
subject: params.subject,
text: params.text,
html: params.html,
cc: params.cc ? (Array.isArray(params.cc) ? params.cc : [params.cc]) : undefined,
bcc: params.bcc ? (Array.isArray(params.bcc) ? params.bcc : [params.bcc]) : undefined,
attachments: params.attachments,
}),
}); });
if (!response.ok) { if (!response.ok) {
@@ -153,6 +159,9 @@ export async function sendEmail(params: SendEmailParams): Promise<{ success: boo
console.error("Resend send error:", response.status, body); console.error("Resend send error:", response.status, body);
return { success: false }; return { success: false };
} }
const responseData = await response.json().catch(() => ({}));
console.log("Resend email sent successfully:", responseData);
return { success: true }; return { success: true };
} }

View File

@@ -3,7 +3,7 @@ import { z } from "zod";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js"; import { createKV } from "../lib/create-kv.js";
import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js"; import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js"; import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML, renderCustomerMessageHTML } from "../lib/email-templates.js";
import { router as rootRouter } from "./index.js"; import { router as rootRouter } from "./index.js";
import { createORPCClient } from "@orpc/client"; import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch"; import { RPCLink } from "@orpc/client/fetch";
@@ -911,4 +911,74 @@ export const router = {
} }
}; };
}), }),
// Admin sendet Nachricht an Kunden
sendCustomerMessage: os
.input(z.object({
sessionId: z.string(),
bookingId: z.string(),
message: z.string().min(1, "Nachricht darf nicht leer sein").max(5000, "Nachricht ist zu lang (max. 5000 Zeichen)"),
}))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const booking = await kv.getItem(input.bookingId);
if (!booking) throw new Error("Buchung nicht gefunden");
// Check if booking has customer email
if (!booking.customerEmail) {
throw new Error("Diese Buchung hat keine E-Mail-Adresse. Bitte kontaktiere den Kunden telefonisch.");
}
// Check if booking is in the future
const today = new Date().toISOString().split("T")[0];
const bookingDate = booking.appointmentDate;
if (bookingDate < today) {
throw new Error("Nachrichten können nur für zukünftige Termine gesendet werden.");
}
// Get treatment name for context
const treatment = await treatmentsKV.getItem(booking.treatmentId);
const treatmentName = treatment?.name || "Behandlung";
// Prepare email with Reply-To header
const ownerName = process.env.OWNER_NAME || "Stargirlnails Kiel";
const emailFrom = process.env.EMAIL_FROM || "Stargirlnails <no-reply@stargirlnails.de>";
const replyToEmail = process.env.ADMIN_EMAIL;
const formattedDate = formatDateGerman(bookingDate);
const html = await renderCustomerMessageHTML({
customerName: booking.customerName,
message: input.message,
appointmentDate: bookingDate,
appointmentTime: booking.appointmentTime,
treatmentName: treatmentName,
});
const textContent = `Hallo ${booking.customerName},\n\nZu deinem Termin:\nBehandlung: ${treatmentName}\nDatum: ${formattedDate}\nUhrzeit: ${booking.appointmentTime}\n\nNachricht von ${ownerName}:\n${input.message}\n\nBei Fragen oder Anliegen kannst du einfach auf diese E-Mail antworten wir helfen dir gerne weiter!\n\nLiebe Grüße,\n${ownerName}`;
// Send email with BCC to admin for monitoring
// Note: Not using explicit 'from' or 'replyTo' to match behavior of other system emails
console.log(`Sending customer message to ${booking.customerEmail} for booking ${input.bookingId}`);
console.log(`Email config: from=${emailFrom}, replyTo=${replyToEmail}, bcc=${replyToEmail}`);
const emailResult = await sendEmail({
to: booking.customerEmail,
subject: `Nachricht zu deinem Termin am ${formattedDate}`,
text: textContent,
html: html,
bcc: replyToEmail ? [replyToEmail] : undefined,
});
if (!emailResult.success) {
console.error(`Failed to send customer message to ${booking.customerEmail}`);
throw new Error("E-Mail konnte nicht versendet werden. Bitte überprüfe die E-Mail-Konfiguration oder versuche es später erneut.");
}
console.log(`Successfully sent customer message to ${booking.customerEmail}`);
return {
success: true,
message: `Nachricht wurde erfolgreich an ${booking.customerEmail} gesendet.`
};
}),
}; };