From bcfc481578743ced88b1a42a355f40c8f8b5c953 Mon Sep 17 00:00:00 2001 From: elpatron Date: Tue, 30 Sep 2025 11:50:37 +0200 Subject: [PATCH] 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 --- src/client/components/admin-bookings.tsx | 63 ++++++++++++++++++++ src/client/components/booking-form.tsx | 76 ++++++++++++++++++++++++ src/server/rpc/bookings.ts | 1 + 3 files changed, 140 insertions(+) diff --git a/src/client/components/admin-bookings.tsx b/src/client/components/admin-bookings.tsx index 52c4942..fbd3bd1 100644 --- a/src/client/components/admin-bookings.tsx +++ b/src/client/components/admin-bookings.tsx @@ -4,6 +4,8 @@ import { queryClient } from "@/client/rpc-client"; export function AdminBookings() { const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]); + const [selectedPhoto, setSelectedPhoto] = useState(""); + const [showPhotoModal, setShowPhotoModal] = useState(false); const { data: bookings } = useQuery( queryClient.bookings.live.list.experimental_liveOptions() @@ -31,6 +33,16 @@ export function AdminBookings() { } }; + const openPhotoModal = (photoData: string) => { + setSelectedPhoto(photoData); + setShowPhotoModal(true); + }; + + const closePhotoModal = () => { + setShowPhotoModal(false); + setSelectedPhoto(""); + }; + const filteredBookings = bookings?.filter(booking => selectedDate ? booking.appointmentDate === selectedDate : true ).sort((a, b) => { @@ -117,6 +129,9 @@ export function AdminBookings() { Date & Time + + Inspiration + Status @@ -148,6 +163,22 @@ export function AdminBookings() {
Slot-ID: {booking.slotId}
)} + + {booking.inspirationPhoto ? ( + + ) : ( + Kein Foto + )} + {booking.status} @@ -211,6 +242,38 @@ export function AdminBookings() { )} + + {/* Photo Modal */} + {showPhotoModal && ( +
+
+
+

Inspiration Foto

+ +
+
+ Inspiration Foto +
+
+ +
+
+
+ )} ); } \ No newline at end of file diff --git a/src/client/components/booking-form.tsx b/src/client/components/booking-form.tsx index 71225a2..cc5aed1 100644 --- a/src/client/components/booking-form.tsx +++ b/src/client/components/booking-form.tsx @@ -11,6 +11,8 @@ export function BookingForm() { const [selectedSlotId, setSelectedSlotId] = useState(""); const [notes, setNotes] = useState(""); const [agbAccepted, setAgbAccepted] = useState(false); + const [inspirationPhoto, setInspirationPhoto] = useState(""); + const [photoPreview, setPhotoPreview] = useState(""); const { data: treatments } = useQuery( queryClient.treatments.live.list.experimental_liveOptions() @@ -33,6 +35,39 @@ export function BookingForm() { const selectedTreatmentData = treatments?.find((t) => t.id === selectedTreatment); const availableSlots = (slotsByDate || []).filter((s) => s.status === "free"); + const handlePhotoUpload = (e: React.ChangeEvent) => { + 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) { @@ -54,6 +89,7 @@ export function BookingForm() { appointmentDate, appointmentTime, notes, + inspirationPhoto, slotId: selectedSlotId, }, { @@ -66,6 +102,11 @@ export function BookingForm() { 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."); }, } @@ -202,6 +243,41 @@ export function BookingForm() { /> + {/* Inspiration Photo Upload */} +
+ +
+ + {photoPreview && ( +
+ Inspiration Preview + +
+ )} +

+ 📸 Lade ein Foto hoch, das als Inspiration für deine Nagelbehandlung dienen soll. Max. 5MB, alle Bildformate erlaubt. +

+
+
+ {/* AGB Acceptance */}
diff --git a/src/server/rpc/bookings.ts b/src/server/rpc/bookings.ts index 1598673..4a5e019 100644 --- a/src/server/rpc/bookings.ts +++ b/src/server/rpc/bookings.ts @@ -22,6 +22,7 @@ const BookingSchema = z.object({ appointmentTime: z.string(), // HH:MM format status: z.enum(["pending", "confirmed", "cancelled", "completed"]), notes: z.string().optional(), + inspirationPhoto: z.string().optional(), // Base64 encoded image data createdAt: z.string(), slotId: z.string().optional(), });