From 85fcde0805930b3811f2c49b287e8ef1d774997e Mon Sep 17 00:00:00 2001 From: elpatron Date: Wed, 1 Oct 2025 13:14:27 +0200 Subject: [PATCH] feat: Token-basierte Kunden-Statusseite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/backlog.md | 8 +- src/client/app.tsx | 25 +- src/client/components/booking-status-page.tsx | 380 ++++++++++++++++++ src/client/components/cancellation-page.tsx | 247 ------------ src/server/lib/email-templates.ts | 19 +- src/server/rpc/bookings.ts | 23 +- src/server/rpc/cancellation.ts | 31 +- 7 files changed, 445 insertions(+), 288 deletions(-) create mode 100644 src/client/components/booking-status-page.tsx delete mode 100644 src/client/components/cancellation-page.tsx diff --git a/docs/backlog.md b/docs/backlog.md index 55a4204..9864b68 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -9,10 +9,10 @@ ### Sicherheit & Qualität - ~~Rate‑Limiting (IP/E‑Mail) für Formularspam~~ -- E‑Mail‑Verifizierung (Double‑Opt‑In) optional +- ~~E‑Mail‑Verifizierung (Double‑Opt‑In) optional~~ - Audit‑Log (wer/was/wann) -- DSGVO: Einwilligungstexte, Löschkonzept -- Impressum +- ~~DSGVO: Einwilligungstexte, Löschkonzept~~ +- ~~Impressum~~ ### E‑Mail & Infrastruktur - Retry/Backoff + Fallback‑Queue bei Resend‑Fehlern @@ -22,7 +22,7 @@ ### UX/UI - ~~Mobiler Kalender mit klarer Slot‑Visualisierung~~ -- Kunden‑Statusseite (pending/confirmed) +- ~~Kunden‑Statusseite (pending/confirmed)~~ - Prominente Fehlerzustände inkl. Hinweise bei Doppelbuchung ### Internationalisierung & Zeitzonen diff --git a/src/client/app.tsx b/src/client/app.tsx index e3c40f5..47463a2 100644 --- a/src/client/app.tsx +++ b/src/client/app.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useAuth } from "@/client/components/auth-provider"; import { LoginForm } from "@/client/components/login-form"; import { UserProfile } from "@/client/components/user-profile"; @@ -8,32 +8,19 @@ import { AdminBookings } from "@/client/components/admin-bookings"; import { AdminCalendar } from "@/client/components/admin-calendar"; import { InitialDataLoader } from "@/client/components/initial-data-loader"; import { AdminAvailability } from "@/client/components/admin-availability"; -import CancellationPage from "@/client/components/cancellation-page"; +import BookingStatusPage from "@/client/components/booking-status-page"; import LegalPage from "@/client/components/legal-page"; function App() { const { user, isLoading, isOwner } = useAuth(); const [activeTab, setActiveTab] = useState<"booking" | "admin-treatments" | "admin-bookings" | "admin-calendar" | "admin-availability" | "profile" | "legal">("booking"); - // Check for cancellation token in URL - useEffect(() => { - const path = window.location.pathname; - if (path.startsWith('/cancel/')) { - const token = path.split('/cancel/')[1]; - if (token) { - // Set a special state to show cancellation page - setActiveTab("cancellation" as any); - return; - } - } - }, []); - - // Handle cancellation page + // Handle booking status page const path = window.location.pathname; - if (path.startsWith('/cancel/')) { - const token = path.split('/cancel/')[1]; + if (path.startsWith('/booking/')) { + const token = path.split('/booking/')[1]; if (token) { - return ; + return ; } } diff --git a/src/client/components/booking-status-page.tsx b/src/client/components/booking-status-page.tsx new file mode 100644 index 0000000..6c0f5b3 --- /dev/null +++ b/src/client/components/booking-status-page.tsx @@ -0,0 +1,380 @@ +import React, { useState } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { queryClient } from "@/client/rpc-client"; + +interface BookingStatusPageProps { + token: string; +} + +type BookingStatus = "pending" | "confirmed" | "cancelled" | "completed"; + +function getStatusInfo(status: BookingStatus) { + switch (status) { + case "pending": + return { + label: "Wartet auf Bestätigung", + color: "yellow", + icon: "⏳", + bgColor: "bg-yellow-50", + borderColor: "border-yellow-200", + textColor: "text-yellow-800", + badgeColor: "bg-yellow-100 text-yellow-800", + }; + case "confirmed": + return { + label: "Bestätigt", + color: "green", + icon: "✓", + bgColor: "bg-green-50", + borderColor: "border-green-200", + textColor: "text-green-800", + badgeColor: "bg-green-100 text-green-800", + }; + case "cancelled": + return { + label: "Storniert", + color: "red", + icon: "✕", + bgColor: "bg-red-50", + borderColor: "border-red-200", + textColor: "text-red-800", + badgeColor: "bg-red-100 text-red-800", + }; + case "completed": + return { + label: "Abgeschlossen", + color: "gray", + icon: "✓", + bgColor: "bg-gray-50", + borderColor: "border-gray-200", + textColor: "text-gray-800", + badgeColor: "bg-gray-100 text-gray-800", + }; + } +} + +export default function BookingStatusPage({ token }: BookingStatusPageProps) { + const [showCancelConfirm, setShowCancelConfirm] = useState(false); + const [isCancelling, setIsCancelling] = useState(false); + const [cancellationResult, setCancellationResult] = useState<{ success: boolean; message: string; formattedDate?: string } | null>(null); + + // Fetch booking details + const { data: booking, isLoading, error, refetch } = useQuery({ + queryKey: ["booking", "status", token], + queryFn: () => queryClient.cancellation.getBookingByToken({ token }), + retry: false, + }); + + // Cancellation mutation + const cancelMutation = useMutation({ + mutationFn: () => queryClient.cancellation.cancelByToken({ token }), + onSuccess: (result) => { + setCancellationResult({ + success: true, + message: result.message, + formattedDate: result.formattedDate, + }); + setIsCancelling(false); + setShowCancelConfirm(false); + refetch(); // Refresh booking data + }, + onError: (error: any) => { + setCancellationResult({ + success: false, + message: error?.message || "Ein Fehler ist aufgetreten.", + }); + setIsCancelling(false); + }, + }); + + const handleCancel = () => { + setIsCancelling(true); + setCancellationResult(null); + cancelMutation.mutate(); + }; + + if (isLoading) { + return ( +
+
+
+
+ Buchung wird geladen... +
+
+
+ ); + } + + if (error) { + return ( +
+
+
+
+ + + +
+

Fehler

+

+ {error?.message || "Der Link ist ungültig oder abgelaufen."} +

+ + Zur Startseite + +
+
+
+ ); + } + + if (!booking) { + return ( +
+
+
+

Buchung nicht gefunden

+

+ Die angeforderte Buchung konnte nicht gefunden werden. +

+ + Zur Startseite + +
+
+
+ ); + } + + const statusInfo = getStatusInfo(booking.status); + + return ( +
+
+ {/* Header */} +
+
+ Stargil Nails Logo + + {statusInfo.icon} {statusInfo.label} + +
+

Buchungsübersicht

+

Hier findest du alle Details zu deinem Termin

+
+ + {/* Cancellation Result */} + {cancellationResult && ( +
+

+ {cancellationResult.message} + {cancellationResult.formattedDate && ( + <>
Stornierter Termin: {cancellationResult.formattedDate} + )} +

+
+ )} + + {/* Status Banner */} +
+
+
{statusInfo.icon}
+
+

+ Status: {statusInfo.label} +

+ {booking.status === "pending" && ( +

+ Wir haben deine Terminanfrage erhalten und werden sie in Kürze prüfen. Du erhältst eine E-Mail, sobald dein Termin bestätigt wurde. +

+ )} + {booking.status === "confirmed" && ( +

+ Dein Termin wurde bestätigt! Wir freuen uns auf dich. Du hast eine Bestätigungs-E-Mail mit Kalendereintrag erhalten. +

+ )} + {booking.status === "cancelled" && ( +

+ Dieser Termin wurde storniert. Du kannst jederzeit einen neuen Termin buchen. +

+ )} + {booking.status === "completed" && ( +

+ Dieser Termin wurde erfolgreich abgeschlossen. Vielen Dank für deinen Besuch! +

+ )} +
+
+
+ + {/* Appointment Details */} +
+

+ + + + Termin-Details +

+
+
+ Datum: + {booking.formattedDate} +
+
+ Uhrzeit: + {booking.appointmentTime} Uhr +
+
+ Behandlung: + {booking.treatmentName} +
+
+ Dauer: + {booking.treatmentDuration} Minuten +
+ {booking.treatmentPrice > 0 && ( +
+ Preis: + {booking.treatmentPrice.toFixed(2)} € +
+ )} + {booking.hoursUntilAppointment > 0 && booking.status !== "cancelled" && booking.status !== "completed" && ( +
+ Verbleibende Zeit: + + {booking.hoursUntilAppointment} Stunde{booking.hoursUntilAppointment !== 1 ? 'n' : ''} + +
+ )} +
+
+ + {/* Customer Details */} +
+

+ + + + Deine Daten +

+
+
+ Name: + {booking.customerName} +
+
+ E-Mail: + {booking.customerEmail} +
+
+ Telefon: + {booking.customerPhone} +
+
+ {booking.notes && ( +
+

Notizen:

+

{booking.notes}

+
+ )} +
+ + {/* Cancellation Section */} + {booking.canCancel && !cancellationResult?.success && ( +
+

+ + + + Termin stornieren +

+ + {!showCancelConfirm ? ( +
+

+ Du kannst diesen Termin noch bis {parseInt(process.env.MIN_STORNO_TIMESPAN || "24")} Stunden vor dem Termin kostenlos stornieren. +

+ +
+ ) : ( +
+
+

Bist du sicher?

+

+ Diese Aktion kann nicht rückgängig gemacht werden. Der Termin wird storniert und der Slot wird wieder für andere Kunden verfügbar. +

+
+
+ + +
+
+ )} +
+ )} + + {!booking.canCancel && booking.status !== "cancelled" && booking.status !== "completed" && ( +
+

+ ℹ️ Stornierungsfrist abgelaufen: Dieser Termin liegt weniger als {parseInt(process.env.MIN_STORNO_TIMESPAN || "24")} Stunden in der Zukunft und kann nicht mehr online storniert werden. Bitte kontaktiere uns direkt. +

+
+ )} + + {/* Footer */} + +
+
+ ); +} + diff --git a/src/client/components/cancellation-page.tsx b/src/client/components/cancellation-page.tsx deleted file mode 100644 index 1464de2..0000000 --- a/src/client/components/cancellation-page.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import React, { useState } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { queryClient } from "@/client/rpc-client"; - -interface CancellationPageProps { - token: string; -} - -export default function CancellationPage({ token }: CancellationPageProps) { - const [isCancelling, setIsCancelling] = useState(false); - const [cancellationResult, setCancellationResult] = useState<{ success: boolean; message: string; formattedDate?: string } | null>(null); - - // Fetch booking details - const { data: booking, isLoading, error } = useQuery({ - queryKey: ["cancellation", "booking", token], - queryFn: () => queryClient.cancellation.getBookingByToken({ token }), - retry: false, - }); - - // Cancellation mutation - const cancelMutation = useMutation({ - mutationFn: () => queryClient.cancellation.cancelByToken({ token }), - onSuccess: (result) => { - setCancellationResult({ - success: true, - message: result.message, - formattedDate: result.formattedDate, - }); - setIsCancelling(false); - }, - onError: (error: any) => { - setCancellationResult({ - success: false, - message: error?.message || "Ein Fehler ist aufgetreten.", - }); - setIsCancelling(false); - }, - }); - - const handleCancel = () => { - setIsCancelling(true); - setCancellationResult(null); - cancelMutation.mutate(); - }; - - if (isLoading) { - return ( -
-
-
-
- Termin wird geladen... -
-
-
- ); - } - - if (error) { - return ( -
-
-
-
- - - -
-

Fehler

-

- {error?.message || "Der Stornierungs-Link ist ungültig oder abgelaufen."} -

- - Zur Startseite - -
-
-
- ); - } - - if (cancellationResult) { - return ( -
-
-
-
- {cancellationResult.success ? ( - - - - ) : ( - - - - )} -
-

- {cancellationResult.success ? 'Termin storniert' : 'Stornierung fehlgeschlagen'} -

-

- {cancellationResult.message} - {cancellationResult.formattedDate && ( - <>
Stornierter Termin: {cancellationResult.formattedDate} - )} -

- - Zur Startseite - -
-
-
- ); - } - - if (!booking) { - return ( -
-
-
-

Termin nicht gefunden

-

- Der angeforderte Termin konnte nicht gefunden werden. -

- - Zur Startseite - -
-
-
- ); - } - - return ( -
-
-
- Stargil Nails Logo -

Termin stornieren

-
- -
-

Termin-Details

-
-
- Name: - {booking.customerName} -
-
- Datum: - {booking.formattedDate} -
-
- Uhrzeit: - {booking.appointmentTime} -
-
- Behandlung: - {(booking as any).treatmentName || 'Unbekannte Behandlung'} -
-
- Status: - - {booking.status === 'confirmed' ? 'Bestätigt' : - booking.status === 'pending' ? 'Ausstehend' : - booking.status} - -
-
-
- - {booking.status === 'cancelled' ? ( -
-

- Dieser Termin wurde bereits storniert. -

-
- ) : ( -
-
-

- ℹ️ Stornierungsfrist: Termine können nur bis zu einer bestimmten Zeit vor dem Termin storniert werden. - Falls die Stornierung nicht möglich ist, erhältst du eine entsprechende Meldung. -

-
-
-

- Hinweis: Nach der Stornierung wird der Termin-Slot wieder für andere Kunden verfügbar. - Eine erneute Buchung ist jederzeit möglich. -

-
- - -
- )} - - -
-
- ); -} diff --git a/src/server/lib/email-templates.ts b/src/server/lib/email-templates.ts index 3264a0b..73a1296 100644 --- a/src/server/lib/email-templates.ts +++ b/src/server/lib/email-templates.ts @@ -58,8 +58,8 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise`; } -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

Hallo ${name},

wir haben deine Anfrage für ${formattedDate} um ${time} erhalten.

Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.

+ ${statusUrl ? ` +
+

