1417 lines
68 KiB
TypeScript
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;
|