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:
2025-09-30 17:48:03 +02:00
parent e5384e46ce
commit 55923e0426
13 changed files with 741 additions and 30 deletions

View File

@@ -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 (

View File

@@ -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>

View File

@@ -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">

View File

@@ -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}

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