feat: Add inspiration photo attachment to admin booking notifications
- Create sendEmailWithInspirationPhoto() function to handle photo attachments - Add renderAdminBookingNotificationHTML() template for admin notifications - Extract photo extension and content from base64 data URLs - Generate unique filenames with customer name and timestamp - Send separate admin notification email with photo attachment - Include comprehensive booking details in admin email - Add visual indicators for photo availability in email template - Support both HTML and text versions of admin notifications - Handle cases where no photo is uploaded gracefully - Import treatments KV to get treatment names for admin emails Features: - Inspiration photos automatically attached to admin notifications - Structured admin email with all booking details - Photo filename includes customer name and timestamp - Fallback handling for missing photos - German localization for admin notifications - Visual photo availability indicators (✅/❌) Changes: - email.ts: Add sendEmailWithInspirationPhoto() function - email-templates.ts: Add renderAdminBookingNotificationHTML() template - bookings.ts: Send admin notifications with photo attachments
This commit is contained in:
@@ -91,4 +91,36 @@ export async function renderBookingCancelledHTML(params: { name: string; date: s
|
||||
return renderBrandedEmail("Termin abgesagt", inner);
|
||||
}
|
||||
|
||||
export async function renderAdminBookingNotificationHTML(params: {
|
||||
name: string;
|
||||
date: string;
|
||||
time: string;
|
||||
treatment: string;
|
||||
phone: string;
|
||||
notes?: string;
|
||||
hasInspirationPhoto: boolean;
|
||||
}) {
|
||||
const { name, date, time, treatment, phone, notes, hasInspirationPhoto } = params;
|
||||
const formattedDate = formatDateGerman(date);
|
||||
const inner = `
|
||||
<p>Hallo Admin,</p>
|
||||
<p>eine neue Buchungsanfrage ist eingegangen:</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;">📅 Buchungsdetails:</p>
|
||||
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
|
||||
<li><strong>Name:</strong> ${name}</li>
|
||||
<li><strong>Telefon:</strong> ${phone}</li>
|
||||
<li><strong>Behandlung:</strong> ${treatment}</li>
|
||||
<li><strong>Datum:</strong> ${formattedDate}</li>
|
||||
<li><strong>Uhrzeit:</strong> ${time}</li>
|
||||
${notes ? `<li><strong>Notizen:</strong> ${notes}</li>` : ''}
|
||||
<li><strong>Inspiration-Foto:</strong> ${hasInspirationPhoto ? '✅ Im Anhang verfügbar' : '❌ Kein Foto hochgeladen'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>Bitte logge dich in das Admin-Panel ein, um die Buchung zu bestätigen oder abzulehnen.</p>
|
||||
<p>Liebe Grüße,<br/>Stargirlnails System</p>
|
||||
`;
|
||||
return renderBrandedEmail("Neue Buchungsanfrage - Admin-Benachrichtigung", inner);
|
||||
}
|
||||
|
||||
|
||||
|
@@ -89,4 +89,35 @@ export async function sendEmailWithAGB(params: SendEmailParams): Promise<{ succe
|
||||
return sendEmail(params);
|
||||
}
|
||||
|
||||
export async function sendEmailWithInspirationPhoto(
|
||||
params: SendEmailParams,
|
||||
photoData: string,
|
||||
customerName: string
|
||||
): Promise<{ success: boolean }> {
|
||||
if (!photoData) {
|
||||
return sendEmail(params);
|
||||
}
|
||||
|
||||
// Extract file extension from base64 data URL
|
||||
const match = photoData.match(/data:image\/([^;]+);base64,(.+)/);
|
||||
if (!match) {
|
||||
console.warn("Invalid photo data format");
|
||||
return sendEmail(params);
|
||||
}
|
||||
|
||||
const [, extension, base64Content] = match;
|
||||
const filename = `inspiration_${customerName.replace(/[^a-zA-Z0-9]/g, '_')}_${Date.now()}.${extension}`;
|
||||
|
||||
params.attachments = [
|
||||
...(params.attachments || []),
|
||||
{
|
||||
filename,
|
||||
content: base64Content,
|
||||
type: `image/${extension}`
|
||||
}
|
||||
];
|
||||
|
||||
return sendEmail(params);
|
||||
}
|
||||
|
||||
|
||||
|
@@ -3,8 +3,8 @@ import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "@/server/lib/create-kv";
|
||||
import { createKV as createAvailabilityKV } from "@/server/lib/create-kv";
|
||||
import { sendEmail, sendEmailWithAGB } from "@/server/lib/email";
|
||||
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML } from "@/server/lib/email-templates";
|
||||
import { sendEmail, sendEmailWithAGB, sendEmailWithInspirationPhoto } from "@/server/lib/email";
|
||||
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML } from "@/server/lib/email-templates";
|
||||
|
||||
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
|
||||
function formatDateGerman(dateString: string): string {
|
||||
@@ -41,6 +41,19 @@ type Availability = {
|
||||
};
|
||||
const availabilityKV = createAvailabilityKV<Availability>("availability");
|
||||
|
||||
// Import treatments KV for admin notifications
|
||||
import { createKV as createTreatmentsKV } from "@/server/lib/create-kv";
|
||||
type Treatment = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
duration: number;
|
||||
category: string;
|
||||
createdAt: string;
|
||||
};
|
||||
const treatmentsKV = createTreatmentsKV<Treatment>("treatments");
|
||||
|
||||
const create = os
|
||||
.input(BookingSchema.omit({ id: true, createdAt: true, status: true }))
|
||||
.handler(async ({ input }) => {
|
||||
@@ -84,9 +97,54 @@ const create = os
|
||||
subject: "Deine Terminanfrage ist eingegangen",
|
||||
text: `Hallo ${input.customerName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze.\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||
html,
|
||||
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
}).catch(() => {});
|
||||
})();
|
||||
|
||||
// Notify admin: new booking request (with photo if available)
|
||||
void (async () => {
|
||||
if (!process.env.ADMIN_EMAIL) return;
|
||||
|
||||
// Get treatment name from KV
|
||||
const allTreatments = await treatmentsKV.getAllItems();
|
||||
const treatment = allTreatments.find(t => t.id === input.treatmentId);
|
||||
const treatmentName = treatment?.name || "Unbekannte Behandlung";
|
||||
|
||||
const adminHtml = await renderAdminBookingNotificationHTML({
|
||||
name: input.customerName,
|
||||
date: input.appointmentDate,
|
||||
time: input.appointmentTime,
|
||||
treatment: treatmentName,
|
||||
phone: input.customerPhone,
|
||||
notes: input.notes,
|
||||
hasInspirationPhoto: !!input.inspirationPhoto
|
||||
});
|
||||
|
||||
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
|
||||
`Name: ${input.customerName}\n` +
|
||||
`Telefon: ${input.customerPhone}\n` +
|
||||
`Behandlung: ${treatmentName}\n` +
|
||||
`Datum: ${formatDateGerman(input.appointmentDate)}\n` +
|
||||
`Uhrzeit: ${input.appointmentTime}\n` +
|
||||
`${input.notes ? `Notizen: ${input.notes}\n` : ''}` +
|
||||
`Inspiration-Foto: ${input.inspirationPhoto ? 'Im Anhang verfügbar' : 'Kein Foto hochgeladen'}\n\n` +
|
||||
`Bitte logge dich in das Admin-Panel ein, um die Buchung zu bearbeiten.`;
|
||||
|
||||
if (input.inspirationPhoto) {
|
||||
await sendEmailWithInspirationPhoto({
|
||||
to: process.env.ADMIN_EMAIL,
|
||||
subject: `Neue Buchungsanfrage - ${input.customerName}`,
|
||||
text: adminText,
|
||||
html: adminHtml,
|
||||
}, input.inspirationPhoto, input.customerName).catch(() => {});
|
||||
} else {
|
||||
await sendEmail({
|
||||
to: process.env.ADMIN_EMAIL,
|
||||
subject: `Neue Buchungsanfrage - ${input.customerName}`,
|
||||
text: adminText,
|
||||
html: adminHtml,
|
||||
}).catch(() => {});
|
||||
}
|
||||
})();
|
||||
return booking;
|
||||
});
|
||||
|
||||
|
Reference in New Issue
Block a user