Files
beauty-bookings/src/client/components/booking-form.tsx
elpatron bcfc481578 feat: Add inspiration photo upload functionality to booking system
- 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
2025-09-30 11:50:37 +02:00

320 lines
12 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 { 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>
);
}