⏳ Termin-Status ansehen:

+

Du kannst den aktuellen Status deiner Buchung jederzeit einsehen:

+ Status ansehen +
+ ` : ''}

📋 Rechtliche Informationen:

Weitere Informationen findest du in unserem Impressum und Datenschutz.

@@ -94,10 +101,10 @@ export async function renderBookingConfirmedHTML(params: { name: string; date: s

Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.

${cancellationUrl ? ` -
-

❌ Termin stornieren:

-

Falls du den Termin stornieren möchtest, kannst du das hier tun:

- Termin stornieren +
+

📅 Termin verwalten:

+

Du kannst deinen Termin-Status einsehen und bei Bedarf stornieren:

+ Termin ansehen & verwalten
` : ''}
diff --git a/src/server/rpc/bookings.ts b/src/server/rpc/bookings.ts index fe6745c..4414088 100644 --- a/src/server/rpc/bookings.ts +++ b/src/server/rpc/bookings.ts @@ -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, }, { diff --git a/src/server/rpc/cancellation.ts b/src/server/rpc/cancellation.ts index f6cbdd1..e20bcae 100644 --- a/src/server/rpc/cancellation.ts +++ b/src/server/rpc/cancellation.ts @@ -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; +type BookingAccessToken = z.output; +// Backwards compatibility alias +type CancellationToken = BookingAccessToken; -const cancellationKV = createKV("cancellation_tokens"); +const cancellationKV = createKV("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("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)), }; });