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() {
|
export function AdminBookings() {
|
||||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
||||||
|
const [selectedPhoto, setSelectedPhoto] = useState<string>("");
|
||||||
|
const [showPhotoModal, setShowPhotoModal] = useState(false);
|
||||||
|
|
||||||
const { data: bookings } = useQuery(
|
const { data: bookings } = useQuery(
|
||||||
queryClient.bookings.live.list.experimental_liveOptions()
|
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 =>
|
const filteredBookings = bookings?.filter(booking =>
|
||||||
selectedDate ? booking.appointmentDate === selectedDate : true
|
selectedDate ? booking.appointmentDate === selectedDate : true
|
||||||
).sort((a, b) => {
|
).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">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Date & Time
|
Date & Time
|
||||||
</th>
|
</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">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Status
|
Status
|
||||||
</th>
|
</th>
|
||||||
@@ -148,6 +163,22 @@ export function AdminBookings() {
|
|||||||
<div className="text-xs text-gray-500">Slot-ID: {booking.slotId}</div>
|
<div className="text-xs text-gray-500">Slot-ID: {booking.slotId}</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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">
|
<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)}`}>
|
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(booking.status)}`}>
|
||||||
{booking.status}
|
{booking.status}
|
||||||
@@ -211,6 +242,38 @@ export function AdminBookings() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
@@ -11,6 +11,8 @@ export function BookingForm() {
|
|||||||
const [selectedSlotId, setSelectedSlotId] = useState<string>("");
|
const [selectedSlotId, setSelectedSlotId] = useState<string>("");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [agbAccepted, setAgbAccepted] = useState(false);
|
const [agbAccepted, setAgbAccepted] = useState(false);
|
||||||
|
const [inspirationPhoto, setInspirationPhoto] = useState<string>("");
|
||||||
|
const [photoPreview, setPhotoPreview] = useState<string>("");
|
||||||
|
|
||||||
const { data: treatments } = useQuery(
|
const { data: treatments } = useQuery(
|
||||||
queryClient.treatments.live.list.experimental_liveOptions()
|
queryClient.treatments.live.list.experimental_liveOptions()
|
||||||
@@ -33,6 +35,39 @@ export function BookingForm() {
|
|||||||
const selectedTreatmentData = treatments?.find((t) => t.id === selectedTreatment);
|
const selectedTreatmentData = treatments?.find((t) => t.id === selectedTreatment);
|
||||||
const availableSlots = (slotsByDate || []).filter((s) => s.status === "free");
|
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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!selectedTreatment || !customerName || !customerEmail || !customerPhone || !appointmentDate || !selectedSlotId) {
|
if (!selectedTreatment || !customerName || !customerEmail || !customerPhone || !appointmentDate || !selectedSlotId) {
|
||||||
@@ -54,6 +89,7 @@ export function BookingForm() {
|
|||||||
appointmentDate,
|
appointmentDate,
|
||||||
appointmentTime,
|
appointmentTime,
|
||||||
notes,
|
notes,
|
||||||
|
inspirationPhoto,
|
||||||
slotId: selectedSlotId,
|
slotId: selectedSlotId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -66,6 +102,11 @@ export function BookingForm() {
|
|||||||
setSelectedSlotId("");
|
setSelectedSlotId("");
|
||||||
setNotes("");
|
setNotes("");
|
||||||
setAgbAccepted(false);
|
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.");
|
alert("Buchung erfolgreich erstellt! Wir werden dich kontaktieren, um deinen Termin zu bestätigen.");
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -202,6 +243,41 @@ export function BookingForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* AGB Acceptance */}
|
||||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
|
@@ -22,6 +22,7 @@ const BookingSchema = z.object({
|
|||||||
appointmentTime: z.string(), // HH:MM format
|
appointmentTime: z.string(), // HH:MM format
|
||||||
status: z.enum(["pending", "confirmed", "cancelled", "completed"]),
|
status: z.enum(["pending", "confirmed", "cancelled", "completed"]),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
|
inspirationPhoto: z.string().optional(), // Base64 encoded image data
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
slotId: z.string().optional(),
|
slotId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
Reference in New Issue
Block a user