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 /> },
|
||||
];
|
||||
|
||||
// ✅ 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;
|
||||
|
||||
|
||||
@@ -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,107 +370,105 @@ const About = () => {
|
||||
</motion.div>
|
||||
</Container>
|
||||
</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' }}>
|
||||
{/* Timeline line */}
|
||||
<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) => (
|
||||
{false && (
|
||||
<Container maxWidth="lg" sx={{ py: 8 }}>
|
||||
<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 }}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
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' },
|
||||
}}
|
||||
<Typography
|
||||
variant="h3"
|
||||
component="h2"
|
||||
textAlign="center"
|
||||
sx={{ mb: 6, color: 'primary.main' }}
|
||||
>
|
||||
<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>
|
||||
Our Journey
|
||||
</Typography>
|
||||
|
||||
{/* Timeline dot */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
backgroundColor: 'secondary.main',
|
||||
borderRadius: '50%',
|
||||
border: `4px solid ${theme.palette.background.paper}`,
|
||||
boxShadow: 2,
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 2,
|
||||
backgroundColor: 'primary.main',
|
||||
transform: 'translateX(-50%)',
|
||||
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>
|
||||
</motion.div>
|
||||
))}
|
||||
</Box>
|
||||
</motion.div>
|
||||
</Container>
|
||||
)}
|
||||
|
||||
</Container>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default About;
|
||||
export default About;
|
||||
|
||||
@@ -1,30 +1,184 @@
|
||||
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 */}
|
||||
</Box>
|
||||
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
export default Booking;
|
||||
export default Booking;
|
||||
|
||||
@@ -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} m² | 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;
|
||||
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
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) {
|
||||
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 }}>
|
||||
|
||||
@@ -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 { id } = useParams();
|
||||
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
|
||||
@@ -85,7 +106,7 @@ const RoomDetails = () => {
|
||||
spaceBetween={10}
|
||||
slidesPerView={1}
|
||||
style={{ borderRadius: 8, overflow: 'hidden' }}
|
||||
>
|
||||
>
|
||||
{imageUrls.map((src, idx) => (
|
||||
<SwiperSlide key={idx}>
|
||||
<Box
|
||||
@@ -99,10 +120,9 @@ 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}`}>
|
||||
<Grid item xs={3} sm={2} md={2} key={`thumb-${idx}`}>
|
||||
<Box
|
||||
component="img"
|
||||
src={src}
|
||||
@@ -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>
|
||||
@@ -127,4 +147,4 @@ const RoomDetails = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomDetails;
|
||||
export default RoomDetails;
|
||||
|
||||
@@ -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"
|
||||
@@ -196,17 +225,24 @@ const Rooms = () => {
|
||||
>
|
||||
{category.name}
|
||||
</Typography>
|
||||
|
||||
|
||||
<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}`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,24 +178,29 @@ 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!' });
|
||||
}
|
||||
|
||||
|
||||
handleCloseDialog();
|
||||
fetchRooms();
|
||||
} catch (error) {
|
||||
console.error('Error saving room:', error);
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: error.response?.data?.message || 'Failed to save room'
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: error.response?.data?.message || 'Failed to save room'
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -185,9 +218,9 @@ const RoomManagement = () => {
|
||||
fetchRooms();
|
||||
} catch (error) {
|
||||
console.error('Error deleting room:', error);
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: error.response?.data?.message || 'Failed to delete room'
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: error.response?.data?.message || 'Failed to delete room'
|
||||
});
|
||||
} finally {
|
||||
setConfirmDialog({ open: false, roomId: null });
|
||||
@@ -224,8 +257,8 @@ const RoomManagement = () => {
|
||||
</Box>
|
||||
|
||||
{message && (
|
||||
<Alert
|
||||
severity={message.type}
|
||||
<Alert
|
||||
severity={message.type}
|
||||
sx={{ mb: 3 }}
|
||||
onClose={() => setMessage(null)}
|
||||
>
|
||||
@@ -316,8 +349,8 @@ const RoomManagement = () => {
|
||||
</TableContainer>
|
||||
|
||||
{/* Add/Edit Room Dialog */}
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onClose={handleCloseDialog}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
@@ -333,7 +366,7 @@ const RoomManagement = () => {
|
||||
</IconButton>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
|
||||
<DialogContent dividers>
|
||||
{currentRoom && (
|
||||
<Grid container spacing={3}>
|
||||
@@ -343,7 +376,7 @@ const RoomManagement = () => {
|
||||
Basic Information
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
|
||||
<Grid item xs={12} md={8}>
|
||||
<TextField
|
||||
fullWidth
|
||||
@@ -353,7 +386,7 @@ const RoomManagement = () => {
|
||||
required
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Room Type</InputLabel>
|
||||
@@ -368,7 +401,26 @@ const RoomManagement = () => {
|
||||
</Select>
|
||||
</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
|
||||
@@ -378,41 +430,41 @@ const RoomManagement = () => {
|
||||
required
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
|
||||
<Grid item xs={6} md={3}>
|
||||
<TextField
|
||||
fullWidth
|
||||
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>
|
||||
|
||||
|
||||
<Grid item xs={6} md={3}>
|
||||
<TextField
|
||||
fullWidth
|
||||
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>
|
||||
|
||||
|
||||
<Grid item xs={6} md={3}>
|
||||
<TextField
|
||||
fullWidth
|
||||
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 }}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
|
||||
<Grid item xs={6} md={6}>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Bed Type</InputLabel>
|
||||
@@ -427,14 +479,14 @@ const RoomManagement = () => {
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
|
||||
<Grid item xs={6} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
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 }}
|
||||
/>
|
||||
@@ -446,14 +498,14 @@ const RoomManagement = () => {
|
||||
Pricing
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
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"
|
||||
@@ -466,7 +518,7 @@ const RoomManagement = () => {
|
||||
Description
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
@@ -477,7 +529,7 @@ const RoomManagement = () => {
|
||||
helperText="Brief one-line description"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
@@ -600,8 +652,8 @@ const RoomManagement = () => {
|
||||
<Button onClick={handleCloseDialog} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="contained"
|
||||
disabled={saving}
|
||||
>
|
||||
|
||||