428 lines
23 KiB
TypeScript
428 lines
23 KiB
TypeScript
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';
|
|
import { useTheme } from '../../../shared/contexts/ThemeContext';
|
|
import { getThemeBackgroundClasses, getThemeHeroBackgroundClasses, getThemeTextClasses, getThemeCardClasses, getThemeInputClasses } from '../../../shared/utils/themeUtils';
|
|
|
|
// Helper function to get icon component from icon name (handles both PascalCase and lowercase)
|
|
const getIconComponent = (iconName?: string, fallback: any = Award) => {
|
|
if (!iconName) return fallback;
|
|
|
|
// Try direct match first (for PascalCase names)
|
|
if ((LucideIcons as any)[iconName]) {
|
|
return (LucideIcons as any)[iconName];
|
|
}
|
|
|
|
// Convert to PascalCase (capitalize first letter)
|
|
const pascalCaseName = iconName.charAt(0).toUpperCase() + iconName.slice(1).toLowerCase();
|
|
if ((LucideIcons as any)[pascalCaseName]) {
|
|
return (LucideIcons as any)[pascalCaseName];
|
|
}
|
|
|
|
return fallback;
|
|
};
|
|
|
|
const ServicesPage: React.FC = () => {
|
|
const { theme } = useTheme();
|
|
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 />;
|
|
}
|
|
|
|
const bgClasses = getThemeBackgroundClasses(theme.theme_layout_mode);
|
|
const heroBgClasses = getThemeHeroBackgroundClasses(theme.theme_layout_mode);
|
|
const textClasses = getThemeTextClasses(theme.theme_layout_mode);
|
|
const cardClasses = getThemeCardClasses(theme.theme_layout_mode);
|
|
const inputClasses = getThemeInputClasses(theme.theme_layout_mode);
|
|
|
|
return (
|
|
<div className={`min-h-screen ${bgClasses} 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 ${heroBgClasses} 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-[var(--luxury-gold)] rounded-full blur-3xl"></div>
|
|
<div className="absolute bottom-10 right-10 w-40 sm:w-64 h-40 sm:h-64 bg-[var(--luxury-gold-dark)] rounded-full blur-3xl"></div>
|
|
</div>
|
|
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[var(--luxury-gold)]/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-[var(--luxury-gold)] via-[var(--luxury-gold-light)] to-[var(--luxury-gold-dark)] 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 ${cardClasses} rounded-lg border-2 border-[var(--luxury-gold)]/40 backdrop-blur-sm shadow-xl shadow-[var(--luxury-gold)]/20 group-hover:border-[var(--luxury-gold)]/60 transition-all duration-300`}>
|
|
<Award className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-[var(--luxury-gold)] 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={`${theme.theme_layout_mode === 'light'
|
|
? 'bg-gradient-to-r from-gray-900 via-[var(--luxury-gold)] to-gray-900'
|
|
: 'bg-gradient-to-r from-white via-[var(--luxury-gold)] 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-[var(--luxury-gold)] to-transparent mx-auto mb-2 sm:mb-3"></div>
|
|
<p className={`text-sm sm:text-base md:text-lg ${textClasses.secondary} 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-[var(--luxury-gold)]/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-[var(--luxury-gold)]/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-[var(--luxury-gold)]/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-[var(--luxury-gold)] 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 ${inputClasses} border border-[var(--luxury-gold)]/20 rounded-xl placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[var(--luxury-gold)]/50 focus:border-[var(--luxury-gold)]/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 ${cardClasses} rounded-2xl border-2 border-[var(--luxury-gold)]/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-[var(--luxury-gold)] to-[var(--luxury-gold-dark)] text-[#0f0f0f] shadow-lg shadow-[var(--luxury-gold)]/40'
|
|
: `${cardClasses} ${textClasses.secondary} border-2 border-[var(--luxury-gold)]/20 hover:border-[var(--luxury-gold)]/50 hover:text-[var(--luxury-gold)] ${theme.theme_layout_mode === 'light' ? 'hover:bg-gray-100' : 'hover:bg-[#1a1a1a]'}`
|
|
}`}
|
|
>
|
|
<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-[var(--luxury-gold)] to-[var(--luxury-gold-dark)] text-[#0f0f0f] shadow-lg shadow-[var(--luxury-gold)]/40'
|
|
: `${cardClasses} ${textClasses.secondary} border-2 border-[var(--luxury-gold)]/20 hover:border-[var(--luxury-gold)]/50 hover:text-[var(--luxury-gold)] ${theme.theme_layout_mode === 'light' ? 'hover:bg-gray-100' : 'hover:bg-[#1a1a1a]'}`
|
|
}`}
|
|
>
|
|
<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 ${textClasses.muted} 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-[var(--luxury-gold)]/10 mb-6">
|
|
<Award className="w-10 h-10 text-[var(--luxury-gold)]" />
|
|
</div>
|
|
<p className={`${textClasses.muted} text-xl font-light`}>No services found</p>
|
|
<p className={`${textClasses.muted} 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 ${cardClasses} rounded-3xl border-2 border-[var(--luxury-gold)]/20 overflow-hidden hover:border-[var(--luxury-gold)]/60 transition-all duration-700 hover:shadow-2xl hover:shadow-[var(--luxury-gold)]/30 hover:-translate-y-3 block`}
|
|
style={{ animationDelay: `${index * 50}ms` }}
|
|
>
|
|
{/* Premium Glow Effects */}
|
|
<div className="absolute inset-0 bg-gradient-to-br from-[var(--luxury-gold)]/0 via-[var(--luxury-gold)]/0 to-[var(--luxury-gold)]/0 group-hover:from-[var(--luxury-gold)]/10 group-hover:via-[var(--luxury-gold)]/5 group-hover:to-[var(--luxury-gold)]/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-[var(--luxury-gold)]/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-[var(--luxury-gold)]/20 to-[var(--luxury-gold-dark)]/10 backdrop-blur-md rounded-2xl px-4 py-2 border border-[var(--luxury-gold)]/40 shadow-xl">
|
|
<div className="flex items-center gap-2 text-[var(--luxury-gold)]">
|
|
<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-[var(--luxury-gold)]/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 ${cardClasses} flex items-center justify-center p-8`}>
|
|
{service.icon && (LucideIcons as any)[service.icon] ? (
|
|
<div className="relative">
|
|
<div className="absolute inset-0 bg-[var(--luxury-gold)]/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-[var(--luxury-gold)] relative z-10 drop-shadow-lg'
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="relative">
|
|
<div className="absolute inset-0 bg-[var(--luxury-gold)]/20 rounded-full blur-2xl"></div>
|
|
{React.createElement(
|
|
getIconComponent(pageContent?.services_fallback_icon, Award),
|
|
{
|
|
className: 'w-16 h-16 sm:w-20 sm:h-20 text-[var(--luxury-gold)] 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-[var(--luxury-gold)]/15 to-[var(--luxury-gold)]/5 text-[var(--luxury-gold)] rounded-full text-xs font-semibold border border-[var(--luxury-gold)]/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 ${textClasses.primary} mb-4 group-hover:text-[var(--luxury-gold)] transition-colors duration-500 line-clamp-2 leading-tight tracking-tight`}>
|
|
{service.title}
|
|
</h2>
|
|
{service.description && (
|
|
<p className={`${textClasses.secondary} 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-[var(--luxury-gold)]/20">
|
|
<div className="flex items-baseline gap-2">
|
|
<span className="text-2xl sm:text-3xl font-serif font-semibold bg-gradient-to-r from-[var(--luxury-gold)] to-[var(--luxury-gold-light)] bg-clip-text text-transparent">
|
|
{formatCurrency(service.price)}
|
|
</span>
|
|
{service.unit && (
|
|
<span className={`text-sm ${textClasses.muted} font-light`}>
|
|
/ {service.unit}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center justify-between pt-4 border-t border-[var(--luxury-gold)]/10">
|
|
<div className="flex items-center gap-3 text-[var(--luxury-gold)] 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-[var(--luxury-gold)] to-transparent group-hover:w-20 transition-all duration-500"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ServicesPage;
|
|
|