Compare commits
1 Commits
v0.1.3
...
cceb4d4e60
Author | SHA1 | Date | |
---|---|---|---|
cceb4d4e60 |
@@ -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,6 +295,7 @@ 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 flex-col space-y-2">
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
{booking.status === "pending" && (
|
{booking.status === "pending" && (
|
||||||
<>
|
<>
|
||||||
@@ -293,6 +338,17 @@ export function AdminBookings() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Show message button for future bookings with email */}
|
||||||
|
{isFutureBooking(booking.appointmentDate) && booking.customerEmail && (
|
||||||
|
<button
|
||||||
|
onClick={() => openMessageModal(booking.id)}
|
||||||
|
className="text-pink-600 hover:text-pink-900 text-left"
|
||||||
|
title="Nachricht an Kunden senden"
|
||||||
|
>
|
||||||
|
💬 Nachricht
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')}</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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -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,13 +131,7 @@ export async function sendEmail(params: SendEmailParams): Promise<{ success: boo
|
|||||||
return { success: false };
|
return { success: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch("https://api.resend.com/emails", {
|
const payload = {
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Authorization": `Bearer ${RESEND_API_KEY}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
from: params.from || DEFAULT_FROM,
|
from: params.from || DEFAULT_FROM,
|
||||||
to: Array.isArray(params.to) ? params.to : [params.to],
|
to: Array.isArray(params.to) ? params.to : [params.to],
|
||||||
subject: params.subject,
|
subject: params.subject,
|
||||||
@@ -144,8 +139,19 @@ 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,
|
||||||
|
reply_to: params.replyTo ? (Array.isArray(params.replyTo) ? params.replyTo : [params.replyTo]) : undefined,
|
||||||
attachments: params.attachments,
|
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", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${RESEND_API_KEY}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
|
||||||
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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.`
|
||||||
|
};
|
||||||
|
}),
|
||||||
};
|
};
|
Reference in New Issue
Block a user