- Move email validation before slot reservation in backend - Remove duplicate frontend email validation - Slots are no longer blocked by failed booking attempts - Clean up unused email error UI components - Ensure slots remain available if email validation fails
447 lines
17 KiB
TypeScript
447 lines
17 KiB
TypeScript
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 = async (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;
|
||
}
|
||
|
||
// Email validation now handled in backend before slot reservation
|
||
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);
|
||
|
||
// Simple error handling for oRPC errors
|
||
let errorText = "Ein unbekannter Fehler ist aufgetreten.";
|
||
|
||
if (error?.cause?.message) {
|
||
errorText = error.cause.message;
|
||
} else if (error?.message && error.message !== "Internal server error") {
|
||
errorText = error.message;
|
||
}
|
||
|
||
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, Allergien, etc..."
|
||
/>
|
||
</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>
|
||
);
|
||
} |