Files
beauty-bookings/src/client/components/booking-form.tsx
elpatron 55923e0426 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
2025-09-30 17:48:03 +02:00

435 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
export function BookingForm() {
const [selectedTreatment, setSelectedTreatment] = useState("");
const [customerName, setCustomerName] = useState("");
const [customerEmail, setCustomerEmail] = useState("");
const [customerPhone, setCustomerPhone] = useState("");
const [appointmentDate, setAppointmentDate] = useState("");
const [selectedSlotId, setSelectedSlotId] = useState<string>("");
const [notes, setNotes] = useState("");
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()
);
// Lade alle Slots live und filtere freie Slots
const { data: allSlots } = useQuery(
queryClient.availability.live.list.experimental_liveOptions()
);
// 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)
: [];
const { mutate: createBooking, isPending } = useMutation(
queryClient.bookings.create.mutationOptions()
);
const selectedTreatmentData = treatments?.find((t) => t.id === selectedTreatment);
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 = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 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;
}
// Check file type
if (!file.type.startsWith('image/')) {
alert("Bitte wähle nur Bilddateien aus.");
return;
}
// 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);
});
};
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 = () => {
setInspirationPhoto("");
setPhotoPreview("");
// Reset file input
const fileInput = document.getElementById('photo-upload') as HTMLInputElement;
if (fileInput) fileInput.value = '';
};
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) {
setErrorMessage("Bitte fülle alle erforderlichen Felder aus.");
return;
}
if (!agbAccepted) {
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,
customerName,
customerEmail,
customerPhone,
appointmentDate,
appointmentTime,
notes,
inspirationPhoto,
slotId: selectedSlotId,
},
{
onSuccess: () => {
setSelectedTreatment("");
setCustomerName("");
setCustomerEmail("");
setCustomerPhone("");
setAppointmentDate("");
setSelectedSlotId("");
setNotes("");
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
return (
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-lg p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Buche deine Nagelbehandlung</h2>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Treatment Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Behandlung auswählen *
</label>
<select
value={selectedTreatment}
onChange={(e) => setSelectedTreatment(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
required
>
<option value="">Wähle eine Behandlung</option>
{treatments?.map((treatment) => (
<option key={treatment.id} value={treatment.id}>
{treatment.name} - {(treatment.price / 100).toFixed(2)} ({treatment.duration} Min)
</option>
))}
</select>
{selectedTreatmentData && (
<p className="mt-2 text-sm text-gray-600">{selectedTreatmentData.description}</p>
)}
</div>
{/* Customer Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Vollständiger Name *
</label>
<input
type="text"
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
E-Mail *
</label>
<input
type="email"
value={customerEmail}
onChange={(e) => setCustomerEmail(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Telefonnummer *
</label>
<input
type="tel"
value={customerPhone}
onChange={(e) => setCustomerPhone(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
required
/>
</div>
{/* Date and Time Selection */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Datum (nur freie Termine) *
</label>
<select
value={appointmentDate}
onChange={(e) => { setAppointmentDate(e.target.value); setSelectedSlotId(""); }}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
required
>
<option value="">Datum auswählen</option>
{availableDates.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
{availableDates.length === 0 && (
<p className="mt-2 text-sm text-gray-500">Aktuell keine freien Termine verfügbar.</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Verfügbare Uhrzeit *
</label>
<select
value={selectedSlotId}
onChange={(e) => setSelectedSlotId(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
disabled={!appointmentDate || !selectedTreatment}
required
>
<option value="">Zeit auswählen</option>
{availableSlots
.sort((a, b) => a.time.localeCompare(b.time))
.map((slot) => (
<option key={slot.id} value={slot.id}>
{slot.time} ({slot.durationMinutes} min)
</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>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Zusätzliche Notizen
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
placeholder="Besondere Wünsche oder Informationen..."
/>
</div>
{/* Inspiration Photo Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Inspiration/Beispiel-Foto (optional)
</label>
<div className="space-y-3">
<input
id="photo-upload"
type="file"
accept="image/*"
onChange={handlePhotoUpload}
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-pink-50 file:text-pink-700 hover:file:bg-pink-100"
/>
{photoPreview && (
<div className="relative inline-block">
<img
src={photoPreview}
alt="Inspiration Preview"
className="w-32 h-32 object-cover rounded-lg border border-gray-200"
/>
<button
type="button"
onClick={removePhoto}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm hover:bg-red-600"
>
×
</button>
</div>
)}
<p className="text-xs text-gray-500">
📸 Lade ein Foto hoch, das als Inspiration für deine Nagelbehandlung dienen soll. Max. 5MB, alle Bildformate erlaubt.
</p>
</div>
</div>
{/* AGB Acceptance */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div className="flex items-start space-x-3">
<input
type="checkbox"
id="agb-acceptance"
checked={agbAccepted}
onChange={(e) => setAgbAccepted(e.target.checked)}
className="mt-1 h-4 w-4 text-pink-600 focus:ring-pink-500 border-gray-300 rounded"
required
/>
<div className="flex-1">
<label htmlFor="agb-acceptance" className="text-sm font-medium text-gray-700 cursor-pointer">
Ich habe die <a
href="/AGB.pdf"
target="_blank"
rel="noopener noreferrer"
className="text-pink-600 hover:text-pink-700 underline font-semibold"
>
Allgemeinen Geschäftsbedingungen (AGB)
</a> gelesen und akzeptiere diese *
</label>
<p className="text-xs text-gray-500 mt-1">
📋 Die AGB enthalten wichtige Informationen zu Buchungsgebühren, Stornierungsregeln und unseren Serviceleistungen.
</p>
</div>
</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}
className="w-full bg-pink-600 text-white py-3 px-4 rounded-md hover:bg-pink-700 focus:ring-2 focus:ring-pink-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed font-medium"
>
{isPending ? "Wird gebucht..." : "Termin buchen"}
</button>
</form>
</div>
);
}