- Extend booking schema with optional inspirationPhoto field (Base64 encoded) - Implement photo upload in booking form with file validation (max 5MB, image files only) - Add photo preview with remove functionality in booking form - Create thumbnail display in admin bookings management - Implement photo popup modal for full-size image viewing - Add inspiration photo column to bookings table - Include photo upload in form reset after successful booking - Add user-friendly photo upload UI with drag-and-drop styling Features: - Optional photo upload for customer inspiration/reference - File size validation (5MB limit) - File type validation (images only) - Photo preview in booking form - Thumbnail display in admin panel - Full-size popup modal for detailed viewing - Responsive design with hover effects - German localization throughout Changes: - booking-form.tsx: Add photo upload UI and functionality - admin-bookings.tsx: Add photo thumbnails and popup modal - bookings.ts: Extend schema with inspirationPhoto field
320 lines
12 KiB
TypeScript
320 lines
12 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 { 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()
|
||
);
|
||
const freeSlots = (allSlots || []).filter((s) => s.status === "free");
|
||
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 || []).filter((s) => s.status === "free");
|
||
|
||
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
|
||
// Check file size (max 5MB)
|
||
if (file.size > 5 * 1024 * 1024) {
|
||
alert("Das Foto ist zu groß. Bitte wähle ein Bild unter 5MB.");
|
||
return;
|
||
}
|
||
|
||
// Check file type
|
||
if (!file.type.startsWith('image/')) {
|
||
alert("Bitte wähle nur Bilddateien aus.");
|
||
return;
|
||
}
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = (event) => {
|
||
const result = event.target?.result as string;
|
||
setInspirationPhoto(result);
|
||
setPhotoPreview(result);
|
||
};
|
||
reader.readAsDataURL(file);
|
||
};
|
||
|
||
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();
|
||
if (!selectedTreatment || !customerName || !customerEmail || !customerPhone || !appointmentDate || !selectedSlotId) {
|
||
alert("Bitte fülle alle erforderlichen Felder aus");
|
||
return;
|
||
}
|
||
if (!agbAccepted) {
|
||
alert("Bitte bestätige die Kenntnisnahme der Allgemeinen Geschäftsbedingungen");
|
||
return;
|
||
}
|
||
const slot = availableSlots.find((s) => s.id === selectedSlotId);
|
||
const appointmentTime = slot?.time || "";
|
||
createBooking(
|
||
{
|
||
treatmentId: selectedTreatment,
|
||
customerName,
|
||
customerEmail,
|
||
customerPhone,
|
||
appointmentDate,
|
||
appointmentTime,
|
||
notes,
|
||
inspirationPhoto,
|
||
slotId: selectedSlotId,
|
||
},
|
||
{
|
||
onSuccess: () => {
|
||
setSelectedTreatment("");
|
||
setCustomerName("");
|
||
setCustomerEmail("");
|
||
setCustomerPhone("");
|
||
setAppointmentDate("");
|
||
setSelectedSlotId("");
|
||
setNotes("");
|
||
setAgbAccepted(false);
|
||
setInspirationPhoto("");
|
||
setPhotoPreview("");
|
||
// 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.");
|
||
},
|
||
}
|
||
);
|
||
};
|
||
|
||
// Get minimum date (today) – nicht mehr genutzt, Datumsauswahl erfolgt aus freien Slots
|
||
const today = new Date().toISOString().split("T")[0];
|
||
|
||
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>
|
||
</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>
|
||
|
||
<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>
|
||
);
|
||
} |