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:
380
src/client/components/booking-status-page.tsx
Normal file
380
src/client/components/booking-status-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
Reference in New Issue
Block a user