Verbessere Booking-Form UX: Reset selectedTime bei Treatment-Wechsel, bessere Loading-States und lokale Datumsvalidierung

This commit is contained in:
2025-10-04 18:09:46 +02:00
parent 3a13c8dffb
commit 97c1d3493f
9 changed files with 1481 additions and 155 deletions

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
@@ -8,7 +8,7 @@ export function BookingForm() {
const [customerEmail, setCustomerEmail] = useState("");
const [customerPhone, setCustomerPhone] = useState("");
const [appointmentDate, setAppointmentDate] = useState("");
const [selectedSlotId, setSelectedSlotId] = useState<string>("");
const [selectedTime, setSelectedTime] = useState("");
const [notes, setNotes] = useState("");
const [agbAccepted, setAgbAccepted] = useState(false);
const [inspirationPhoto, setInspirationPhoto] = useState<string>("");
@@ -19,58 +19,44 @@ export function BookingForm() {
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;
// Dynamische Verfügbarkeitsabfrage für das gewählte Datum und die Behandlung
const { data: availableTimes, isLoading, isFetching, error } = useQuery({
...queryClient.recurringAvailability.getAvailableTimes.queryOptions({
input: {
date: appointmentDate,
treatmentId: selectedTreatment
}
}),
enabled: !!appointmentDate && !!selectedTreatment
});
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);
// }
// Clear selectedTime when treatment changes
const handleTreatmentChange = (treatmentId: string) => {
setSelectedTreatment(treatmentId);
setSelectedTime("");
};
// Clear selectedTime when it becomes invalid
useEffect(() => {
if (selectedTime && availableTimes && !availableTimes.includes(selectedTime)) {
setSelectedTime("");
}
}, [availableTimes, selectedTime]);
// Helper function for local date in YYYY-MM-DD format
const getLocalYmd = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -153,7 +139,7 @@ export function BookingForm() {
// agbAccepted
// });
if (!selectedTreatment || !customerName || !customerEmail || !customerPhone || !appointmentDate || !selectedSlotId) {
if (!selectedTreatment || !customerName || !customerEmail || !customerPhone || !appointmentDate || !selectedTime) {
setErrorMessage("Bitte fülle alle erforderlichen Felder aus.");
return;
}
@@ -162,9 +148,8 @@ export function BookingForm() {
return;
}
// Email validation now handled in backend before slot reservation
const slot = availableSlots.find((s) => s.id === selectedSlotId);
const appointmentTime = slot?.time || "";
// Email validation now handled in backend before booking creation
const appointmentTime = selectedTime;
// console.log("Creating booking with data:", {
// treatmentId: selectedTreatment,
// customerName,
@@ -173,8 +158,7 @@ export function BookingForm() {
// appointmentDate,
// appointmentTime,
// notes,
// inspirationPhoto,
// slotId: selectedSlotId,
// inspirationPhoto
// });
createBooking(
{
@@ -186,7 +170,6 @@ export function BookingForm() {
appointmentTime,
notes,
inspirationPhoto,
slotId: selectedSlotId,
},
{
onSuccess: () => {
@@ -195,7 +178,7 @@ export function BookingForm() {
setCustomerEmail("");
setCustomerPhone("");
setAppointmentDate("");
setSelectedSlotId("");
setSelectedTime("");
setNotes("");
setAgbAccepted(false);
setInspirationPhoto("");
@@ -224,7 +207,8 @@ export function BookingForm() {
);
};
// Get minimum date (today) nicht mehr genutzt, Datumsauswahl erfolgt aus freien Slots
// Dynamische Zeitauswahl: Kunde wählt beliebiges zukünftiges Datum,
// System berechnet verfügbare Zeiten in 15-Minuten-Intervallen basierend auf wiederkehrenden Regeln
return (
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-lg p-6">
@@ -238,7 +222,7 @@ export function BookingForm() {
</label>
<select
value={selectedTreatment}
onChange={(e) => setSelectedTreatment(e.target.value)}
onChange={(e) => handleTreatmentChange(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
>
@@ -299,48 +283,53 @@ export function BookingForm() {
<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) *
Wunschdatum *
</label>
<select
<input
type="date"
value={appointmentDate}
onChange={(e) => { setAppointmentDate(e.target.value); setSelectedSlotId(""); }}
onChange={(e) => { setAppointmentDate(e.target.value); setSelectedTime(""); }}
min={getLocalYmd()}
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 *
Verfügbare Uhrzeit (15-Min-Raster) *
</label>
<select
value={selectedSlotId}
onChange={(e) => setSelectedSlotId(e.target.value)}
value={selectedTime}
onChange={(e) => setSelectedTime(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}
disabled={!appointmentDate || !selectedTreatment || isLoading || isFetching}
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>
))}
{availableTimes?.map((time) => (
<option key={time} value={time}>
{time}
</option>
))}
</select>
{appointmentDate && availableSlots.length === 0 && (
{appointmentDate && selectedTreatment && isLoading && (
<p className="mt-2 text-sm text-gray-500">
Keine freien Zeitslots für {appointmentDate} verfügbar.
Lade verfügbare Zeiten...
</p>
)}
{appointmentDate && selectedTreatment && error && (
<p className="mt-2 text-sm text-red-500">
Fehler beim Laden der verfügbaren Zeiten. Bitte versuche es erneut.
</p>
)}
{appointmentDate && selectedTreatment && !isLoading && !isFetching && !error && (!availableTimes || availableTimes.length === 0) && (
<p className="mt-2 text-sm text-gray-500">
Keine verfügbaren Zeiten für dieses Datum. Bitte wähle ein anderes Datum.
</p>
)}
{selectedTreatmentData && (
<p className="mt-1 text-xs text-gray-500">Dauer: {selectedTreatmentData.duration} Minuten</p>
)}
</div>
</div>