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(null); const [uploadingImages, setUploadingImages] = useState(false); const [selectedFiles, setSelectedFiles] = useState([]); const [customAmenityInput, setCustomAmenityInput] = useState(''); const [editingAmenity, setEditingAmenity] = useState<{ name: string; newName: string } | null>(null); const [availableAmenities, setAvailableAmenities] = useState([]); const [roomTypes, setRoomTypes] = useState>([]); const [deletingImageUrl, setDeletingImageUrl] = useState(null); const [failedImageUrls, setFailedImageUrls] = useState>(new Set()); const [loadingImageUrls, setLoadingImageUrls] = useState>(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(); 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) => { 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 ; } if (!editingRoom) { return (

Room Not Found

The room you're looking for doesn't exist or has been removed.

); } 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 (
{/* Luxury Header with Glassmorphism */}
{/* Decorative Background Elements */}

Edit Room

{editingRoom.featured && (
Featured
)}

{editingRoom.room_number}

{editingRoom.room_type?.name || 'Room Type'}

Floor {editingRoom.floor}

{/* Main Form Container */}
{/* Decorative Elements */}
{/* Basic Information Section */}

Basic Information

Essential room details and specifications

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 />
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" />
{/* Featured Checkbox - Luxury Style */}
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 && ( )}
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" />

Leave empty to use room type base price

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" />

Room-specific capacity

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" />
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" />