Files
beauty-bookings/src/client/components/booking-form.tsx
elpatron 5baa231d3c Fix: Slot reservation only after successful email validation
- 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
2025-10-02 13:39:13 +02:00

447 lines
17 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 = 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>
);
}