update
This commit is contained in:
@@ -76,6 +76,8 @@ const AccessibilityPage = lazy(() => import('./features/content/pages/Accessibil
|
||||
const FAQPage = lazy(() => import('./features/content/pages/FAQPage'));
|
||||
const BlogPage = lazy(() => import('./features/content/pages/BlogPage'));
|
||||
const BlogDetailPage = lazy(() => import('./features/content/pages/BlogDetailPage'));
|
||||
const ServicesPage = lazy(() => import('./features/content/pages/ServicesPage'));
|
||||
const ServiceDetailPage = lazy(() => import('./features/content/pages/ServiceDetailPage'));
|
||||
|
||||
const AdminDashboardPage = lazy(() => import('./pages/admin/DashboardPage'));
|
||||
const InvoiceManagementPage = lazy(() => import('./pages/admin/InvoiceManagementPage'));
|
||||
@@ -109,6 +111,7 @@ const GDPRManagementPage = lazy(() => import('./pages/admin/GDPRManagementPage')
|
||||
const WebhookManagementPage = lazy(() => import('./pages/admin/WebhookManagementPage'));
|
||||
const APIKeyManagementPage = lazy(() => import('./pages/admin/APIKeyManagementPage'));
|
||||
const BackupManagementPage = lazy(() => import('./pages/admin/BackupManagementPage'));
|
||||
const ServiceManagementPage = lazy(() => import('./pages/admin/ServiceManagementPage'));
|
||||
|
||||
const StaffDashboardPage = lazy(() => import('./pages/staff/DashboardPage'));
|
||||
const StaffBookingManagementPage = lazy(() => import('./pages/staff/BookingManagementPage'));
|
||||
@@ -361,6 +364,14 @@ function App() {
|
||||
path="blog/:slug"
|
||||
element={<BlogDetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="services"
|
||||
element={<ServicesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="services/:slug"
|
||||
element={<ServiceDetailPage />}
|
||||
/>
|
||||
|
||||
{}
|
||||
<Route
|
||||
@@ -681,6 +692,10 @@ function App() {
|
||||
path="backups"
|
||||
element={<BackupManagementPage />}
|
||||
/>
|
||||
<Route
|
||||
path="services"
|
||||
element={<ServiceManagementPage />}
|
||||
/>
|
||||
<Route
|
||||
path="profile"
|
||||
element={<AdminProfilePage />}
|
||||
|
||||
@@ -19,20 +19,26 @@ import {
|
||||
import bannerService from '../services/bannerService';
|
||||
import roomService from '../../rooms/services/roomService';
|
||||
import pageContentService from '../services/pageContentService';
|
||||
import serviceService from '../../hotel_services/services/serviceService';
|
||||
import { useFormatCurrency } from '../../payments/hooks/useFormatCurrency';
|
||||
import type { Banner } from '../services/bannerService';
|
||||
import type { Room } from '../../rooms/services/roomService';
|
||||
import type { PageContent } from '../services/pageContentService';
|
||||
import type { Service } from '../../hotel_services/services/serviceService';
|
||||
|
||||
const HomePage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const [banners, setBanners] = useState<Banner[]>([]);
|
||||
const [featuredRooms, setFeaturedRooms] = useState<Room[]>([]);
|
||||
const [newestRooms, setNewestRooms] = useState<Room[]>([]);
|
||||
const [pageContent, setPageContent] = useState<PageContent | null>(null);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [isLoadingBanners, setIsLoadingBanners] =
|
||||
useState(true);
|
||||
const [isLoadingRooms, setIsLoadingRooms] = useState(true);
|
||||
const [isLoadingNewest, setIsLoadingNewest] = useState(true);
|
||||
const [, setIsLoadingContent] = useState(true);
|
||||
const [isLoadingServices, setIsLoadingServices] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [apiError, setApiError] = useState(false);
|
||||
const [apiErrorMessage, setApiErrorMessage] = useState<string>('');
|
||||
@@ -95,6 +101,27 @@ const HomePage: React.FC = () => {
|
||||
}, [featuredRooms, newestRooms]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
fetchServices();
|
||||
}, []);
|
||||
|
||||
const fetchServices = async () => {
|
||||
try {
|
||||
setIsLoadingServices(true);
|
||||
const response = await serviceService.getServices({
|
||||
status: 'active',
|
||||
limit: 6, // Only fetch first 6 for homepage
|
||||
});
|
||||
if (response.success && response.data?.services) {
|
||||
setServices(response.data.services);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching services:', error);
|
||||
} finally {
|
||||
setIsLoadingServices(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPageContent = async () => {
|
||||
try {
|
||||
@@ -967,48 +994,64 @@ const HomePage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{}
|
||||
{pageContent?.luxury_services && pageContent.luxury_services.length > 0 && (
|
||||
{services.length > 0 && (
|
||||
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
|
||||
<div className="text-center mb-8 md:mb-10 animate-fade-in">
|
||||
<div className="inline-block mb-3">
|
||||
<div className="h-0.5 w-16 md:w-20 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto rounded-full"></div>
|
||||
</div>
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-serif font-bold text-gray-900 tracking-tight mb-3 md:mb-4 px-4">
|
||||
{pageContent.luxury_services_section_title || 'Luxury Services'}
|
||||
{pageContent?.luxury_services_section_title || 'Luxury Services'}
|
||||
</h2>
|
||||
{pageContent.luxury_services_section_subtitle && (
|
||||
{pageContent?.luxury_services_section_subtitle && (
|
||||
<p className="text-sm sm:text-base md:text-lg text-gray-600 font-light tracking-wide max-w-2xl mx-auto mt-3 md:mt-4 px-4 leading-relaxed">
|
||||
{pageContent.luxury_services_section_subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 md:gap-6 px-4">
|
||||
{pageContent.luxury_services.map((service: any, index: number) => (
|
||||
<div key={index} className="relative bg-white rounded-lg md:rounded-xl p-5 md:p-6 group hover:shadow-xl hover:shadow-[#d4af37]/10 transition-all duration-300 animate-fade-in border border-gray-100/50 hover:border-[#d4af37]/25 hover:-translate-y-1" style={{ animationDelay: `${index * 0.1}s` }}>
|
||||
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-t-lg md:rounded-t-xl"></div>
|
||||
{service.image ? (
|
||||
<div className="w-full h-40 md:h-48 mb-4 md:mb-5 rounded-lg md:rounded-xl overflow-hidden shadow-lg group-hover:scale-105 transition-transform duration-300 border border-gray-100 group-hover:border-[#d4af37]/25">
|
||||
<img src={service.image} alt={service.title} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-14 h-14 md:w-16 md:h-16 bg-gradient-to-br from-[#d4af37]/15 via-[#f5d76e]/10 to-[#d4af37]/15 rounded-lg flex items-center justify-center mb-4 md:mb-5 mx-auto group-hover:scale-110 group-hover:shadow-lg group-hover:shadow-[#d4af37]/20 transition-all duration-300 border border-[#d4af37]/25 group-hover:border-[#d4af37]/40">
|
||||
{service.icon && (LucideIcons as any)[service.icon] ? (
|
||||
React.createElement((LucideIcons as any)[service.icon], {
|
||||
className: 'w-7 h-7 md:w-8 md:h-8 text-[#d4af37] drop-shadow-md'
|
||||
})
|
||||
) : (
|
||||
{services.slice(0, 6).map((service, index: number) => {
|
||||
const serviceSlug = service.slug || service.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
return (
|
||||
<Link
|
||||
key={service.id}
|
||||
to={`/services/${serviceSlug}`}
|
||||
className="relative bg-white rounded-lg md:rounded-xl p-5 md:p-6 group hover:shadow-xl hover:shadow-[#d4af37]/10 transition-all duration-300 animate-fade-in border border-gray-100/50 hover:border-[#d4af37]/25 hover:-translate-y-1 block"
|
||||
style={{ animationDelay: `${index * 0.1}s` }}
|
||||
>
|
||||
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-t-lg md:rounded-t-xl"></div>
|
||||
{service.image ? (
|
||||
<div className="w-full h-40 md:h-48 mb-4 md:mb-5 rounded-lg md:rounded-xl overflow-hidden shadow-lg group-hover:scale-105 transition-transform duration-300 border border-gray-100 group-hover:border-[#d4af37]/25">
|
||||
<img src={service.image} alt={service.name} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-14 h-14 md:w-16 md:h-16 bg-gradient-to-br from-[#d4af37]/15 via-[#f5d76e]/10 to-[#d4af37]/15 rounded-lg flex items-center justify-center mb-4 md:mb-5 mx-auto group-hover:scale-110 group-hover:shadow-lg group-hover:shadow-[#d4af37]/20 transition-all duration-300 border border-[#d4af37]/25 group-hover:border-[#d4af37]/40">
|
||||
<span className="text-2xl md:text-3xl">✨</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg md:text-xl font-serif font-semibold mb-2 md:mb-3 text-gray-900 group-hover:text-[#d4af37] transition-colors duration-300 tracking-tight">
|
||||
{service.title}
|
||||
</h3>
|
||||
<p className="text-sm md:text-base text-gray-600 leading-relaxed font-light tracking-wide">
|
||||
{service.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg md:text-xl font-serif font-semibold mb-2 md:mb-3 text-gray-900 group-hover:text-[#d4af37] transition-colors duration-300 tracking-tight">
|
||||
{service.name}
|
||||
</h3>
|
||||
<p className="text-sm md:text-base text-gray-600 leading-relaxed font-light tracking-wide">
|
||||
{service.description || 'Premium service for your comfort'}
|
||||
</p>
|
||||
{service.price && (
|
||||
<div className="mt-3 text-sm font-semibold text-[#d4af37]">
|
||||
Starting from {formatCurrency(service.price)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-center mt-8 md:mt-10">
|
||||
<Link
|
||||
to="/services"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] rounded-lg font-medium hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 shadow-lg shadow-[#d4af37]/30 hover:shadow-xl hover:shadow-[#d4af37]/40 hover:-translate-y-0.5"
|
||||
>
|
||||
<span>View All Services</span>
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
709
Frontend/src/features/content/pages/ServiceDetailPage.tsx
Normal file
709
Frontend/src/features/content/pages/ServiceDetailPage.tsx
Normal file
@@ -0,0 +1,709 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Share2, Tag, Star } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import pageContentService, { PageContent } from '../services/pageContentService';
|
||||
import serviceService from '../../hotel_services/services/serviceService';
|
||||
import Loading from '../../../shared/components/Loading';
|
||||
import { createSanitizedHtml } from '../../../shared/utils/htmlSanitizer';
|
||||
import { useFormatCurrency } from '../../payments/hooks/useFormatCurrency';
|
||||
|
||||
interface ServiceSection {
|
||||
type: 'hero' | 'text' | 'image' | 'gallery' | 'quote' | 'features' | 'cta' | 'video';
|
||||
title?: string;
|
||||
content?: string;
|
||||
image?: string;
|
||||
images?: string[];
|
||||
quote?: string;
|
||||
author?: string;
|
||||
features?: Array<{ title: string; description: string; icon?: string }>;
|
||||
cta_text?: string;
|
||||
cta_link?: string;
|
||||
video_url?: string;
|
||||
alignment?: 'left' | 'center' | 'right';
|
||||
background_color?: string;
|
||||
text_color?: string;
|
||||
is_visible?: boolean;
|
||||
}
|
||||
|
||||
interface ServiceDetail {
|
||||
id: string | number;
|
||||
title: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
content?: string;
|
||||
image?: string;
|
||||
icon?: string;
|
||||
price?: number;
|
||||
unit?: string;
|
||||
category?: string;
|
||||
type: 'luxury' | 'hotel';
|
||||
sections?: ServiceSection[];
|
||||
meta_title?: string;
|
||||
meta_description?: string;
|
||||
meta_keywords?: string;
|
||||
}
|
||||
|
||||
const ServiceDetailPage: React.FC = () => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const [service, setService] = useState<ServiceDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [relatedServices, setRelatedServices] = useState<ServiceDetail[]>([]);
|
||||
const [pageContent, setPageContent] = useState<PageContent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (slug) {
|
||||
fetchService();
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
const fetchService = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// First, try to fetch from hotel services by slug (primary source)
|
||||
try {
|
||||
const serviceResponse = await serviceService.getServiceBySlug(slug!);
|
||||
if (serviceResponse.success && serviceResponse.data?.service) {
|
||||
const service = serviceResponse.data.service;
|
||||
|
||||
// Parse sections if it's a string
|
||||
let sections: ServiceSection[] = [];
|
||||
if (service.sections) {
|
||||
if (typeof service.sections === 'string') {
|
||||
try {
|
||||
sections = JSON.parse(service.sections);
|
||||
} catch {
|
||||
sections = [];
|
||||
}
|
||||
} else if (Array.isArray(service.sections)) {
|
||||
sections = service.sections;
|
||||
}
|
||||
}
|
||||
|
||||
const serviceDetail: ServiceDetail = {
|
||||
id: service.id,
|
||||
title: service.name,
|
||||
slug: service.slug || slug!,
|
||||
description: service.description,
|
||||
content: service.content,
|
||||
image: service.image,
|
||||
category: service.category,
|
||||
type: 'hotel',
|
||||
price: service.price,
|
||||
unit: service.unit,
|
||||
sections: sections,
|
||||
meta_title: service.meta_title,
|
||||
meta_description: service.meta_description,
|
||||
meta_keywords: service.meta_keywords,
|
||||
};
|
||||
|
||||
setService(serviceDetail);
|
||||
|
||||
// Set meta tags
|
||||
if (serviceDetail.meta_title) {
|
||||
document.title = serviceDetail.meta_title;
|
||||
}
|
||||
if (serviceDetail.meta_description) {
|
||||
let metaDescription = document.querySelector('meta[name="description"]');
|
||||
if (!metaDescription) {
|
||||
metaDescription = document.createElement('meta');
|
||||
metaDescription.setAttribute('name', 'description');
|
||||
document.head.appendChild(metaDescription);
|
||||
}
|
||||
metaDescription.setAttribute('content', serviceDetail.meta_description);
|
||||
}
|
||||
|
||||
// Fetch related services
|
||||
await fetchRelatedServices(serviceDetail);
|
||||
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// If hotel service not found, fall back to luxury services
|
||||
console.log('Hotel service not found, trying luxury services...');
|
||||
}
|
||||
|
||||
// Fallback: Fetch page content for luxury services
|
||||
const contentResponse = await pageContentService.getHomeContent();
|
||||
if (contentResponse.status === 'success' && contentResponse.data?.page_content) {
|
||||
const content = contentResponse.data.page_content;
|
||||
|
||||
// Handle luxury_services
|
||||
if (typeof content.luxury_services === 'string') {
|
||||
try {
|
||||
content.luxury_services = JSON.parse(content.luxury_services);
|
||||
} catch {
|
||||
content.luxury_services = [];
|
||||
}
|
||||
} else if (!Array.isArray(content.luxury_services)) {
|
||||
content.luxury_services = content.luxury_services || [];
|
||||
}
|
||||
|
||||
setPageContent(content);
|
||||
|
||||
// Find service by slug
|
||||
const luxuryService = content.luxury_services?.find((s: any) => s.slug === slug);
|
||||
if (luxuryService) {
|
||||
const serviceDetail: ServiceDetail = {
|
||||
id: `luxury-${luxuryService.slug}`,
|
||||
title: luxuryService.title || 'Service',
|
||||
slug: luxuryService.slug || slug || '',
|
||||
description: luxuryService.description,
|
||||
content: luxuryService.content,
|
||||
image: luxuryService.image,
|
||||
icon: luxuryService.icon,
|
||||
category: luxuryService.category,
|
||||
type: 'luxury',
|
||||
sections: luxuryService.sections || [],
|
||||
meta_title: luxuryService.meta_title,
|
||||
meta_description: luxuryService.meta_description,
|
||||
meta_keywords: luxuryService.meta_keywords,
|
||||
};
|
||||
|
||||
setService(serviceDetail);
|
||||
|
||||
// Set meta tags
|
||||
if (serviceDetail.meta_title) {
|
||||
document.title = serviceDetail.meta_title;
|
||||
}
|
||||
if (serviceDetail.meta_description) {
|
||||
let metaDescription = document.querySelector('meta[name="description"]');
|
||||
if (!metaDescription) {
|
||||
metaDescription = document.createElement('meta');
|
||||
metaDescription.setAttribute('name', 'description');
|
||||
document.head.appendChild(metaDescription);
|
||||
}
|
||||
metaDescription.setAttribute('content', serviceDetail.meta_description);
|
||||
}
|
||||
|
||||
// Fetch related services
|
||||
await fetchRelatedServices(serviceDetail);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Service not found
|
||||
navigate('/services');
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching service:', error);
|
||||
navigate('/services');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRelatedServices = async (currentService: ServiceDetail) => {
|
||||
try {
|
||||
const services: ServiceDetail[] = [];
|
||||
|
||||
// Fetch hotel services first
|
||||
const servicesResponse = await serviceService.getServices({
|
||||
status: 'active',
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
if (servicesResponse.success && servicesResponse.data?.services) {
|
||||
servicesResponse.data.services.forEach((service: any) => {
|
||||
// Skip current service
|
||||
if (currentService.type === 'hotel' && service.id === currentService.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter by category if available
|
||||
if (currentService.category && service.category !== currentService.category) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serviceSlug = service.slug || service.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
services.push({
|
||||
id: service.id,
|
||||
title: service.name,
|
||||
slug: serviceSlug,
|
||||
description: service.description,
|
||||
image: service.image,
|
||||
category: service.category,
|
||||
type: 'hotel',
|
||||
price: service.price,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add luxury services as fallback
|
||||
if (pageContent?.luxury_services && Array.isArray(pageContent.luxury_services)) {
|
||||
pageContent.luxury_services.forEach((s: any, index: number) => {
|
||||
if (s.slug && s.slug !== currentService.slug) {
|
||||
if (!currentService.category || s.category === currentService.category) {
|
||||
// Check if already in services (by slug)
|
||||
const serviceSlug = s.slug || s.title?.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
const exists = services.some(serv => serv.slug === serviceSlug);
|
||||
if (!exists) {
|
||||
services.push({
|
||||
id: `luxury-${index}`,
|
||||
title: s.title || 'Service',
|
||||
slug: s.slug,
|
||||
description: s.description,
|
||||
image: s.image,
|
||||
icon: s.icon,
|
||||
category: s.category,
|
||||
type: 'luxury',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setRelatedServices(services.slice(0, 3));
|
||||
} catch (error) {
|
||||
console.error('Error fetching related services:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
if (navigator.share && service) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: service.title,
|
||||
text: service.description,
|
||||
url: window.location.href,
|
||||
});
|
||||
} catch (error) {
|
||||
// User cancelled or error occurred
|
||||
}
|
||||
} else {
|
||||
// Fallback: copy to clipboard
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
alert('Link copied to clipboard!');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (!service) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-black text-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">Service not found</h1>
|
||||
<Link to="/services" className="text-[#d4af37] hover:underline">
|
||||
Back to Services
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-full" style={{ width: '100vw', position: 'relative', left: '50%', right: '50%', marginLeft: '-50vw', marginRight: '-50vw', marginTop: '-1.5rem', marginBottom: '-1.5rem' }}>
|
||||
{/* Hero Section with Featured Image - Enhanced Luxury */}
|
||||
{service.image && (
|
||||
<div className="relative w-full h-[60vh] min-h-[500px] max-h-[800px] overflow-hidden">
|
||||
<div className="absolute inset-0">
|
||||
<img
|
||||
src={service.image}
|
||||
alt={service.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/60 to-black/30"></div>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/40 via-transparent to-black/40"></div>
|
||||
</div>
|
||||
|
||||
{/* Luxury Overlay Pattern */}
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-0 left-0 w-96 h-96 bg-[#d4af37] rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-0 right-0 w-96 h-96 bg-[#c9a227] rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
{/* Hero Content Overlay */}
|
||||
<div className="absolute inset-0 flex items-end">
|
||||
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20 pb-12 lg:pb-16">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{service.category && (
|
||||
<div className="flex flex-wrap gap-3 mb-6">
|
||||
<span className="inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-br from-[#d4af37]/20 to-[#c9a227]/10 backdrop-blur-md rounded-xl text-[#d4af37] text-sm font-semibold border border-[#d4af37]/40 shadow-xl">
|
||||
<Tag className="w-4 h-4" />
|
||||
{service.category}
|
||||
</span>
|
||||
{service.type === 'luxury' && (
|
||||
<span className="inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-br from-[#d4af37]/30 to-[#c9a227]/20 backdrop-blur-md rounded-xl text-[#0f0f0f] text-sm font-bold border border-[#d4af37]/50 shadow-xl">
|
||||
<Star className="w-4 h-4 fill-[#0f0f0f]" />
|
||||
Premium
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-serif font-bold text-white mb-6 leading-tight drop-shadow-2xl">
|
||||
{service.title}
|
||||
</h1>
|
||||
{service.description && (
|
||||
<p className="text-xl sm:text-2xl lg:text-3xl text-gray-200 font-light leading-relaxed max-w-4xl mb-8 drop-shadow-lg">
|
||||
{service.description}
|
||||
</p>
|
||||
)}
|
||||
{service.price !== undefined && (
|
||||
<div className="flex items-baseline gap-3 mb-8">
|
||||
<span className="text-4xl sm:text-5xl lg:text-6xl font-serif font-bold bg-gradient-to-r from-[#d4af37] via-[#f5d76e] to-[#d4af37] bg-clip-text text-transparent drop-shadow-lg">
|
||||
{formatCurrency(service.price)}
|
||||
</span>
|
||||
{service.unit && (
|
||||
<span className="text-xl sm:text-2xl text-gray-300 font-light">
|
||||
/ {service.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative Bottom Border */}
|
||||
<div className="absolute bottom-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-[#d4af37]/50 to-transparent"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content - Enhanced Luxury Layout */}
|
||||
<div className="w-full py-12 sm:py-16 lg:py-24">
|
||||
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Back Button - Only show if no hero image */}
|
||||
{!service.image && (
|
||||
<Link
|
||||
to="/services"
|
||||
className="inline-flex items-center gap-3 px-6 py-3 bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] border-2 border-[#d4af37]/20 rounded-xl text-gray-300 hover:text-[#d4af37] hover:border-[#d4af37]/50 transition-all duration-300 mb-12 group"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
|
||||
<span className="font-medium">Back to Services</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Service Header - Only if no hero image */}
|
||||
{!service.image && (
|
||||
<div className="mb-12">
|
||||
{service.category && (
|
||||
<div className="flex flex-wrap gap-3 mb-6">
|
||||
<Link
|
||||
to={`/services?category=${encodeURIComponent(service.category)}`}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-br from-[#d4af37]/15 to-[#d4af37]/5 text-[#d4af37] rounded-xl text-sm font-semibold border border-[#d4af37]/30 hover:border-[#d4af37]/50 transition-all"
|
||||
>
|
||||
<Tag className="w-4 h-4" />
|
||||
{service.category}
|
||||
</Link>
|
||||
{service.type === 'luxury' && (
|
||||
<span className="inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-br from-[#d4af37]/20 to-[#c9a227]/10 text-[#d4af37] rounded-xl text-sm font-bold border border-[#d4af37]/40">
|
||||
<Star className="w-4 h-4" />
|
||||
Premium
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-6 mb-6">
|
||||
{service.icon && (LucideIcons as any)[service.icon] && (
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="absolute inset-0 bg-[#d4af37]/20 rounded-full blur-3xl"></div>
|
||||
{React.createElement((LucideIcons as any)[service.icon], {
|
||||
className: 'w-20 h-20 sm:w-24 sm:h-24 text-[#d4af37] relative z-10 drop-shadow-2xl'
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-serif font-bold text-white mb-6 leading-tight">
|
||||
{service.title}
|
||||
</h1>
|
||||
{service.price !== undefined && (
|
||||
<div className="flex items-baseline gap-3 mb-6">
|
||||
<span className="text-4xl sm:text-5xl font-serif font-bold bg-gradient-to-r from-[#d4af37] to-[#f5d76e] bg-clip-text text-transparent">
|
||||
{formatCurrency(service.price)}
|
||||
</span>
|
||||
{service.unit && (
|
||||
<span className="text-xl text-gray-400 font-light">
|
||||
/ {service.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{service.description && (
|
||||
<p className="text-2xl text-gray-300 mb-8 font-light leading-relaxed max-w-4xl">
|
||||
{service.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-6 pt-8 border-t border-[#d4af37]/20">
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="flex items-center gap-3 px-6 py-3 bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] border-2 border-[#d4af37]/20 rounded-xl text-[#d4af37] hover:border-[#d4af37]/50 hover:bg-[#d4af37]/5 transition-all duration-300 group"
|
||||
>
|
||||
<Share2 className="w-5 h-5 group-hover:scale-110 transition-transform" />
|
||||
<span className="font-medium">Share</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Share Button for Hero Image Layout */}
|
||||
{service.image && (
|
||||
<div className="flex justify-end mb-12">
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="flex items-center gap-3 px-6 py-3 bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] border-2 border-[#d4af37]/20 rounded-xl text-[#d4af37] hover:border-[#d4af37]/50 hover:bg-[#d4af37]/5 transition-all duration-300 group"
|
||||
>
|
||||
<Share2 className="w-5 h-5 group-hover:scale-110 transition-transform" />
|
||||
<span className="font-medium">Share</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service Content - Enhanced Luxury Styling */}
|
||||
{service.content && (
|
||||
<article className="prose prose-invert prose-xl max-w-none mb-16
|
||||
prose-headings:text-white prose-headings:font-serif prose-headings:font-bold
|
||||
prose-h2:text-4xl prose-h2:mt-16 prose-h2:mb-8 prose-h2:border-b-2 prose-h2:border-[#d4af37]/30 prose-h2:pb-4 prose-h2:pt-2
|
||||
prose-h3:text-3xl prose-h3:mt-12 prose-h3:mb-6 prose-h3:text-[#d4af37]
|
||||
prose-h4:text-2xl prose-h4:mt-10 prose-h4:mb-5
|
||||
prose-p:text-gray-300 prose-p:font-light prose-p:leading-relaxed prose-p:mb-8 prose-p:text-lg
|
||||
prose-ul:text-gray-300 prose-ul:font-light prose-ul:my-8 prose-ul:text-lg
|
||||
prose-ol:text-gray-300 prose-ol:font-light prose-ol:my-8 prose-ol:text-lg
|
||||
prose-li:text-gray-300 prose-li:mb-4 prose-li:leading-relaxed
|
||||
prose-strong:text-[#d4af37] prose-strong:font-semibold
|
||||
prose-a:text-[#d4af37] prose-a:no-underline hover:prose-a:underline prose-a:font-medium
|
||||
prose-img:rounded-2xl prose-img:shadow-2xl prose-img:border-2 prose-img:border-[#d4af37]/20 prose-img:my-12
|
||||
prose-blockquote:border-l-4 prose-blockquote:border-[#d4af37] prose-blockquote:pl-6 prose-blockquote:italic prose-blockquote:text-gray-300
|
||||
[&_*]:text-gray-300 [&_h1]:text-white [&_h2]:text-white [&_h3]:text-[#d4af37] [&_h4]:text-white
|
||||
[&_strong]:text-[#d4af37] [&_b]:text-[#d4af37] [&_a]:text-[#d4af37]
|
||||
[&_ul]:space-y-3 [&_ol]:space-y-3"
|
||||
>
|
||||
<div
|
||||
className="bg-gradient-to-br from-[#1a1a1a]/50 to-[#0f0f0f]/50 rounded-3xl border-2 border-[#d4af37]/10 p-8 md:p-12 lg:p-16 backdrop-blur-sm"
|
||||
dangerouslySetInnerHTML={createSanitizedHtml(
|
||||
service.content || '<p>No content available.</p>'
|
||||
)}
|
||||
/>
|
||||
</article>
|
||||
)}
|
||||
|
||||
{/* Luxury Sections - Enhanced */}
|
||||
{service.sections && service.sections.length > 0 && (
|
||||
<div className="space-y-20 mb-16">
|
||||
{service.sections
|
||||
.filter((section) => section.is_visible !== false)
|
||||
.map((section, index) => (
|
||||
<div key={index}>
|
||||
{/* Hero Section - Enhanced */}
|
||||
{section.type === 'hero' && (
|
||||
<div className="relative rounded-3xl overflow-hidden border-2 border-[#d4af37]/30 shadow-2xl group">
|
||||
{section.image && (
|
||||
<div className="absolute inset-0">
|
||||
<img src={section.image} alt={section.title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-1000" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/95 via-black/70 to-black/40"></div>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/40 via-transparent to-black/40"></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-0 left-0 w-96 h-96 bg-[#d4af37] rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-0 right-0 w-96 h-96 bg-[#c9a227] rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
<div className="relative px-8 py-20 md:px-16 md:py-32 text-center">
|
||||
{section.title && (
|
||||
<h2 className="text-5xl md:text-6xl lg:text-7xl font-serif font-bold text-white mb-8 leading-tight drop-shadow-2xl">
|
||||
{section.title}
|
||||
</h2>
|
||||
)}
|
||||
{section.content && (
|
||||
<p className="text-2xl md:text-3xl text-gray-200 font-light leading-relaxed max-w-4xl mx-auto drop-shadow-lg">
|
||||
{section.content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-[#d4af37]/50 to-transparent"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text Section */}
|
||||
{section.type === 'text' && (
|
||||
<div className={`bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] rounded-3xl border-2 border-[#d4af37]/20 p-8 md:p-12 shadow-xl ${
|
||||
section.alignment === 'center' ? 'text-center' : section.alignment === 'right' ? 'text-right' : 'text-left'
|
||||
}`}>
|
||||
{section.title && (
|
||||
<h3 className="text-3xl md:text-4xl font-serif font-bold text-white mb-6">
|
||||
{section.title}
|
||||
</h3>
|
||||
)}
|
||||
{section.content && (
|
||||
<div
|
||||
className="text-gray-300 font-light leading-relaxed text-lg"
|
||||
dangerouslySetInnerHTML={createSanitizedHtml(section.content)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Section */}
|
||||
{section.type === 'image' && section.image && (
|
||||
<div className="rounded-3xl overflow-hidden border-2 border-[#d4af37]/20 shadow-2xl">
|
||||
<img src={section.image} alt={section.title || 'Service image'} className="w-full h-auto" />
|
||||
{section.title && (
|
||||
<div className="bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] px-6 py-4 border-t border-[#d4af37]/20">
|
||||
<p className="text-gray-400 text-sm font-light italic text-center">{section.title}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gallery Section */}
|
||||
{section.type === 'gallery' && section.images && section.images.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{section.images.map((img, imgIndex) => (
|
||||
<div key={imgIndex} className="rounded-2xl overflow-hidden border-2 border-[#d4af37]/20 shadow-xl group hover:border-[#d4af37]/50 transition-all">
|
||||
<img src={img} alt={`Gallery image ${imgIndex + 1}`} className="w-full h-64 object-cover group-hover:scale-110 transition-transform duration-500" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quote Section */}
|
||||
{section.type === 'quote' && (
|
||||
<div className="relative bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] rounded-3xl border-2 border-[#d4af37]/30 p-8 md:p-12 shadow-2xl">
|
||||
<div className="absolute top-6 left-6 text-6xl text-[#d4af37]/20 font-serif">"</div>
|
||||
{section.quote && (
|
||||
<blockquote className="text-2xl md:text-3xl font-serif font-light text-white italic mb-6 relative z-10 pl-8">
|
||||
{section.quote}
|
||||
</blockquote>
|
||||
)}
|
||||
{section.author && (
|
||||
<cite className="text-[#d4af37] text-lg font-medium not-italic block text-right">
|
||||
— {section.author}
|
||||
</cite>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Features Section - Enhanced */}
|
||||
{section.type === 'features' && section.features && (
|
||||
<div>
|
||||
{section.title && (
|
||||
<h3 className="text-4xl md:text-5xl font-serif font-bold text-white mb-12 text-center border-b-2 border-[#d4af37]/30 pb-6">
|
||||
{section.title}
|
||||
</h3>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{section.features.map((feature, featIndex) => {
|
||||
const IconComponent = feature.icon && (LucideIcons as any)[feature.icon]
|
||||
? (LucideIcons as any)[feature.icon]
|
||||
: null;
|
||||
return (
|
||||
<div key={featIndex} className="group relative bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#0a0a0a] rounded-3xl border-2 border-[#d4af37]/20 p-8 shadow-2xl hover:border-[#d4af37]/60 hover:shadow-[#d4af37]/20 transition-all duration-500 hover:-translate-y-2">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37]/0 to-[#d4af37]/0 group-hover:from-[#d4af37]/5 group-hover:to-transparent rounded-3xl transition-all duration-500"></div>
|
||||
<div className="relative z-10">
|
||||
{IconComponent && (
|
||||
<div className="mb-6 relative">
|
||||
<div className="absolute inset-0 bg-[#d4af37]/20 rounded-full blur-2xl group-hover:blur-3xl transition-all"></div>
|
||||
{React.createElement(IconComponent, {
|
||||
className: 'w-12 h-12 text-[#d4af37] relative z-10 group-hover:scale-110 transition-transform duration-300'
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<h4 className="text-2xl font-bold text-white mb-4 group-hover:text-[#d4af37] transition-colors">{feature.title}</h4>
|
||||
<p className="text-gray-300 font-light leading-relaxed text-base">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA Section */}
|
||||
{section.type === 'cta' && (
|
||||
<div className="relative bg-gradient-to-br from-[#d4af37]/10 via-[#c9a227]/5 to-[#d4af37]/10 rounded-3xl border-2 border-[#d4af37]/30 p-8 md:p-12 text-center shadow-2xl">
|
||||
{section.title && (
|
||||
<h3 className="text-3xl md:text-4xl font-serif font-bold text-white mb-4">
|
||||
{section.title}
|
||||
</h3>
|
||||
)}
|
||||
{section.content && (
|
||||
<p className="text-xl text-gray-300 font-light mb-8 max-w-2xl mx-auto">
|
||||
{section.content}
|
||||
</p>
|
||||
)}
|
||||
{section.cta_text && section.cta_link && (
|
||||
<a
|
||||
href={section.cta_link}
|
||||
className="inline-flex items-center gap-3 px-8 py-4 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] font-semibold rounded-xl hover:from-[#f5d76e] hover:to-[#d4af37] transition-all shadow-lg hover:shadow-xl hover:scale-105"
|
||||
>
|
||||
{section.cta_text}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video Section */}
|
||||
{section.type === 'video' && section.video_url && (
|
||||
<div className="rounded-3xl overflow-hidden border-2 border-[#d4af37]/20 shadow-2xl bg-black">
|
||||
<div className="aspect-video">
|
||||
<iframe
|
||||
src={section.video_url.replace('watch?v=', 'embed/').replace('vimeo.com/', 'player.vimeo.com/video/')}
|
||||
className="w-full h-full"
|
||||
allowFullScreen
|
||||
title="Service video"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Related Services */}
|
||||
{relatedServices.length > 0 && (
|
||||
<div className="mt-16 pt-12 border-t border-[#d4af37]/20">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-white mb-8">Related Services</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{relatedServices.map((relatedService) => (
|
||||
<Link
|
||||
key={relatedService.id}
|
||||
to={`/services/${relatedService.slug}`}
|
||||
className="group bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] rounded-xl border border-[#d4af37]/20 overflow-hidden hover:border-[#d4af37]/50 transition-all duration-300"
|
||||
>
|
||||
{relatedService.image && (
|
||||
<div className="relative h-40 overflow-hidden">
|
||||
<img
|
||||
src={relatedService.image}
|
||||
alt={relatedService.title}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-bold text-white mb-2 group-hover:text-[#d4af37] transition-colors line-clamp-2">
|
||||
{relatedService.title}
|
||||
</h3>
|
||||
{relatedService.description && (
|
||||
<p className="text-gray-400 text-sm line-clamp-2 font-light">
|
||||
{relatedService.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceDetailPage;
|
||||
|
||||
393
Frontend/src/features/content/pages/ServicesPage.tsx
Normal file
393
Frontend/src/features/content/pages/ServicesPage.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Search, Sparkles, ArrowRight, Tag, Award, Star } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import pageContentService, { PageContent } from '../services/pageContentService';
|
||||
import serviceService, { Service } from '../../hotel_services/services/serviceService';
|
||||
import Loading from '../../../shared/components/Loading';
|
||||
import { useFormatCurrency } from '../../payments/hooks/useFormatCurrency';
|
||||
|
||||
const ServicesPage: React.FC = () => {
|
||||
const [pageContent, setPageContent] = useState<PageContent | null>(null);
|
||||
const [hotelServices, setHotelServices] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [allCategories, setAllCategories] = useState<string[]>([]);
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch page content for luxury services
|
||||
const contentResponse = await pageContentService.getHomeContent();
|
||||
if (contentResponse.status === 'success' && contentResponse.data?.page_content) {
|
||||
const content = contentResponse.data.page_content;
|
||||
|
||||
// Handle luxury_services - can be string, array, or null/undefined
|
||||
if (typeof content.luxury_services === 'string') {
|
||||
try {
|
||||
content.luxury_services = JSON.parse(content.luxury_services);
|
||||
} catch {
|
||||
content.luxury_services = [];
|
||||
}
|
||||
} else if (!Array.isArray(content.luxury_services)) {
|
||||
content.luxury_services = content.luxury_services || [];
|
||||
}
|
||||
|
||||
setPageContent(content);
|
||||
|
||||
// Extract categories from luxury services
|
||||
const categories = new Set<string>();
|
||||
if (Array.isArray(content.luxury_services)) {
|
||||
content.luxury_services.forEach((service: any) => {
|
||||
if (service.category) {
|
||||
categories.add(service.category);
|
||||
}
|
||||
});
|
||||
}
|
||||
setAllCategories(Array.from(categories));
|
||||
}
|
||||
|
||||
// Fetch hotel services from API
|
||||
const servicesResponse = await serviceService.getServices({
|
||||
status: 'active',
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
if (servicesResponse.success && servicesResponse.data?.services) {
|
||||
setHotelServices(servicesResponse.data.services);
|
||||
|
||||
// Add categories from hotel services
|
||||
const hotelCategories = new Set(allCategories);
|
||||
servicesResponse.data.services.forEach((service: Service) => {
|
||||
// You can add category logic here if services have categories
|
||||
});
|
||||
setAllCategories(Array.from(hotelCategories));
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching services:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Combine hotel services (primary) with luxury services from page content (fallback)
|
||||
const allServices = React.useMemo(() => {
|
||||
const services: Array<{
|
||||
id: string | number;
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
icon?: string;
|
||||
price?: number;
|
||||
unit?: string;
|
||||
category?: string;
|
||||
type: 'luxury' | 'hotel';
|
||||
slug?: string;
|
||||
}> = [];
|
||||
|
||||
// Add hotel services first (primary source)
|
||||
hotelServices.forEach((service: Service) => {
|
||||
// Generate slug if not present
|
||||
const serviceSlug = service.slug || service.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
services.push({
|
||||
id: service.id,
|
||||
title: service.name,
|
||||
description: service.description || '',
|
||||
image: service.image,
|
||||
price: service.price,
|
||||
unit: service.unit,
|
||||
category: service.category || 'Services',
|
||||
type: 'hotel',
|
||||
slug: serviceSlug,
|
||||
});
|
||||
});
|
||||
|
||||
// Add luxury services from page content (only if not already in hotel services)
|
||||
if (pageContent?.luxury_services && Array.isArray(pageContent.luxury_services)) {
|
||||
pageContent.luxury_services.forEach((service: any, index: number) => {
|
||||
// Check if this service already exists in hotel services by slug
|
||||
const existingSlug = service.slug || service.title?.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
const existsInHotel = hotelServices.some((hs: Service) => {
|
||||
const hotelSlug = hs.slug || hs.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
return hotelSlug === existingSlug;
|
||||
});
|
||||
|
||||
// Only add if not already in hotel services
|
||||
if (!existsInHotel) {
|
||||
services.push({
|
||||
id: `luxury-${index}`,
|
||||
title: service.title || 'Service',
|
||||
description: service.description || '',
|
||||
image: service.image,
|
||||
icon: service.icon,
|
||||
category: service.category,
|
||||
type: 'luxury',
|
||||
slug: service.slug,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return services;
|
||||
}, [pageContent, hotelServices]);
|
||||
|
||||
// Filter services based on search and category
|
||||
const filteredServices = React.useMemo(() => {
|
||||
return allServices.filter((service) => {
|
||||
const matchesSearch =
|
||||
!searchTerm ||
|
||||
service.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
service.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesCategory = !selectedCategory || service.category === selectedCategory;
|
||||
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
}, [allServices, searchTerm, selectedCategory]);
|
||||
|
||||
if (loading && allServices.length === 0) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-full" style={{ width: '100vw', position: 'relative', left: '50%', right: '50%', marginLeft: '-50vw', marginRight: '-50vw', marginTop: '-1.5rem', marginBottom: '-1.5rem' }}>
|
||||
{/* Hero Section */}
|
||||
<div className="w-full bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a] border-b border-[#d4af37]/10 pt-6 sm:pt-7 md:pt-8 overflow-hidden relative">
|
||||
{/* Background Effects */}
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-10 left-10 w-32 sm:w-48 h-32 sm:h-48 bg-[#d4af37] rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-10 right-10 w-40 sm:w-64 h-40 sm:h-64 bg-[#c9a227] rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[#d4af37]/50 to-transparent"></div>
|
||||
|
||||
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20 py-4 sm:py-5 md:py-6 relative z-10">
|
||||
<div className="max-w-2xl mx-auto text-center px-2">
|
||||
<div className="flex justify-center mb-2 sm:mb-3 md:mb-4">
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37] via-[#f5d76e] to-[#c9a227] rounded-xl blur-lg opacity-40 group-hover:opacity-60 transition-opacity duration-500"></div>
|
||||
<div className="relative p-2 sm:p-2.5 md:p-3 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg border-2 border-[#d4af37]/40 backdrop-blur-sm shadow-xl shadow-[#d4af37]/20 group-hover:border-[#d4af37]/60 transition-all duration-300">
|
||||
<Award className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-[#d4af37] drop-shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl xs:text-3xl sm:text-4xl md:text-5xl font-serif font-semibold mb-2 sm:mb-3 tracking-tight leading-tight px-2">
|
||||
<span className="bg-gradient-to-r from-white via-[#d4af37] to-white bg-clip-text text-transparent">
|
||||
{pageContent?.luxury_services_section_title || 'Our Services'}
|
||||
</span>
|
||||
</h1>
|
||||
<div className="w-12 sm:w-16 md:w-20 h-0.5 bg-gradient-to-r from-transparent via-[#d4af37] to-transparent mx-auto mb-2 sm:mb-3"></div>
|
||||
<p className="text-sm sm:text-base md:text-lg text-gray-300 font-light leading-relaxed max-w-xl mx-auto tracking-wide px-2 sm:px-4">
|
||||
{pageContent?.luxury_services_section_subtitle || 'Discover our premium services designed to enhance your stay'}
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-center gap-2 text-[#d4af37]/60">
|
||||
<Sparkles className="w-4 h-4 animate-pulse" />
|
||||
<span className="text-xs sm:text-sm font-light tracking-wider uppercase">Premium Services</span>
|
||||
<Sparkles className="w-4 h-4 animate-pulse" style={{ animationDelay: '0.5s' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[#d4af37]/30 to-transparent"></div>
|
||||
</div>
|
||||
|
||||
{/* Main Content - Full Width */}
|
||||
<div className="w-full py-12 sm:py-16 lg:py-20">
|
||||
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20">
|
||||
{/* Search Section */}
|
||||
<div className="mb-12 sm:mb-16">
|
||||
<div className="flex flex-col lg:flex-row gap-6 mb-8">
|
||||
<div className="flex-1 relative group">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/10 to-transparent rounded-xl blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-5 top-1/2 transform -translate-y-1/2 text-[#d4af37] w-5 h-5 z-10" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search services..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-14 pr-5 py-4 bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] border border-[#d4af37]/20 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37]/50 transition-all duration-300 backdrop-blur-sm font-light"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories Filter - Top Center */}
|
||||
{allCategories.length > 0 && (
|
||||
<div className="mb-12 flex justify-center">
|
||||
<div className="inline-flex flex-wrap items-center gap-3 bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#0a0a0a] rounded-2xl border-2 border-[#d4af37]/20 p-4 backdrop-blur-xl shadow-2xl">
|
||||
<button
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
className={`group relative px-6 py-3 rounded-xl text-sm font-medium transition-all duration-300 overflow-hidden ${
|
||||
selectedCategory === null
|
||||
? 'bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] shadow-lg shadow-[#d4af37]/40'
|
||||
: 'bg-gradient-to-br from-[#0f0f0f] to-[#0a0a0a] text-gray-300 border-2 border-[#d4af37]/20 hover:border-[#d4af37]/50 hover:text-[#d4af37]'
|
||||
}`}
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-2">
|
||||
<Tag className="w-4 h-4" />
|
||||
All Services
|
||||
</span>
|
||||
</button>
|
||||
{allCategories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={`group relative px-6 py-3 rounded-xl text-sm font-medium transition-all duration-300 overflow-hidden ${
|
||||
selectedCategory === category
|
||||
? 'bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] shadow-lg shadow-[#d4af37]/40'
|
||||
: 'bg-gradient-to-br from-[#0f0f0f] to-[#0a0a0a] text-gray-300 border-2 border-[#d4af37]/20 hover:border-[#d4af37]/50 hover:text-[#d4af37]'
|
||||
}`}
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-2">
|
||||
<Tag className="w-4 h-4" />
|
||||
{category}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Services Section - Centered */}
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Results Count */}
|
||||
{!loading && filteredServices.length > 0 && (
|
||||
<div className="mb-8 text-gray-400 font-light text-sm text-center">
|
||||
Showing {filteredServices.length} of {allServices.length} services
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Services Grid - Luxury Design - Centered */}
|
||||
{filteredServices.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-[#d4af37]/10 mb-6">
|
||||
<Award className="w-10 h-10 text-[#d4af37]" />
|
||||
</div>
|
||||
<p className="text-gray-400 text-xl font-light">No services found</p>
|
||||
<p className="text-gray-500 text-sm mt-2">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 lg:gap-10 mb-12 max-w-7xl w-full">
|
||||
{filteredServices.map((service, index) => {
|
||||
// Use the slug from the service object, or generate a fallback from title
|
||||
const serviceSlug = service.slug || service.title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
|
||||
if (!serviceSlug) {
|
||||
console.warn('Service missing slug:', service);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={service.id}
|
||||
to={`/services/${serviceSlug}`}
|
||||
className="group relative bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#0a0a0a] rounded-3xl border-2 border-[#d4af37]/20 overflow-hidden hover:border-[#d4af37]/60 transition-all duration-700 hover:shadow-2xl hover:shadow-[#d4af37]/30 hover:-translate-y-3 block"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
{/* Premium Glow Effects */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37]/0 via-[#d4af37]/0 to-[#d4af37]/0 group-hover:from-[#d4af37]/10 group-hover:via-[#d4af37]/5 group-hover:to-[#d4af37]/10 transition-all duration-700 rounded-3xl blur-2xl opacity-0 group-hover:opacity-100"></div>
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-[#d4af37]/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10">
|
||||
{service.image ? (
|
||||
<div className="relative h-72 sm:h-80 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent z-10"></div>
|
||||
<img
|
||||
src={service.image}
|
||||
alt={service.title}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-1000 ease-out"
|
||||
/>
|
||||
{/* Premium Badge Overlay */}
|
||||
{service.type === 'luxury' && (
|
||||
<div className="absolute top-6 left-6 z-20">
|
||||
<div className="bg-gradient-to-br from-[#d4af37]/20 to-[#c9a227]/10 backdrop-blur-md rounded-2xl px-4 py-2 border border-[#d4af37]/40 shadow-xl">
|
||||
<div className="flex items-center gap-2 text-[#d4af37]">
|
||||
<Star className="w-4 h-4" />
|
||||
<span className="text-sm font-semibold">Premium</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Luxury Corner Accent */}
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-[#d4af37]/20 to-transparent rounded-bl-full opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-48 sm:h-56 bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] flex items-center justify-center p-8">
|
||||
{service.icon && (LucideIcons as any)[service.icon] ? (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-[#d4af37]/20 rounded-full blur-2xl"></div>
|
||||
{React.createElement((LucideIcons as any)[service.icon], {
|
||||
className: 'w-16 h-16 sm:w-20 sm:h-20 text-[#d4af37] relative z-10 drop-shadow-lg'
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-[#d4af37]/20 rounded-full blur-2xl"></div>
|
||||
<Award className="w-16 h-16 sm:w-20 sm:h-20 text-[#d4af37] relative z-10 drop-shadow-lg" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-8">
|
||||
{service.category && (
|
||||
<div className="flex flex-wrap gap-2 mb-5">
|
||||
<span className="inline-flex items-center gap-1.5 px-4 py-1.5 bg-gradient-to-br from-[#d4af37]/15 to-[#d4af37]/5 text-[#d4af37] rounded-full text-xs font-semibold border border-[#d4af37]/30 backdrop-blur-sm shadow-lg">
|
||||
<Tag className="w-3 h-3" />
|
||||
{service.category}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-2xl sm:text-3xl font-serif font-bold text-white mb-4 group-hover:text-[#d4af37] transition-colors duration-500 line-clamp-2 leading-tight tracking-tight">
|
||||
{service.title}
|
||||
</h2>
|
||||
{service.description && (
|
||||
<p className="text-gray-300 text-base mb-6 line-clamp-3 font-light leading-relaxed">
|
||||
{service.description}
|
||||
</p>
|
||||
)}
|
||||
{service.price !== undefined && (
|
||||
<div className="mb-6 pb-6 border-b border-[#d4af37]/20">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl sm:text-3xl font-serif font-semibold bg-gradient-to-r from-[#d4af37] to-[#f5d76e] bg-clip-text text-transparent">
|
||||
{formatCurrency(service.price)}
|
||||
</span>
|
||||
{service.unit && (
|
||||
<span className="text-sm text-gray-400 font-light">
|
||||
/ {service.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-[#d4af37]/10">
|
||||
<div className="flex items-center gap-3 text-[#d4af37] group-hover:gap-4 transition-all duration-300">
|
||||
<span className="text-sm font-semibold tracking-wide uppercase">Learn More</span>
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-2 transition-transform duration-300" />
|
||||
</div>
|
||||
<div className="w-12 h-0.5 bg-gradient-to-r from-[#d4af37] to-transparent group-hover:w-20 transition-all duration-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServicesPage;
|
||||
|
||||
@@ -74,7 +74,33 @@ export interface PageContent {
|
||||
stats?: Array<{ number: string; label: string; icon?: string }>;
|
||||
luxury_services_section_title?: string;
|
||||
luxury_services_section_subtitle?: string;
|
||||
luxury_services?: Array<{ icon?: string; title: string; description: string; image?: string }>;
|
||||
luxury_services?: Array<{
|
||||
icon?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
slug?: string;
|
||||
category?: string;
|
||||
content?: string;
|
||||
sections?: Array<{
|
||||
type: 'hero' | 'text' | 'image' | 'gallery' | 'quote' | 'features' | 'cta' | 'video';
|
||||
title?: string;
|
||||
content?: string;
|
||||
image?: string;
|
||||
images?: string[];
|
||||
quote?: string;
|
||||
author?: string;
|
||||
features?: Array<{ title: string; description: string; icon?: string }>;
|
||||
cta_text?: string;
|
||||
cta_link?: string;
|
||||
video_url?: string;
|
||||
alignment?: 'left' | 'center' | 'right';
|
||||
is_visible?: boolean;
|
||||
}>;
|
||||
meta_title?: string;
|
||||
meta_description?: string;
|
||||
meta_keywords?: string;
|
||||
}>;
|
||||
luxury_experiences_section_title?: string;
|
||||
luxury_experiences_section_subtitle?: string;
|
||||
luxury_experiences?: Array<{ icon?: string; title: string; description: string; image?: string }>;
|
||||
@@ -173,7 +199,33 @@ export interface UpdatePageContentData {
|
||||
stats?: Array<{ number: string; label: string; icon?: string }>;
|
||||
luxury_services_section_title?: string;
|
||||
luxury_services_section_subtitle?: string;
|
||||
luxury_services?: Array<{ icon?: string; title: string; description: string; image?: string }>;
|
||||
luxury_services?: Array<{
|
||||
icon?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
slug?: string;
|
||||
category?: string;
|
||||
content?: string;
|
||||
sections?: Array<{
|
||||
type: 'hero' | 'text' | 'image' | 'gallery' | 'quote' | 'features' | 'cta' | 'video';
|
||||
title?: string;
|
||||
content?: string;
|
||||
image?: string;
|
||||
images?: string[];
|
||||
quote?: string;
|
||||
author?: string;
|
||||
features?: Array<{ title: string; description: string; icon?: string }>;
|
||||
cta_text?: string;
|
||||
cta_link?: string;
|
||||
video_url?: string;
|
||||
alignment?: 'left' | 'center' | 'right';
|
||||
is_visible?: boolean;
|
||||
}>;
|
||||
meta_title?: string;
|
||||
meta_description?: string;
|
||||
meta_keywords?: string;
|
||||
}>;
|
||||
luxury_experiences_section_title?: string;
|
||||
luxury_experiences_section_subtitle?: string;
|
||||
luxury_experiences?: Array<{ icon?: string; title: string; description: string; image?: string }>;
|
||||
|
||||
@@ -1,12 +1,37 @@
|
||||
import apiClient from '../../../shared/services/apiClient';
|
||||
|
||||
export interface ServiceSection {
|
||||
type: 'hero' | 'text' | 'image' | 'gallery' | 'quote' | 'features' | 'cta' | 'video';
|
||||
title?: string;
|
||||
content?: string;
|
||||
image?: string;
|
||||
images?: string[];
|
||||
quote?: string;
|
||||
author?: string;
|
||||
features?: Array<{ title: string; description: string; icon?: string }>;
|
||||
cta_text?: string;
|
||||
cta_link?: string;
|
||||
video_url?: string;
|
||||
alignment?: 'left' | 'center' | 'right';
|
||||
is_visible?: boolean;
|
||||
}
|
||||
|
||||
export interface Service {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
price: number;
|
||||
unit?: string;
|
||||
category?: string;
|
||||
slug?: string;
|
||||
image?: string;
|
||||
content?: string;
|
||||
sections?: ServiceSection[] | string; // Can be array or JSON string
|
||||
meta_title?: string;
|
||||
meta_description?: string;
|
||||
meta_keywords?: string;
|
||||
status: 'active' | 'inactive';
|
||||
is_active?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
@@ -31,6 +56,14 @@ export interface CreateServiceData {
|
||||
description?: string;
|
||||
price: number;
|
||||
unit?: string;
|
||||
category?: string;
|
||||
slug?: string;
|
||||
image?: string;
|
||||
content?: string;
|
||||
sections?: ServiceSection[] | string;
|
||||
meta_title?: string;
|
||||
meta_description?: string;
|
||||
meta_keywords?: string;
|
||||
status?: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
@@ -39,6 +72,14 @@ export interface UpdateServiceData {
|
||||
description?: string;
|
||||
price?: number;
|
||||
unit?: string;
|
||||
category?: string;
|
||||
slug?: string;
|
||||
image?: string;
|
||||
content?: string;
|
||||
sections?: ServiceSection[] | string;
|
||||
meta_title?: string;
|
||||
meta_description?: string;
|
||||
meta_keywords?: string;
|
||||
status?: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
@@ -75,6 +116,18 @@ export const getServiceById = async (
|
||||
};
|
||||
};
|
||||
|
||||
export const getServiceBySlug = async (
|
||||
slug: string
|
||||
): Promise<{ success: boolean; data: { service: Service } }> => {
|
||||
const response = await apiClient.get(`/services/slug/${slug}`);
|
||||
const data = response.data;
|
||||
// Handle both 'status: success' and 'success: true' formats
|
||||
return {
|
||||
success: data.status === 'success' || data.success === true,
|
||||
data: data.data || {},
|
||||
};
|
||||
};
|
||||
|
||||
export const createService = async (
|
||||
data: CreateServiceData
|
||||
): Promise<{ success: boolean; data: { service: Service }; message: string }> => {
|
||||
@@ -131,6 +184,7 @@ export const useService = async (data: {
|
||||
export default {
|
||||
getServices,
|
||||
getServiceById,
|
||||
getServiceBySlug,
|
||||
createService,
|
||||
updateService,
|
||||
deleteService,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,11 +19,9 @@ import {
|
||||
Edit,
|
||||
Trash2,
|
||||
X,
|
||||
Calendar,
|
||||
Wrench
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
import bookingService, { Booking } from '../../features/bookings/services/bookingService';
|
||||
import serviceService, { Service } from '../../features/hotel_services/services/serviceService';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import CurrencyIcon from '../../shared/components/CurrencyIcon';
|
||||
@@ -32,7 +30,7 @@ import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurren
|
||||
import { parseDateLocal } from '../../shared/utils/format';
|
||||
import CreateBookingModal from '../../features/hotel_services/components/CreateBookingModal';
|
||||
|
||||
type ReceptionTab = 'overview' | 'check-in' | 'check-out' | 'bookings' | 'services';
|
||||
type ReceptionTab = 'overview' | 'check-in' | 'check-out' | 'bookings';
|
||||
|
||||
interface GuestInfo {
|
||||
name: string;
|
||||
@@ -89,28 +87,6 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
const bookingItemsPerPage = 5;
|
||||
const [showCreateBookingModal, setShowCreateBookingModal] = useState(false);
|
||||
|
||||
|
||||
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [servicesLoading, setServicesLoading] = useState(true);
|
||||
const [showServiceModal, setShowServiceModal] = useState(false);
|
||||
const [editingService, setEditingService] = useState<Service | null>(null);
|
||||
const [serviceFilters, setServiceFilters] = useState({
|
||||
search: '',
|
||||
status: '',
|
||||
});
|
||||
const [serviceCurrentPage, setServiceCurrentPage] = useState(1);
|
||||
const [serviceTotalPages, setServiceTotalPages] = useState(1);
|
||||
const [serviceTotalItems, setServiceTotalItems] = useState(0);
|
||||
const serviceItemsPerPage = 5;
|
||||
const [serviceFormData, setServiceFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
price: 0,
|
||||
unit: 'time',
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
});
|
||||
|
||||
|
||||
const handleCheckInSearch = async () => {
|
||||
if (!checkInBookingNumber.trim()) {
|
||||
@@ -406,107 +382,11 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
};
|
||||
|
||||
|
||||
const fetchServices = useCallback(async () => {
|
||||
try {
|
||||
setServicesLoading(true);
|
||||
const response = await serviceService.getServices({
|
||||
...serviceFilters,
|
||||
page: serviceCurrentPage,
|
||||
limit: serviceItemsPerPage,
|
||||
});
|
||||
setServices(response.data.services);
|
||||
if (response.data.pagination) {
|
||||
setServiceTotalPages(response.data.pagination.totalPages);
|
||||
setServiceTotalItems(response.data.pagination.total);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load services list');
|
||||
} finally {
|
||||
setServicesLoading(false);
|
||||
}
|
||||
}, [serviceFilters.search, serviceFilters.status, serviceCurrentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setServiceCurrentPage(1);
|
||||
}, [serviceFilters.search, serviceFilters.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'services') {
|
||||
fetchServices();
|
||||
}
|
||||
}, [activeTab, fetchServices]);
|
||||
|
||||
const handleServiceSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingService) {
|
||||
await serviceService.updateService(editingService.id, serviceFormData);
|
||||
toast.success('Service updated successfully');
|
||||
} else {
|
||||
await serviceService.createService(serviceFormData);
|
||||
toast.success('Service added successfully');
|
||||
}
|
||||
setShowServiceModal(false);
|
||||
resetServiceForm();
|
||||
fetchServices();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'An error occurred');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditService = (service: Service) => {
|
||||
setEditingService(service);
|
||||
setServiceFormData({
|
||||
name: service.name,
|
||||
description: service.description || '',
|
||||
price: service.price,
|
||||
unit: service.unit || 'time',
|
||||
status: service.status,
|
||||
});
|
||||
setShowServiceModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteService = async (id: number) => {
|
||||
if (!window.confirm('Are you sure you want to delete this service?')) return;
|
||||
|
||||
try {
|
||||
await serviceService.deleteService(id);
|
||||
toast.success('Service deleted successfully');
|
||||
fetchServices();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to delete service');
|
||||
}
|
||||
};
|
||||
|
||||
const resetServiceForm = () => {
|
||||
setEditingService(null);
|
||||
setServiceFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
price: 0,
|
||||
unit: 'time',
|
||||
status: 'active',
|
||||
});
|
||||
};
|
||||
|
||||
const getServiceStatusBadge = (status: string) => {
|
||||
return status === 'active' ? (
|
||||
<span className="px-4 py-1.5 rounded-full text-xs font-semibold border shadow-sm bg-gradient-to-r from-emerald-50 to-green-50 text-emerald-800 border-emerald-200">
|
||||
Active
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-4 py-1.5 rounded-full text-xs font-semibold border shadow-sm bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200">
|
||||
Inactive
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview' as ReceptionTab, label: 'Overview', icon: LogIn },
|
||||
{ id: 'check-in' as ReceptionTab, label: 'Check-in', icon: LogIn },
|
||||
{ id: 'check-out' as ReceptionTab, label: 'Check-out', icon: LogOut },
|
||||
{ id: 'bookings' as ReceptionTab, label: 'Bookings', icon: Calendar },
|
||||
{ id: 'services' as ReceptionTab, label: 'Services', icon: Wrench },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -677,38 +557,6 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-blue-50/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => setActiveTab('services')}
|
||||
className="group relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-purple-100/50 p-8 cursor-pointer transition-all duration-300 hover:shadow-2xl hover:scale-105 hover:border-purple-300/60 overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-purple-400/10 to-transparent rounded-bl-full"></div>
|
||||
<div className="relative space-y-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-purple-400 to-purple-600 rounded-xl blur-md opacity-50 group-hover:opacity-75 transition-opacity"></div>
|
||||
<div className="relative p-3.5 rounded-xl bg-gradient-to-br from-purple-500 to-purple-600 shadow-lg border border-purple-400/50 group-hover:scale-110 transition-transform">
|
||||
<Wrench className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 text-xl mb-1">Services</h3>
|
||||
<div className="h-1 w-12 bg-gradient-to-r from-purple-500 to-purple-600 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
Manage hotel services and amenities
|
||||
</p>
|
||||
<div className="pt-5 border-t border-gray-100">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500 font-medium">Manage Services</span>
|
||||
<ChevronRight className="w-5 h-5 text-purple-600 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-purple-50/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1878,248 +1726,6 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{}
|
||||
{activeTab === 'services' && (
|
||||
<div className="space-y-8">
|
||||
{servicesLoading && <Loading />}
|
||||
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-gradient-to-br from-purple-500/10 to-violet-500/10 border border-purple-200/40">
|
||||
<Wrench className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<h2 className="text-xl sm:text-2xl md:text-2xl font-extrabold text-gray-900">Service Management</h2>
|
||||
</div>
|
||||
<p className="text-gray-600 text-base max-w-2xl leading-relaxed">
|
||||
Manage hotel services and amenities
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetServiceForm();
|
||||
setShowServiceModal(true);
|
||||
}}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-purple-500 to-purple-600 text-white rounded-xl font-semibold hover:from-purple-600 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Add Service
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5 group-focus-within:text-purple-500 transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search services..."
|
||||
value={serviceFilters.search}
|
||||
onChange={(e) => setServiceFilters({ ...serviceFilters, search: e.target.value })}
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 placeholder-gray-400 font-medium shadow-sm hover:shadow-md"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={serviceFilters.status}
|
||||
onChange={(e) => setServiceFilters({ ...serviceFilters, status: e.target.value })}
|
||||
className="w-full px-4 py-3.5 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-xl sm:rounded-2xl shadow-xl border border-gray-200/50 overflow-hidden">
|
||||
<div className="overflow-x-auto -mx-2 sm:mx-0 px-2 sm:px-0">
|
||||
<table className="w-full min-w-[640px] sm:min-w-full">
|
||||
<thead>
|
||||
<tr className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900">
|
||||
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Service Name</th>
|
||||
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700 hidden sm:table-cell">Description</th>
|
||||
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Price</th>
|
||||
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Unit</th>
|
||||
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Status</th>
|
||||
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-right text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-slate-100">
|
||||
{services.map((service) => (
|
||||
<tr
|
||||
key={service.id}
|
||||
className="hover:bg-gradient-to-r hover:from-purple-50/30 hover:to-violet-50/30 transition-all duration-200 group border-b border-slate-100"
|
||||
>
|
||||
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap">
|
||||
<div className="text-xs sm:text-sm font-semibold text-gray-900">{service.name}</div>
|
||||
<div className="text-xs text-gray-500 sm:hidden mt-0.5 truncate max-w-[150px]">{service.description}</div>
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 hidden sm:table-cell">
|
||||
<div className="text-xs sm:text-sm text-gray-700 max-w-xs truncate">{service.description}</div>
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap">
|
||||
<div className="text-xs sm:text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
|
||||
{formatCurrency(service.price)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap">
|
||||
<div className="text-xs sm:text-sm text-gray-600">{service.unit}</div>
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap">
|
||||
{getServiceStatusBadge(service.status)}
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end gap-1 sm:gap-2">
|
||||
<button
|
||||
onClick={() => handleEditService(service)}
|
||||
className="p-1.5 sm:p-2 rounded-lg text-blue-600 hover:text-blue-700 hover:bg-blue-50 transition-all duration-200 shadow-sm hover:shadow-md border border-blue-200 hover:border-blue-300"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteService(service.id)}
|
||||
className="p-1.5 sm:p-2 rounded-lg text-rose-600 hover:text-rose-700 hover:bg-rose-50 transition-all duration-200 shadow-sm hover:shadow-md border border-rose-200 hover:border-rose-300"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={serviceCurrentPage}
|
||||
totalPages={serviceTotalPages}
|
||||
onPageChange={setServiceCurrentPage}
|
||||
totalItems={serviceTotalItems}
|
||||
itemsPerPage={serviceItemsPerPage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{}
|
||||
{showServiceModal && (
|
||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-hidden border border-slate-200">
|
||||
{}
|
||||
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-6 py-5 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-lg sm:text-xl md:text-xl font-bold text-amber-100">
|
||||
{editingService ? 'Update Service' : 'Add New Service'}
|
||||
</h2>
|
||||
<p className="text-amber-200/80 text-sm font-light mt-1">
|
||||
{editingService ? 'Modify service information' : 'Create a new service'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowServiceModal(false);
|
||||
resetServiceForm();
|
||||
}}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-120px)]">
|
||||
<form onSubmit={handleServiceSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Service Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={serviceFormData.name}
|
||||
onChange={(e) => setServiceFormData({ ...serviceFormData, name: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={serviceFormData.description}
|
||||
onChange={(e) => setServiceFormData({ ...serviceFormData, description: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Price
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={serviceFormData.price}
|
||||
onChange={(e) => setServiceFormData({ ...serviceFormData, price: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
required
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Unit
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={serviceFormData.unit}
|
||||
onChange={(e) => setServiceFormData({ ...serviceFormData, unit: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
placeholder="e.g: time, hour, day..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={serviceFormData.status}
|
||||
onChange={(e) => setServiceFormData({ ...serviceFormData, status: e.target.value as any })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-slate-700 font-medium shadow-sm cursor-pointer"
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-4 border-t border-slate-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowServiceModal(false);
|
||||
resetServiceForm();
|
||||
}}
|
||||
className="flex-1 px-6 py-3 border-2 border-slate-300 rounded-xl text-slate-700 font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-6 py-3 bg-gradient-to-r from-purple-500 to-purple-600 text-white rounded-xl font-semibold hover:from-purple-600 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
{editingService ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Booking Modal */}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Plus, Search, Edit, Trash2, X } from 'lucide-react';
|
||||
import { Plus, Search, Edit, Trash2, X, Upload, Image as ImageIcon } from 'lucide-react';
|
||||
import serviceService, { Service } from '../../features/hotel_services/services/serviceService';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import Pagination from '../../shared/components/Pagination';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
import IconPicker from '../../features/system/components/IconPicker';
|
||||
import ConfirmationDialog from '../../shared/components/ConfirmationDialog';
|
||||
|
||||
const ServiceManagementPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
@@ -26,8 +28,20 @@ const ServiceManagementPage: React.FC = () => {
|
||||
description: '',
|
||||
price: 0,
|
||||
unit: 'time',
|
||||
category: '',
|
||||
slug: '',
|
||||
image: '',
|
||||
content: '',
|
||||
sections: [] as any[],
|
||||
meta_title: '',
|
||||
meta_description: '',
|
||||
meta_keywords: '',
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
});
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; id: number | null }>({ show: false, id: null });
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
@@ -60,11 +74,18 @@ const ServiceManagementPage: React.FC = () => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
// Auto-generate slug if not provided
|
||||
const dataToSubmit = {
|
||||
...formData,
|
||||
slug: formData.slug || formData.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''),
|
||||
sections: formData.sections.length > 0 ? JSON.stringify(formData.sections) : null,
|
||||
};
|
||||
|
||||
if (editingService) {
|
||||
await serviceService.updateService(editingService.id, formData);
|
||||
await serviceService.updateService(editingService.id, dataToSubmit);
|
||||
toast.success('Service updated successfully');
|
||||
} else {
|
||||
await serviceService.createService(formData);
|
||||
await serviceService.createService(dataToSubmit);
|
||||
toast.success('Service added successfully');
|
||||
}
|
||||
setShowModal(false);
|
||||
@@ -77,25 +98,62 @@ const ServiceManagementPage: React.FC = () => {
|
||||
|
||||
const handleEdit = (service: Service) => {
|
||||
setEditingService(service);
|
||||
|
||||
// Parse sections if it's a string
|
||||
let sections: any[] = [];
|
||||
if (service.sections) {
|
||||
if (typeof service.sections === 'string') {
|
||||
try {
|
||||
sections = JSON.parse(service.sections);
|
||||
} catch {
|
||||
sections = [];
|
||||
}
|
||||
} else if (Array.isArray(service.sections)) {
|
||||
sections = service.sections;
|
||||
}
|
||||
}
|
||||
|
||||
setFormData({
|
||||
name: service.name,
|
||||
description: service.description || '',
|
||||
price: service.price,
|
||||
unit: service.unit || 'time',
|
||||
category: service.category || '',
|
||||
slug: service.slug || '',
|
||||
image: service.image || '',
|
||||
content: service.content || '',
|
||||
sections: sections,
|
||||
meta_title: service.meta_title || '',
|
||||
meta_description: service.meta_description || '',
|
||||
meta_keywords: service.meta_keywords || '',
|
||||
status: service.status,
|
||||
});
|
||||
|
||||
// Set image preview if image exists
|
||||
if (service.image) {
|
||||
setImagePreview(service.image.startsWith('http') ? service.image : `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}${service.image}`);
|
||||
} else {
|
||||
setImagePreview(null);
|
||||
}
|
||||
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!window.confirm('Are you sure you want to delete this service?')) return;
|
||||
const handleDeleteClick = (id: number) => {
|
||||
setDeleteConfirm({ show: true, id });
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirm.id) return;
|
||||
|
||||
try {
|
||||
await serviceService.deleteService(id);
|
||||
await serviceService.deleteService(deleteConfirm.id);
|
||||
toast.success('Service deleted successfully');
|
||||
setDeleteConfirm({ show: false, id: null });
|
||||
fetchServices();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to delete service');
|
||||
setDeleteConfirm({ show: false, id: null });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -106,8 +164,48 @@ const ServiceManagementPage: React.FC = () => {
|
||||
description: '',
|
||||
price: 0,
|
||||
unit: 'time',
|
||||
category: '',
|
||||
slug: '',
|
||||
image: '',
|
||||
content: '',
|
||||
sections: [],
|
||||
meta_title: '',
|
||||
meta_description: '',
|
||||
meta_keywords: '',
|
||||
status: 'active',
|
||||
});
|
||||
setImagePreview(null);
|
||||
setImageFile(null);
|
||||
};
|
||||
|
||||
const handleImageUpload = async (file: File) => {
|
||||
try {
|
||||
setUploadingImage(true);
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
// Use the page content image upload endpoint or create a service-specific one
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8000'}/api/page-content/upload-image`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'success' && data.data?.image_url) {
|
||||
setFormData(prev => ({ ...prev, image: data.data.image_url }));
|
||||
setImagePreview(data.data.image_url);
|
||||
toast.success('Image uploaded successfully');
|
||||
} else {
|
||||
throw new Error(data.message || 'Upload failed');
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to upload image');
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -216,7 +314,7 @@ const ServiceManagementPage: React.FC = () => {
|
||||
<Edit className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(service.id)}
|
||||
onClick={() => handleDeleteClick(service.id)}
|
||||
className="p-2 rounded-lg text-rose-600 hover:text-rose-700 hover:bg-rose-50 transition-all duration-200 shadow-sm hover:shadow-md border border-rose-200 hover:border-rose-300"
|
||||
title="Delete"
|
||||
>
|
||||
@@ -242,7 +340,7 @@ const ServiceManagementPage: React.FC = () => {
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-4 animate-fade-in">
|
||||
<div className="min-h-full flex items-start justify-center py-8">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-md my-8 max-h-[calc(100vh-4rem)] overflow-hidden border border-slate-200 animate-scale-in">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-4xl my-8 max-h-[calc(100vh-4rem)] overflow-hidden border border-slate-200 animate-scale-in">
|
||||
{}
|
||||
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-6 py-5 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -302,16 +400,228 @@ const ServiceManagementPage: React.FC = () => {
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Unit
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.unit}
|
||||
onChange={(e) => setFormData({ ...formData, unit: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
placeholder="e.g: time, hour, day..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Category
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
placeholder="e.g: Spa, Dining, Concierge..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Unit
|
||||
Slug (URL-friendly identifier)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.unit}
|
||||
onChange={(e) => setFormData({ ...formData, unit: e.target.value })}
|
||||
value={formData.slug}
|
||||
onChange={(e) => {
|
||||
const slug = e.target.value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
setFormData({ ...formData, slug });
|
||||
}}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
placeholder="e.g: time, hour, day..."
|
||||
placeholder="auto-generated-from-name"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Leave empty to auto-generate from name</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Image URL
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.image}
|
||||
onChange={(e) => setFormData({ ...formData, image: e.target.value })}
|
||||
className="flex-1 px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
handleImageUpload(file);
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
id="image-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="image-upload"
|
||||
className="px-4 py-3 bg-amber-500 text-white rounded-xl hover:bg-amber-600 transition-colors cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Upload
|
||||
</label>
|
||||
</div>
|
||||
{imagePreview && (
|
||||
<div className="mt-2">
|
||||
<img src={imagePreview} alt="Preview" className="w-32 h-32 object-cover rounded-lg" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Content (HTML)
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm font-mono text-sm"
|
||||
rows={6}
|
||||
placeholder="Full HTML content for service detail page..."
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Supports HTML formatting. This content will appear on the service detail page.</p>
|
||||
</div>
|
||||
|
||||
{/* Sections Editor */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider">
|
||||
Sections (Advanced Content)
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFormData({
|
||||
...formData,
|
||||
sections: [...formData.sections, { type: 'text', title: '', content: '', is_visible: true }]
|
||||
});
|
||||
}}
|
||||
className="text-xs px-3 py-1.5 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Add Section
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto p-3 bg-slate-50 rounded-xl border border-slate-200">
|
||||
{formData.sections.map((section: any, sectionIndex: number) => (
|
||||
<div key={sectionIndex} className="p-3 bg-white border border-slate-300 rounded-lg">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<select
|
||||
value={section?.type || 'text'}
|
||||
onChange={(e) => {
|
||||
const updatedSections = [...formData.sections];
|
||||
updatedSections[sectionIndex] = { ...updatedSections[sectionIndex], type: e.target.value };
|
||||
setFormData({ ...formData, sections: updatedSections });
|
||||
}}
|
||||
className="px-3 py-1.5 border border-slate-300 rounded text-sm"
|
||||
>
|
||||
<option value="text">Text</option>
|
||||
<option value="hero">Hero</option>
|
||||
<option value="image">Image</option>
|
||||
<option value="gallery">Gallery</option>
|
||||
<option value="quote">Quote</option>
|
||||
<option value="features">Features</option>
|
||||
<option value="cta">Call to Action</option>
|
||||
<option value="video">Video</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const updatedSections = formData.sections.filter((_, i) => i !== sectionIndex);
|
||||
setFormData({ ...formData, sections: updatedSections });
|
||||
}}
|
||||
className="text-red-600 hover:text-red-700 text-sm px-2 py-1"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={section?.title || ''}
|
||||
onChange={(e) => {
|
||||
const updatedSections = [...formData.sections];
|
||||
updatedSections[sectionIndex] = { ...updatedSections[sectionIndex], title: e.target.value };
|
||||
setFormData({ ...formData, sections: updatedSections });
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded mb-2 text-sm"
|
||||
placeholder="Section title"
|
||||
/>
|
||||
<textarea
|
||||
value={section?.content || ''}
|
||||
onChange={(e) => {
|
||||
const updatedSections = [...formData.sections];
|
||||
updatedSections[sectionIndex] = { ...updatedSections[sectionIndex], content: e.target.value };
|
||||
setFormData({ ...formData, sections: updatedSections });
|
||||
}}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded text-sm"
|
||||
placeholder="Section content (HTML supported)"
|
||||
/>
|
||||
{section?.type === 'image' && (
|
||||
<input
|
||||
type="url"
|
||||
value={section?.image || ''}
|
||||
onChange={(e) => {
|
||||
const updatedSections = [...formData.sections];
|
||||
updatedSections[sectionIndex] = { ...updatedSections[sectionIndex], image: e.target.value };
|
||||
setFormData({ ...formData, sections: updatedSections });
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded mt-2 text-sm"
|
||||
placeholder="Image URL"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{formData.sections.length === 0 && (
|
||||
<p className="text-xs text-slate-500 italic text-center py-4">No sections added. Click "Add Section" to create advanced content blocks.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
SEO Meta Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.meta_title}
|
||||
onChange={(e) => setFormData({ ...formData, meta_title: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
placeholder="Service Name - Luxury Hotel"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
SEO Meta Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.meta_description}
|
||||
onChange={(e) => setFormData({ ...formData, meta_description: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
rows={2}
|
||||
placeholder="Brief description for search engines..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
SEO Meta Keywords
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.meta_keywords}
|
||||
onChange={(e) => setFormData({ ...formData, meta_keywords: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
placeholder="keyword1, keyword2, keyword3"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -348,6 +658,18 @@ const ServiceManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<ConfirmationDialog
|
||||
isOpen={deleteConfirm.show}
|
||||
onClose={() => setDeleteConfirm({ show: false, id: null })}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Service"
|
||||
message="Are you sure you want to delete this service? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -447,6 +447,15 @@ const RoomDetailPage: React.FC = () => {
|
||||
: (roomType?.amenities || [])
|
||||
}
|
||||
/>
|
||||
<div className="mt-6 pt-6 border-t border-[#d4af37]/20">
|
||||
<Link
|
||||
to="/services"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-[#d4af37]/20 to-[#c9a227]/10 hover:from-[#d4af37]/30 hover:to-[#d4af37]/20 text-[#d4af37] rounded-lg font-medium transition-all duration-300 border border-[#d4af37]/30 hover:border-[#d4af37]/50"
|
||||
>
|
||||
<Award className="w-4 h-4" />
|
||||
<span className="text-sm">View All Services</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ interface NavbarProps {
|
||||
export const navLinks = [
|
||||
{ to: '/', label: 'Home' },
|
||||
{ to: '/rooms', label: 'Rooms' },
|
||||
{ to: '/services', label: 'Services' },
|
||||
{ to: '/about', label: 'About' },
|
||||
{ to: '/contact', label: 'Contact' },
|
||||
{ to: '/blog', label: 'Blog' },
|
||||
|
||||
Reference in New Issue
Block a user