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 */}
+
+
+
+
+ {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.
+
+
setShowCancelConfirm(true)}
+ className="w-full bg-red-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-red-700 transition-colors flex items-center justify-center"
+ >
+
+
+
+ Termin 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.
+
+
+
+
+ {isCancelling ? (
+ <>
+
+ Storniere...
+ >
+ ) : (
+ <>Ja, stornieren>
+ )}
+
+
setShowCancelConfirm(false)}
+ disabled={isCancelling}
+ className="flex-1 bg-gray-200 text-gray-800 py-3 px-4 rounded-lg font-medium hover:bg-gray-300 disabled:opacity-50 transition-colors"
+ >
+ Abbrechen
+
+
+
+ )}
+
+ )}
+
+ {!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 (
-
-
-
-
-
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.
-
-
-
-
- {isCancelling ? (
- <>
-
- Storniere...
- >
- ) : (
- <>
-
-
-
- Ich möchte diesen Termin stornieren
- >
- )}
-
-
- )}
-
-
-
-
- );
-}
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
+
` : ''}
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)),
};
});