599 lines
22 KiB
TypeScript
599 lines
22 KiB
TypeScript
import { useState, useEffect, useMemo } from "react";
|
||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||
import { queryClient } from "@/client/rpc-client";
|
||
|
||
// Feature flag for multi-treatments availability API compatibility
|
||
const USE_MULTI_TREATMENTS_AVAILABILITY = false;
|
||
|
||
export function BookingForm() {
|
||
const [selectedTreatments, setSelectedTreatments] = useState<Array<{id: string, name: string, duration: number, price: number}>>([]);
|
||
const [customerName, setCustomerName] = useState("");
|
||
const [customerEmail, setCustomerEmail] = useState("");
|
||
const [customerPhone, setCustomerPhone] = useState("");
|
||
const [appointmentDate, setAppointmentDate] = useState("");
|
||
const [selectedTime, setSelectedTime] = useState("");
|
||
const [notes, setNotes] = useState("");
|
||
const [agbAccepted, setAgbAccepted] = useState(false);
|
||
const [ageConfirmed, setAgeConfirmed] = useState(false);
|
||
const [inspirationPhoto, setInspirationPhoto] = useState<string>("");
|
||
const [photoPreview, setPhotoPreview] = useState<string>("");
|
||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||
const [isInitialized, setIsInitialized] = useState(false);
|
||
|
||
// Load saved customer data from localStorage on mount
|
||
useEffect(() => {
|
||
const savedName = localStorage.getItem("bookingForm_customerName");
|
||
const savedEmail = localStorage.getItem("bookingForm_customerEmail");
|
||
const savedPhone = localStorage.getItem("bookingForm_customerPhone");
|
||
|
||
if (savedName) setCustomerName(savedName);
|
||
if (savedEmail) setCustomerEmail(savedEmail);
|
||
if (savedPhone) setCustomerPhone(savedPhone);
|
||
|
||
setIsInitialized(true);
|
||
}, []);
|
||
|
||
// Save customer data to localStorage when it changes (after initial load)
|
||
useEffect(() => {
|
||
if (!isInitialized) return;
|
||
if (customerName) {
|
||
localStorage.setItem("bookingForm_customerName", customerName);
|
||
} else {
|
||
localStorage.removeItem("bookingForm_customerName");
|
||
}
|
||
}, [customerName, isInitialized]);
|
||
|
||
useEffect(() => {
|
||
if (!isInitialized) return;
|
||
if (customerEmail) {
|
||
localStorage.setItem("bookingForm_customerEmail", customerEmail);
|
||
} else {
|
||
localStorage.removeItem("bookingForm_customerEmail");
|
||
}
|
||
}, [customerEmail, isInitialized]);
|
||
|
||
useEffect(() => {
|
||
if (!isInitialized) return;
|
||
if (customerPhone) {
|
||
localStorage.setItem("bookingForm_customerPhone", customerPhone);
|
||
} else {
|
||
localStorage.removeItem("bookingForm_customerPhone");
|
||
}
|
||
}, [customerPhone, isInitialized]);
|
||
|
||
const { data: treatments } = useQuery(
|
||
queryClient.treatments.live.list.experimental_liveOptions()
|
||
);
|
||
|
||
// Comment 3: Compute total duration and price once per render
|
||
const totalDuration = useMemo(
|
||
() => selectedTreatments.reduce((sum, t) => sum + t.duration, 0),
|
||
[selectedTreatments]
|
||
);
|
||
|
||
const totalPrice = useMemo(
|
||
() => selectedTreatments.reduce((sum, t) => sum + t.price, 0),
|
||
[selectedTreatments]
|
||
);
|
||
|
||
// Comment 1: Dynamische Verfügbarkeitsabfrage mit Kompatibilitäts-Fallback
|
||
const availabilityQueryInput = USE_MULTI_TREATMENTS_AVAILABILITY
|
||
? { date: appointmentDate, treatmentIds: selectedTreatments.map(t => t.id) }
|
||
: { date: appointmentDate, treatmentId: selectedTreatments[0]?.id ?? "" };
|
||
|
||
const availabilityQueryEnabled = USE_MULTI_TREATMENTS_AVAILABILITY
|
||
? !!appointmentDate && selectedTreatments.length > 0
|
||
: !!appointmentDate && selectedTreatments.length > 0;
|
||
|
||
const { data: availableTimes, isLoading, isFetching, error } = useQuery({
|
||
...queryClient.recurringAvailability.getAvailableTimes.queryOptions({
|
||
input: availabilityQueryInput as any
|
||
}),
|
||
enabled: availabilityQueryEnabled
|
||
});
|
||
|
||
const { mutate: createBooking, isPending } = useMutation(
|
||
queryClient.bookings.create.mutationOptions()
|
||
);
|
||
|
||
// Comment 2: Handle treatment checkbox toggle with functional state updates
|
||
const handleTreatmentToggle = (treatment: {id: string, name: string, duration: number, price: number}) => {
|
||
setSelectedTreatments((prev) => {
|
||
const isSelected = prev.some(t => t.id === treatment.id);
|
||
|
||
if (isSelected) {
|
||
// Remove from selection
|
||
return prev.filter(t => t.id !== treatment.id);
|
||
} else if (prev.length < 3) {
|
||
// Add to selection (only if limit not reached)
|
||
return [...prev, {
|
||
id: treatment.id,
|
||
name: treatment.name,
|
||
duration: treatment.duration,
|
||
price: treatment.price
|
||
}];
|
||
}
|
||
|
||
// Return unchanged if limit reached
|
||
return prev;
|
||
});
|
||
|
||
// Clear selected time when treatments change
|
||
setSelectedTime("");
|
||
};
|
||
|
||
// Comment 4: Reconcile selectedTreatments when treatments list changes
|
||
useEffect(() => {
|
||
if (!treatments) return;
|
||
|
||
setSelectedTreatments((prev) => {
|
||
const validTreatments = prev.filter((selected) =>
|
||
treatments.some((t) => t.id === selected.id)
|
||
);
|
||
|
||
// Only update state if something changed to avoid unnecessary re-renders
|
||
if (validTreatments.length !== prev.length) {
|
||
return validTreatments;
|
||
}
|
||
return prev;
|
||
});
|
||
}, [treatments]);
|
||
|
||
// 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];
|
||
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:", {
|
||
// selectedTreatments,
|
||
// customerName,
|
||
// customerEmail,
|
||
// customerPhone,
|
||
// appointmentDate,
|
||
// selectedSlotId,
|
||
// agbAccepted
|
||
// });
|
||
|
||
if (selectedTreatments.length === 0 || !customerName || !customerEmail || !customerPhone || !appointmentDate || !selectedTime) {
|
||
if (selectedTreatments.length === 0) {
|
||
setErrorMessage("Bitte wähle mindestens eine Behandlung aus.");
|
||
} else {
|
||
setErrorMessage("Bitte fülle alle erforderlichen Felder aus.");
|
||
}
|
||
return;
|
||
}
|
||
if (!agbAccepted) {
|
||
setErrorMessage("Bitte bestätige die Kenntnisnahme der Allgemeinen Geschäftsbedingungen.");
|
||
return;
|
||
}
|
||
if (!ageConfirmed) {
|
||
setErrorMessage("Bitte bestätige, dass du mindestens 16 Jahre alt bist.");
|
||
return;
|
||
}
|
||
|
||
// Email validation now handled in backend before booking creation
|
||
const appointmentTime = selectedTime;
|
||
// console.log("Creating booking with data:", {
|
||
// treatments: selectedTreatments,
|
||
// customerName,
|
||
// customerEmail,
|
||
// customerPhone,
|
||
// appointmentDate,
|
||
// appointmentTime,
|
||
// notes,
|
||
// inspirationPhoto
|
||
// });
|
||
createBooking(
|
||
{
|
||
treatments: selectedTreatments,
|
||
customerName,
|
||
customerEmail,
|
||
customerPhone,
|
||
appointmentDate,
|
||
appointmentTime,
|
||
notes,
|
||
inspirationPhoto,
|
||
},
|
||
{
|
||
onSuccess: () => {
|
||
setSelectedTreatments([]);
|
||
setCustomerName("");
|
||
setCustomerEmail("");
|
||
setCustomerPhone("");
|
||
setAppointmentDate("");
|
||
setSelectedTime("");
|
||
setNotes("");
|
||
setAgbAccepted(false);
|
||
setAgeConfirmed(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);
|
||
},
|
||
}
|
||
);
|
||
};
|
||
|
||
// 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">
|
||
<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>
|
||
<div className="flex justify-between items-center mb-2">
|
||
<label className="block text-sm font-medium text-gray-700">
|
||
Behandlungen auswählen (1-3) *
|
||
</label>
|
||
<span className="text-sm text-gray-600">
|
||
{selectedTreatments.length} von 3 ausgewählt
|
||
</span>
|
||
</div>
|
||
|
||
{/* Checkbox List Container */}
|
||
<div className="max-h-96 overflow-y-auto border border-gray-300 rounded-md p-3 space-y-2" aria-label="Wähle bis zu 3 Behandlungen">
|
||
{treatments?.map((treatment) => {
|
||
const isSelected = selectedTreatments.some(t => t.id === treatment.id);
|
||
const isDisabled = selectedTreatments.length >= 3 && !isSelected;
|
||
|
||
return (
|
||
<div key={treatment.id} className="flex items-start space-x-3">
|
||
<input
|
||
type="checkbox"
|
||
id={`treatment-${treatment.id}`}
|
||
checked={isSelected}
|
||
disabled={isDisabled}
|
||
onChange={() => handleTreatmentToggle({
|
||
id: treatment.id,
|
||
name: treatment.name,
|
||
duration: treatment.duration,
|
||
price: treatment.price
|
||
})}
|
||
className="h-4 w-4 text-pink-600 border-gray-300 rounded flex-shrink-0 mt-1"
|
||
/>
|
||
<label htmlFor={`treatment-${treatment.id}`} className={`flex-1 text-sm cursor-pointer ${isDisabled ? 'text-gray-400' : 'text-gray-700'}`}>
|
||
{treatment.name} - {treatment.duration} Min - {(treatment.price / 100).toFixed(2)} €
|
||
</label>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Treatment Descriptions */}
|
||
{selectedTreatments.length > 0 && (
|
||
<div className="mt-3 space-y-2">
|
||
{selectedTreatments.map((selectedTreatment) => {
|
||
const fullTreatment = treatments?.find(t => t.id === selectedTreatment.id);
|
||
return fullTreatment ? (
|
||
<p key={selectedTreatment.id} className="text-sm text-gray-600">
|
||
<span className="font-medium">{fullTreatment.name}:</span> {fullTreatment.description}
|
||
</p>
|
||
) : null;
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Live Calculation Display */}
|
||
{selectedTreatments.length > 0 && (
|
||
<div className="mt-3 bg-pink-50 border border-pink-200 rounded-lg p-4">
|
||
<p className="font-semibold text-pink-700">
|
||
📊 Gesamt: {totalDuration} Min | {(totalPrice / 100).toFixed(2)} €
|
||
</p>
|
||
</div>
|
||
)}
|
||
</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">
|
||
Wunschdatum *
|
||
</label>
|
||
<input
|
||
type="date"
|
||
value={appointmentDate}
|
||
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
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Verfügbare Uhrzeit (15-Min-Raster) *
|
||
</label>
|
||
<select
|
||
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 || selectedTreatments.length === 0 || isLoading || isFetching}
|
||
required
|
||
>
|
||
<option value="">Zeit auswählen</option>
|
||
{availableTimes?.map((time) => (
|
||
<option key={time} value={time}>
|
||
{time}
|
||
</option>
|
||
))}
|
||
</select>
|
||
{appointmentDate && selectedTreatments.length > 0 && isLoading && (
|
||
<p className="mt-2 text-sm text-gray-500">
|
||
Lade verfügbare Zeiten...
|
||
</p>
|
||
)}
|
||
{appointmentDate && selectedTreatments.length > 0 && error && (
|
||
<p className="mt-2 text-sm text-red-500">
|
||
Fehler beim Laden der verfügbaren Zeiten. Bitte versuche es erneut.
|
||
</p>
|
||
)}
|
||
{appointmentDate && selectedTreatments.length > 0 && !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>
|
||
)}
|
||
{selectedTreatments.length > 0 && (
|
||
<p className="mt-1 text-xs text-gray-500">Gesamtdauer: {totalDuration} Minuten</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 space-y-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 className="flex items-start space-x-3">
|
||
<input
|
||
type="checkbox"
|
||
id="age-confirmation"
|
||
checked={ageConfirmed}
|
||
onChange={(e) => setAgeConfirmed(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="age-confirmation" className="text-sm font-medium text-gray-700 cursor-pointer">
|
||
Ich bestätige, dass ich mindestens 16 Jahre alt bin *
|
||
</label>
|
||
</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>
|
||
);
|
||
} |