feat: Token-basierte Kunden-Statusseite
- Neue /booking/{token} Route für einheitliche Buchungsübersicht - Vollständige Termin-Details mit Status-Badges (pending/confirmed/cancelled/completed) - Integrierte Stornierungsfunktion mit Bestätigungsdialog - Anzeige von Behandlungsdetails, Kundendaten und verbleibender Zeit - Automatische Berechnung ob Stornierung noch möglich - Responsive UI mit modernem Design Server-Erweiterungen: - BookingAccessToken statt CancellationToken (semantisch präziser) - Erweiterte Rückgabe von getBookingByToken (Preis, Dauer, canCancel, hoursUntilAppointment) - Token-Generierung bei Buchungserstellung (pending) und Bestätigung E-Mail-Integration: - Status-Links in pending-Mails - 'Termin verwalten' statt 'Termin stornieren' in confirmed-Mails - Einheitliches Branding (Pink/Orange statt Rot) Aufgeräumt: - Legacy cancellation-page.tsx entfernt - /cancel/ Route entfernt (keine Rückwärtskompatibilität nötig) - Backlog aktualisiert
This commit is contained in:
@@ -58,8 +58,8 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise<stri
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export async function renderBookingPendingHTML(params: { name: string; date: string; time: string }) {
|
||||
const { name, date, time } = params;
|
||||
export async function renderBookingPendingHTML(params: { name: string; date: string; time: string; statusUrl?: string }) {
|
||||
const { name, date, time, statusUrl } = params;
|
||||
const formattedDate = formatDateGerman(date);
|
||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
@@ -69,6 +69,13 @@ export async function renderBookingPendingHTML(params: { name: string; date: str
|
||||
<p>Hallo ${name},</p>
|
||||
<p>wir haben deine Anfrage für <strong>${formattedDate}</strong> um <strong>${time}</strong> erhalten.</p>
|
||||
<p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p>
|
||||
${statusUrl ? `
|
||||
<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;">⏳ Termin-Status ansehen:</p>
|
||||
<p style="margin: 8px 0 12px 0; color: #475569;">Du kannst den aktuellen Status deiner Buchung jederzeit einsehen:</p>
|
||||
<a href="${statusUrl}" style="display: inline-block; background-color: #f59e0b; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Status ansehen</a>
|
||||
</div>
|
||||
` : ''}
|
||||
<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>
|
||||
@@ -94,10 +101,10 @@ export async function renderBookingConfirmedHTML(params: { name: string; date: s
|
||||
<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>
|
||||
${cancellationUrl ? `
|
||||
<div style="background-color: #fef3f2; border-left: 4px solid #ef4444; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; font-weight: 600; color: #ef4444;">❌ Termin stornieren:</p>
|
||||
<p style="margin: 8px 0 12px 0; color: #475569;">Falls du den Termin stornieren möchtest, kannst du das hier tun:</p>
|
||||
<a href="${cancellationUrl}" style="display: inline-block; background-color: #ef4444; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Termin stornieren</a>
|
||||
<div style="background-color: #fef9f5; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Termin verwalten:</p>
|
||||
<p style="margin: 8px 0 12px 0; color: #475569;">Du kannst deinen Termin-Status einsehen und bei Bedarf stornieren:</p>
|
||||
<a href="${cancellationUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Termin ansehen & verwalten</a>
|
||||
</div>
|
||||
` : ''}
|
||||
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
|
@@ -158,13 +158,22 @@ const create = os
|
||||
|
||||
// Notify customer: request received (pending)
|
||||
void (async () => {
|
||||
// Create booking access token for status viewing
|
||||
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: id });
|
||||
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
|
||||
|
||||
const formattedDate = formatDateGerman(input.appointmentDate);
|
||||
const homepageUrl = generateUrl();
|
||||
const html = await renderBookingPendingHTML({ name: input.customerName, date: input.appointmentDate, time: input.appointmentTime });
|
||||
const html = await renderBookingPendingHTML({
|
||||
name: input.customerName,
|
||||
date: input.appointmentDate,
|
||||
time: input.appointmentTime,
|
||||
statusUrl: bookingUrl
|
||||
});
|
||||
await sendEmail({
|
||||
to: input.customerEmail,
|
||||
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\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||
text: `Hallo ${input.customerName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.\n\nTermin-Status ansehen: ${bookingUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||
html,
|
||||
}).catch(() => {});
|
||||
})();
|
||||
@@ -292,18 +301,18 @@ const updateStatus = os
|
||||
// Email notifications on status changes
|
||||
try {
|
||||
if (input.status === "confirmed") {
|
||||
// Create cancellation token for this booking
|
||||
const cancellationToken = await queryClient.cancellation.createToken({ bookingId: booking.id });
|
||||
// Create booking access token for this booking (status + cancellation)
|
||||
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: booking.id });
|
||||
|
||||
const formattedDate = formatDateGerman(booking.appointmentDate);
|
||||
const cancellationUrl = generateUrl(`/cancel/${cancellationToken.token}`);
|
||||
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
|
||||
const homepageUrl = generateUrl();
|
||||
|
||||
const html = await renderBookingConfirmedHTML({
|
||||
name: booking.customerName,
|
||||
date: booking.appointmentDate,
|
||||
time: booking.appointmentTime,
|
||||
cancellationUrl
|
||||
cancellationUrl: bookingUrl // Now points to booking status page
|
||||
});
|
||||
|
||||
// Get treatment information for ICS file
|
||||
@@ -315,7 +324,7 @@ const updateStatus = os
|
||||
await sendEmailWithAGBAndCalendar({
|
||||
to: booking.customerEmail,
|
||||
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
||||
text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${formattedDate} 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\nFalls du den Termin stornieren möchtest, kannst du das hier tun: ${cancellationUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
|
||||
text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${formattedDate} 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\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
|
||||
html,
|
||||
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
}, {
|
||||
|
@@ -4,18 +4,21 @@ import { createKV } from "@/server/lib/create-kv";
|
||||
import { createKV as createAvailabilityKV } from "@/server/lib/create-kv";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
// Schema for cancellation token
|
||||
const CancellationTokenSchema = z.object({
|
||||
// Schema for booking access token (used for both status viewing and cancellation)
|
||||
const BookingAccessTokenSchema = z.object({
|
||||
id: z.string(),
|
||||
bookingId: z.string(),
|
||||
token: z.string(),
|
||||
expiresAt: z.string(),
|
||||
createdAt: z.string(),
|
||||
purpose: z.enum(["booking_access"]), // For future extensibility
|
||||
});
|
||||
|
||||
type CancellationToken = z.output<typeof CancellationTokenSchema>;
|
||||
type BookingAccessToken = z.output<typeof BookingAccessTokenSchema>;
|
||||
// Backwards compatibility alias
|
||||
type CancellationToken = BookingAccessToken;
|
||||
|
||||
const cancellationKV = createKV<CancellationToken>("cancellation_tokens");
|
||||
const cancellationKV = createKV<BookingAccessToken>("cancellation_tokens");
|
||||
|
||||
// Types for booking and availability
|
||||
type Booking = {
|
||||
@@ -70,12 +73,13 @@ const createToken = os
|
||||
expiresAt.setDate(expiresAt.getDate() + 30);
|
||||
|
||||
const token = randomUUID();
|
||||
const cancellationToken: CancellationToken = {
|
||||
const cancellationToken: BookingAccessToken = {
|
||||
id: randomUUID(),
|
||||
bookingId: input.bookingId,
|
||||
token,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
purpose: "booking_access",
|
||||
};
|
||||
|
||||
await cancellationKV.setItem(cancellationToken.id, cancellationToken);
|
||||
@@ -105,15 +109,32 @@ const getBookingByToken = os
|
||||
const treatmentsKV = createKV<any>("treatments");
|
||||
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||
|
||||
// Calculate if cancellation is still possible
|
||||
const minStornoTimespan = parseInt(process.env.MIN_STORNO_TIMESPAN || "24");
|
||||
const appointmentDateTime = new Date(`${booking.appointmentDate}T${booking.appointmentTime}:00`);
|
||||
const now = new Date();
|
||||
const timeDifferenceHours = (appointmentDateTime.getTime() - now.getTime()) / (1000 * 60 * 60);
|
||||
const canCancel = timeDifferenceHours >= minStornoTimespan &&
|
||||
booking.status !== "cancelled" &&
|
||||
booking.status !== "completed";
|
||||
|
||||
return {
|
||||
id: booking.id,
|
||||
customerName: booking.customerName,
|
||||
customerEmail: booking.customerEmail,
|
||||
customerPhone: booking.customerPhone,
|
||||
appointmentDate: booking.appointmentDate,
|
||||
appointmentTime: booking.appointmentTime,
|
||||
treatmentId: booking.treatmentId,
|
||||
treatmentName: treatment?.name || "Unbekannte Behandlung",
|
||||
treatmentDuration: treatment?.duration || 60,
|
||||
treatmentPrice: treatment?.price || 0,
|
||||
status: booking.status,
|
||||
notes: booking.notes,
|
||||
formattedDate: formatDateGerman(booking.appointmentDate),
|
||||
createdAt: booking.createdAt,
|
||||
canCancel,
|
||||
hoursUntilAppointment: Math.max(0, Math.round(timeDifferenceHours)),
|
||||
};
|
||||
});
|
||||
|
||||
|
Reference in New Issue
Block a user