Implementiere Stornierungssystem und E-Mail-Links zur Hauptseite
- Neues Stornierungssystem mit sicheren Token-basierten Links - Stornierungsfrist konfigurierbar über MIN_STORNO_TIMESPAN (24h Standard) - Stornierungs-Seite mit Buchungsdetails und Ein-Klick-Stornierung - Automatische Slot-Freigabe bei Stornierung - Stornierungs-Link in Bestätigungs-E-Mails integriert - Alle E-Mails enthalten jetzt Links zur Hauptseite (DOMAIN Variable) - Schöne HTML-Buttons und Text-Links in allen E-Mail-Templates - Vollständige Validierung: Vergangenheits-Check, Token-Ablauf, Stornierungsfrist - Responsive Stornierungs-Seite mit Loading-States und Fehlerbehandlung - Dokumentation in README.md aktualisiert
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAuth } from "@/client/components/auth-provider";
|
||||
import { LoginForm } from "@/client/components/login-form";
|
||||
import { UserProfile } from "@/client/components/user-profile";
|
||||
@@ -8,11 +8,34 @@ import { AdminBookings } from "@/client/components/admin-bookings";
|
||||
import { AdminCalendar } from "@/client/components/admin-calendar";
|
||||
import { InitialDataLoader } from "@/client/components/initial-data-loader";
|
||||
import { AdminAvailability } from "@/client/components/admin-availability";
|
||||
import CancellationPage from "@/client/components/cancellation-page";
|
||||
|
||||
function App() {
|
||||
const { user, isLoading, isOwner } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState<"booking" | "admin-treatments" | "admin-bookings" | "admin-calendar" | "admin-availability" | "profile">("booking");
|
||||
|
||||
// Check for cancellation token in URL
|
||||
useEffect(() => {
|
||||
const path = window.location.pathname;
|
||||
if (path.startsWith('/cancel/')) {
|
||||
const token = path.split('/cancel/')[1];
|
||||
if (token) {
|
||||
// Set a special state to show cancellation page
|
||||
setActiveTab("cancellation" as any);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle cancellation page
|
||||
const path = window.location.pathname;
|
||||
if (path.startsWith('/cancel/')) {
|
||||
const token = path.split('/cancel/')[1];
|
||||
if (token) {
|
||||
return <CancellationPage token={token} />;
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading spinner while checking authentication
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
@@ -110,8 +110,6 @@ export function AdminAvailability() {
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Verfügbarkeiten verwalten</h2>
|
||||
|
||||
{/* Slot Type Selection */}
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">Neuen Slot anlegen</h3>
|
||||
|
@@ -66,8 +66,6 @@ export function AdminBookings() {
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Manage Bookings</h2>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
|
@@ -13,6 +13,7 @@ export function BookingForm() {
|
||||
const [agbAccepted, setAgbAccepted] = useState(false);
|
||||
const [inspirationPhoto, setInspirationPhoto] = useState<string>("");
|
||||
const [photoPreview, setPhotoPreview] = useState<string>("");
|
||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||
|
||||
const { data: treatments } = useQuery(
|
||||
queryClient.treatments.live.list.experimental_liveOptions()
|
||||
@@ -22,7 +23,26 @@ export function BookingForm() {
|
||||
const { data: allSlots } = useQuery(
|
||||
queryClient.availability.live.list.experimental_liveOptions()
|
||||
);
|
||||
const freeSlots = (allSlots || []).filter((s) => s.status === "free");
|
||||
|
||||
// Filtere freie Slots und entferne vergangene Termine
|
||||
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
const freeSlots = (allSlots || []).filter((s) => {
|
||||
// Nur freie Slots
|
||||
if (s.status !== "free") return false;
|
||||
|
||||
// Nur zukünftige oder heutige Termine
|
||||
if (s.date < today) return false;
|
||||
|
||||
// Für heute: nur zukünftige Uhrzeiten
|
||||
if (s.date === today) {
|
||||
const now = new Date();
|
||||
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
if (s.time <= currentTime) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const availableDates = Array.from(new Set(freeSlots.map((s) => s.date))).sort();
|
||||
const slotsByDate = appointmentDate
|
||||
? freeSlots.filter((s) => s.date === appointmentDate)
|
||||
@@ -33,15 +53,32 @@ export function BookingForm() {
|
||||
);
|
||||
|
||||
const selectedTreatmentData = treatments?.find((t) => t.id === selectedTreatment);
|
||||
const availableSlots = (slotsByDate || []).filter((s) => s.status === "free");
|
||||
const availableSlots = slotsByDate || []; // Slots sind bereits gefiltert
|
||||
|
||||
// Debug logging (commented out - uncomment if needed)
|
||||
// console.log("Debug - All slots:", allSlots);
|
||||
// console.log("Debug - Free slots:", freeSlots);
|
||||
// console.log("Debug - Available dates:", availableDates);
|
||||
// console.log("Debug - Selected date:", appointmentDate);
|
||||
// console.log("Debug - Slots by date:", slotsByDate);
|
||||
// console.log("Debug - Available slots:", availableSlots);
|
||||
|
||||
// Additional debugging for slot status
|
||||
// if (allSlots && allSlots.length > 0) {
|
||||
// const statusCounts = allSlots.reduce((acc, slot) => {
|
||||
// acc[slot.status] = (acc[slot.status] || 0) + 1;
|
||||
// return acc;
|
||||
// }, {} as Record<string, number>);
|
||||
// console.log("Debug - Slot status counts:", statusCounts);
|
||||
// }
|
||||
|
||||
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Check file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert("Das Foto ist zu groß. Bitte wähle ein Bild unter 5MB.");
|
||||
|
||||
// Check file size (max 2MB for better performance)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
alert("Das Foto ist zu groß. Bitte wähle ein Bild unter 2MB.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -51,13 +88,46 @@ export function BookingForm() {
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const result = event.target?.result as string;
|
||||
setInspirationPhoto(result);
|
||||
setPhotoPreview(result);
|
||||
// Compress the image before converting to base64
|
||||
const compressImage = (file: File, maxWidth: number = 800, quality: number = 0.8): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
// Calculate new dimensions
|
||||
let { width, height } = img;
|
||||
if (width > maxWidth) {
|
||||
height = (height * maxWidth) / width;
|
||||
width = maxWidth;
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// Draw and compress
|
||||
ctx?.drawImage(img, 0, 0, width, height);
|
||||
const compressedDataUrl = canvas.toDataURL('image/jpeg', quality);
|
||||
resolve(compressedDataUrl);
|
||||
};
|
||||
|
||||
img.onerror = reject;
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
try {
|
||||
const compressedDataUrl = await compressImage(file);
|
||||
setInspirationPhoto(compressedDataUrl);
|
||||
setPhotoPreview(compressedDataUrl);
|
||||
// console.log(`Photo compressed: ${file.size} bytes → ${compressedDataUrl.length} chars`);
|
||||
} catch (error) {
|
||||
console.error('Photo compression failed:', error);
|
||||
alert('Fehler beim Verarbeiten des Bildes. Bitte versuche es mit einem anderen Bild.');
|
||||
return;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const removePhoto = () => {
|
||||
@@ -70,16 +140,39 @@ export function BookingForm() {
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrorMessage(""); // Clear any previous error messages
|
||||
|
||||
// console.log("Form submitted with data:", {
|
||||
// selectedTreatment,
|
||||
// customerName,
|
||||
// customerEmail,
|
||||
// customerPhone,
|
||||
// appointmentDate,
|
||||
// selectedSlotId,
|
||||
// agbAccepted
|
||||
// });
|
||||
|
||||
if (!selectedTreatment || !customerName || !customerEmail || !customerPhone || !appointmentDate || !selectedSlotId) {
|
||||
alert("Bitte fülle alle erforderlichen Felder aus");
|
||||
setErrorMessage("Bitte fülle alle erforderlichen Felder aus.");
|
||||
return;
|
||||
}
|
||||
if (!agbAccepted) {
|
||||
alert("Bitte bestätige die Kenntnisnahme der Allgemeinen Geschäftsbedingungen");
|
||||
setErrorMessage("Bitte bestätige die Kenntnisnahme der Allgemeinen Geschäftsbedingungen.");
|
||||
return;
|
||||
}
|
||||
const slot = availableSlots.find((s) => s.id === selectedSlotId);
|
||||
const appointmentTime = slot?.time || "";
|
||||
// console.log("Creating booking with data:", {
|
||||
// treatmentId: selectedTreatment,
|
||||
// customerName,
|
||||
// customerEmail,
|
||||
// customerPhone,
|
||||
// appointmentDate,
|
||||
// appointmentTime,
|
||||
// notes,
|
||||
// inspirationPhoto,
|
||||
// slotId: selectedSlotId,
|
||||
// });
|
||||
createBooking(
|
||||
{
|
||||
treatmentId: selectedTreatment,
|
||||
@@ -104,17 +197,22 @@ export function BookingForm() {
|
||||
setAgbAccepted(false);
|
||||
setInspirationPhoto("");
|
||||
setPhotoPreview("");
|
||||
setErrorMessage("");
|
||||
// Reset file input
|
||||
const fileInput = document.getElementById('photo-upload') as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
alert("Buchung erfolgreich erstellt! Wir werden dich kontaktieren, um deinen Termin zu bestätigen.");
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error("Booking error:", error);
|
||||
const errorText = error?.message || error?.toString() || "Ein unbekannter Fehler ist aufgetreten.";
|
||||
setErrorMessage(errorText);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Get minimum date (today) – nicht mehr genutzt, Datumsauswahl erfolgt aus freien Slots
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-lg p-6">
|
||||
@@ -226,6 +324,11 @@ export function BookingForm() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{appointmentDate && availableSlots.length === 0 && (
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Keine freien Zeitslots für {appointmentDate} verfügbar.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -307,6 +410,18 @@ export function BookingForm() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{errorMessage && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md mb-4">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="font-medium">{errorMessage}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
|
247
src/client/components/cancellation-page.tsx
Normal file
247
src/client/components/cancellation-page.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
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>
|
||||
);
|
||||
}
|
@@ -27,6 +27,10 @@ async function getLogoDataUrl(): Promise<string | null> {
|
||||
|
||||
async function renderBrandedEmail(title: string, bodyHtml: string): Promise<string> {
|
||||
const logo = await getLogoDataUrl();
|
||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const homepageUrl = `${protocol}://${domain}`;
|
||||
|
||||
return `
|
||||
<div style="font-family: Arial, sans-serif; color: #0f172a; background:#fdf2f8; padding:24px;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px; margin:0 auto; background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.06)">
|
||||
@@ -42,6 +46,9 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise<stri
|
||||
${bodyHtml}
|
||||
</div>
|
||||
<hr style="border:none; border-top:1px solid #f1f5f9; margin:24px 0" />
|
||||
<div style="text-align:center; margin-bottom:16px;">
|
||||
<a href="${homepageUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px;">Zur Website</a>
|
||||
</div>
|
||||
<div style="font-size:12px; color:#64748b; text-align:center;">
|
||||
© ${new Date().getFullYear()} Stargirlnails Kiel • Professional Nail Care
|
||||
</div>
|
||||
@@ -63,8 +70,8 @@ export async function renderBookingPendingHTML(params: { name: string; date: str
|
||||
return renderBrandedEmail("Deine Terminanfrage ist eingegangen", inner);
|
||||
}
|
||||
|
||||
export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string }) {
|
||||
const { name, date, time } = params;
|
||||
export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string; cancellationUrl?: string }) {
|
||||
const { name, date, time, cancellationUrl } = params;
|
||||
const formattedDate = formatDateGerman(date);
|
||||
const inner = `
|
||||
<p>Hallo ${name},</p>
|
||||
@@ -74,6 +81,13 @@ export async function renderBookingConfirmedHTML(params: { name: string; date: s
|
||||
<p style="margin: 0; font-weight: 600; color: #db2777;">📋 Wichtiger Hinweis:</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>
|
||||
${cancellationUrl ? `
|
||||
<div style="background-color: #fef3f2; border-left: 4px solid #ef4444; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; font-weight: 600; color: #ef4444;">❌ Termin stornieren:</p>
|
||||
<p style="margin: 8px 0 12px 0; color: #475569;">Falls du den Termin stornieren möchtest, kannst du das hier tun:</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>
|
||||
</div>
|
||||
` : ''}
|
||||
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
|
||||
`;
|
||||
return renderBrandedEmail("Termin bestätigt", inner);
|
||||
|
@@ -108,6 +108,14 @@ export async function sendEmailWithInspirationPhoto(
|
||||
const [, extension, base64Content] = match;
|
||||
const filename = `inspiration_${customerName.replace(/[^a-zA-Z0-9]/g, '_')}_${Date.now()}.${extension}`;
|
||||
|
||||
// Check if attachment is too large (max 1MB base64 content)
|
||||
if (base64Content.length > 1024 * 1024) {
|
||||
console.warn(`Photo attachment too large: ${base64Content.length} chars, skipping attachment`);
|
||||
return sendEmail(params);
|
||||
}
|
||||
|
||||
// console.log(`Sending email with photo attachment: ${filename}, size: ${base64Content.length} chars`);
|
||||
|
||||
params.attachments = [
|
||||
...(params.attachments || []),
|
||||
{
|
||||
|
@@ -85,7 +85,34 @@ const remove = os
|
||||
});
|
||||
|
||||
const list = os.handler(async () => {
|
||||
return kv.getAllItems();
|
||||
const allSlots = await kv.getAllItems();
|
||||
|
||||
// Filter out past slots automatically
|
||||
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
const now = new Date();
|
||||
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
|
||||
const filteredSlots = allSlots.filter(slot => {
|
||||
// Keep slots for future dates
|
||||
if (slot.date > today) return true;
|
||||
|
||||
// For today: only keep future time slots
|
||||
if (slot.date === today) {
|
||||
return slot.time > currentTime;
|
||||
}
|
||||
|
||||
// Remove past slots
|
||||
return false;
|
||||
});
|
||||
|
||||
// Debug logging (commented out - uncomment if needed)
|
||||
// const statusCounts = filteredSlots.reduce((acc, slot) => {
|
||||
// acc[slot.status] = (acc[slot.status] || 0) + 1;
|
||||
// return acc;
|
||||
// }, {} as Record<string, number>);
|
||||
// console.log(`Availability list: ${filteredSlots.length} slots (${JSON.stringify(statusCounts)})`);
|
||||
|
||||
return filteredSlots;
|
||||
});
|
||||
|
||||
const get = os.input(z.string()).handler(async ({ input }) => {
|
||||
|
@@ -5,6 +5,13 @@ import { createKV } from "@/server/lib/create-kv";
|
||||
import { createKV as createAvailabilityKV } from "@/server/lib/create-kv";
|
||||
import { sendEmail, sendEmailWithAGB, sendEmailWithInspirationPhoto } from "@/server/lib/email";
|
||||
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML } from "@/server/lib/email-templates";
|
||||
import { router } from "@/server/rpc";
|
||||
import { createORPCClient } from "@orpc/client";
|
||||
import { RPCLink } from "@orpc/client/fetch";
|
||||
|
||||
// Create a server-side client to call other RPC endpoints
|
||||
const link = new RPCLink({ url: "http://localhost:5173/rpc" });
|
||||
const queryClient = createORPCClient<typeof router>(link);
|
||||
|
||||
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
|
||||
function formatDateGerman(dateString: string): string {
|
||||
@@ -57,6 +64,27 @@ const treatmentsKV = createTreatmentsKV<Treatment>("treatments");
|
||||
const create = os
|
||||
.input(BookingSchema.omit({ id: true, createdAt: true, status: true }))
|
||||
.handler(async ({ input }) => {
|
||||
// console.log("Booking create called with input:", {
|
||||
// ...input,
|
||||
// inspirationPhoto: input.inspirationPhoto ? `[${input.inspirationPhoto.length} chars]` : null
|
||||
// });
|
||||
|
||||
try {
|
||||
// Validate that the booking is not in the past
|
||||
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
if (input.appointmentDate < today) {
|
||||
throw new Error("Buchungen für vergangene Termine sind nicht möglich.");
|
||||
}
|
||||
|
||||
// For today's bookings, check if the time is not in the past
|
||||
if (input.appointmentDate === today) {
|
||||
const now = new Date();
|
||||
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
if (input.appointmentTime <= currentTime) {
|
||||
throw new Error("Buchungen für vergangene Uhrzeiten sind nicht möglich.");
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent double booking: same customer email with pending/confirmed on same date
|
||||
const existing = await kv.getAllItems();
|
||||
const hasConflict = existing.some(b =>
|
||||
@@ -91,11 +119,14 @@ const create = os
|
||||
// Notify customer: request received (pending)
|
||||
void (async () => {
|
||||
const formattedDate = formatDateGerman(input.appointmentDate);
|
||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const homepageUrl = `${protocol}://${domain}`;
|
||||
const html = await renderBookingPendingHTML({ name: input.customerName, date: input.appointmentDate, time: input.appointmentTime });
|
||||
await sendEmail({
|
||||
to: input.customerEmail,
|
||||
subject: "Deine Terminanfrage ist eingegangen",
|
||||
text: `Hallo ${input.customerName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze.\n\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.\n\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||
html,
|
||||
}).catch(() => {});
|
||||
})();
|
||||
@@ -119,6 +150,10 @@ const create = os
|
||||
hasInspirationPhoto: !!input.inspirationPhoto
|
||||
});
|
||||
|
||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const homepageUrl = `${protocol}://${domain}`;
|
||||
|
||||
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
|
||||
`Name: ${input.customerName}\n` +
|
||||
`Telefon: ${input.customerPhone}\n` +
|
||||
@@ -127,6 +162,7 @@ const create = os
|
||||
`Uhrzeit: ${input.appointmentTime}\n` +
|
||||
`${input.notes ? `Notizen: ${input.notes}\n` : ''}` +
|
||||
`Inspiration-Foto: ${input.inspirationPhoto ? 'Im Anhang verfügbar' : 'Kein Foto hochgeladen'}\n\n` +
|
||||
`Zur Website: ${homepageUrl}\n\n` +
|
||||
`Bitte logge dich in das Admin-Panel ein, um die Buchung zu bearbeiten.`;
|
||||
|
||||
if (input.inspirationPhoto) {
|
||||
@@ -146,13 +182,17 @@ const create = os
|
||||
}
|
||||
})();
|
||||
return booking;
|
||||
} catch (error) {
|
||||
console.error("Booking creation error:", error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// Owner check reuse (simple inline version)
|
||||
type Session = { id: string; userId: string; expiresAt: string; createdAt: string };
|
||||
type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string };
|
||||
const sessionsKV = createAvailabilityKV<Session>("sessions");
|
||||
const usersKV = createAvailabilityKV<User>("users");
|
||||
const sessionsKV = createKV<Session>("sessions");
|
||||
const usersKV = createKV<User>("users");
|
||||
async function assertOwner(sessionId: string): Promise<void> {
|
||||
const session = await sessionsKV.getItem(sessionId);
|
||||
if (!session) throw new Error("Invalid session");
|
||||
@@ -180,6 +220,8 @@ const updateStatus = os
|
||||
if (booking.slotId) {
|
||||
const slot = await availabilityKV.getItem(booking.slotId);
|
||||
if (slot) {
|
||||
// console.log(`Updating slot ${slot.id} (${slot.date} ${slot.time}) from ${slot.status} to ${input.status}`);
|
||||
|
||||
if (input.status === "cancelled") {
|
||||
// Free the slot again
|
||||
await availabilityKV.setItem(slot.id, {
|
||||
@@ -187,6 +229,7 @@ const updateStatus = os
|
||||
status: "free",
|
||||
reservedByBookingId: undefined,
|
||||
});
|
||||
// console.log(`Slot ${slot.id} freed due to cancellation`);
|
||||
} else if (input.status === "pending") {
|
||||
// keep reserved as pending
|
||||
if (slot.status !== "reserved") {
|
||||
@@ -195,6 +238,7 @@ const updateStatus = os
|
||||
status: "reserved",
|
||||
reservedByBookingId: booking.id,
|
||||
});
|
||||
// console.log(`Slot ${slot.id} reserved for pending booking`);
|
||||
}
|
||||
} else if (input.status === "confirmed" || input.status === "completed") {
|
||||
// keep reserved; optionally noop
|
||||
@@ -204,6 +248,7 @@ const updateStatus = os
|
||||
status: "reserved",
|
||||
reservedByBookingId: booking.id,
|
||||
});
|
||||
// console.log(`Slot ${slot.id} confirmed as reserved`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,22 +256,40 @@ const updateStatus = os
|
||||
// Email notifications on status changes
|
||||
try {
|
||||
if (input.status === "confirmed") {
|
||||
// Create cancellation token for this booking
|
||||
const cancellationToken = await queryClient.cancellation.createToken({ bookingId: booking.id });
|
||||
|
||||
const formattedDate = formatDateGerman(booking.appointmentDate);
|
||||
const html = await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
|
||||
const cancellationUrl = `${process.env.FRONTEND_URL || 'http://localhost:5173'}/cancel/${cancellationToken.token}`;
|
||||
|
||||
const html = await renderBookingConfirmedHTML({
|
||||
name: booking.customerName,
|
||||
date: booking.appointmentDate,
|
||||
time: booking.appointmentTime,
|
||||
cancellationUrl
|
||||
});
|
||||
|
||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const homepageUrl = `${protocol}://${domain}`;
|
||||
|
||||
await sendEmailWithAGB({
|
||||
to: booking.customerEmail,
|
||||
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
||||
text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\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\nFalls du den Termin stornieren möchtest, kannst du das hier tun: ${cancellationUrl}\n\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
|
||||
html,
|
||||
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
});
|
||||
} else if (input.status === "cancelled") {
|
||||
const formattedDate = formatDateGerman(booking.appointmentDate);
|
||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const homepageUrl = `${protocol}://${domain}`;
|
||||
const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
|
||||
await sendEmail({
|
||||
to: booking.customerEmail,
|
||||
subject: "Dein Termin wurde abgesagt",
|
||||
text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||
text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||
html,
|
||||
cc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
});
|
||||
|
199
src/server/rpc/cancellation.ts
Normal file
199
src/server/rpc/cancellation.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { createKV } from "@/server/lib/create-kv";
|
||||
import { createKV as createAvailabilityKV } from "@/server/lib/create-kv";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
// Schema for cancellation token
|
||||
const CancellationTokenSchema = z.object({
|
||||
id: z.string(),
|
||||
bookingId: z.string(),
|
||||
token: z.string(),
|
||||
expiresAt: z.string(),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
|
||||
type CancellationToken = z.output<typeof CancellationTokenSchema>;
|
||||
|
||||
const cancellationKV = createKV<CancellationToken>("cancellation_tokens");
|
||||
|
||||
// Types for booking and availability
|
||||
type Booking = {
|
||||
id: string;
|
||||
treatmentId: string;
|
||||
customerName: string;
|
||||
customerEmail: string;
|
||||
customerPhone: string;
|
||||
appointmentDate: string;
|
||||
appointmentTime: string;
|
||||
notes?: string;
|
||||
inspirationPhoto?: string;
|
||||
slotId?: string;
|
||||
status: "pending" | "confirmed" | "cancelled" | "completed";
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type Availability = {
|
||||
id: string;
|
||||
date: string;
|
||||
time: string;
|
||||
durationMinutes: number;
|
||||
status: "free" | "reserved";
|
||||
reservedByBookingId?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const bookingsKV = createKV<Booking>("bookings");
|
||||
const availabilityKV = createAvailabilityKV<Availability>("availability");
|
||||
|
||||
// Helper function to convert date from yyyy-mm-dd to dd.mm.yyyy
|
||||
function formatDateGerman(dateString: string): string {
|
||||
const [year, month, day] = dateString.split('-');
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
// Create cancellation token for a booking
|
||||
const createToken = os
|
||||
.input(z.object({ bookingId: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
const booking = await bookingsKV.getItem(input.bookingId);
|
||||
if (!booking) {
|
||||
throw new Error("Booking not found");
|
||||
}
|
||||
|
||||
if (booking.status === "cancelled") {
|
||||
throw new Error("Booking is already cancelled");
|
||||
}
|
||||
|
||||
// Create token that expires in 30 days
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 30);
|
||||
|
||||
const token = randomUUID();
|
||||
const cancellationToken: CancellationToken = {
|
||||
id: randomUUID(),
|
||||
bookingId: input.bookingId,
|
||||
token,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await cancellationKV.setItem(cancellationToken.id, cancellationToken);
|
||||
return { token, expiresAt: expiresAt.toISOString() };
|
||||
});
|
||||
|
||||
// Get booking details by token
|
||||
const getBookingByToken = os
|
||||
.input(z.object({ token: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
const tokens = await cancellationKV.getAllItems();
|
||||
const validToken = tokens.find(t =>
|
||||
t.token === input.token &&
|
||||
new Date(t.expiresAt) > new Date()
|
||||
);
|
||||
|
||||
if (!validToken) {
|
||||
throw new Error("Invalid or expired cancellation token");
|
||||
}
|
||||
|
||||
const booking = await bookingsKV.getItem(validToken.bookingId);
|
||||
if (!booking) {
|
||||
throw new Error("Booking not found");
|
||||
}
|
||||
|
||||
// Get treatment details
|
||||
const treatmentsKV = createKV<any>("treatments");
|
||||
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||
|
||||
return {
|
||||
id: booking.id,
|
||||
customerName: booking.customerName,
|
||||
appointmentDate: booking.appointmentDate,
|
||||
appointmentTime: booking.appointmentTime,
|
||||
treatmentId: booking.treatmentId,
|
||||
treatmentName: treatment?.name || "Unbekannte Behandlung",
|
||||
status: booking.status,
|
||||
formattedDate: formatDateGerman(booking.appointmentDate),
|
||||
};
|
||||
});
|
||||
|
||||
// Cancel booking by token
|
||||
const cancelByToken = os
|
||||
.input(z.object({ token: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
const tokens = await cancellationKV.getAllItems();
|
||||
const validToken = tokens.find(t =>
|
||||
t.token === input.token &&
|
||||
new Date(t.expiresAt) > new Date()
|
||||
);
|
||||
|
||||
if (!validToken) {
|
||||
throw new Error("Invalid or expired cancellation token");
|
||||
}
|
||||
|
||||
const booking = await bookingsKV.getItem(validToken.bookingId);
|
||||
if (!booking) {
|
||||
throw new Error("Booking not found");
|
||||
}
|
||||
|
||||
// Check if booking is already cancelled
|
||||
if (booking.status === "cancelled") {
|
||||
throw new Error("Booking is already cancelled");
|
||||
}
|
||||
|
||||
// Check minimum cancellation timespan from environment variable
|
||||
const minStornoTimespan = parseInt(process.env.MIN_STORNO_TIMESPAN || "24"); // Default: 24 hours
|
||||
const appointmentDateTime = new Date(`${booking.appointmentDate}T${booking.appointmentTime}:00`);
|
||||
const now = new Date();
|
||||
const timeDifferenceHours = (appointmentDateTime.getTime() - now.getTime()) / (1000 * 60 * 60);
|
||||
|
||||
if (timeDifferenceHours < minStornoTimespan) {
|
||||
throw new Error(`Stornierung ist nur bis ${minStornoTimespan} Stunden vor dem Termin möglich. Der Termin liegt nur noch ${Math.round(timeDifferenceHours)} Stunden in der Zukunft.`);
|
||||
}
|
||||
|
||||
// Check if booking is in the past (additional safety check)
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
if (booking.appointmentDate < today) {
|
||||
throw new Error("Cannot cancel past bookings");
|
||||
}
|
||||
|
||||
// For today's bookings, check if the time is not in the past
|
||||
if (booking.appointmentDate === today) {
|
||||
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
if (booking.appointmentTime <= currentTime) {
|
||||
throw new Error("Cannot cancel bookings that have already started");
|
||||
}
|
||||
}
|
||||
|
||||
// Update booking status
|
||||
const updatedBooking = { ...booking, status: "cancelled" as const };
|
||||
await bookingsKV.setItem(booking.id, updatedBooking);
|
||||
|
||||
// Free the slot if it exists
|
||||
if (booking.slotId) {
|
||||
const slot = await availabilityKV.getItem(booking.slotId);
|
||||
if (slot) {
|
||||
const updatedSlot: Availability = {
|
||||
...slot,
|
||||
status: "free",
|
||||
reservedByBookingId: undefined,
|
||||
};
|
||||
await availabilityKV.setItem(slot.id, updatedSlot);
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate the token
|
||||
await cancellationKV.removeItem(validToken.id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Booking cancelled successfully",
|
||||
formattedDate: formatDateGerman(booking.appointmentDate),
|
||||
};
|
||||
});
|
||||
|
||||
export const router = {
|
||||
createToken,
|
||||
getBookingByToken,
|
||||
cancelByToken,
|
||||
};
|
@@ -3,6 +3,7 @@ import { router as treatments } from "./treatments";
|
||||
import { router as bookings } from "./bookings";
|
||||
import { router as auth } from "./auth";
|
||||
import { router as availability } from "./availability";
|
||||
import { router as cancellation } from "./cancellation";
|
||||
|
||||
export const router = {
|
||||
demo,
|
||||
@@ -10,4 +11,5 @@ export const router = {
|
||||
bookings,
|
||||
auth,
|
||||
availability,
|
||||
cancellation,
|
||||
};
|
||||
|
Reference in New Issue
Block a user