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
This commit is contained in:
2025-09-30 11:50:37 +02:00
parent aeb32da6c2
commit bcfc481578
3 changed files with 140 additions and 0 deletions

View File

@@ -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<string>("");
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() {
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date & Time
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Inspiration
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
@@ -148,6 +163,22 @@ export function AdminBookings() {
<div className="text-xs text-gray-500">Slot-ID: {booking.slotId}</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{booking.inspirationPhoto ? (
<button
onClick={() => openPhotoModal(booking.inspirationPhoto!)}
className="block"
>
<img
src={booking.inspirationPhoto}
alt="Inspiration"
className="w-12 h-12 object-cover rounded-lg border border-gray-200 hover:border-pink-300 cursor-pointer"
/>
</button>
) : (
<span className="text-gray-400 text-sm">Kein Foto</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(booking.status)}`}>
{booking.status}
@@ -211,6 +242,38 @@ export function AdminBookings() {
</div>
)}
</div>
{/* Photo Modal */}
{showPhotoModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-2xl max-h-[90vh] overflow-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-gray-900">Inspiration Foto</h3>
<button
onClick={closePhotoModal}
className="text-gray-400 hover:text-gray-600 text-2xl"
>
×
</button>
</div>
<div className="flex justify-center">
<img
src={selectedPhoto}
alt="Inspiration Foto"
className="max-w-full max-h-[70vh] object-contain rounded-lg"
/>
</div>
<div className="mt-4 text-center">
<button
onClick={closePhotoModal}
className="bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700"
>
Schließen
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -11,6 +11,8 @@ export function BookingForm() {
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()
@@ -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<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) {
@@ -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() {
/>
</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">

View File

@@ -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(),
});