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

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