Files
Hotel-Booking/Frontend/src/pages/admin/EditRoomPage.tsx
Iliyan Angelov e32527ae8c update
2025-12-02 22:16:57 +02:00

1417 lines
68 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Upload, Image as ImageIcon, Check, X, Plus, Edit as EditIcon, Trash2, Sparkles, Crown } from 'lucide-react';
import roomService, { Room } from '../../features/rooms/services/roomService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import apiClient from '../../shared/services/apiClient';
import { logger } from '../../shared/utils/logger';
import { useRoomContext } from '../../features/rooms/contexts/RoomContext';
const EditRoomPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { updateRoom: contextUpdateRoom, refreshRooms } = useRoomContext();
const [loading, setLoading] = useState(true);
const [editingRoom, setEditingRoom] = useState<Room | null>(null);
const [uploadingImages, setUploadingImages] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [customAmenityInput, setCustomAmenityInput] = useState('');
const [editingAmenity, setEditingAmenity] = useState<{ name: string; newName: string } | null>(null);
const [availableAmenities, setAvailableAmenities] = useState<string[]>([]);
const [roomTypes, setRoomTypes] = useState<Array<{ id: number; name: string }>>([]);
const [deletingImageUrl, setDeletingImageUrl] = useState<string | null>(null);
const [failedImageUrls, setFailedImageUrls] = useState<Set<string>>(new Set());
const [loadingImageUrls, setLoadingImageUrls] = useState<Set<string>>(new Set());
const [formData, setFormData] = useState({
room_number: '',
floor: 1,
room_type_id: 1,
status: 'available' as 'available' | 'occupied' | 'maintenance',
featured: false,
price: '',
description: '',
capacity: '',
room_size: '',
view: '',
amenities: [] as string[],
});
useEffect(() => {
if (id) {
fetchRoomData();
}
fetchAvailableAmenities();
fetchRoomTypes();
}, [id]);
// Reset image loading states when room data changes
useEffect(() => {
if (editingRoom) {
// Clear failed images when room data is loaded/refreshed
setFailedImageUrls(new Set());
setLoadingImageUrls(new Set());
// Check for cached images after a brief delay to allow DOM to render
setTimeout(() => {
const allImageElements = document.querySelectorAll('img[data-image-id^="room-image-"]');
allImageElements.forEach((img) => {
const imgElement = img as HTMLImageElement;
if (imgElement.complete && imgElement.naturalWidth > 0) {
const imageId = imgElement.getAttribute('data-image-id');
if (imageId) {
// Extract the original path from the image ID
const match = imageId.match(/room-image-\d+-(.+)/);
if (match) {
const originalPath = match[1];
console.log('EditRoomPage - Found cached image on mount:', originalPath);
setFailedImageUrls(prev => {
const newSet = new Set(prev);
newSet.delete(originalPath);
return newSet;
});
setLoadingImageUrls(prev => {
const newSet = new Set(prev);
newSet.delete(originalPath);
return newSet;
});
}
}
}
});
}, 100);
}
}, [editingRoom?.id]);
const fetchRoomTypes = async () => {
try {
const response = await roomService.getRooms({ limit: 100, page: 1 });
const allUniqueRoomTypes = new Map<number, { id: number; name: string }>();
response.data.rooms.forEach((room: Room) => {
if (room.room_type && !allUniqueRoomTypes.has(room.room_type.id)) {
allUniqueRoomTypes.set(room.room_type.id, {
id: room.room_type.id,
name: room.room_type.name,
});
}
});
if (response.data.pagination && response.data.pagination.totalPages > 1) {
const totalPages = response.data.pagination.totalPages;
for (let page = 2; page <= totalPages; page++) {
try {
const pageResponse = await roomService.getRooms({ limit: 100, page });
pageResponse.data.rooms.forEach((room: Room) => {
if (room.room_type && !allUniqueRoomTypes.has(room.room_type.id)) {
allUniqueRoomTypes.set(room.room_type.id, {
id: room.room_type.id,
name: room.room_type.name,
});
}
});
} catch (err) {
logger.error(`Failed to fetch page ${page}`, err);
}
}
}
if (allUniqueRoomTypes.size > 0) {
setRoomTypes(Array.from(allUniqueRoomTypes.values()));
}
} catch (err) {
logger.error('Failed to fetch room types', err);
}
};
const fetchAvailableAmenities = async () => {
try {
const response = await roomService.getAmenities();
if (response.data?.amenities) {
setAvailableAmenities(response.data.amenities);
}
} catch (error) {
logger.error('Failed to fetch amenities', error);
}
};
const fetchRoomData = async () => {
if (!id) return null;
try {
setLoading(true);
const roomId = parseInt(id);
const response = await roomService.getRoomById(roomId);
const room = response.data.room;
setEditingRoom(room);
let amenitiesArray: string[] = [];
const roomAmenities = room.amenities as string[] | string | undefined;
if (roomAmenities) {
if (Array.isArray(roomAmenities)) {
amenitiesArray = roomAmenities;
} else if (typeof roomAmenities === 'string') {
try {
const parsed = JSON.parse(roomAmenities);
amenitiesArray = Array.isArray(parsed) ? parsed : [];
} catch {
amenitiesArray = roomAmenities.split(',').map((a: string) => a.trim()).filter(Boolean);
}
}
}
setFormData({
room_number: room.room_number,
floor: room.floor,
room_type_id: room.room_type_id,
status: room.status,
featured: room.featured,
price: room.price?.toString() || '',
description: room.description || '',
capacity: room.capacity?.toString() || '',
room_size: room.room_size || '',
view: room.view || '',
amenities: amenitiesArray,
});
return room;
} catch (error: any) {
logger.error('Failed to fetch room data', error);
toast.error(error.response?.data?.message || 'Failed to load room data');
navigate('/admin/advanced-rooms');
return null;
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingRoom) return;
try {
const updateData = {
...formData,
price: formData.price ? parseFloat(formData.price) : undefined,
description: formData.description || undefined,
capacity: formData.capacity ? parseInt(formData.capacity) : undefined,
room_size: formData.room_size || undefined,
view: formData.view || undefined,
amenities: Array.isArray(formData.amenities) ? formData.amenities : [],
};
await contextUpdateRoom(editingRoom.id, updateData);
toast.success('Room updated successfully');
await refreshRooms();
navigate('/admin/advanced-rooms');
} catch (error: any) {
toast.error(error.response?.data?.message || 'An error occurred');
}
};
const toggleAmenity = (amenity: string) => {
setFormData(prev => ({
...prev,
amenities: prev.amenities.includes(amenity)
? prev.amenities.filter(a => a !== amenity)
: [...prev.amenities, amenity]
}));
};
const handleAddCustomAmenity = () => {
const trimmed = customAmenityInput.trim();
if (trimmed && !formData.amenities.includes(trimmed)) {
setFormData(prev => ({
...prev,
amenities: [...prev.amenities, trimmed]
}));
setCustomAmenityInput('');
}
};
const handleRemoveAmenity = (amenity: string) => {
setFormData(prev => ({
...prev,
amenities: prev.amenities.filter(a => a !== amenity)
}));
};
const handleEditAmenity = (amenity: string) => {
setEditingAmenity({ name: amenity, newName: amenity });
};
const handleSaveAmenityEdit = async () => {
if (!editingAmenity || editingAmenity.name === editingAmenity.newName.trim()) {
setEditingAmenity(null);
return;
}
const newName = editingAmenity.newName.trim();
if (!newName) {
toast.error('Amenity name cannot be empty');
return;
}
try {
await roomService.updateAmenity(editingAmenity.name, newName);
toast.success(`Amenity "${editingAmenity.name}" updated to "${newName}"`);
setAvailableAmenities(prev => {
const updated = prev.map(a => a === editingAmenity.name ? newName : a);
return updated.sort();
});
setFormData(prev => ({
...prev,
amenities: prev.amenities.map(a => a === editingAmenity.name ? newName : a)
}));
setEditingAmenity(null);
await fetchAvailableAmenities();
await refreshRooms();
} catch (error: any) {
toast.error(error.response?.data?.message || error.response?.data?.detail || 'Failed to update amenity');
}
};
const handleDeleteAmenity = async (amenity: string) => {
if (!window.confirm(`Are you sure you want to delete "${amenity}"? This will remove it from all rooms and room types.`)) {
return;
}
try {
await roomService.deleteAmenity(amenity);
toast.success(`Amenity "${amenity}" deleted successfully`);
setAvailableAmenities(prev => prev.filter(a => a !== amenity));
setFormData(prev => ({
...prev,
amenities: prev.amenities.filter(a => a !== amenity)
}));
await fetchAvailableAmenities();
await refreshRooms();
} catch (error: any) {
toast.error(error.response?.data?.message || error.response?.data?.detail || 'Failed to delete amenity');
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const files = Array.from(e.target.files);
setSelectedFiles(files);
}
};
const handleUploadImages = async () => {
if (!editingRoom || selectedFiles.length === 0) return;
try {
setUploadingImages(true);
const formData = new FormData();
selectedFiles.forEach(file => {
formData.append('images', file);
});
await apiClient.post(`/rooms/${editingRoom.id}/images`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
toast.success('Images uploaded successfully');
setSelectedFiles([]);
await refreshRooms();
await fetchRoomData();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to upload images');
} finally {
setUploadingImages(false);
}
};
const handleDeleteImage = async (imageUrl: string) => {
if (!editingRoom) return;
if (!window.confirm('Are you sure you want to delete this image?')) return;
try {
setDeletingImageUrl(imageUrl);
// Immediately mark as failed to prevent error handler from firing
setFailedImageUrls(prev => new Set([...prev, imageUrl]));
// For external URLs (like Unsplash), keep the full URL
// For local files, extract the path
let imagePath = imageUrl;
const isExternalUrl = imageUrl.startsWith('http://') || imageUrl.startsWith('https://');
if (isExternalUrl) {
// For external URLs, use the full URL as stored in database
imagePath = imageUrl;
} else {
// For local files, extract the path
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
try {
const url = new URL(imageUrl);
imagePath = url.pathname;
} catch (e) {
const match = imageUrl.match(/(\/uploads\/.*)/);
imagePath = match ? match[1] : imageUrl;
}
}
}
// Also try the normalized format for comparison
const normalizedForDb = normalizeForComparison(imageUrl);
// Log what we're trying to delete
console.log('EditRoomPage - Deleting image:', {
originalUrl: imageUrl,
imagePath,
normalizedForDb,
isExternalUrl,
roomImages: editingRoom.images
});
// Try deleting with the full URL/path first (most likely to match database)
let deleteSuccess = false;
try {
const response = await apiClient.delete(`/rooms/${editingRoom.id}/images`, {
params: { image_url: imagePath },
});
console.log('EditRoomPage - Delete successful with imagePath:', response.data);
deleteSuccess = true;
} catch (firstError: any) {
console.warn('EditRoomPage - Delete failed with imagePath, trying normalizedForDb:', firstError.response?.data);
// If that fails and formats are different, try with the normalized format
if (normalizedForDb !== imagePath && !isExternalUrl) {
try {
const response = await apiClient.delete(`/rooms/${editingRoom.id}/images`, {
params: { image_url: normalizedForDb },
});
console.log('EditRoomPage - Delete successful with normalizedForDb:', response.data);
deleteSuccess = true;
} catch (secondError: any) {
console.error('EditRoomPage - Delete failed with both formats:', secondError.response?.data);
throw secondError;
}
} else {
throw firstError;
}
}
if (!deleteSuccess) {
throw new Error('Failed to delete image');
}
toast.success('Image deleted successfully');
// Clear the image from state immediately
setFailedImageUrls(prev => {
const newSet = new Set(prev);
newSet.delete(imageUrl);
newSet.delete(imagePath);
newSet.delete(normalizedForDb);
return newSet;
});
setLoadingImageUrls(prev => {
const newSet = new Set(prev);
newSet.delete(imageUrl);
newSet.delete(imagePath);
newSet.delete(normalizedForDb);
return newSet;
});
// Refetch room data to get updated image list
await refreshRooms();
const updatedRoom = await fetchRoomData();
// Verify the image was actually removed
if (updatedRoom) {
const stillExists = (updatedRoom.images || []).some((img: any) => {
const imgStr = String(img);
return imgStr === imageUrl ||
imgStr === imagePath ||
imgStr === normalizedForDb ||
normalizeForComparison(imgStr) === normalizedForDb;
});
if (stillExists) {
console.warn('EditRoomPage - Image still exists after deletion:', {
imageUrl,
updatedImages: updatedRoom.images
});
toast.warning('Image may still be in the list. Please refresh the page.');
}
}
} catch (error: any) {
logger.error('Error deleting image', error);
toast.error(error.response?.data?.message || error.response?.data?.detail || 'Unable to delete image');
// Remove from failed list if deletion failed so user can try again
setFailedImageUrls(prev => {
const newSet = new Set(prev);
newSet.delete(imageUrl);
return newSet;
});
} finally {
setDeletingImageUrl(null);
}
};
const handleRemoveBrokenImage = async (imageUrl: string) => {
if (!editingRoom) return;
if (!window.confirm('This image file is missing. Remove it from the room?')) return;
try {
setDeletingImageUrl(imageUrl);
// For external URLs (like Unsplash), keep the full URL
// For local files, extract the path
let imagePath = imageUrl;
const isExternalUrl = imageUrl.startsWith('http://') || imageUrl.startsWith('https://');
if (isExternalUrl) {
// For external URLs, use the full URL as stored in database
imagePath = imageUrl;
} else {
// For local files, extract the path
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
try {
const url = new URL(imageUrl);
imagePath = url.pathname;
} catch (e) {
const match = imageUrl.match(/(\/uploads\/.*)/);
imagePath = match ? match[1] : imageUrl;
}
}
}
// Also try the normalized format for comparison
const normalizedForDb = normalizeForComparison(imageUrl);
// Try deleting with the normalized path first
try {
await apiClient.delete(`/rooms/${editingRoom.id}/images`, {
params: { image_url: imagePath },
});
} catch (firstError: any) {
// If that fails, try with the database format
if (normalizedForDb !== imagePath) {
await apiClient.delete(`/rooms/${editingRoom.id}/images`, {
params: { image_url: normalizedForDb },
});
} else {
throw firstError;
}
}
toast.success('Broken image reference removed');
// Clear the image from state immediately before refetching
setFailedImageUrls(prev => {
const newSet = new Set(prev);
newSet.delete(imageUrl);
return newSet;
});
setLoadingImageUrls(prev => {
const newSet = new Set(prev);
newSet.delete(imageUrl);
return newSet;
});
// Refetch room data to get updated image list
await refreshRooms();
await fetchRoomData();
} catch (error: any) {
logger.error('Error removing broken image', error);
toast.error(error.response?.data?.message || error.response?.data?.detail || 'Unable to remove image reference');
} finally {
setDeletingImageUrl(null);
}
};
if (loading) {
return <Loading />;
}
if (!editingRoom) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 via-white to-amber-50/30">
<div className="text-center p-8 bg-white/80 backdrop-blur-xl rounded-3xl shadow-2xl border border-amber-200/50 max-w-md w-full mx-4">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-gradient-to-br from-amber-100 to-amber-200 flex items-center justify-center">
<X className="w-10 h-10 text-amber-600" />
</div>
<h2 className="text-2xl font-bold text-slate-900 mb-3">Room Not Found</h2>
<p className="text-slate-600 mb-6">The room you're looking for doesn't exist or has been removed.</p>
<button
onClick={() => navigate('/admin/advanced-rooms')}
className="px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl hover:from-amber-600 hover:to-amber-700 transition-all duration-300 font-semibold shadow-lg hover:shadow-xl transform hover:scale-105"
>
Back to Rooms
</button>
</div>
</div>
);
}
const apiBaseUrl = import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000';
const normalizeImageUrl = (img: string): string => {
if (!img) return '';
// If it's already a full URL, return as is
if (img.startsWith('http://') || img.startsWith('https://')) {
// Only reject if it contains obviously malformed characters that could cause parsing issues
if (img.includes('[') || img.includes(']') || img.includes('"')) {
return '';
}
return img;
}
// If it's a data URI, return as is (but validate to prevent parsing issues)
if (img.startsWith('data:')) {
// Basic validation - if it looks like a complete data URI, allow it
if (img.includes(';base64,') && img.length > 50) {
return img;
}
// Reject malformed data URIs
return '';
}
// Handle local file paths (like room-*.png)
let cleanPath = img.trim();
// If it's just a filename (like "room-xxx.png"), add the uploads/rooms path
if (cleanPath.match(/^room-[\w-]+\.(png|jpg|jpeg|gif|webp|webm)$/i)) {
cleanPath = `/uploads/rooms/${cleanPath}`;
}
// If it starts with / but not /uploads, check if it's a room filename
else if (cleanPath.startsWith('/') && !cleanPath.startsWith('/uploads/')) {
if (cleanPath.match(/^\/room-[\w-]+\.(png|jpg|jpeg|gif|webp|webm)$/i)) {
cleanPath = `/uploads/rooms${cleanPath}`;
} else {
// Assume it should be in uploads
cleanPath = `/uploads${cleanPath}`;
}
}
// If it doesn't start with /, add it
else if (!cleanPath.startsWith('/')) {
// Check if it looks like a room filename
if (cleanPath.match(/^room-[\w-]+\.(png|jpg|jpeg|gif|webp|webm)$/i)) {
cleanPath = `/uploads/rooms/${cleanPath}`;
} else {
cleanPath = `/uploads/${cleanPath}`;
}
}
// If it already has /uploads/rooms/, use it as is
else if (cleanPath.startsWith('/uploads/rooms/')) {
// Already correct
}
// If it has /uploads/ but not /uploads/rooms/, check if it's a room file
else if (cleanPath.startsWith('/uploads/') && !cleanPath.startsWith('/uploads/rooms/')) {
const filename = cleanPath.split('/').pop() || '';
if (filename.match(/^room-[\w-]+\.(png|jpg|jpeg|gif|webp|webm)$/i)) {
cleanPath = `/uploads/rooms/${filename}`;
}
}
// Construct the full URL
return `${apiBaseUrl}${cleanPath}`;
};
const normalizeForComparison = (img: string): string => {
if (!img) return '';
if (img.startsWith('http://') || img.startsWith('https://')) {
try {
const url = new URL(img);
return url.pathname;
} catch {
const match = img.match(/(\/uploads\/.*)/);
return match ? match[1] : img;
}
}
return img.startsWith('/') ? img : `/${img}`;
};
const roomImages = editingRoom.images || [];
const roomTypeImages = editingRoom.room_type?.images || [];
const normalizedRoomImages = roomImages.map((ri: any) => normalizeForComparison(String(ri || '')));
const allImages = [
...roomImages.filter((img: any) => img != null && String(img).trim() !== ''),
...roomTypeImages.filter((img: any) => {
if (!img || String(img).trim() === '') return false;
const normalized = normalizeForComparison(String(img));
return !normalizedRoomImages.includes(normalized);
})
];
// Debug logging (can be removed later)
if (allImages.length > 0) {
console.log('EditRoomPage - Images found:', {
roomImages: roomImages.length,
roomTypeImages: roomTypeImages.length,
allImages: allImages.length,
sampleImage: allImages[0],
normalizedUrl: allImages[0] ? normalizeImageUrl(String(allImages[0])) : 'none',
allImagePaths: allImages.map((img: any) => String(img)),
normalizedUrls: allImages.map((img: any) => normalizeImageUrl(String(img)))
});
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-amber-50/20 -m-3 sm:-m-4 md:-m-6 p-3 sm:p-4 md:p-6 lg:p-8">
{/* Luxury Header with Glassmorphism */}
<div className="mb-6 sm:mb-8">
<div className="bg-white/70 backdrop-blur-2xl rounded-3xl shadow-2xl border border-amber-200/30 p-4 sm:p-6 md:p-8 relative overflow-hidden">
{/* Decorative Background Elements */}
<div className="absolute top-0 right-0 w-64 h-64 bg-gradient-to-br from-amber-200/20 to-transparent rounded-full blur-3xl -mr-32 -mt-32"></div>
<div className="absolute bottom-0 left-0 w-48 h-48 bg-gradient-to-tr from-amber-100/20 to-transparent rounded-full blur-2xl -ml-24 -mb-24"></div>
<div className="relative z-10 flex flex-col sm:flex-row sm:items-center gap-4 sm:gap-6">
<button
onClick={() => navigate('/admin/advanced-rooms')}
className="self-start p-3 rounded-2xl bg-gradient-to-br from-white to-slate-50 text-slate-600 hover:text-amber-600 hover:from-amber-50 hover:to-amber-100 transition-all duration-300 shadow-lg hover:shadow-xl border border-slate-200 hover:border-amber-300 transform hover:scale-105 group"
>
<ArrowLeft className="w-5 h-5 sm:w-6 sm:h-6 group-hover:-translate-x-1 transition-transform" />
</button>
<div className="flex-1">
<div className="flex flex-wrap items-center gap-3 mb-2">
<div className="h-1.5 w-12 sm:w-16 bg-gradient-to-r from-amber-400 via-amber-500 to-amber-600 rounded-full shadow-lg"></div>
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-slate-900 via-amber-700 to-slate-900 bg-clip-text text-transparent tracking-tight">
Edit Room
</h1>
{editingRoom.featured && (
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-gradient-to-r from-amber-400/20 to-amber-500/20 rounded-full border border-amber-400/30">
<Crown className="w-4 h-4 text-amber-600" />
<span className="text-xs font-semibold text-amber-700">Featured</span>
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2 sm:gap-4 mt-2">
<p className="text-base sm:text-lg font-mono font-bold text-amber-600 bg-amber-50 px-4 py-2 rounded-xl border border-amber-200 shadow-sm">
{editingRoom.room_number}
</p>
<p className="text-sm sm:text-base text-slate-600 font-medium">
{editingRoom.room_type?.name || 'Room Type'}
</p>
<span className="hidden sm:inline text-slate-400"></span>
<p className="text-sm sm:text-base text-slate-500">Floor {editingRoom.floor}</p>
</div>
</div>
</div>
</div>
</div>
{/* Main Form Container */}
<div className="bg-white/70 backdrop-blur-2xl rounded-3xl shadow-2xl border border-amber-200/30 p-4 sm:p-6 md:p-8 lg:p-10 relative overflow-hidden">
{/* Decorative Elements */}
<div className="absolute top-0 right-0 w-96 h-96 bg-gradient-to-bl from-amber-100/10 to-transparent rounded-full blur-3xl -mr-48 -mt-48"></div>
<div className="absolute bottom-0 left-0 w-72 h-72 bg-gradient-to-tr from-amber-50/10 to-transparent rounded-full blur-2xl -ml-36 -mb-36"></div>
<form onSubmit={handleSubmit} className="relative z-10 space-y-8 sm:space-y-10">
{/* Basic Information Section */}
<div className="space-y-6">
<div className="flex items-center gap-3 mb-6">
<div className="h-10 w-1.5 bg-gradient-to-b from-amber-400 via-amber-500 to-amber-600 rounded-full shadow-lg"></div>
<div>
<h2 className="text-xl sm:text-2xl font-bold bg-gradient-to-r from-slate-900 to-slate-700 bg-clip-text text-transparent">
Basic Information
</h2>
<p className="text-sm text-slate-500 mt-1">Essential room details and specifications</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<div className="group">
<label className="block text-sm font-semibold text-slate-700 mb-2.5 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-amber-500" />
Room Number
</label>
<input
type="text"
value={formData.room_number}
onChange={(e) => setFormData({ ...formData, room_number: e.target.value })}
className="w-full px-5 py-3.5 bg-gradient-to-br from-white to-slate-50/50 border-2 border-slate-200 rounded-2xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100/50 transition-all duration-300 text-slate-800 font-medium shadow-sm hover:shadow-md focus:shadow-lg placeholder:text-slate-400"
placeholder="e.g., 1001"
required
/>
</div>
<div className="group">
<label className="block text-sm font-semibold text-slate-700 mb-2.5 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-amber-500" />
Floor
</label>
<input
type="number"
value={formData.floor}
onChange={(e) => setFormData({ ...formData, floor: parseInt(e.target.value) })}
className="w-full px-5 py-3.5 bg-gradient-to-br from-white to-slate-50/50 border-2 border-slate-200 rounded-2xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100/50 transition-all duration-300 text-slate-800 font-medium shadow-sm hover:shadow-md focus:shadow-lg"
required
min="1"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<div className="group">
<label className="block text-sm font-semibold text-slate-700 mb-2.5 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-amber-500" />
Room Type
</label>
<select
value={formData.room_type_id}
onChange={(e) => setFormData({ ...formData, room_type_id: parseInt(e.target.value) })}
className="w-full px-5 py-3.5 bg-gradient-to-br from-white to-slate-50/50 border-2 border-slate-200 rounded-2xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100/50 transition-all duration-300 text-slate-800 font-medium shadow-sm hover:shadow-md focus:shadow-lg cursor-pointer appearance-none bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNNiA5TDEyIDE1TDE4IDkiIHN0cm9rZT0iIzkyOUE1IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg==')] bg-[length:20px] bg-[right_1rem_center] bg-no-repeat pr-12"
required
>
{roomTypes.length > 0 ? (
roomTypes.map((roomType) => (
<option key={roomType.id} value={roomType.id}>
{roomType.name}
</option>
))
) : (
<option value="">Loading...</option>
)}
</select>
</div>
<div className="group">
<label className="block text-sm font-semibold text-slate-700 mb-2.5 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-amber-500" />
Status
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
className="w-full px-5 py-3.5 bg-gradient-to-br from-white to-slate-50/50 border-2 border-slate-200 rounded-2xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100/50 transition-all duration-300 text-slate-800 font-medium shadow-sm hover:shadow-md focus:shadow-lg cursor-pointer appearance-none bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNNiA5TDEyIDE1TDE4IDkiIHN0cm9rZT0iIzkyOUE1IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg==')] bg-[length:20px] bg-[right_1rem_center] bg-no-repeat pr-12"
required
>
<option value="available">Available</option>
<option value="occupied">Occupied</option>
<option value="maintenance">Maintenance</option>
</select>
</div>
</div>
{/* Featured Checkbox - Luxury Style */}
<div className="flex items-center gap-4 p-5 sm:p-6 bg-gradient-to-br from-amber-50/50 to-amber-100/30 rounded-2xl border-2 border-amber-200/50 shadow-lg hover:shadow-xl transition-all duration-300 group">
<div className="relative">
<input
type="checkbox"
id="featured"
checked={formData.featured}
onChange={(e) => setFormData({ ...formData, featured: e.target.checked })}
className="w-6 h-6 sm:w-7 sm:h-7 text-amber-600 bg-white border-2 border-amber-300 rounded-xl focus:ring-4 focus:ring-amber-200 cursor-pointer transition-all duration-300 checked:bg-gradient-to-br checked:from-amber-400 checked:to-amber-600 checked:border-amber-500 shadow-md hover:shadow-lg"
/>
{formData.featured && (
<Crown className="absolute inset-0 m-auto w-4 h-4 sm:w-5 sm:h-5 text-white pointer-events-none" />
)}
</div>
<label htmlFor="featured" className="flex-1 text-sm sm:text-base text-slate-700 cursor-pointer font-semibold flex items-center gap-2 group-hover:text-amber-700 transition-colors">
<Crown className="w-5 h-5 text-amber-500" />
Featured Room
<span className="text-xs text-amber-600 bg-amber-100 px-2 py-1 rounded-lg ml-auto">Premium</span>
</label>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<div className="group">
<label className="block text-sm font-semibold text-slate-700 mb-2.5 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-amber-500" />
Room Price
</label>
<input
type="number"
step="0.01"
min="0"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: e.target.value })}
className="w-full px-5 py-3.5 bg-gradient-to-br from-white to-slate-50/50 border-2 border-slate-200 rounded-2xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100/50 transition-all duration-300 text-slate-800 font-medium shadow-sm hover:shadow-md focus:shadow-lg placeholder:text-slate-400"
placeholder="e.g., 150.00"
/>
<p className="text-xs text-slate-500 mt-2 ml-1">
Leave empty to use room type base price
</p>
</div>
<div className="group">
<label className="block text-sm font-semibold text-slate-700 mb-2.5 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-amber-500" />
Max Guests
</label>
<input
type="number"
min="1"
value={formData.capacity}
onChange={(e) => setFormData({ ...formData, capacity: e.target.value })}
className="w-full px-5 py-3.5 bg-gradient-to-br from-white to-slate-50/50 border-2 border-slate-200 rounded-2xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100/50 transition-all duration-300 text-slate-800 font-medium shadow-sm hover:shadow-md focus:shadow-lg placeholder:text-slate-400"
placeholder="e.g., 4"
/>
<p className="text-xs text-slate-500 mt-2 ml-1">
Room-specific capacity
</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<div className="group">
<label className="block text-sm font-semibold text-slate-700 mb-2.5 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-amber-500" />
Room Size
</label>
<input
type="text"
value={formData.room_size}
onChange={(e) => setFormData({ ...formData, room_size: e.target.value })}
className="w-full px-5 py-3.5 bg-gradient-to-br from-white to-slate-50/50 border-2 border-slate-200 rounded-2xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100/50 transition-all duration-300 text-slate-800 font-medium shadow-sm hover:shadow-md focus:shadow-lg placeholder:text-slate-400"
placeholder="e.g., 1 Room, 50 sqm"
/>
</div>
<div className="group">
<label className="block text-sm font-semibold text-slate-700 mb-2.5 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-amber-500" />
View
</label>
<input
type="text"
value={formData.view}
onChange={(e) => setFormData({ ...formData, view: e.target.value })}
className="w-full px-5 py-3.5 bg-gradient-to-br from-white to-slate-50/50 border-2 border-slate-200 rounded-2xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100/50 transition-all duration-300 text-slate-800 font-medium shadow-sm hover:shadow-md focus:shadow-lg placeholder:text-slate-400"
placeholder="e.g., City View, Ocean View"
/>
</div>
</div>
<div className="group">
<label className="block text-sm font-semibold text-slate-700 mb-2.5 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-amber-500" />
Room Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={5}
className="w-full px-5 py-4 bg-gradient-to-br from-white to-slate-50/50 border-2 border-slate-200 rounded-2xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100/50 transition-all duration-300 text-slate-800 font-medium shadow-sm hover:shadow-md focus:shadow-lg resize-none placeholder:text-slate-400"
placeholder="Enter a detailed description for this room..."
/>
<p className="text-xs text-slate-500 mt-2 ml-1">
Leave empty to use room type description
</p>
</div>
</div>
{/* Amenities Section */}
<div className="space-y-6">
<div className="flex items-center gap-3 mb-6">
<div className="h-10 w-1.5 bg-gradient-to-b from-amber-400 via-amber-500 to-amber-600 rounded-full shadow-lg"></div>
<div>
<h2 className="text-xl sm:text-2xl font-bold bg-gradient-to-r from-slate-900 to-slate-700 bg-clip-text text-transparent">
Amenities & Features
</h2>
<p className="text-sm text-slate-500 mt-1">Customize room amenities and special features</p>
</div>
</div>
{/* Custom Amenity Input */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<input
type="text"
value={customAmenityInput}
onChange={(e) => setCustomAmenityInput(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddCustomAmenity();
}
}}
placeholder="Add custom amenity..."
className="w-full px-5 py-3.5 bg-gradient-to-br from-white to-slate-50/50 border-2 border-slate-200 rounded-2xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100/50 transition-all duration-300 text-slate-800 font-medium shadow-sm hover:shadow-md focus:shadow-lg placeholder:text-slate-400"
/>
</div>
<button
type="button"
onClick={handleAddCustomAmenity}
disabled={!customAmenityInput.trim()}
className="px-6 py-3.5 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-2xl hover:from-amber-600 hover:to-amber-700 transition-all duration-300 font-semibold shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 transform hover:scale-105 disabled:hover:scale-100"
>
<Plus className="w-5 h-5" />
<span className="hidden sm:inline">Add</span>
</button>
</div>
{/* Selected Amenities */}
{formData.amenities.length > 0 && (
<div className="p-5 bg-gradient-to-br from-amber-50/50 to-amber-100/30 rounded-2xl border-2 border-amber-200/50 shadow-lg">
<p className="text-sm font-semibold text-slate-700 mb-4 flex items-center gap-2">
<Check className="w-4 h-4 text-amber-600" />
Selected Amenities ({formData.amenities.length})
</p>
<div className="flex flex-wrap gap-2.5">
{formData.amenities.map((amenity) => {
const isCustom = !availableAmenities.includes(amenity);
return (
<div
key={amenity}
className="group flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-amber-100 to-amber-50 rounded-xl border border-amber-300 shadow-md hover:shadow-lg transition-all duration-300"
>
<span className="text-sm font-semibold text-amber-800">{amenity}</span>
{isCustom && (
<span className="text-xs font-medium text-amber-700 bg-amber-200/50 px-2 py-0.5 rounded-lg">Custom</span>
)}
<button
type="button"
onClick={() => handleRemoveAmenity(amenity)}
className="ml-1 p-1 rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 transition-all duration-200"
title="Remove amenity"
>
<X className="w-4 h-4" />
</button>
</div>
);
})}
</div>
</div>
)}
{/* Available Amenities */}
<div className="border-2 border-slate-200 rounded-2xl p-4 sm:p-6 max-h-96 overflow-y-auto bg-gradient-to-br from-slate-50/50 to-white shadow-inner custom-scrollbar">
{availableAmenities.length === 0 ? (
<p className="text-sm text-slate-500 text-center py-8">Loading amenities...</p>
) : (
<div className="space-y-3">
<p className="text-sm font-semibold text-slate-700 mb-4 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-amber-500" />
Available Amenities
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-1 gap-3">
{availableAmenities.map((amenity) => {
const isSelected = formData.amenities.includes(amenity);
const isEditing = editingAmenity?.name === amenity;
return (
<div
key={amenity}
className={`flex items-center gap-3 p-4 rounded-2xl transition-all duration-300 ${
isSelected
? 'bg-gradient-to-r from-amber-50 to-amber-100/50 border-2 border-amber-400 shadow-lg'
: 'bg-white border-2 border-slate-200 hover:border-amber-300 hover:shadow-md'
}`}
>
<label className="flex items-center gap-3 flex-1 cursor-pointer">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleAmenity(amenity)}
className="hidden"
/>
<div className={`w-6 h-6 rounded-xl border-2 flex items-center justify-center transition-all duration-300 flex-shrink-0 shadow-md ${
isSelected
? 'bg-gradient-to-br from-amber-400 to-amber-600 border-amber-500 shadow-amber-200'
: 'border-slate-300 bg-white'
}`}>
{isSelected && <Check className="w-4 h-4 text-white font-bold" />}
</div>
{isEditing ? (
<div className="flex items-center gap-2 flex-1">
<input
type="text"
value={editingAmenity.newName}
onChange={(e) => setEditingAmenity({ ...editingAmenity, newName: e.target.value })}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSaveAmenityEdit();
} else if (e.key === 'Escape') {
setEditingAmenity(null);
}
}}
className="flex-1 px-4 py-2 bg-white border-2 border-amber-400 rounded-xl text-slate-800 text-sm font-medium focus:ring-2 focus:ring-amber-300"
autoFocus
/>
<button
type="button"
onClick={handleSaveAmenityEdit}
className="p-2 rounded-xl bg-green-50 text-green-600 hover:bg-green-100 transition-colors shadow-sm"
title="Save"
>
<Check className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => setEditingAmenity(null)}
className="p-2 rounded-xl bg-slate-100 text-slate-600 hover:bg-slate-200 transition-colors shadow-sm"
title="Cancel"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<span className={`text-sm flex-1 font-medium transition-colors ${
isSelected ? 'text-amber-800' : 'text-slate-700'
}`}>
{amenity}
</span>
)}
</label>
{!isEditing && (
<div className="flex items-center gap-2 flex-shrink-0">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleEditAmenity(amenity);
}}
className="p-2.5 rounded-xl bg-blue-50 border border-blue-200 text-blue-600 hover:bg-blue-100 hover:border-blue-300 transition-all duration-200 shadow-sm hover:shadow-md"
title="Edit amenity"
>
<EditIcon className="w-4 h-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleDeleteAmenity(amenity);
}}
className="p-2.5 rounded-xl bg-red-50 border border-red-200 text-red-600 hover:bg-red-100 hover:border-red-300 transition-all duration-200 shadow-sm hover:shadow-md"
title="Delete amenity"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
</div>
{/* Room Images Section */}
<div className="space-y-6">
<div className="flex items-center gap-3 mb-6">
<div className="h-10 w-1.5 bg-gradient-to-b from-amber-400 via-amber-500 to-amber-600 rounded-full shadow-lg"></div>
<div>
<h2 className="text-xl sm:text-2xl font-bold bg-gradient-to-r from-slate-900 to-slate-700 bg-clip-text text-transparent flex items-center gap-2">
<ImageIcon className="w-6 h-6 text-amber-500" />
Room Images
</h2>
<p className="text-sm text-slate-500 mt-1">Manage room photographs and visuals</p>
</div>
</div>
{allImages.length > 0 ? (
<div className="mb-6">
<p className="text-sm font-semibold text-slate-700 mb-4 flex items-center gap-2">
<ImageIcon className="w-4 h-4 text-amber-500" />
Current Images ({allImages.length})
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">
{(() => {
const filteredImages = allImages.filter((img) => {
// Convert to string for comparison
const imgStr = String(img);
// Only filter out deleting images (not failed ones - we'll show them with error state)
if (deletingImageUrl === imgStr) {
return false;
}
// Filter out empty or null images
if (!img || imgStr.trim() === '') {
return false;
}
// Check if we can create a valid URL
const imageUrl = normalizeImageUrl(imgStr);
if (!imageUrl) {
console.warn('EditRoomPage - Skipping image with invalid URL:', imgStr);
return false;
}
return true;
});
console.log('EditRoomPage - Filtered images count:', filteredImages.length, 'out of', allImages.length);
return filteredImages;
})()
.map((img, index) => {
// Convert to string for processing
const imgStr = String(img);
const imageUrl = normalizeImageUrl(imgStr);
const normalizedImg = normalizeForComparison(imgStr);
const normalizedRoomImgs = roomImages.map((ri: any) => normalizeForComparison(String(ri || '')));
const isRoomImage = normalizedRoomImgs.includes(normalizedImg);
const isDeleting = deletingImageUrl === imgStr;
const hasFailed = failedImageUrls.has(imgStr);
const isLoading = loadingImageUrls.has(imgStr);
// Safety check - should not happen due to filter, but double-check
if (isDeleting || !imageUrl) {
return null;
}
// Determine if we should use crossOrigin for CORS
// Only use crossOrigin for external URLs (different origin)
// Same-origin requests don't need crossOrigin and it can cause OpaqueResponseBlocking errors
const isExternalUrl = imageUrl.startsWith('http://') || imageUrl.startsWith('https://');
const isSameOrigin = isExternalUrl && (
imageUrl.startsWith(apiBaseUrl) ||
imageUrl.startsWith(window.location.origin)
);
const useCrossOrigin = isExternalUrl && !isSameOrigin;
return (
<div key={`${imgStr}-${index}`} className="relative group rounded-2xl overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 transform hover:scale-105">
<div className="aspect-square overflow-hidden bg-gradient-to-br from-slate-100 to-slate-200 flex items-center justify-center relative">
{/* Loading overlay - only show if loading and not failed */}
{isLoading && !hasFailed && (
<div className="absolute inset-0 flex items-center justify-center bg-slate-100/80 z-20 pointer-events-none">
<div className="w-8 h-8 border-2 border-amber-500 border-t-transparent rounded-full animate-spin"></div>
</div>
)}
{/* Error state */}
{hasFailed && (
<div className="absolute inset-0 w-full h-full flex flex-col items-center justify-center p-4 text-center bg-slate-100 z-30">
<ImageIcon className="w-12 h-12 text-slate-400 mb-2" />
<p className="text-xs text-slate-500 font-medium mb-1">Failed to load</p>
{imageUrl.includes('/uploads/') && (
<p className="text-xs text-red-500 font-medium mb-2">File not found</p>
)}
<div className="flex gap-2 mt-2">
<button
type="button"
onClick={() => {
// Retry loading the image by clearing error state
// The img element will be re-rendered when hasFailed becomes false
setFailedImageUrls(prev => {
const newSet = new Set(prev);
newSet.delete(imgStr);
return newSet;
});
setLoadingImageUrls(prev => {
const newSet = new Set(prev);
newSet.add(imgStr);
return newSet;
});
}}
className="px-3 py-1.5 text-xs bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors"
>
Retry
</button>
{isRoomImage && imageUrl.includes('/uploads/') && (
<button
type="button"
onClick={() => handleRemoveBrokenImage(imgStr)}
disabled={isDeleting}
className="px-3 py-1.5 text-xs bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50"
>
Remove
</button>
)}
</div>
</div>
)}
{/* Image element - always render but conditionally show */}
<img
key={`img-${imgStr}-${index}`}
data-image-id={`room-image-${index}-${imgStr}`}
src={imageUrl}
alt={`Room Image ${index + 1}`}
className={`w-full h-full object-cover group-hover:scale-110 transition-all duration-500 ${
hasFailed ? 'opacity-0 absolute pointer-events-none' : 'opacity-100'
}`}
loading="lazy"
crossOrigin={useCrossOrigin ? "anonymous" : undefined}
onError={(e) => {
const target = e.target as HTMLImageElement;
const retryCount = parseInt(target.dataset.retryCount || '0');
// Check if it's a 404 (file not found)
const is404 = target.src && (target.src.includes('404') || target.complete === false);
console.warn('EditRoomPage - Image load error:', {
imageUrl,
originalPath: imgStr,
retryCount,
hasCrossOrigin: !!target.crossOrigin,
is404: is404 || 'unknown',
note: is404 ? 'File may not exist on server. Check if file was deleted or path is incorrect.' : 'CORS or network error'
});
// Try retrying without crossOrigin if it was set and we haven't retried yet
if (useCrossOrigin && target.crossOrigin && retryCount === 0) {
target.dataset.retryCount = '1';
target.removeAttribute('crossorigin');
target.src = '';
// Force reload by setting src again
setTimeout(() => {
target.src = imageUrl;
}, 100);
return;
}
// Only mark as failed after retries are exhausted
if (!target.dataset.errorHandled) {
target.dataset.errorHandled = 'true';
// Mark as failed
setFailedImageUrls(prev => {
const newSet = new Set(prev);
newSet.add(imgStr);
return newSet;
});
setLoadingImageUrls(prev => {
const newSet = new Set(prev);
newSet.delete(imgStr);
return newSet;
});
// Stop event propagation to prevent further errors
e.stopPropagation();
e.preventDefault();
}
}}
onLoad={(e) => {
const target = e.target as HTMLImageElement;
console.log('EditRoomPage - Image loaded successfully:', {
imageUrl,
originalPath: imgStr,
naturalWidth: target.naturalWidth,
naturalHeight: target.naturalHeight,
complete: target.complete,
cached: target.complete && target.naturalWidth > 0
});
// Clear failed state if image loads successfully
setFailedImageUrls(prev => {
if (prev.has(imgStr)) {
console.log('EditRoomPage - Clearing failed state for:', imgStr);
}
const newSet = new Set(prev);
newSet.delete(imgStr);
return newSet;
});
setLoadingImageUrls(prev => {
const newSet = new Set(prev);
newSet.delete(imgStr);
return newSet;
});
// Ensure image is visible
target.style.opacity = '1';
target.style.display = 'block';
}}
onLoadStart={() => {
// Mark as loading when image starts loading
setLoadingImageUrls(prev => {
const newSet = new Set(prev);
newSet.add(imgStr);
return newSet;
});
// Clear failed state when starting to load
setFailedImageUrls(prev => {
const newSet = new Set(prev);
newSet.delete(imgStr);
return newSet;
});
}}
/>
</div>
{isRoomImage && !hasFailed && (
<>
<div className="absolute top-2 left-2 bg-gradient-to-r from-amber-500/90 to-amber-600/90 backdrop-blur-md text-white px-3 py-1.5 rounded-xl text-xs font-semibold opacity-0 group-hover:opacity-100 transition-opacity duration-300 shadow-lg">
Room Image
</div>
<button
type="button"
onClick={() => handleDeleteImage(imgStr)}
disabled={isDeleting}
className="absolute top-2 right-2 bg-gradient-to-r from-red-500/90 to-red-600/90 backdrop-blur-md text-white p-2.5 rounded-xl opacity-0 group-hover:opacity-100 transition-all duration-300 hover:scale-110 shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
{isDeleting ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
) : (
<X className="w-4 h-4" />
)}
</button>
</>
)}
</div>
);
})}
</div>
</div>
) : (
<div className="mb-6 p-8 sm:p-12 bg-gradient-to-br from-slate-50 to-slate-100/50 rounded-2xl border-2 border-dashed border-slate-300 text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-200 flex items-center justify-center">
<ImageIcon className="w-8 h-8 text-slate-400" />
</div>
<p className="text-sm sm:text-base text-slate-600 font-medium">
No images uploaded yet
</p>
<p className="text-xs text-slate-500 mt-1">Upload images below to display them here</p>
</div>
)}
{/* Upload Section */}
<div className="p-5 sm:p-6 bg-gradient-to-br from-slate-50/50 to-white rounded-2xl border-2 border-slate-200 shadow-lg">
<label className="block text-sm font-semibold text-slate-700 mb-4 flex items-center gap-2">
<Upload className="w-5 h-5 text-amber-500" />
Add New Images
</label>
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
<div className="flex-1">
<input
type="file"
accept="image/*"
multiple
onChange={handleFileSelect}
className="w-full text-sm text-slate-600 file:mr-4 file:py-3 file:px-6 file:rounded-xl file:border-0 file:text-sm file:font-semibold file:bg-gradient-to-r file:from-amber-500 file:to-amber-600 file:text-white file:cursor-pointer file:shadow-lg file:hover:shadow-xl file:transition-all file:duration-300 file:transform file:hover:scale-105 bg-white rounded-xl border-2 border-slate-200"
/>
</div>
<button
type="button"
onClick={handleUploadImages}
disabled={selectedFiles.length === 0 || uploadingImages}
className="px-6 sm:px-8 py-3.5 bg-gradient-to-r from-green-500 to-green-600 text-white rounded-xl hover:from-green-600 hover:to-green-700 disabled:from-slate-300 disabled:to-slate-400 disabled:cursor-not-allowed flex items-center justify-center gap-2 font-semibold shadow-lg hover:shadow-xl transition-all duration-300 transform hover:scale-105 disabled:hover:scale-100"
>
<Upload className="w-5 h-5" />
{uploadingImages ? 'Uploading...' : 'Upload'}
</button>
</div>
{selectedFiles.length > 0 && (
<p className="text-sm text-amber-600 font-medium mt-3 flex items-center gap-2">
<Check className="w-4 h-4" />
{selectedFiles.length} file(s) selected
</p>
)}
</div>
</div>
{/* Form Actions - Luxury Buttons */}
<div className="flex flex-col sm:flex-row gap-4 pt-6 border-t-2 border-slate-200">
<button
type="button"
onClick={() => navigate('/admin/advanced-rooms')}
className="flex-1 px-6 py-4 bg-gradient-to-br from-white to-slate-50 border-2 border-slate-300 text-slate-700 rounded-2xl hover:border-slate-400 hover:from-slate-50 hover:to-slate-100 transition-all duration-300 font-semibold shadow-lg hover:shadow-xl transform hover:scale-105"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-6 py-4 bg-gradient-to-r from-amber-500 via-amber-600 to-amber-700 text-white rounded-2xl hover:from-amber-600 hover:via-amber-700 hover:to-amber-800 transition-all duration-300 font-bold shadow-xl hover:shadow-2xl transform hover:scale-105 flex items-center justify-center gap-2"
>
<Check className="w-5 h-5" />
Update Room
</button>
</div>
</form>
</div>
</div>
);
};
export default EditRoomPage;