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:
@@ -9,10 +9,10 @@
|
|||||||
|
|
||||||
### Sicherheit & Qualität
|
### Sicherheit & Qualität
|
||||||
- ~~Rate‑Limiting (IP/E‑Mail) für Formularspam~~
|
- ~~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)
|
- Audit‑Log (wer/was/wann)
|
||||||
- DSGVO: Einwilligungstexte, Löschkonzept
|
- ~~DSGVO: Einwilligungstexte, Löschkonzept~~
|
||||||
- Impressum
|
- ~~Impressum~~
|
||||||
|
|
||||||
### E‑Mail & Infrastruktur
|
### E‑Mail & Infrastruktur
|
||||||
- Retry/Backoff + Fallback‑Queue bei Resend‑Fehlern
|
- Retry/Backoff + Fallback‑Queue bei Resend‑Fehlern
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
### UX/UI
|
### UX/UI
|
||||||
- ~~Mobiler Kalender mit klarer Slot‑Visualisierung~~
|
- ~~Mobiler Kalender mit klarer Slot‑Visualisierung~~
|
||||||
- Kunden‑Statusseite (pending/confirmed)
|
- ~~Kunden‑Statusseite (pending/confirmed)~~
|
||||||
- Prominente Fehlerzustände inkl. Hinweise bei Doppelbuchung
|
- Prominente Fehlerzustände inkl. Hinweise bei Doppelbuchung
|
||||||
|
|
||||||
### Internationalisierung & Zeitzonen
|
### Internationalisierung & Zeitzonen
|
||||||
|
@@ -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} />;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@@ -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;">
|
||||||
|
@@ -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,
|
||||||
}, {
|
}, {
|
||||||
|
@@ -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)),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user