This commit is contained in:
yotakii
2026-01-13 16:07:35 +03:00
parent e77c325a5f
commit 9dda03b40d
18 changed files with 742 additions and 327 deletions

View File

@@ -0,0 +1,60 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Add Room Category</title>
</head>
<body style="font-family: sans-serif; max-width: 720px; margin: 40px auto;">
<h2>Add Room Category (Admin)</h2>
<p>Make sure you are logged in to /admin/login first.</p>
<label>Name</label><br />
<input id="name" style="width:100%;padding:8px" value="Deluxe" /><br /><br />
<label>Slug (lowercase, no spaces)</label><br />
<input id="slug" style="width:100%;padding:8px" value="deluxe" /><br /><br />
<label>Description</label><br />
<textarea id="description" style="width:100%;padding:8px" rows="4">Deluxe rooms</textarea><br /><br />
<button id="btn" style="padding:10px 16px">Create Category</button>
<pre id="out" style="background:#111;color:#0f0;padding:12px;margin-top:16px;white-space:pre-wrap"></pre>
<script>
document.getElementById("btn").onclick = async () => {
const token = localStorage.getItem("adminToken");
if (!token) {
document.getElementById("out").textContent = "❌ No adminToken found. Please login at /admin/login first.";
return;
}
const payload = {
name: document.getElementById("name").value.trim(),
slug: document.getElementById("slug").value.trim(),
description: document.getElementById("description").value.trim(),
images: [],
features: [],
isActive: true,
displayOrder: 1
};
try {
const res = await fetch("http://localhost:5080/api/room-categories", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token
},
body: JSON.stringify(payload)
});
const data = await res.json();
document.getElementById("out").textContent = "Status: " + res.status + "\n" + JSON.stringify(data, null, 2);
} catch (e) {
document.getElementById("out").textContent = "❌ Error: " + e.message;
}
};
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -49,6 +49,10 @@ const menuItems = [
{ title: 'Settings', path: '/admin/settings', icon: <SettingsIcon /> }, { title: 'Settings', path: '/admin/settings', icon: <SettingsIcon /> },
]; ];
// ✅ hide from sidebar only (keep code/routes for future)
const hiddenMenuTitles = new Set(['Guests', 'Blog']);
const adminMenuItems = menuItems.filter(item => !hiddenMenuTitles.has(item.title));
const AdminLayout = () => { const AdminLayout = () => {
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md')); const isMobile = useMediaQuery(theme.breakpoints.down('md'));
@@ -102,7 +106,7 @@ const AdminLayout = () => {
</Toolbar> </Toolbar>
<Divider /> <Divider />
<List> <List>
{menuItems.map((item) => ( {adminMenuItems.map((item) => (
<ListItem key={item.title} disablePadding> <ListItem key={item.title} disablePadding>
<ListItemButton <ListItemButton
selected={location.pathname === item.path} selected={location.pathname === item.path}
@@ -259,4 +263,3 @@ const AdminLayout = () => {
}; };
export default AdminLayout; export default AdminLayout;

View File

@@ -20,7 +20,25 @@ import {
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Helmet } from 'react-helmet-async'; import { Helmet } from 'react-helmet-async';
import staticData from '../utils/staticData'; import api from '../utils/api'; // ✅ بدل staticData
const API_BASE = process.env.REACT_APP_API_URL || 'http://localhost:5080';
const toMediaUrl = (url) => {
if (!url) return url;
if (/^https?:\/\//i.test(url)) {
if (url.includes('localhost:3060/uploads/')) return url.replace('http://localhost:3060', API_BASE);
return url;
}
if (url.startsWith('/uploads/')) return `${API_BASE}${url}`;
return url; // /images/... stays on client
};
const unwrapContent = (res) => {
return res?.data?.data?.content || res?.data?.data || res?.data || null;
};
// ✅ remove HTML tags مثل <p>..</p>
const stripHtml = (html = '') => String(html).replace(/<[^>]*>/g, '').trim();
const About = () => { const About = () => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@@ -29,20 +47,27 @@ const About = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const currentLanguage = i18n.language; const currentLanguage = i18n.language;
// Fetch about page content from static data // Fetch about page content from API بدل static data
useEffect(() => { useEffect(() => {
let mounted = true;
const fetchContent = async () => { const fetchContent = async () => {
try { try {
const aboutContent = await staticData.getAboutContent(); const res = await api.get('/api/content/about');
setContent(aboutContent); const aboutContent = unwrapContent(res);
if (mounted) setContent(aboutContent);
} catch (error) { } catch (error) {
console.error('Error loading about page content:', error); console.error('Error loading about page content:', error);
} finally { } finally {
setLoading(false); if (mounted) setLoading(false);
} }
}; };
fetchContent(); fetchContent();
return () => {
mounted = false;
};
}, []); }, []);
const values = [ const values = [
@@ -68,7 +93,6 @@ const About = () => {
} }
]; ];
const milestones = [ const milestones = [
{ {
year: '1985', year: '1985',
@@ -111,25 +135,40 @@ const About = () => {
); );
} }
// Use translations for non-English languages, static data for English // Use translations for non-English languages, API content for English
const useTranslations = currentLanguage !== 'en'; const useTranslations = currentLanguage !== 'en';
// Fallback content - prioritize translations for non-English // ✅ stripHtml only for content coming from dashboard (English)
const heroTitle = useTranslations ? t('about.heroTitle') : (content?.hero?.title || t('about.heroTitle')); const heroTitle = useTranslations ? t('about.heroTitle') : stripHtml(content?.hero?.title || t('about.heroTitle'));
const heroSubtitle = useTranslations ? t('about.heroSubtitle') : (content?.hero?.subtitle || t('about.heroSubtitle')); const heroSubtitle = useTranslations ? t('about.heroSubtitle') : stripHtml(content?.hero?.subtitle || t('about.heroSubtitle'));
const heroDescription = useTranslations ? '' : (content?.hero?.description || ''); const heroDescription = useTranslations ? '' : stripHtml(content?.hero?.description || '');
const heroImage = content?.hero?.backgroundImage || '/images/about-hero.jpg'; const heroImage = toMediaUrl(content?.hero?.backgroundImage) || '/images/about-hero.jpg';
// Get sections from content - use translations for non-English // Get sections from content - use translations for non-English
const heritageSectionStatic = content?.sections?.find(s => s.sectionId === 'heritage') || {}; const heritageSectionStatic = content?.sections?.find(s => s.sectionId === 'heritage') || {};
const heritageSection = useTranslations ? { const heritageSection = useTranslations ? {
title: t('about.heritageTitle'), title: t('about.heritageTitle'),
content: t('about.heritageContent'), content: t('about.heritageContent'),
image: heritageSectionStatic.image || '/images/about.jpg' image: toMediaUrl(heritageSectionStatic.image) || '/images/about.jpg'
} : heritageSectionStatic; } : {
...heritageSectionStatic,
title: stripHtml(heritageSectionStatic.title || ''),
subtitle: stripHtml(heritageSectionStatic.subtitle || ''),
content: stripHtml(heritageSectionStatic.content || ''),
image: toMediaUrl(heritageSectionStatic.image) || '/images/about.jpg'
};
const missionSection = content?.sections?.find(s => s.sectionId === 'mission') || {}; const missionSection = content?.sections?.find(s => s.sectionId === 'mission') || {};
const visionSection = content?.sections?.find(s => s.sectionId === 'vision') || {}; const visionSection = content?.sections?.find(s => s.sectionId === 'vision') || {};
const valuesSection = content?.sections?.find(s => s.sectionId === 'values') || {}; const valuesSectionStatic = content?.sections?.find(s => s.sectionId === 'values') || {};
const valuesSection = useTranslations ? valuesSectionStatic : {
...valuesSectionStatic,
title: stripHtml(valuesSectionStatic.title || ''),
content: stripHtml(valuesSectionStatic.content || ''),
// items (إذا موجودة) نتركها كما هي بدون تغيير
};
return ( return (
<> <>
@@ -162,7 +201,6 @@ const About = () => {
component="h1" component="h1"
sx={{ sx={{
mb: 3, mb: 3,
// Use theme heading font
fontSize: { xs: '2.5rem', md: '4rem' }, fontSize: { xs: '2.5rem', md: '4rem' },
}} }}
> >
@@ -225,7 +263,7 @@ const About = () => {
sx={{ sx={{
lineHeight: 1.8, lineHeight: 1.8,
fontSize: '1.1rem', fontSize: '1.1rem',
whiteSpace: 'pre-line' // Preserve line breaks whiteSpace: 'pre-line'
}} }}
> >
{heritageSection.content} {heritageSection.content}
@@ -332,107 +370,105 @@ const About = () => {
</motion.div> </motion.div>
</Container> </Container>
</Box> </Box>
{false && (
<Container maxWidth="lg" sx={{ py: 8 }}>
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
viewport={{ once: true }}
>
<Typography
variant="h3"
component="h2"
textAlign="center"
sx={{ mb: 6, color: 'primary.main' }}
>
Our Journey
</Typography>
<Box sx={{ position: 'relative' }}> {false && (
{/* Timeline line */} <Container maxWidth="lg" sx={{ py: 8 }}>
<Box
sx={{
position: 'absolute',
left: '50%',
top: 0,
bottom: 0,
width: 2,
backgroundColor: 'primary.main',
transform: 'translateX(-50%)',
display: { xs: 'none', md: 'block' },
}}
/>
{milestones.map((milestone, index) => (
<motion.div <motion.div
key={index} initial={{ opacity: 0, y: 30 }}
initial={{ opacity: 0, x: index % 2 === 0 ? -50 : 50 }} whileInView={{ opacity: 1, y: 0 }}
whileInView={{ opacity: 1, x: 0 }} transition={{ duration: 0.8 }}
transition={{ duration: 0.6, delay: index * 0.2 }}
viewport={{ once: true }} viewport={{ once: true }}
> >
<Box <Typography
sx={{ variant="h3"
display: 'flex', component="h2"
alignItems: 'center', textAlign="center"
mb: 6, sx={{ mb: 6, color: 'primary.main' }}
flexDirection: {
xs: 'column',
md: index % 2 === 0 ? 'row' : 'row-reverse',
},
textAlign: { xs: 'center', md: 'left' },
}}
> >
<Box sx={{ flex: 1, px: { xs: 0, md: 4 } }}> Our Journey
<Card </Typography>
sx={{
p: 3,
maxWidth: 400,
mx: { xs: 'auto', md: index % 2 === 0 ? 0 : 'auto' },
}}
>
<Typography
variant="h4"
component="h3"
sx={{ color: 'secondary.main', fontWeight: 700, mb: 1 }}
>
{milestone.year}
</Typography>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
{milestone.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{milestone.description}
</Typography>
</Card>
</Box>
{/* Timeline dot */} <Box sx={{ position: 'relative' }}>
<Box <Box
sx={{ sx={{
width: 20, position: 'absolute',
height: 20, left: '50%',
backgroundColor: 'secondary.main', top: 0,
borderRadius: '50%', bottom: 0,
border: `4px solid ${theme.palette.background.paper}`, width: 2,
boxShadow: 2, backgroundColor: 'primary.main',
transform: 'translateX(-50%)',
display: { xs: 'none', md: 'block' }, display: { xs: 'none', md: 'block' },
zIndex: 1,
}} }}
/> />
<Box sx={{ flex: 1 }} /> {milestones.map((milestone, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: index % 2 === 0 ? -50 : 50 }}
whileInView={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6, delay: index * 0.2 }}
viewport={{ once: true }}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
mb: 6,
flexDirection: {
xs: 'column',
md: index % 2 === 0 ? 'row' : 'row-reverse',
},
textAlign: { xs: 'center', md: 'left' },
}}
>
<Box sx={{ flex: 1, px: { xs: 0, md: 4 } }}>
<Card
sx={{
p: 3,
maxWidth: 400,
mx: { xs: 'auto', md: index % 2 === 0 ? 0 : 'auto' },
}}
>
<Typography
variant="h4"
component="h3"
sx={{ color: 'secondary.main', fontWeight: 700, mb: 1 }}
>
{milestone.year}
</Typography>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
{milestone.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{milestone.description}
</Typography>
</Card>
</Box>
<Box
sx={{
width: 20,
height: 20,
backgroundColor: 'secondary.main',
borderRadius: '50%',
border: `4px solid ${theme.palette.background.paper}`,
boxShadow: 2,
display: { xs: 'none', md: 'block' },
zIndex: 1,
}}
/>
<Box sx={{ flex: 1 }} />
</Box>
</motion.div>
))}
</Box> </Box>
</motion.div> </motion.div>
))} </Container>
</Box> )}
</motion.div>
</Container>
)}
</> </>
); );
}; };
export default About; export default About;

View File

@@ -1,30 +1,184 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import { Container, Typography, Box } from '@mui/material'; import {
Container, Typography, Box, TextField, Button,
MenuItem, Alert, CircularProgress
} from '@mui/material';
import api from '../utils/api';
const Booking = () => { const Booking = () => {
const [rooms, setRooms] = useState([]);
const [loadingRooms, setLoadingRooms] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState(null);
const [form, setForm] = useState({
firstName: '',
lastName: '',
email: '',
phone: '',
roomId: '',
checkInDate: '',
checkOutDate: '',
adults: 1,
children: 0,
specialRequests: ''
});
useEffect(() => {
const loadRooms = async () => {
try {
const res = await api.get('/api/rooms?limit=100');
setRooms(res?.data?.data?.rooms || []);
} catch (e) {
setMessage({ type: 'error', text: 'Failed to load rooms' });
} finally {
setLoadingRooms(false);
}
};
loadRooms();
}, []);
const handleChange = (key) => (e) => {
setForm(prev => ({ ...prev, [key]: e.target.value }));
};
const handleSubmit = async (e) => {
e.preventDefault();
setSubmitting(true);
setMessage(null);
try {
const payload = {
guestInfo: {
firstName: form.firstName,
lastName: form.lastName,
email: form.email,
phone: form.phone
},
roomId: form.roomId,
checkInDate: form.checkInDate,
checkOutDate: form.checkOutDate,
numberOfGuests: {
adults: Number(form.adults),
children: Number(form.children || 0)
},
specialRequests: form.specialRequests
};
const res = await api.post('/api/bookings/request', payload);
const bookingNumber = res?.data?.data?.booking?.bookingNumber;
setMessage({
type: 'success',
text: bookingNumber
? `Booking request submitted! Booking #: ${bookingNumber}`
: 'Booking request submitted successfully!'
});
// reset minimal
setForm(prev => ({ ...prev, specialRequests: '' }));
} catch (error) {
setMessage({
type: 'error',
text: error.response?.data?.message || 'Failed to submit booking request'
});
} finally {
setSubmitting(false);
}
};
return ( return (
<Container maxWidth="lg" sx={{ py: 8 }}> <Container maxWidth="sm" sx={{ py: 8 }}>
<Box sx={{ textAlign: 'center', mb: 6 }}> <Box sx={{ textAlign: 'center', mb: 4 }}>
<Typography variant="h3" component="h1" gutterBottom> <Typography variant="h3" component="h1" gutterBottom>
Book Your Stay Book Your Stay
</Typography> </Typography>
<Typography variant="h5" color="text.secondary"> <Typography variant="h6" color="text.secondary">
Reserve your room at our luxury hotel Submit a booking request (Pay at hotel)
</Typography> </Typography>
</Box> </Box>
<Box sx={{
display: 'flex', {message && (
flexDirection: 'column', <Alert severity={message.type} sx={{ mb: 3 }} onClose={() => setMessage(null)}>
alignItems: 'center', {message.text}
gap: 4 </Alert>
}}> )}
<Typography variant="body1" sx={{ textAlign: 'center', maxWidth: 800 }}>
Booking form coming soon... {loadingRooms ? (
</Typography> <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
{/* Add booking form here */} <CircularProgress />
</Box> </Box>
) : (
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField label="First Name" value={form.firstName} onChange={handleChange('firstName')} required />
<TextField label="Last Name" value={form.lastName} onChange={handleChange('lastName')} required />
<TextField label="Email" type="email" value={form.email} onChange={handleChange('email')} required />
<TextField label="Phone" value={form.phone} onChange={handleChange('phone')} required />
<TextField
select
label="Select Room"
value={form.roomId}
onChange={handleChange('roomId')}
required
>
{rooms.map(r => (
<MenuItem key={r._id} value={r._id}>
{r.name} (#{r.roomNumber}) - ${r.basePrice}/night
</MenuItem>
))}
</TextField>
<TextField
label="Check-in"
type="date"
value={form.checkInDate}
onChange={handleChange('checkInDate')}
InputLabelProps={{ shrink: true }}
required
/>
<TextField
label="Check-out"
type="date"
value={form.checkOutDate}
onChange={handleChange('checkOutDate')}
InputLabelProps={{ shrink: true }}
required
/>
<TextField
label="Adults"
type="number"
value={form.adults}
onChange={handleChange('adults')}
inputProps={{ min: 1 }}
required
/>
<TextField
label="Children"
type="number"
value={form.children}
onChange={handleChange('children')}
inputProps={{ min: 0 }}
/>
<TextField
label="Special Requests"
value={form.specialRequests}
onChange={handleChange('specialRequests')}
multiline
rows={3}
/>
<Button type="submit" variant="contained" size="large" disabled={submitting}>
{submitting ? 'Submitting...' : 'Submit Booking Request'}
</Button>
</Box>
)}
</Container> </Container>
); );
}; };
export default Booking; export default Booking;

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { import {
Container, Container,
Typography, Typography,
@@ -19,7 +19,7 @@ import {
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Helmet } from 'react-helmet-async'; import { Helmet } from 'react-helmet-async';
import staticData from '../utils/staticData'; import api from '../utils/api';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos'; import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos';
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
@@ -31,35 +31,90 @@ import 'swiper/css/navigation';
import 'swiper/css/thumbs'; import 'swiper/css/thumbs';
import 'swiper/css/free-mode'; import 'swiper/css/free-mode';
const API_BASE = process.env.REACT_APP_API_URL || 'http://localhost:5080';
// يحوّل روابط uploads لتكون من الـ CMS بدل الـ client
const toMediaUrl = (url) => {
if (!url) return url;
// إذا رابط كامل http/https
if (/^https?:\/\//i.test(url)) {
// Fix: لو كان بالغلط على 3060/uploads
if (url.includes('localhost:3060/uploads/')) {
return url.replace('http://localhost:3060', API_BASE);
}
return url;
}
// إذا /uploads/... خليه من API_BASE
if (url.startsWith('/uploads/')) return `${API_BASE}${url}`;
// باقي الروابط مثل /images/... خليها عادي
return url;
};
const CategoryGallery = () => { const CategoryGallery = () => {
const { categorySlug } = useParams(); const { categorySlug } = useParams();
const [category, setCategory] = useState(null); const [category, setCategory] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0); const [lightboxIndex, setLightboxIndex] = useState(0);
const [thumbsSwiper, setThumbsSwiper] = useState(null); const [thumbsSwiper, setThumbsSwiper] = useState(null);
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
useEffect(() => { useEffect(() => {
let mounted = true;
const fetchCategory = async () => { const fetchCategory = async () => {
try { try {
const category = await staticData.getRoomCategory(categorySlug); setLoading(true);
setCategory(category); setError(null);
const res = await api.get(`/api/room-categories/${categorySlug}`);
const cat = res?.data?.data?.category || null;
if (mounted) setCategory(cat);
} catch (err) { } catch (err) {
console.error('Error loading category:', err); console.error('Error loading category:', err);
setError('Failed to load room category. Please try again later.'); if (mounted) setError('Category not found');
} finally { } finally {
setLoading(false); if (mounted) setLoading(false);
} }
}; };
if (categorySlug) { if (categorySlug) fetchCategory();
fetchCategory();
} return () => {
mounted = false;
};
}, [categorySlug]); }, [categorySlug]);
const rooms = category?.rooms || [];
// ✅ Gallery images:
// إذا الكاتيجوري عندها صور استخدميها، إذا لا، استخدمي صور الغرف التابعة
const images = useMemo(() => {
const catImages = category?.images || [];
if (catImages.length > 0) return catImages;
// fallback: اجمع صور الغرف
const roomImages = rooms
.flatMap((r) => r?.images || [])
.filter((img) => img?.url);
// إزالة التكرار حسب url
const seen = new Set();
return roomImages.filter((img) => {
if (seen.has(img.url)) return false;
seen.add(img.url);
return true;
});
}, [category, rooms]);
const handleImageClick = (index) => { const handleImageClick = (index) => {
setLightboxIndex(index); setLightboxIndex(index);
setLightboxOpen(true); setLightboxOpen(true);
@@ -70,15 +125,11 @@ const CategoryGallery = () => {
}; };
const handlePrevious = () => { const handlePrevious = () => {
setLightboxIndex((prev) => setLightboxIndex((prev) => (prev > 0 ? prev - 1 : images.length - 1));
prev > 0 ? prev - 1 : (category.images.length - 1)
);
}; };
const handleNext = () => { const handleNext = () => {
setLightboxIndex((prev) => setLightboxIndex((prev) => (prev < images.length - 1 ? prev + 1 : 0));
prev < category.images.length - 1 ? prev + 1 : 0
);
}; };
if (loading) { if (loading) {
@@ -102,9 +153,6 @@ const CategoryGallery = () => {
); );
} }
const images = category.images || [];
const rooms = category.rooms || [];
return ( return (
<> <>
<Helmet> <Helmet>
@@ -117,7 +165,7 @@ const CategoryGallery = () => {
sx={{ sx={{
minHeight: '40vh', minHeight: '40vh',
background: category.primaryImage background: category.primaryImage
? `linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.5)), url(${category.primaryImage}) center/cover` ? `linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.5)), url(${toMediaUrl(category.primaryImage)}) center/cover`
: 'linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.5)), url("/images/hero.jpg") center/cover', : 'linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.5)), url("/images/hero.jpg") center/cover',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -135,30 +183,22 @@ const CategoryGallery = () => {
<Typography <Typography
variant="h1" variant="h1"
component="h1" component="h1"
sx={{ sx={{ mb: 2, fontSize: { xs: '2.5rem', md: '4rem' } }}
mb: 2,
fontSize: { xs: '2.5rem', md: '4rem' },
}}
> >
{category.name} {category.name}
</Typography> </Typography>
<Typography <Typography
variant="h6" variant="h6"
component="p" component="p"
sx={{ sx={{ maxWidth: 700, mx: 'auto', fontWeight: 300, mb: 2 }}
maxWidth: 700,
mx: 'auto',
fontWeight: 300,
mb: 2,
}}
> >
{category.description} {category.description}
</Typography> </Typography>
{category.priceRange && category.priceRange.min > 0 && ( {category.priceRange && category.priceRange.min > 0 && (
<Typography variant="h6" sx={{ fontWeight: 400 }}> <Typography variant="h6" sx={{ fontWeight: 400 }}>
From ${category.priceRange.min} From ${category.priceRange.min}
{category.priceRange.max > category.priceRange.min && {category.priceRange.max > category.priceRange.min ? ` - $${category.priceRange.max}` : ''}
` - $${category.priceRange.max}`}
/night /night
</Typography> </Typography>
)} )}
@@ -191,7 +231,7 @@ const CategoryGallery = () => {
<SwiperSlide key={index}> <SwiperSlide key={index}>
<Box <Box
component="img" component="img"
src={image.url} src={toMediaUrl(image.url)}
alt={image.alt || `${category.name} - Image ${index + 1}`} alt={image.alt || `${category.name} - Image ${index + 1}`}
onClick={() => handleImageClick(index)} onClick={() => handleImageClick(index)}
sx={{ sx={{
@@ -200,9 +240,7 @@ const CategoryGallery = () => {
objectFit: 'cover', objectFit: 'cover',
cursor: 'pointer', cursor: 'pointer',
transition: 'transform 0.3s', transition: 'transform 0.3s',
'&:hover': { '&:hover': { transform: 'scale(1.02)' },
transform: 'scale(1.02)',
},
}} }}
/> />
</SwiperSlide> </SwiperSlide>
@@ -225,7 +263,7 @@ const CategoryGallery = () => {
<SwiperSlide key={index}> <SwiperSlide key={index}>
<Box <Box
component="img" component="img"
src={image.url} src={toMediaUrl(image.url)}
alt={`Thumbnail ${index + 1}`} alt={`Thumbnail ${index + 1}`}
onClick={() => handleImageClick(index)} onClick={() => handleImageClick(index)}
sx={{ sx={{
@@ -236,9 +274,7 @@ const CategoryGallery = () => {
cursor: 'pointer', cursor: 'pointer',
border: '2px solid transparent', border: '2px solid transparent',
transition: 'border-color 0.3s', transition: 'border-color 0.3s',
'&:hover': { '&:hover': { borderColor: 'primary.main' },
borderColor: 'primary.main',
},
}} }}
/> />
</SwiperSlide> </SwiperSlide>
@@ -260,6 +296,7 @@ const CategoryGallery = () => {
<Typography variant="h4" component="h2" sx={{ fontWeight: 600, mb: 4 }}> <Typography variant="h4" component="h2" sx={{ fontWeight: 600, mb: 4 }}>
Available Rooms ({rooms.length}) Available Rooms ({rooms.length})
</Typography> </Typography>
<Grid container spacing={4}> <Grid container spacing={4}>
{rooms.map((room) => ( {rooms.map((room) => (
<Grid item xs={12} md={6} lg={4} key={room._id}> <Grid item xs={12} md={6} lg={4} key={room._id}>
@@ -269,45 +306,44 @@ const CategoryGallery = () => {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
transition: 'transform 0.3s, box-shadow 0.3s', transition: 'transform 0.3s, box-shadow 0.3s',
'&:hover': { '&:hover': { transform: 'translateY(-4px)', boxShadow: 4 },
transform: 'translateY(-4px)',
boxShadow: 4,
},
}} }}
> >
<CardMedia <CardMedia
component="img" component="img"
height="200" height="200"
image={ image={
room.images && room.images.length > 0 toMediaUrl(
? room.images.find(img => img.isPrimary)?.url || room.images[0].url room.images?.find((img) => img.isPrimary)?.url ||
: '/images/room-default.jpg' room.images?.[0]?.url
) || '/images/room-default.jpg'
} }
alt={room.name} alt={room.name}
sx={{ objectFit: 'cover' }} sx={{ objectFit: 'cover' }}
onError={(e) => { e.currentTarget.src = '/images/room-default.jpg'; }}
/> />
<CardContent sx={{ flexGrow: 1 }}> <CardContent sx={{ flexGrow: 1 }}>
<Typography variant="h6" component="h3" gutterBottom sx={{ fontWeight: 600 }}> <Typography variant="h6" component="h3" gutterBottom sx={{ fontWeight: 600 }}>
{room.name} {room.name}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{room.shortDescription} {room.shortDescription}
</Typography> </Typography>
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Room {room.roomNumber} | {room.size} | Max {room.maxOccupancy} guests Room {room.roomNumber} | {room.size} | Max {room.maxOccupancy} guests
</Typography> </Typography>
</Box> </Box>
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
{room.amenities && room.amenities.slice(0, 3).map((amenity, idx) => ( {room.amenities && room.amenities.slice(0, 3).map((amenity, idx) => (
<Chip <Chip key={idx} label={amenity} size="small" sx={{ mr: 0.5, mb: 0.5 }} />
key={idx}
label={amenity}
size="small"
sx={{ mr: 0.5, mb: 0.5 }}
/>
))} ))}
</Box> </Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 'auto' }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 'auto' }}>
<Box> <Box>
<Typography variant="h6" color="primary" sx={{ fontWeight: 700 }}> <Typography variant="h6" color="primary" sx={{ fontWeight: 700 }}>
@@ -317,6 +353,7 @@ const CategoryGallery = () => {
per night per night
</Typography> </Typography>
</Box> </Box>
<Button <Button
component={Link} component={Link}
to={`/rooms/${room.slug || room._id}`} to={`/rooms/${room.slug || room._id}`}
@@ -336,13 +373,7 @@ const CategoryGallery = () => {
{/* Back Button */} {/* Back Button */}
<Box sx={{ textAlign: 'center', mt: 6 }}> <Box sx={{ textAlign: 'center', mt: 6 }}>
<Button <Button component={Link} to="/rooms" variant="outlined" size="large" sx={{ px: 4 }}>
component={Link}
to="/rooms"
variant="outlined"
size="large"
sx={{ px: 4 }}
>
Back to All Rooms Back to All Rooms
</Button> </Button>
</Box> </Box>
@@ -372,9 +403,7 @@ const CategoryGallery = () => {
color: 'white', color: 'white',
zIndex: 2, zIndex: 2,
backgroundColor: 'rgba(0, 0, 0, 0.5)', backgroundColor: 'rgba(0, 0, 0, 0.5)',
'&:hover': { '&:hover': { backgroundColor: 'rgba(0, 0, 0, 0.7)' },
backgroundColor: 'rgba(0, 0, 0, 0.7)',
},
}} }}
> >
<CloseIcon /> <CloseIcon />
@@ -390,13 +419,12 @@ const CategoryGallery = () => {
color: 'white', color: 'white',
zIndex: 2, zIndex: 2,
backgroundColor: 'rgba(0, 0, 0, 0.5)', backgroundColor: 'rgba(0, 0, 0, 0.5)',
'&:hover': { '&:hover': { backgroundColor: 'rgba(0, 0, 0, 0.7)' },
backgroundColor: 'rgba(0, 0, 0, 0.7)',
},
}} }}
> >
<ArrowBackIosIcon /> <ArrowBackIosIcon />
</IconButton> </IconButton>
<IconButton <IconButton
onClick={handleNext} onClick={handleNext}
sx={{ sx={{
@@ -405,9 +433,7 @@ const CategoryGallery = () => {
color: 'white', color: 'white',
zIndex: 2, zIndex: 2,
backgroundColor: 'rgba(0, 0, 0, 0.5)', backgroundColor: 'rgba(0, 0, 0, 0.5)',
'&:hover': { '&:hover': { backgroundColor: 'rgba(0, 0, 0, 0.7)' },
backgroundColor: 'rgba(0, 0, 0, 0.7)',
},
}} }}
> >
<ArrowForwardIosIcon /> <ArrowForwardIosIcon />
@@ -418,7 +444,7 @@ const CategoryGallery = () => {
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.img <motion.img
key={lightboxIndex} key={lightboxIndex}
src={images[lightboxIndex]?.url} src={toMediaUrl(images[lightboxIndex]?.url)}
alt={images[lightboxIndex]?.alt || `${category.name} - Image ${lightboxIndex + 1}`} alt={images[lightboxIndex]?.alt || `${category.name} - Image ${lightboxIndex + 1}`}
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
@@ -457,4 +483,3 @@ const CategoryGallery = () => {
}; };
export default CategoryGallery; export default CategoryGallery;

View File

@@ -22,7 +22,24 @@ import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Helmet } from 'react-helmet-async'; import { Helmet } from 'react-helmet-async';
import staticData from '../utils/staticData'; import api from '../utils/api';
const API_BASE = process.env.REACT_APP_API_URL || 'http://localhost:5080';
const toMediaUrl = (url) => {
if (!url) return url;
if (/^https?:\/\//i.test(url)) {
if (url.includes('localhost:3060/uploads/')) return url.replace('http://localhost:3060', API_BASE);
return url;
}
if (url.startsWith('/uploads/')) return `${API_BASE}${url}`;
return url; // /images/... stays on client
};
const unwrapContent = (res) => {
return res?.data?.data?.content || res?.data?.data || res?.data || null;
};
const stripHtml = (html = '') => String(html).replace(/<[^>]*>/g, '').trim();
const Home = () => { const Home = () => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@@ -32,22 +49,32 @@ const Home = () => {
const currentLanguage = i18n.language; const currentLanguage = i18n.language;
useEffect(() => { useEffect(() => {
let mounted = true;
const fetchContent = async () => { const fetchContent = async () => {
try { try {
const [homeContent, categories] = await Promise.all([ const [homeRes, catRes] = await Promise.all([
staticData.getHomeContent(), api.get('/api/content/home'),
staticData.getRoomCategories(), api.get('/api/room-categories'),
]); ]);
setContent(homeContent);
setRoomCategories(categories.slice(0, 3)); const homeContent = unwrapContent(homeRes);
const categories = catRes?.data?.data?.categories || [];
if (mounted) {
setContent(homeContent);
setRoomCategories(categories.slice(0, 3));
}
} catch (error) { } catch (error) {
console.error('Error loading homepage content:', error); console.error('Error loading homepage content:', error);
} finally { } finally {
setLoading(false); if (mounted) setLoading(false);
} }
}; };
fetchContent(); fetchContent();
return () => { mounted = false; };
}, []); }, []);
const features = [ const features = [
@@ -81,27 +108,29 @@ const Home = () => {
}, },
]; ];
const heroImage = content?.hero?.backgroundImage || '/images/hero.jpg'; const heroImage = toMediaUrl(content?.hero?.backgroundImage) || '/images/hero.jpg';
const welcomeSection =
content?.sections?.find((s) => s.sectionId === 'welcome') || {};
const welcomeSection = content?.sections?.find((s) => s.sectionId === 'welcome') || {};
const useTranslations = currentLanguage !== 'en'; const useTranslations = currentLanguage !== 'en';
const welcomeTitle = useTranslations const welcomeTitle = useTranslations
? t('home.welcomeTitle') ? t('home.welcomeTitle')
: (welcomeSection.title || t('home.welcomeTitle')); : stripHtml(welcomeSection.title || t('home.welcomeTitle'));
const welcomeSubtitle = useTranslations const welcomeSubtitle = useTranslations
? t('home.welcomeSubtitle') ? t('home.welcomeSubtitle')
: (welcomeSection.subtitle || t('home.welcomeSubtitle')); : stripHtml(welcomeSection.subtitle || t('home.welcomeSubtitle'));
const welcomeContent = useTranslations const welcomeContent = useTranslations
? t('home.welcomeDescription') ? t('home.welcomeDescription')
: (welcomeSection.content || t('home.welcomeDescription')); : stripHtml(welcomeSection.content || t('home.welcomeDescription'));
const roomTypes = roomCategories.map((category) => ({ const roomTypes = roomCategories.map((category) => ({
id: category._id || category.slug, id: category._id || category.slug,
name: category.name, name: category.name,
image: category.primaryImage || '/images/room-default.jpg', image: toMediaUrl(category.primaryImage) || '/images/room-default.jpg',
features: category.features?.slice(0, 4) || [], features: category.features?.slice(0, 4) || [],
slug: category.slug, slug: category.slug,
})); }));
@@ -114,8 +143,6 @@ const Home = () => {
); );
} }
const LOGO_BOTTOM = { xs: 40, sm: 55, md: 70 };
return ( return (
<> <>
<Helmet> <Helmet>
@@ -130,8 +157,7 @@ const Home = () => {
/> />
</Helmet> </Helmet>
{/* Hero Section - Logo and Image Only */} {/* Hero */}
<Box <Box
sx={{ sx={{
minHeight: '100vh', minHeight: '100vh',
@@ -143,7 +169,7 @@ const Home = () => {
color: 'white', color: 'white',
textAlign: 'center', textAlign: 'center',
position: 'relative', position: 'relative',
pb: { xs: 25, md: 80}, pb: { xs: 25, md: 80 },
}} }}
> >
<Container maxWidth="lg" sx={{ width: '100%' }}> <Container maxWidth="lg" sx={{ width: '100%' }}>
@@ -169,7 +195,7 @@ const Home = () => {
</Container> </Container>
</Box> </Box>
{/* Welcome Section */} {/* Welcome */}
<Container maxWidth="lg" sx={{ py: 8 }}> <Container maxWidth="lg" sx={{ py: 8 }}>
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
@@ -202,7 +228,7 @@ const Home = () => {
</motion.div> </motion.div>
</Container> </Container>
{/* Features Section */} {/* Features */}
<Box sx={{ backgroundColor: 'background.alt', py: 8 }}> <Box sx={{ backgroundColor: 'background.alt', py: 8 }}>
<Container maxWidth="lg"> <Container maxWidth="lg">
<motion.div <motion.div
@@ -283,7 +309,7 @@ const Home = () => {
</Container> </Container>
</Box> </Box>
{/* Rooms Preview Section */} {/* Rooms Preview */}
<Container maxWidth="lg" sx={{ py: 8 }}> <Container maxWidth="lg" sx={{ py: 8 }}>
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
@@ -320,6 +346,7 @@ const Home = () => {
transition: 'transform 0.3s ease', transition: 'transform 0.3s ease',
'&:hover': { transform: 'scale(1.05)' }, '&:hover': { transform: 'scale(1.05)' },
}} }}
onError={(e) => { e.currentTarget.src = '/images/room-default.jpg'; }}
/> />
<CardContent sx={{ p: 3 }}> <CardContent sx={{ p: 3 }}>

View File

@@ -1,82 +1,103 @@
import React, { useMemo } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Container, Typography, Box, Grid, Card, CardContent, Chip } from '@mui/material'; import { Container, Typography, Box, Grid, Card, CardContent, Chip, CircularProgress, Button } from '@mui/material';
import { useParams } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { Swiper, SwiperSlide } from 'swiper/react'; import { Swiper, SwiperSlide } from 'swiper/react';
import SwiperCore from 'swiper'; import SwiperCore from 'swiper';
import 'swiper/css'; import 'swiper/css';
import 'swiper/css/navigation'; import 'swiper/css/navigation';
import 'swiper/css/thumbs'; import 'swiper/css/thumbs';
import { Navigation, Thumbs } from 'swiper/modules'; import { Navigation, Thumbs } from 'swiper/modules';
import api from '../utils/api';
const ROOM_CONFIG = {
'1': {
name: 'Deluxe Room',
folder: '/images/rooms/deluxe',
features: ['King Bed', 'City View', 'Free WiFi', 'Mini Bar'],
price: 199,
images: [
'01.jpg',
'02.jpg',
'03.jpg',
'04.jpg',
'05.jpg'
],
},
'2': {
name: 'Executive Suite',
folder: '/images/rooms/executive',
features: ['Separate Living Room', 'Premium View', 'Butler Service', 'Complimentary Breakfast'],
price: 349,
images: [
'01.jpg',
'02.jpg',
'03.jpg',
'04.jpg',
'05.jpg'
],
},
'3': {
name: 'Presidential Suite',
folder: '/images/rooms/presidential',
features: ['2 Bedrooms', 'Private Terrace', 'Personal Chef', 'Spa Access'],
price: 599,
images: [
'01.jpg',
'02.jpg',
'03.jpg',
'04.jpg',
'05.jpg'
],
},
};
SwiperCore.use([Navigation, Thumbs]); SwiperCore.use([Navigation, Thumbs]);
const isObjectId = (v) => /^[0-9a-fA-F]{24}$/.test(v || '');
const RoomDetails = () => { const RoomDetails = () => {
const { id } = useParams(); const { id } = useParams();
const room = ROOM_CONFIG[id] || ROOM_CONFIG['1']; const [room, setRoom] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchRoom = async () => {
try {
setLoading(true);
let res;
if (isObjectId(id)) {
res = await api.get(`/api/rooms/${id}`);
} else {
res = await api.get(`/api/rooms/slug/${id}`);
}
const data = res?.data?.data || null;
if (!data) {
setError('Room not found');
setRoom(null);
} else {
setRoom(data);
setError(null);
}
} catch (err) {
console.error('Error loading room:', err);
setError('Failed to load room details.');
setRoom(null);
} finally {
setLoading(false);
}
};
fetchRoom();
}, [id]);
const imageUrls = useMemo(() => { const imageUrls = useMemo(() => {
return (room.images || []).map(name => `${room.folder}/${name}`); const imgs = room?.images || [];
return (imgs || []).map((img) => img?.url).filter(Boolean);
}, [room]); }, [room]);
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '70vh' }}>
<CircularProgress />
</Box>
);
}
if (error || !room) {
return (
<Container maxWidth="lg" sx={{ py: 8, textAlign: 'center' }}>
<Typography variant="h5" color="error" gutterBottom>{error || 'Room not found'}</Typography>
<Button component={Link} to="/rooms" variant="contained" sx={{ mt: 2 }}>
Back to Rooms
</Button>
</Container>
);
}
const features = room.amenities || [];
return ( return (
<Container maxWidth="lg" sx={{ py: 6 }}> <Container maxWidth="lg" sx={{ py: 6 }}>
<Box sx={{ textAlign: 'center', mb: 4 }}> <Box sx={{ textAlign: 'center', mb: 4 }}>
<Typography variant="h3" component="h1" gutterBottom> <Typography variant="h3" component="h1" gutterBottom>
{room.name} {room.name}
</Typography> </Typography>
<Typography variant="h6" color="text.secondary"> <Typography variant="h6" color="text.secondary">
From ${room.price} <Typography component="span" variant="body2" color="text.secondary">per night</Typography> From ${room.basePrice}{' '}
<Typography component="span" variant="body2" color="text.secondary">
per night
</Typography>
</Typography> </Typography>
<Box sx={{ mt: 2 }}> <Box sx={{ mt: 2 }}>
{room.features.map((f, i) => ( {features.map((f, i) => (
<Chip key={i} label={f} size="small" sx={{ mr: 1, mb: 1 }} /> <Chip key={i} label={f} size="small" sx={{ mr: 1, mb: 1 }} />
))} ))}
</Box> </Box>
</Box> </Box>
{/* Gallery */}
{imageUrls.length > 0 ? ( {imageUrls.length > 0 ? (
<> <>
<Swiper <Swiper
@@ -85,7 +106,7 @@ const RoomDetails = () => {
spaceBetween={10} spaceBetween={10}
slidesPerView={1} slidesPerView={1}
style={{ borderRadius: 8, overflow: 'hidden' }} style={{ borderRadius: 8, overflow: 'hidden' }}
> >
{imageUrls.map((src, idx) => ( {imageUrls.map((src, idx) => (
<SwiperSlide key={idx}> <SwiperSlide key={idx}>
<Box <Box
@@ -99,10 +120,9 @@ const RoomDetails = () => {
))} ))}
</Swiper> </Swiper>
{/* Thumbnails */}
<Grid container spacing={2} sx={{ mt: 1 }}> <Grid container spacing={2} sx={{ mt: 1 }}>
{imageUrls.map((src, idx) => ( {imageUrls.map((src, idx) => (
<Grid item xs={3} sm={2} md={2} key={`thumb-${idx}`}> <Grid item xs={3} sm={2} md={2} key={`thumb-${idx}`}>
<Box <Box
component="img" component="img"
src={src} src={src}
@@ -118,7 +138,7 @@ const RoomDetails = () => {
<Card sx={{ mt: 3 }}> <Card sx={{ mt: 3 }}>
<CardContent> <CardContent>
<Typography variant="body1" color="text.secondary"> <Typography variant="body1" color="text.secondary">
No images have been added yet. Place files in {room.folder} and refresh. No images have been added yet.
</Typography> </Typography>
</CardContent> </CardContent>
</Card> </Card>
@@ -127,4 +147,4 @@ const RoomDetails = () => {
); );
}; };
export default RoomDetails; export default RoomDetails;

View File

@@ -16,29 +16,47 @@ import {
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Helmet } from 'react-helmet-async'; import { Helmet } from 'react-helmet-async';
import staticData from '../utils/staticData'; import api from '../utils/api';
import ImageIcon from '@mui/icons-material/Image'; import ImageIcon from '@mui/icons-material/Image';
import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityIcon from '@mui/icons-material/Visibility';
const API_BASE = process.env.REACT_APP_API_URL || 'http://localhost:5080';
const toMediaUrl = (url) => {
if (!url) return url;
if (/^https?:\/\//i.test(url)) return url;
if (url.startsWith('/uploads/')) return `${API_BASE}${url}`;
return url;
};
const Rooms = () => { const Rooms = () => {
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
useEffect(() => { useEffect(() => {
let mounted = true;
const fetchCategories = async () => { const fetchCategories = async () => {
try { try {
const categories = await staticData.getRoomCategories(); setLoading(true);
setCategories(categories); setError(null);
const res = await api.get('/api/room-categories');
const cats = res?.data?.data?.categories || [];
if (mounted) setCategories(cats);
} catch (err) { } catch (err) {
console.error('Error loading room categories:', err); console.error('Error loading room categories:', err);
setError('Failed to load room categories. Please try again later.'); if (mounted) setError('Failed to load room categories. Please try again later.');
} finally { } finally {
setLoading(false); if (mounted) setLoading(false);
} }
}; };
fetchCategories(); fetchCategories();
return () => {
mounted = false;
};
}, []); }, []);
if (loading) { if (loading) {
@@ -66,8 +84,14 @@ const Rooms = () => {
<> <>
<Helmet> <Helmet>
<title>Our Rooms - Old Vine Hotel</title> <title>Our Rooms - Old Vine Hotel</title>
<meta name="description" content="Discover our luxurious room categories at Old Vine Hotel. From single rooms to suites, find the perfect accommodation for your stay in Old Damascus." /> <meta
<meta name="keywords" content="hotel rooms, suites, luxury accommodation, damascus rooms, single room, double room, suite" /> name="description"
content="Discover our luxurious room categories at Old Vine Hotel. From single rooms to suites, find the perfect accommodation for your stay in Old Damascus."
/>
<meta
name="keywords"
content="hotel rooms, suites, luxury accommodation, damascus rooms, single room, double room, suite"
/>
</Helmet> </Helmet>
{/* Hero Section */} {/* Hero Section */}
@@ -120,6 +144,9 @@ const Rooms = () => {
<Typography variant="h5" color="text.secondary"> <Typography variant="h5" color="text.secondary">
No room categories available at the moment. No room categories available at the moment.
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Please add categories from the admin dashboard.
</Typography>
</Box> </Box>
) : ( ) : (
<Grid container spacing={4}> <Grid container spacing={4}>
@@ -134,6 +161,7 @@ const Rooms = () => {
<Card <Card
sx={{ sx={{
height: '100%', height: '100%',
minHeight: 560, // ✅ unify card heights
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
transition: 'transform 0.3s, box-shadow 0.3s', transition: 'transform 0.3s, box-shadow 0.3s',
@@ -150,13 +178,14 @@ const Rooms = () => {
<CardMedia <CardMedia
component="img" component="img"
height="300" height="300"
image={ image={toMediaUrl(category.primaryImage) || '/images/room-default.jpg'}
category.primaryImage || '/images/room-default.jpg'
}
alt={category.name} alt={category.name}
sx={{ objectFit: 'cover' }} sx={{ objectFit: 'cover' }}
onError={(e) => {
e.currentTarget.src = '/images/room-default.jpg';
}}
/> />
{/* Image Count Badge */}
{category.imageCount > 0 && ( {category.imageCount > 0 && (
<Badge <Badge
badgeContent={category.imageCount} badgeContent={category.imageCount}
@@ -187,7 +216,7 @@ const Rooms = () => {
)} )}
</Box> </Box>
<CardContent sx={{ flexGrow: 1, p: 3 }}> <CardContent sx={{ flexGrow: 1, p: 3, display: 'flex', flexDirection: 'column' }}>
<Typography <Typography
variant="h4" variant="h4"
component="h2" component="h2"
@@ -196,17 +225,24 @@ const Rooms = () => {
> >
{category.name} {category.name}
</Typography> </Typography>
<Typography <Typography
variant="body1" variant="body1"
color="text.secondary" color="text.secondary"
sx={{ mb: 3, minHeight: 60 }} sx={{
mb: 3,
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
minHeight: 72, // ✅ consistent text height
}}
> >
{category.shortDescription || category.description} {category.shortDescription || category.description}
</Typography> </Typography>
{/* Features */} {/* Features */}
{category.features && category.features.length > 0 && ( {Array.isArray(category.features) && category.features.length > 0 && (
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
{category.features.slice(0, 4).map((feature, idx) => ( {category.features.slice(0, 4).map((feature, idx) => (
<Chip <Chip
@@ -222,26 +258,28 @@ const Rooms = () => {
/> />
))} ))}
{category.features.length > 4 && ( {category.features.length > 4 && (
<Chip <Chip label={`+${category.features.length - 4} more`} size="small" sx={{ mb: 1 }} />
label={`+${category.features.length - 4} more`}
size="small"
sx={{ mb: 1 }}
/>
)} )}
</Box> </Box>
)} )}
{/* Stats */} {/* Stats */}
<Box sx={{ mb: 2, display: 'flex', gap: 3, flexWrap: 'wrap' }}> <Box sx={{ mt: 'auto', mb: 2, display: 'flex', gap: 3, flexWrap: 'wrap' }}>
{category.roomCount > 0 && ( {category.roomCount > 0 && (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{category.roomCount} {category.roomCount === 1 ? 'Room' : 'Rooms'} Available {category.roomCount} {category.roomCount === 1 ? 'Room' : 'Rooms'} Available
</Typography> </Typography>
)} )}
{category.priceRange?.min > 0 && (
<Typography variant="body2" color="text.secondary">
From ${category.priceRange.min}/night
</Typography>
)}
</Box> </Box>
</CardContent> </CardContent>
<CardActions sx={{ p: 3, pt: 0 }}> <CardActions sx={{ p: 3, pt: 0, mt: 'auto' }}>
<Button <Button
component={Link} component={Link}
to={`/rooms/category/${category.slug}`} to={`/rooms/category/${category.slug}`}

View File

@@ -90,7 +90,7 @@ const BookingManagement = () => {
bookings.map((booking) => ( bookings.map((booking) => (
<TableRow key={booking._id}> <TableRow key={booking._id}>
<TableCell>{booking.bookingNumber}</TableCell> <TableCell>{booking.bookingNumber}</TableCell>
<TableCell>{booking.guest?.name || 'N/A'}</TableCell> <TableCell>{`${booking.guest?.firstName || ''} ${booking.guest?.lastName || ''}`.trim() || booking.guest?.email || 'N/A'}</TableCell>
<TableCell>{new Date(booking.checkInDate).toLocaleDateString()}</TableCell> <TableCell>{new Date(booking.checkInDate).toLocaleDateString()}</TableCell>
<TableCell>{new Date(booking.checkOutDate).toLocaleDateString()}</TableCell> <TableCell>{new Date(booking.checkOutDate).toLocaleDateString()}</TableCell>
<TableCell>{booking.room?.name || 'N/A'}</TableCell> <TableCell>{booking.room?.name || 'N/A'}</TableCell>

View File

@@ -53,6 +53,7 @@ const ROOM_TYPES = ['Standard', 'Deluxe', 'Suite', 'Executive Suite', 'President
const RoomManagement = () => { const RoomManagement = () => {
const [rooms, setRooms] = useState([]); const [rooms, setRooms] = useState([]);
const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [message, setMessage] = useState(null); const [message, setMessage] = useState(null);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
@@ -62,12 +63,14 @@ const RoomManagement = () => {
useEffect(() => { useEffect(() => {
fetchRooms(); fetchRooms();
fetchCategories();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const fetchRooms = async () => { const fetchRooms = async () => {
try { try {
const response = await api.get('/api/rooms?limit=100'); const response = await api.get('/api/rooms?limit=100');
setRooms(response.data.data.rooms); setRooms(response?.data?.data?.rooms || []);
} catch (error) { } catch (error) {
console.error('Error fetching rooms:', error); console.error('Error fetching rooms:', error);
setMessage({ type: 'error', text: 'Failed to load rooms' }); setMessage({ type: 'error', text: 'Failed to load rooms' });
@@ -76,11 +79,29 @@ const RoomManagement = () => {
} }
}; };
const fetchCategories = async () => {
try {
// Prefer admin/all (includes inactive too)
const res = await api.get('/api/room-categories/admin/all');
setCategories(res?.data?.data?.categories || []);
} catch (e) {
// Fallback to public endpoint
try {
const res = await api.get('/api/room-categories');
setCategories(res?.data?.data?.categories || []);
} catch (err) {
console.error('Error fetching categories:', err);
setMessage({ type: 'error', text: 'Failed to load room categories' });
}
}
};
const handleOpenDialog = (room = null) => { const handleOpenDialog = (room = null) => {
if (room) { if (room) {
// Edit mode // Edit mode
setCurrentRoom({ setCurrentRoom({
...room, ...room,
category: room?.category?._id || room?.category || '', // handle populated or raw id
images: room.images || [], images: room.images || [],
amenities: room.amenities || [], amenities: room.amenities || [],
}); });
@@ -89,6 +110,7 @@ const RoomManagement = () => {
setCurrentRoom({ setCurrentRoom({
name: '', name: '',
type: 'Deluxe', type: 'Deluxe',
category: categories?.[0]?._id || '', // ✅ important
roomNumber: '', roomNumber: '',
floor: 1, floor: 1,
size: 0, size: 0,
@@ -134,6 +156,10 @@ const RoomManagement = () => {
} else { } else {
newImages[index] = { url: value, alt: '', isPrimary: index === 0 }; newImages[index] = { url: value, alt: '', isPrimary: index === 0 };
} }
// Ensure first image stays primary if user adds images
if (index === 0) newImages[0].isPrimary = true;
handleChange('images', newImages); handleChange('images', newImages);
}; };
@@ -143,6 +169,8 @@ const RoomManagement = () => {
const removeImage = (index) => { const removeImage = (index) => {
const newImages = currentRoom.images.filter((_, idx) => idx !== index); const newImages = currentRoom.images.filter((_, idx) => idx !== index);
// Ensure primary image exists if list not empty
if (newImages.length > 0) newImages[0].isPrimary = true;
handleChange('images', newImages); handleChange('images', newImages);
}; };
@@ -150,24 +178,29 @@ const RoomManagement = () => {
setSaving(true); setSaving(true);
setMessage(null); setMessage(null);
// ✅ required for website grouping
if (!currentRoom?.category) {
setSaving(false);
setMessage({ type: 'error', text: 'Please select a category for this room.' });
return;
}
try { try {
if (currentRoom._id) { if (currentRoom._id) {
// Update existing room
await api.put(`/api/rooms/${currentRoom._id}`, currentRoom); await api.put(`/api/rooms/${currentRoom._id}`, currentRoom);
setMessage({ type: 'success', text: 'Room updated successfully!' }); setMessage({ type: 'success', text: 'Room updated successfully!' });
} else { } else {
// Create new room
await api.post('/api/rooms', currentRoom); await api.post('/api/rooms', currentRoom);
setMessage({ type: 'success', text: 'Room created successfully!' }); setMessage({ type: 'success', text: 'Room created successfully!' });
} }
handleCloseDialog(); handleCloseDialog();
fetchRooms(); fetchRooms();
} catch (error) { } catch (error) {
console.error('Error saving room:', error); console.error('Error saving room:', error);
setMessage({ setMessage({
type: 'error', type: 'error',
text: error.response?.data?.message || 'Failed to save room' text: error.response?.data?.message || 'Failed to save room'
}); });
} finally { } finally {
setSaving(false); setSaving(false);
@@ -185,9 +218,9 @@ const RoomManagement = () => {
fetchRooms(); fetchRooms();
} catch (error) { } catch (error) {
console.error('Error deleting room:', error); console.error('Error deleting room:', error);
setMessage({ setMessage({
type: 'error', type: 'error',
text: error.response?.data?.message || 'Failed to delete room' text: error.response?.data?.message || 'Failed to delete room'
}); });
} finally { } finally {
setConfirmDialog({ open: false, roomId: null }); setConfirmDialog({ open: false, roomId: null });
@@ -224,8 +257,8 @@ const RoomManagement = () => {
</Box> </Box>
{message && ( {message && (
<Alert <Alert
severity={message.type} severity={message.type}
sx={{ mb: 3 }} sx={{ mb: 3 }}
onClose={() => setMessage(null)} onClose={() => setMessage(null)}
> >
@@ -316,8 +349,8 @@ const RoomManagement = () => {
</TableContainer> </TableContainer>
{/* Add/Edit Room Dialog */} {/* Add/Edit Room Dialog */}
<Dialog <Dialog
open={dialogOpen} open={dialogOpen}
onClose={handleCloseDialog} onClose={handleCloseDialog}
maxWidth="md" maxWidth="md"
fullWidth fullWidth
@@ -333,7 +366,7 @@ const RoomManagement = () => {
</IconButton> </IconButton>
</Box> </Box>
</DialogTitle> </DialogTitle>
<DialogContent dividers> <DialogContent dividers>
{currentRoom && ( {currentRoom && (
<Grid container spacing={3}> <Grid container spacing={3}>
@@ -343,7 +376,7 @@ const RoomManagement = () => {
Basic Information Basic Information
</Typography> </Typography>
</Grid> </Grid>
<Grid item xs={12} md={8}> <Grid item xs={12} md={8}>
<TextField <TextField
fullWidth fullWidth
@@ -353,7 +386,7 @@ const RoomManagement = () => {
required required
/> />
</Grid> </Grid>
<Grid item xs={12} md={4}> <Grid item xs={12} md={4}>
<FormControl fullWidth required> <FormControl fullWidth required>
<InputLabel>Room Type</InputLabel> <InputLabel>Room Type</InputLabel>
@@ -368,7 +401,26 @@ const RoomManagement = () => {
</Select> </Select>
</FormControl> </FormControl>
</Grid> </Grid>
{/* ✅ Category */}
<Grid item xs={12} md={6}>
<FormControl fullWidth required>
<InputLabel>Category</InputLabel>
<Select
value={currentRoom.category || ''}
label="Category"
onChange={(e) => handleChange('category', e.target.value)}
>
{categories.map((c) => (
<MenuItem key={c._id} value={c._id}>
{c.name}
</MenuItem>
))}
</Select>
<FormHelperText>Select which category this room belongs to</FormHelperText>
</FormControl>
</Grid>
<Grid item xs={6} md={3}> <Grid item xs={6} md={3}>
<TextField <TextField
fullWidth fullWidth
@@ -378,41 +430,41 @@ const RoomManagement = () => {
required required
/> />
</Grid> </Grid>
<Grid item xs={6} md={3}> <Grid item xs={6} md={3}>
<TextField <TextField
fullWidth fullWidth
type="number" type="number"
label="Floor" label="Floor"
value={currentRoom.floor} value={currentRoom.floor}
onChange={(e) => handleChange('floor', parseInt(e.target.value))} onChange={(e) => handleChange('floor', parseInt(e.target.value || '0', 10))}
required required
/> />
</Grid> </Grid>
<Grid item xs={6} md={3}> <Grid item xs={6} md={3}>
<TextField <TextField
fullWidth fullWidth
type="number" type="number"
label="Size (m²)" label="Size (m²)"
value={currentRoom.size} value={currentRoom.size}
onChange={(e) => handleChange('size', parseInt(e.target.value))} onChange={(e) => handleChange('size', parseInt(e.target.value || '0', 10))}
required required
/> />
</Grid> </Grid>
<Grid item xs={6} md={3}> <Grid item xs={6} md={3}>
<TextField <TextField
fullWidth fullWidth
type="number" type="number"
label="Max Guests" label="Max Guests"
value={currentRoom.maxOccupancy} value={currentRoom.maxOccupancy}
onChange={(e) => handleChange('maxOccupancy', parseInt(e.target.value))} onChange={(e) => handleChange('maxOccupancy', parseInt(e.target.value || '1', 10))}
required required
inputProps={{ min: 1 }} inputProps={{ min: 1 }}
/> />
</Grid> </Grid>
<Grid item xs={6} md={6}> <Grid item xs={6} md={6}>
<FormControl fullWidth required> <FormControl fullWidth required>
<InputLabel>Bed Type</InputLabel> <InputLabel>Bed Type</InputLabel>
@@ -427,14 +479,14 @@ const RoomManagement = () => {
</Select> </Select>
</FormControl> </FormControl>
</Grid> </Grid>
<Grid item xs={6} md={6}> <Grid item xs={6} md={6}>
<TextField <TextField
fullWidth fullWidth
type="number" type="number"
label="Number of Beds" label="Number of Beds"
value={currentRoom.bedCount} value={currentRoom.bedCount}
onChange={(e) => handleChange('bedCount', parseInt(e.target.value))} onChange={(e) => handleChange('bedCount', parseInt(e.target.value || '1', 10))}
required required
inputProps={{ min: 1 }} inputProps={{ min: 1 }}
/> />
@@ -446,14 +498,14 @@ const RoomManagement = () => {
Pricing Pricing
</Typography> </Typography>
</Grid> </Grid>
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
<TextField <TextField
fullWidth fullWidth
type="number" type="number"
label="Base Price (per night)" label="Base Price (per night)"
value={currentRoom.basePrice} value={currentRoom.basePrice}
onChange={(e) => handleChange('basePrice', parseFloat(e.target.value))} onChange={(e) => handleChange('basePrice', parseFloat(e.target.value || '0'))}
required required
inputProps={{ min: 0, step: 0.01 }} inputProps={{ min: 0, step: 0.01 }}
helperText="Price in USD" helperText="Price in USD"
@@ -466,7 +518,7 @@ const RoomManagement = () => {
Description Description
</Typography> </Typography>
</Grid> </Grid>
<Grid item xs={12}> <Grid item xs={12}>
<TextField <TextField
fullWidth fullWidth
@@ -477,7 +529,7 @@ const RoomManagement = () => {
helperText="Brief one-line description" helperText="Brief one-line description"
/> />
</Grid> </Grid>
<Grid item xs={12}> <Grid item xs={12}>
<TextField <TextField
fullWidth fullWidth
@@ -600,8 +652,8 @@ const RoomManagement = () => {
<Button onClick={handleCloseDialog} disabled={saving}> <Button onClick={handleCloseDialog} disabled={saving}>
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
variant="contained" variant="contained"
disabled={saving} disabled={saving}
> >