Files
Hotel-Booking/Frontend/src/features/content/pages/ServicesPage.tsx
Iliyan Angelov e43a95eafb updates
2025-12-09 00:14:21 +02:00

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;