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 /> },
];
// ✅ 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 theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
@@ -102,7 +106,7 @@ const AdminLayout = () => {
</Toolbar>
<Divider />
<List>
{menuItems.map((item) => (
{adminMenuItems.map((item) => (
<ListItem key={item.title} disablePadding>
<ListItemButton
selected={location.pathname === item.path}
@@ -259,4 +263,3 @@ const AdminLayout = () => {
};
export default AdminLayout;

View File

@@ -20,7 +20,25 @@ import {
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
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 { t, i18n } = useTranslation();
@@ -29,20 +47,27 @@ const About = () => {
const [loading, setLoading] = useState(true);
const currentLanguage = i18n.language;
// Fetch about page content from static data
// Fetch about page content from API بدل static data
useEffect(() => {
let mounted = true;
const fetchContent = async () => {
try {
const aboutContent = await staticData.getAboutContent();
setContent(aboutContent);
const res = await api.get('/api/content/about');
const aboutContent = unwrapContent(res);
if (mounted) setContent(aboutContent);
} catch (error) {
console.error('Error loading about page content:', error);
} finally {
setLoading(false);
if (mounted) setLoading(false);
}
};
fetchContent();
return () => {
mounted = false;
};
}, []);
const values = [
@@ -68,7 +93,6 @@ const About = () => {
}
];
const milestones = [
{
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';
// Fallback content - prioritize translations for non-English
const heroTitle = useTranslations ? t('about.heroTitle') : (content?.hero?.title || t('about.heroTitle'));
const heroSubtitle = useTranslations ? t('about.heroSubtitle') : (content?.hero?.subtitle || t('about.heroSubtitle'));
const heroDescription = useTranslations ? '' : (content?.hero?.description || '');
const heroImage = content?.hero?.backgroundImage || '/images/about-hero.jpg';
// ✅ stripHtml only for content coming from dashboard (English)
const heroTitle = useTranslations ? t('about.heroTitle') : stripHtml(content?.hero?.title || t('about.heroTitle'));
const heroSubtitle = useTranslations ? t('about.heroSubtitle') : stripHtml(content?.hero?.subtitle || t('about.heroSubtitle'));
const heroDescription = useTranslations ? '' : stripHtml(content?.hero?.description || '');
const heroImage = toMediaUrl(content?.hero?.backgroundImage) || '/images/about-hero.jpg';
// Get sections from content - use translations for non-English
const heritageSectionStatic = content?.sections?.find(s => s.sectionId === 'heritage') || {};
const heritageSection = useTranslations ? {
title: t('about.heritageTitle'),
content: t('about.heritageContent'),
image: heritageSectionStatic.image || '/images/about.jpg'
} : heritageSectionStatic;
image: toMediaUrl(heritageSectionStatic.image) || '/images/about.jpg'
} : {
...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 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 (
<>
@@ -162,7 +201,6 @@ const About = () => {
component="h1"
sx={{
mb: 3,
// Use theme heading font
fontSize: { xs: '2.5rem', md: '4rem' },
}}
>
@@ -225,7 +263,7 @@ const About = () => {
sx={{
lineHeight: 1.8,
fontSize: '1.1rem',
whiteSpace: 'pre-line' // Preserve line breaks
whiteSpace: 'pre-line'
}}
>
{heritageSection.content}
@@ -332,6 +370,7 @@ const About = () => {
</motion.div>
</Container>
</Box>
{false && (
<Container maxWidth="lg" sx={{ py: 8 }}>
<motion.div
@@ -350,7 +389,6 @@ const About = () => {
</Typography>
<Box sx={{ position: 'relative' }}>
{/* Timeline line */}
<Box
sx={{
position: 'absolute',
@@ -408,7 +446,6 @@ const About = () => {
</Card>
</Box>
{/* Timeline dot */}
<Box
sx={{
width: 20,
@@ -429,8 +466,7 @@ const About = () => {
</Box>
</motion.div>
</Container>
)}
)}
</>
);
};

View File

@@ -1,28 +1,182 @@
import React from 'react';
import { Container, Typography, Box } from '@mui/material';
import React, { useEffect, useState } from 'react';
import {
Container, Typography, Box, TextField, Button,
MenuItem, Alert, CircularProgress
} from '@mui/material';
import api from '../utils/api';
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 (
<Container maxWidth="lg" sx={{ py: 8 }}>
<Box sx={{ textAlign: 'center', mb: 6 }}>
<Container maxWidth="sm" sx={{ py: 8 }}>
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Typography variant="h3" component="h1" gutterBottom>
Book Your Stay
</Typography>
<Typography variant="h5" color="text.secondary">
Reserve your room at our luxury hotel
<Typography variant="h6" color="text.secondary">
Submit a booking request (Pay at hotel)
</Typography>
</Box>
<Box sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 4
}}>
<Typography variant="body1" sx={{ textAlign: 'center', maxWidth: 800 }}>
Booking form coming soon...
</Typography>
{/* Add booking form here */}
{message && (
<Alert severity={message.type} sx={{ mb: 3 }} onClose={() => setMessage(null)}>
{message.text}
</Alert>
)}
{loadingRooms ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</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>
);
};

View File

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

View File

@@ -22,7 +22,24 @@ import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
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 { t, i18n } = useTranslation();
@@ -32,22 +49,32 @@ const Home = () => {
const currentLanguage = i18n.language;
useEffect(() => {
let mounted = true;
const fetchContent = async () => {
try {
const [homeContent, categories] = await Promise.all([
staticData.getHomeContent(),
staticData.getRoomCategories(),
const [homeRes, catRes] = await Promise.all([
api.get('/api/content/home'),
api.get('/api/room-categories'),
]);
const homeContent = unwrapContent(homeRes);
const categories = catRes?.data?.data?.categories || [];
if (mounted) {
setContent(homeContent);
setRoomCategories(categories.slice(0, 3));
}
} catch (error) {
console.error('Error loading homepage content:', error);
} finally {
setLoading(false);
if (mounted) setLoading(false);
}
};
fetchContent();
return () => { mounted = false; };
}, []);
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 welcomeTitle = useTranslations
? t('home.welcomeTitle')
: (welcomeSection.title || t('home.welcomeTitle'));
: stripHtml(welcomeSection.title || t('home.welcomeTitle'));
const welcomeSubtitle = useTranslations
? t('home.welcomeSubtitle')
: (welcomeSection.subtitle || t('home.welcomeSubtitle'));
: stripHtml(welcomeSection.subtitle || t('home.welcomeSubtitle'));
const welcomeContent = useTranslations
? t('home.welcomeDescription')
: (welcomeSection.content || t('home.welcomeDescription'));
: stripHtml(welcomeSection.content || t('home.welcomeDescription'));
const roomTypes = roomCategories.map((category) => ({
id: category._id || category.slug,
name: category.name,
image: category.primaryImage || '/images/room-default.jpg',
image: toMediaUrl(category.primaryImage) || '/images/room-default.jpg',
features: category.features?.slice(0, 4) || [],
slug: category.slug,
}));
@@ -114,8 +143,6 @@ const Home = () => {
);
}
const LOGO_BOTTOM = { xs: 40, sm: 55, md: 70 };
return (
<>
<Helmet>
@@ -130,8 +157,7 @@ const Home = () => {
/>
</Helmet>
{/* Hero Section - Logo and Image Only */}
{/* Hero */}
<Box
sx={{
minHeight: '100vh',
@@ -143,7 +169,7 @@ const Home = () => {
color: 'white',
textAlign: 'center',
position: 'relative',
pb: { xs: 25, md: 80},
pb: { xs: 25, md: 80 },
}}
>
<Container maxWidth="lg" sx={{ width: '100%' }}>
@@ -169,7 +195,7 @@ const Home = () => {
</Container>
</Box>
{/* Welcome Section */}
{/* Welcome */}
<Container maxWidth="lg" sx={{ py: 8 }}>
<motion.div
initial={{ opacity: 0, y: 30 }}
@@ -202,7 +228,7 @@ const Home = () => {
</motion.div>
</Container>
{/* Features Section */}
{/* Features */}
<Box sx={{ backgroundColor: 'background.alt', py: 8 }}>
<Container maxWidth="lg">
<motion.div
@@ -283,7 +309,7 @@ const Home = () => {
</Container>
</Box>
{/* Rooms Preview Section */}
{/* Rooms Preview */}
<Container maxWidth="lg" sx={{ py: 8 }}>
<motion.div
initial={{ opacity: 0, y: 30 }}
@@ -320,6 +346,7 @@ const Home = () => {
transition: 'transform 0.3s ease',
'&:hover': { transform: 'scale(1.05)' },
}}
onError={(e) => { e.currentTarget.src = '/images/room-default.jpg'; }}
/>
<CardContent sx={{ p: 3 }}>

View File

@@ -1,82 +1,103 @@
import React, { useMemo } from 'react';
import { Container, Typography, Box, Grid, Card, CardContent, Chip } from '@mui/material';
import { useParams } from 'react-router-dom';
import React, { useEffect, useMemo, useState } from 'react';
import { Container, Typography, Box, Grid, Card, CardContent, Chip, CircularProgress, Button } from '@mui/material';
import { useParams, Link } from 'react-router-dom';
import { Swiper, SwiperSlide } from 'swiper/react';
import SwiperCore from 'swiper';
import 'swiper/css';
import 'swiper/css/navigation';
import 'swiper/css/thumbs';
import { Navigation, Thumbs } from 'swiper/modules';
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'
],
},
};
import api from '../utils/api';
SwiperCore.use([Navigation, Thumbs]);
const isObjectId = (v) => /^[0-9a-fA-F]{24}$/.test(v || '');
const RoomDetails = () => {
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(() => {
return (room.images || []).map(name => `${room.folder}/${name}`);
const imgs = room?.images || [];
return (imgs || []).map((img) => img?.url).filter(Boolean);
}, [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 (
<Container maxWidth="lg" sx={{ py: 6 }}>
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Typography variant="h3" component="h1" gutterBottom>
{room.name}
</Typography>
<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>
<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 }} />
))}
</Box>
</Box>
{/* Gallery */}
{imageUrls.length > 0 ? (
<>
<Swiper
@@ -99,7 +120,6 @@ const RoomDetails = () => {
))}
</Swiper>
{/* Thumbnails */}
<Grid container spacing={2} sx={{ mt: 1 }}>
{imageUrls.map((src, idx) => (
<Grid item xs={3} sm={2} md={2} key={`thumb-${idx}`}>
@@ -118,7 +138,7 @@ const RoomDetails = () => {
<Card sx={{ mt: 3 }}>
<CardContent>
<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>
</CardContent>
</Card>

View File

@@ -16,29 +16,47 @@ import {
import { Link } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Helmet } from 'react-helmet-async';
import staticData from '../utils/staticData';
import api from '../utils/api';
import ImageIcon from '@mui/icons-material/Image';
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 [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let mounted = true;
const fetchCategories = async () => {
try {
const categories = await staticData.getRoomCategories();
setCategories(categories);
setLoading(true);
setError(null);
const res = await api.get('/api/room-categories');
const cats = res?.data?.data?.categories || [];
if (mounted) setCategories(cats);
} catch (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 {
setLoading(false);
if (mounted) setLoading(false);
}
};
fetchCategories();
return () => {
mounted = false;
};
}, []);
if (loading) {
@@ -66,8 +84,14 @@ const Rooms = () => {
<>
<Helmet>
<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 name="keywords" content="hotel rooms, suites, luxury accommodation, damascus rooms, single room, double room, suite" />
<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
name="keywords"
content="hotel rooms, suites, luxury accommodation, damascus rooms, single room, double room, suite"
/>
</Helmet>
{/* Hero Section */}
@@ -120,6 +144,9 @@ const Rooms = () => {
<Typography variant="h5" color="text.secondary">
No room categories available at the moment.
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Please add categories from the admin dashboard.
</Typography>
</Box>
) : (
<Grid container spacing={4}>
@@ -134,6 +161,7 @@ const Rooms = () => {
<Card
sx={{
height: '100%',
minHeight: 560, // ✅ unify card heights
display: 'flex',
flexDirection: 'column',
transition: 'transform 0.3s, box-shadow 0.3s',
@@ -150,13 +178,14 @@ const Rooms = () => {
<CardMedia
component="img"
height="300"
image={
category.primaryImage || '/images/room-default.jpg'
}
image={toMediaUrl(category.primaryImage) || '/images/room-default.jpg'}
alt={category.name}
sx={{ objectFit: 'cover' }}
onError={(e) => {
e.currentTarget.src = '/images/room-default.jpg';
}}
/>
{/* Image Count Badge */}
{category.imageCount > 0 && (
<Badge
badgeContent={category.imageCount}
@@ -187,7 +216,7 @@ const Rooms = () => {
)}
</Box>
<CardContent sx={{ flexGrow: 1, p: 3 }}>
<CardContent sx={{ flexGrow: 1, p: 3, display: 'flex', flexDirection: 'column' }}>
<Typography
variant="h4"
component="h2"
@@ -200,13 +229,20 @@ const Rooms = () => {
<Typography
variant="body1"
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}
</Typography>
{/* Features */}
{category.features && category.features.length > 0 && (
{Array.isArray(category.features) && category.features.length > 0 && (
<Box sx={{ mb: 3 }}>
{category.features.slice(0, 4).map((feature, idx) => (
<Chip
@@ -222,26 +258,28 @@ const Rooms = () => {
/>
))}
{category.features.length > 4 && (
<Chip
label={`+${category.features.length - 4} more`}
size="small"
sx={{ mb: 1 }}
/>
<Chip label={`+${category.features.length - 4} more`} size="small" sx={{ mb: 1 }} />
)}
</Box>
)}
{/* 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 && (
<Typography variant="body2" color="text.secondary">
{category.roomCount} {category.roomCount === 1 ? 'Room' : 'Rooms'} Available
</Typography>
)}
{category.priceRange?.min > 0 && (
<Typography variant="body2" color="text.secondary">
From ${category.priceRange.min}/night
</Typography>
)}
</Box>
</CardContent>
<CardActions sx={{ p: 3, pt: 0 }}>
<CardActions sx={{ p: 3, pt: 0, mt: 'auto' }}>
<Button
component={Link}
to={`/rooms/category/${category.slug}`}

View File

@@ -90,7 +90,7 @@ const BookingManagement = () => {
bookings.map((booking) => (
<TableRow key={booking._id}>
<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.checkOutDate).toLocaleDateString()}</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 [rooms, setRooms] = useState([]);
const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState(null);
const [dialogOpen, setDialogOpen] = useState(false);
@@ -62,12 +63,14 @@ const RoomManagement = () => {
useEffect(() => {
fetchRooms();
fetchCategories();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const fetchRooms = async () => {
try {
const response = await api.get('/api/rooms?limit=100');
setRooms(response.data.data.rooms);
setRooms(response?.data?.data?.rooms || []);
} catch (error) {
console.error('Error fetching rooms:', error);
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) => {
if (room) {
// Edit mode
setCurrentRoom({
...room,
category: room?.category?._id || room?.category || '', // handle populated or raw id
images: room.images || [],
amenities: room.amenities || [],
});
@@ -89,6 +110,7 @@ const RoomManagement = () => {
setCurrentRoom({
name: '',
type: 'Deluxe',
category: categories?.[0]?._id || '', // ✅ important
roomNumber: '',
floor: 1,
size: 0,
@@ -134,6 +156,10 @@ const RoomManagement = () => {
} else {
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);
};
@@ -143,6 +169,8 @@ const RoomManagement = () => {
const removeImage = (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);
};
@@ -150,13 +178,18 @@ const RoomManagement = () => {
setSaving(true);
setMessage(null);
// ✅ required for website grouping
if (!currentRoom?.category) {
setSaving(false);
setMessage({ type: 'error', text: 'Please select a category for this room.' });
return;
}
try {
if (currentRoom._id) {
// Update existing room
await api.put(`/api/rooms/${currentRoom._id}`, currentRoom);
setMessage({ type: 'success', text: 'Room updated successfully!' });
} else {
// Create new room
await api.post('/api/rooms', currentRoom);
setMessage({ type: 'success', text: 'Room created successfully!' });
}
@@ -369,6 +402,25 @@ const RoomManagement = () => {
</FormControl>
</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}>
<TextField
fullWidth
@@ -385,7 +437,7 @@ const RoomManagement = () => {
type="number"
label="Floor"
value={currentRoom.floor}
onChange={(e) => handleChange('floor', parseInt(e.target.value))}
onChange={(e) => handleChange('floor', parseInt(e.target.value || '0', 10))}
required
/>
</Grid>
@@ -396,7 +448,7 @@ const RoomManagement = () => {
type="number"
label="Size (m²)"
value={currentRoom.size}
onChange={(e) => handleChange('size', parseInt(e.target.value))}
onChange={(e) => handleChange('size', parseInt(e.target.value || '0', 10))}
required
/>
</Grid>
@@ -407,7 +459,7 @@ const RoomManagement = () => {
type="number"
label="Max Guests"
value={currentRoom.maxOccupancy}
onChange={(e) => handleChange('maxOccupancy', parseInt(e.target.value))}
onChange={(e) => handleChange('maxOccupancy', parseInt(e.target.value || '1', 10))}
required
inputProps={{ min: 1 }}
/>
@@ -434,7 +486,7 @@ const RoomManagement = () => {
type="number"
label="Number of Beds"
value={currentRoom.bedCount}
onChange={(e) => handleChange('bedCount', parseInt(e.target.value))}
onChange={(e) => handleChange('bedCount', parseInt(e.target.value || '1', 10))}
required
inputProps={{ min: 1 }}
/>
@@ -453,7 +505,7 @@ const RoomManagement = () => {
type="number"
label="Base Price (per night)"
value={currentRoom.basePrice}
onChange={(e) => handleChange('basePrice', parseFloat(e.target.value))}
onChange={(e) => handleChange('basePrice', parseFloat(e.target.value || '0'))}
required
inputProps={{ min: 0, step: 0.01 }}
helperText="Price in USD"