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:
@@ -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>
|
||||
);
|
||||
}
|
@@ -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">
|
||||
|
@@ -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(),
|
||||
});
|
||||
|
Reference in New Issue
Block a user