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

@@ -9,10 +9,10 @@
### Sicherheit & Qualität ### Sicherheit & Qualität
- ~~RateLimiting (IP/EMail) für Formularspam~~ - ~~RateLimiting (IP/EMail) für Formularspam~~
- EMailVerifizierung (DoubleOptIn) optional - ~~EMailVerifizierung (DoubleOptIn) optional~~
- AuditLog (wer/was/wann) - AuditLog (wer/was/wann)
- DSGVO: Einwilligungstexte, Löschkonzept - ~~DSGVO: Einwilligungstexte, Löschkonzept~~
- Impressum - ~~Impressum~~
### EMail & Infrastruktur ### EMail & Infrastruktur
- Retry/Backoff + FallbackQueue bei ResendFehlern - Retry/Backoff + FallbackQueue bei ResendFehlern
@@ -22,7 +22,7 @@
### UX/UI ### UX/UI
- ~~Mobiler Kalender mit klarer SlotVisualisierung~~ - ~~Mobiler Kalender mit klarer SlotVisualisierung~~
- KundenStatusseite (pending/confirmed) - ~~KundenStatusseite (pending/confirmed)~~
- Prominente Fehlerzustände inkl. Hinweise bei Doppelbuchung - Prominente Fehlerzustände inkl. Hinweise bei Doppelbuchung
### Internationalisierung & Zeitzonen ### Internationalisierung & Zeitzonen

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState } from "react";
import { useAuth } from "@/client/components/auth-provider"; import { useAuth } from "@/client/components/auth-provider";
import { LoginForm } from "@/client/components/login-form"; import { LoginForm } from "@/client/components/login-form";
import { UserProfile } from "@/client/components/user-profile"; 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 { AdminCalendar } from "@/client/components/admin-calendar";
import { InitialDataLoader } from "@/client/components/initial-data-loader"; import { InitialDataLoader } from "@/client/components/initial-data-loader";
import { AdminAvailability } from "@/client/components/admin-availability"; 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"; import LegalPage from "@/client/components/legal-page";
function App() { function App() {
const { user, isLoading, isOwner } = useAuth(); const { user, isLoading, isOwner } = useAuth();
const [activeTab, setActiveTab] = useState<"booking" | "admin-treatments" | "admin-bookings" | "admin-calendar" | "admin-availability" | "profile" | "legal">("booking"); const [activeTab, setActiveTab] = useState<"booking" | "admin-treatments" | "admin-bookings" | "admin-calendar" | "admin-availability" | "profile" | "legal">("booking");
// Check for cancellation token in URL // Handle booking status page
useEffect(() => {
const path = window.location.pathname; const path = window.location.pathname;
if (path.startsWith('/cancel/')) { if (path.startsWith('/booking/')) {
const token = path.split('/cancel/')[1]; const token = path.split('/booking/')[1];
if (token) { if (token) {
// Set a special state to show cancellation page return <BookingStatusPage token={token} />;
setActiveTab("cancellation" as any);
return;
}
}
}, []);
// Handle cancellation page
const path = window.location.pathname;
if (path.startsWith('/cancel/')) {
const token = path.split('/cancel/')[1];
if (token) {
return <CancellationPage token={token} />;
} }
} }

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

View File

@@ -58,8 +58,8 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise<stri
</div>`; </div>`;
} }
export async function renderBookingPendingHTML(params: { name: string; date: string; time: string }) { export async function renderBookingPendingHTML(params: { name: string; date: string; time: string; statusUrl?: string }) {
const { name, date, time } = params; const { name, date, time, statusUrl } = params;
const formattedDate = formatDateGerman(date); const formattedDate = formatDateGerman(date);
const domain = process.env.DOMAIN || 'localhost:5173'; const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https'; const protocol = domain.includes('localhost') ? 'http' : 'https';
@@ -69,6 +69,13 @@ export async function renderBookingPendingHTML(params: { name: string; date: str
<p>Hallo ${name},</p> <p>Hallo ${name},</p>
<p>wir haben deine Anfrage für <strong>${formattedDate}</strong> um <strong>${time}</strong> erhalten.</p> <p>wir haben deine Anfrage für <strong>${formattedDate}</strong> um <strong>${time}</strong> erhalten.</p>
<p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p> <p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p>
${statusUrl ? `
<div style="background-color: #fef9f5; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #f59e0b;">⏳ Termin-Status ansehen:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Du kannst den aktuellen Status deiner Buchung jederzeit einsehen:</p>
<a href="${statusUrl}" style="display: inline-block; background-color: #f59e0b; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Status ansehen</a>
</div>
` : ''}
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;"> <div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p> <p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p> <p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>
@@ -94,10 +101,10 @@ export async function renderBookingConfirmedHTML(params: { name: string; date: s
<p style="margin: 8px 0 0 0; color: #475569;">Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.</p> <p style="margin: 8px 0 0 0; color: #475569;">Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.</p>
</div> </div>
${cancellationUrl ? ` ${cancellationUrl ? `
<div style="background-color: #fef3f2; border-left: 4px solid #ef4444; padding: 16px; margin: 20px 0; border-radius: 4px;"> <div style="background-color: #fef9f5; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #ef4444;"> Termin stornieren:</p> <p style="margin: 0; font-weight: 600; color: #db2777;">📅 Termin verwalten:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Falls du den Termin stornieren möchtest, kannst du das hier tun:</p> <p style="margin: 8px 0 12px 0; color: #475569;">Du kannst deinen Termin-Status einsehen und bei Bedarf stornieren:</p>
<a href="${cancellationUrl}" style="display: inline-block; background-color: #ef4444; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Termin stornieren</a> <a href="${cancellationUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Termin ansehen & verwalten</a>
</div> </div>
` : ''} ` : ''}
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;"> <div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">

View File

@@ -158,13 +158,22 @@ const create = os
// Notify customer: request received (pending) // Notify customer: request received (pending)
void (async () => { 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 formattedDate = formatDateGerman(input.appointmentDate);
const homepageUrl = generateUrl(); 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({ await sendEmail({
to: input.customerEmail, to: input.customerEmail,
subject: "Deine Terminanfrage ist eingegangen", 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, html,
}).catch(() => {}); }).catch(() => {});
})(); })();
@@ -292,18 +301,18 @@ const updateStatus = os
// Email notifications on status changes // Email notifications on status changes
try { try {
if (input.status === "confirmed") { if (input.status === "confirmed") {
// Create cancellation token for this booking // Create booking access token for this booking (status + cancellation)
const cancellationToken = await queryClient.cancellation.createToken({ bookingId: booking.id }); const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: booking.id });
const formattedDate = formatDateGerman(booking.appointmentDate); const formattedDate = formatDateGerman(booking.appointmentDate);
const cancellationUrl = generateUrl(`/cancel/${cancellationToken.token}`); const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
const homepageUrl = generateUrl(); const homepageUrl = generateUrl();
const html = await renderBookingConfirmedHTML({ const html = await renderBookingConfirmedHTML({
name: booking.customerName, name: booking.customerName,
date: booking.appointmentDate, date: booking.appointmentDate,
time: booking.appointmentTime, time: booking.appointmentTime,
cancellationUrl cancellationUrl: bookingUrl // Now points to booking status page
}); });
// Get treatment information for ICS file // Get treatment information for ICS file
@@ -315,7 +324,7 @@ const updateStatus = os
await sendEmailWithAGBAndCalendar({ await sendEmailWithAGBAndCalendar({
to: booking.customerEmail, to: booking.customerEmail,
subject: "Dein Termin wurde bestätigt - AGB im Anhang", 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, html,
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined, cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
}, { }, {

View File

@@ -4,18 +4,21 @@ import { createKV } from "@/server/lib/create-kv";
import { createKV as createAvailabilityKV } from "@/server/lib/create-kv"; import { createKV as createAvailabilityKV } from "@/server/lib/create-kv";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
// Schema for cancellation token // Schema for booking access token (used for both status viewing and cancellation)
const CancellationTokenSchema = z.object({ const BookingAccessTokenSchema = z.object({
id: z.string(), id: z.string(),
bookingId: z.string(), bookingId: z.string(),
token: z.string(), token: z.string(),
expiresAt: z.string(), expiresAt: z.string(),
createdAt: 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 // Types for booking and availability
type Booking = { type Booking = {
@@ -70,12 +73,13 @@ const createToken = os
expiresAt.setDate(expiresAt.getDate() + 30); expiresAt.setDate(expiresAt.getDate() + 30);
const token = randomUUID(); const token = randomUUID();
const cancellationToken: CancellationToken = { const cancellationToken: BookingAccessToken = {
id: randomUUID(), id: randomUUID(),
bookingId: input.bookingId, bookingId: input.bookingId,
token, token,
expiresAt: expiresAt.toISOString(), expiresAt: expiresAt.toISOString(),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
purpose: "booking_access",
}; };
await cancellationKV.setItem(cancellationToken.id, cancellationToken); await cancellationKV.setItem(cancellationToken.id, cancellationToken);
@@ -105,15 +109,32 @@ const getBookingByToken = os
const treatmentsKV = createKV<any>("treatments"); const treatmentsKV = createKV<any>("treatments");
const treatment = await treatmentsKV.getItem(booking.treatmentId); 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 { return {
id: booking.id, id: booking.id,
customerName: booking.customerName, customerName: booking.customerName,
customerEmail: booking.customerEmail,
customerPhone: booking.customerPhone,
appointmentDate: booking.appointmentDate, appointmentDate: booking.appointmentDate,
appointmentTime: booking.appointmentTime, appointmentTime: booking.appointmentTime,
treatmentId: booking.treatmentId, treatmentId: booking.treatmentId,
treatmentName: treatment?.name || "Unbekannte Behandlung", treatmentName: treatment?.name || "Unbekannte Behandlung",
treatmentDuration: treatment?.duration || 60,
treatmentPrice: treatment?.price || 0,
status: booking.status, status: booking.status,
notes: booking.notes,
formattedDate: formatDateGerman(booking.appointmentDate), formattedDate: formatDateGerman(booking.appointmentDate),
createdAt: booking.createdAt,
canCancel,
hoursUntilAppointment: Math.max(0, Math.round(timeDifferenceHours)),
}; };
}); });