edit
60
client/public/add-category.html
Normal 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>
|
||||||
BIN
client/public/uploads/1-1768295870059-239826802.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
client/public/uploads/2-1768295870063-802829438.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
client/public/uploads/3-1768295870064-579147982.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
client/public/uploads/4-1768295870068-442995448.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
client/public/uploads/5-1768295870072-844168095.png
Normal file
|
After Width: | Height: | Size: 230 KiB |
BIN
client/public/uploads/888-1768295870105-385689950.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
client/public/uploads/ai-accounting-1768295870109-673067819.jpg
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
client/public/uploads/optimized-3-1768295870064-579147982.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,105 +370,103 @@ 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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,28 +1,182 @@
|
|||||||
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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} m² | Max {room.maxOccupancy} guests
|
Room {room.roomNumber} | {room.size} m² | 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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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,7 +120,6 @@ 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}`}>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -200,13 +229,20 @@ const Rooms = () => {
|
|||||||
<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}`}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,13 +178,18 @@ 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!' });
|
||||||
}
|
}
|
||||||
@@ -369,6 +402,25 @@ const RoomManagement = () => {
|
|||||||
</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
|
||||||
@@ -385,7 +437,7 @@ const RoomManagement = () => {
|
|||||||
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>
|
||||||
@@ -396,7 +448,7 @@ const RoomManagement = () => {
|
|||||||
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>
|
||||||
@@ -407,7 +459,7 @@ const RoomManagement = () => {
|
|||||||
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 }}
|
||||||
/>
|
/>
|
||||||
@@ -434,7 +486,7 @@ const RoomManagement = () => {
|
|||||||
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 }}
|
||||||
/>
|
/>
|
||||||
@@ -453,7 +505,7 @@ const RoomManagement = () => {
|
|||||||
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"
|
||||||
|
|||||||