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

@@ -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 (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-2xl w-full">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-pink-500"></div>
<span className="ml-3 text-gray-600">Buchung wird geladen...</span>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-4 bg-red-100 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Fehler</h2>
<p className="text-gray-600 mb-4">
{error?.message || "Der Link ist ungültig oder abgelaufen."}
</p>
<a
href="/"
className="inline-flex items-center px-4 py-2 bg-pink-600 text-white rounded-lg hover:bg-pink-700 transition-colors"
>
Zur Startseite
</a>
</div>
</div>
</div>
);
}
if (!booking) {
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
<div className="text-center">
<h2 className="text-xl font-bold text-gray-900 mb-2">Buchung nicht gefunden</h2>
<p className="text-gray-600 mb-4">
Die angeforderte Buchung konnte nicht gefunden werden.
</p>
<a
href="/"
className="inline-flex items-center px-4 py-2 bg-pink-600 text-white rounded-lg hover:bg-pink-700 transition-colors"
>
Zur Startseite
</a>
</div>
</div>
</div>
);
}
const statusInfo = getStatusInfo(booking.status);
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 py-8 px-4">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<img
src="/assets/stargilnails_logo_transparent_112.png"
alt="Stargil Nails Logo"
className="w-16 h-16 object-contain"
/>
<span className={`px-4 py-2 rounded-full text-sm font-semibold ${statusInfo.badgeColor}`}>
{statusInfo.icon} {statusInfo.label}
</span>
</div>
<h1 className="text-2xl font-bold text-gray-900">Buchungsübersicht</h1>
<p className="text-gray-600 mt-1">Hier findest du alle Details zu deinem Termin</p>
</div>
{/* Cancellation Result */}
{cancellationResult && (
<div className={`mb-6 p-4 rounded-lg ${
cancellationResult.success ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'
}`}>
<p className={cancellationResult.success ? 'text-green-800' : 'text-red-800'}>
{cancellationResult.message}
{cancellationResult.formattedDate && (
<><br />Stornierter Termin: {cancellationResult.formattedDate}</>
)}
</p>
</div>
)}
{/* Status Banner */}
<div className={`${statusInfo.bgColor} border ${statusInfo.borderColor} rounded-lg p-6 mb-6`}>
<div className="flex items-start">
<div className={`text-4xl mr-4 ${statusInfo.textColor}`}>{statusInfo.icon}</div>
<div className="flex-1">
<h2 className={`text-xl font-bold ${statusInfo.textColor} mb-2`}>
Status: {statusInfo.label}
</h2>
{booking.status === "pending" && (
<p className={statusInfo.textColor}>
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.
</p>
)}
{booking.status === "confirmed" && (
<p className={statusInfo.textColor}>
Dein Termin wurde bestätigt! Wir freuen uns auf dich. Du hast eine Bestätigungs-E-Mail mit Kalendereintrag erhalten.
</p>
)}
{booking.status === "cancelled" && (
<p className={statusInfo.textColor}>
Dieser Termin wurde storniert. Du kannst jederzeit einen neuen Termin buchen.
</p>
)}
{booking.status === "completed" && (
<p className={statusInfo.textColor}>
Dieser Termin wurde erfolgreich abgeschlossen. Vielen Dank für deinen Besuch!
</p>
)}
</div>
</div>
</div>
{/* Appointment Details */}
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<svg className="w-5 h-5 mr-2 text-pink-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Termin-Details
</h2>
<div className="space-y-3">
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Datum:</span>
<span className="font-medium text-gray-900">{booking.formattedDate}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Uhrzeit:</span>
<span className="font-medium text-gray-900">{booking.appointmentTime} Uhr</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Behandlung:</span>
<span className="font-medium text-gray-900">{booking.treatmentName}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Dauer:</span>
<span className="font-medium text-gray-900">{booking.treatmentDuration} Minuten</span>
</div>
{booking.treatmentPrice > 0 && (
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Preis:</span>
<span className="font-medium text-gray-900">{booking.treatmentPrice.toFixed(2)} </span>
</div>
)}
{booking.hoursUntilAppointment > 0 && booking.status !== "cancelled" && booking.status !== "completed" && (
<div className="flex justify-between py-2">
<span className="text-gray-600">Verbleibende Zeit:</span>
<span className="font-medium text-pink-600">
{booking.hoursUntilAppointment} Stunde{booking.hoursUntilAppointment !== 1 ? 'n' : ''}
</span>
</div>
)}
</div>
</div>
{/* Customer Details */}
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<svg className="w-5 h-5 mr-2 text-pink-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
Deine Daten
</h2>
<div className="space-y-3">
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Name:</span>
<span className="font-medium text-gray-900">{booking.customerName}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">E-Mail:</span>
<span className="font-medium text-gray-900">{booking.customerEmail}</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-600">Telefon:</span>
<span className="font-medium text-gray-900">{booking.customerPhone}</span>
</div>
</div>
{booking.notes && (
<div className="mt-4 pt-4 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-700 mb-2">Notizen:</h3>
<p className="text-gray-600 text-sm">{booking.notes}</p>
</div>
)}
</div>
{/* Cancellation Section */}
{booking.canCancel && !cancellationResult?.success && (
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<svg className="w-5 h-5 mr-2 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Termin stornieren
</h2>
{!showCancelConfirm ? (
<div>
<p className="text-gray-600 mb-4">
Du kannst diesen Termin noch bis {parseInt(process.env.MIN_STORNO_TIMESPAN || "24")} Stunden vor dem Termin kostenlos stornieren.
</p>
<button
onClick={() => 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"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Termin stornieren
</button>
</div>
) : (
<div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<p className="text-red-800 font-semibold mb-2">Bist du sicher?</p>
<p className="text-red-700 text-sm">
Diese Aktion kann nicht rückgängig gemacht werden. Der Termin wird storniert und der Slot wird wieder für andere Kunden verfügbar.
</p>
</div>
<div className="flex gap-3">
<button
onClick={handleCancel}
disabled={isCancelling}
className="flex-1 bg-red-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center"
>
{isCancelling ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Storniere...
</>
) : (
<>Ja, stornieren</>
)}
</button>
<button
onClick={() => 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
</button>
</div>
</div>
)}
</div>
)}
{!booking.canCancel && booking.status !== "cancelled" && booking.status !== "completed" && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<p className="text-yellow-800 text-sm">
<strong> Stornierungsfrist abgelaufen:</strong> 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.
</p>
</div>
)}
{/* Footer */}
<div className="text-center">
<a
href="/"
className="inline-flex items-center text-pink-600 hover:text-pink-700 font-medium"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Zurück zur Startseite
</a>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-pink-500"></div>
<span className="ml-3 text-gray-600">Termin wird geladen...</span>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-4 bg-red-100 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Fehler</h2>
<p className="text-gray-600 mb-4">
{error?.message || "Der Stornierungs-Link ist ungültig oder abgelaufen."}
</p>
<a
href="/"
className="inline-flex items-center px-4 py-2 bg-pink-600 text-white rounded-lg hover:bg-pink-700 transition-colors"
>
Zur Startseite
</a>
</div>
</div>
</div>
);
}
if (cancellationResult) {
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
<div className="text-center">
<div className={`w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center ${
cancellationResult.success ? 'bg-green-100' : 'bg-red-100'
}`}>
{cancellationResult.success ? (
<svg className="w-8 h-8 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
</div>
<h2 className={`text-xl font-bold mb-2 ${
cancellationResult.success ? 'text-green-700' : 'text-red-700'
}`}>
{cancellationResult.success ? 'Termin storniert' : 'Stornierung fehlgeschlagen'}
</h2>
<p className="text-gray-600 mb-4">
{cancellationResult.message}
{cancellationResult.formattedDate && (
<><br />Stornierter Termin: {cancellationResult.formattedDate}</>
)}
</p>
<a
href="/"
className="inline-flex items-center px-4 py-2 bg-pink-600 text-white rounded-lg hover:bg-pink-700 transition-colors"
>
Zur Startseite
</a>
</div>
</div>
</div>
);
}
if (!booking) {
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
<div className="text-center">
<h2 className="text-xl font-bold text-gray-900 mb-2">Termin nicht gefunden</h2>
<p className="text-gray-600 mb-4">
Der angeforderte Termin konnte nicht gefunden werden.
</p>
<a
href="/"
className="inline-flex items-center px-4 py-2 bg-pink-600 text-white rounded-lg hover:bg-pink-700 transition-colors"
>
Zur Startseite
</a>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
<div className="text-center mb-6">
<img
src="/assets/stargilnails_logo_transparent_112.png"
alt="Stargil Nails Logo"
className="w-16 h-16 mx-auto mb-4 object-contain"
/>
<h1 className="text-2xl font-bold text-gray-900">Termin stornieren</h1>
</div>
<div className="bg-gray-50 rounded-lg p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Termin-Details</h2>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600">Name:</span>
<span className="font-medium text-gray-900">{booking.customerName}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Datum:</span>
<span className="font-medium text-gray-900">{booking.formattedDate}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Uhrzeit:</span>
<span className="font-medium text-gray-900">{booking.appointmentTime}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Behandlung:</span>
<span className="font-medium text-gray-900">{(booking as any).treatmentName || 'Unbekannte Behandlung'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Status:</span>
<span className={`font-medium ${
booking.status === 'confirmed' ? 'text-green-600' :
booking.status === 'pending' ? 'text-yellow-600' :
'text-gray-600'
}`}>
{booking.status === 'confirmed' ? 'Bestätigt' :
booking.status === 'pending' ? 'Ausstehend' :
booking.status}
</span>
</div>
</div>
</div>
{booking.status === 'cancelled' ? (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<p className="text-yellow-800 text-center">
Dieser Termin wurde bereits storniert.
</p>
</div>
) : (
<div className="space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-blue-800 text-sm">
<strong> Stornierungsfrist:</strong> 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.
</p>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-red-800 text-sm">
<strong>Hinweis:</strong> Nach der Stornierung wird der Termin-Slot wieder für andere Kunden verfügbar.
Eine erneute Buchung ist jederzeit möglich.
</p>
</div>
<button
onClick={handleCancel}
disabled={isCancelling}
className="w-full bg-red-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center"
>
{isCancelling ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Storniere...
</>
) : (
<>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Ich möchte diesen Termin stornieren
</>
)}
</button>
</div>
)}
<div className="text-center mt-6">
<a
href="/"
className="text-pink-600 hover:text-pink-700 text-sm"
>
Zurück zur Startseite
</a>
</div>
</div>
</div>
);
}