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:
2025-10-01 13:14:27 +02:00
parent 8ee2a2b3b6
commit 85fcde0805
7 changed files with 445 additions and 288 deletions

View File

@@ -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)),
};
});