This commit is contained in:
Iliyan Angelov
2025-12-05 00:01:15 +02:00
parent 9f1aeb32da
commit 99da0afecd
24 changed files with 3701 additions and 569 deletions

View File

@@ -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 />}

View File

@@ -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>
)}

View 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;

View 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;

View File

@@ -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 }>;

View File

@@ -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

View File

@@ -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 */}

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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' },