update
This commit is contained in:
393
Frontend/src/features/content/pages/ServicesPage.tsx
Normal file
393
Frontend/src/features/content/pages/ServicesPage.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Search, Sparkles, ArrowRight, Tag, Award, Star } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import pageContentService, { PageContent } from '../services/pageContentService';
|
||||
import serviceService, { Service } from '../../hotel_services/services/serviceService';
|
||||
import Loading from '../../../shared/components/Loading';
|
||||
import { useFormatCurrency } from '../../payments/hooks/useFormatCurrency';
|
||||
|
||||
const ServicesPage: React.FC = () => {
|
||||
const [pageContent, setPageContent] = useState<PageContent | null>(null);
|
||||
const [hotelServices, setHotelServices] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [allCategories, setAllCategories] = useState<string[]>([]);
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch page content for luxury services
|
||||
const contentResponse = await pageContentService.getHomeContent();
|
||||
if (contentResponse.status === 'success' && contentResponse.data?.page_content) {
|
||||
const content = contentResponse.data.page_content;
|
||||
|
||||
// Handle luxury_services - can be string, array, or null/undefined
|
||||
if (typeof content.luxury_services === 'string') {
|
||||
try {
|
||||
content.luxury_services = JSON.parse(content.luxury_services);
|
||||
} catch {
|
||||
content.luxury_services = [];
|
||||
}
|
||||
} else if (!Array.isArray(content.luxury_services)) {
|
||||
content.luxury_services = content.luxury_services || [];
|
||||
}
|
||||
|
||||
setPageContent(content);
|
||||
|
||||
// Extract categories from luxury services
|
||||
const categories = new Set<string>();
|
||||
if (Array.isArray(content.luxury_services)) {
|
||||
content.luxury_services.forEach((service: any) => {
|
||||
if (service.category) {
|
||||
categories.add(service.category);
|
||||
}
|
||||
});
|
||||
}
|
||||
setAllCategories(Array.from(categories));
|
||||
}
|
||||
|
||||
// Fetch hotel services from API
|
||||
const servicesResponse = await serviceService.getServices({
|
||||
status: 'active',
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
if (servicesResponse.success && servicesResponse.data?.services) {
|
||||
setHotelServices(servicesResponse.data.services);
|
||||
|
||||
// Add categories from hotel services
|
||||
const hotelCategories = new Set(allCategories);
|
||||
servicesResponse.data.services.forEach((service: Service) => {
|
||||
// You can add category logic here if services have categories
|
||||
});
|
||||
setAllCategories(Array.from(hotelCategories));
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching services:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Combine hotel services (primary) with luxury services from page content (fallback)
|
||||
const allServices = React.useMemo(() => {
|
||||
const services: Array<{
|
||||
id: string | number;
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
icon?: string;
|
||||
price?: number;
|
||||
unit?: string;
|
||||
category?: string;
|
||||
type: 'luxury' | 'hotel';
|
||||
slug?: string;
|
||||
}> = [];
|
||||
|
||||
// Add hotel services first (primary source)
|
||||
hotelServices.forEach((service: Service) => {
|
||||
// Generate slug if not present
|
||||
const serviceSlug = service.slug || service.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
services.push({
|
||||
id: service.id,
|
||||
title: service.name,
|
||||
description: service.description || '',
|
||||
image: service.image,
|
||||
price: service.price,
|
||||
unit: service.unit,
|
||||
category: service.category || 'Services',
|
||||
type: 'hotel',
|
||||
slug: serviceSlug,
|
||||
});
|
||||
});
|
||||
|
||||
// Add luxury services from page content (only if not already in hotel services)
|
||||
if (pageContent?.luxury_services && Array.isArray(pageContent.luxury_services)) {
|
||||
pageContent.luxury_services.forEach((service: any, index: number) => {
|
||||
// Check if this service already exists in hotel services by slug
|
||||
const existingSlug = service.slug || service.title?.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
const existsInHotel = hotelServices.some((hs: Service) => {
|
||||
const hotelSlug = hs.slug || hs.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
return hotelSlug === existingSlug;
|
||||
});
|
||||
|
||||
// Only add if not already in hotel services
|
||||
if (!existsInHotel) {
|
||||
services.push({
|
||||
id: `luxury-${index}`,
|
||||
title: service.title || 'Service',
|
||||
description: service.description || '',
|
||||
image: service.image,
|
||||
icon: service.icon,
|
||||
category: service.category,
|
||||
type: 'luxury',
|
||||
slug: service.slug,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return services;
|
||||
}, [pageContent, hotelServices]);
|
||||
|
||||
// Filter services based on search and category
|
||||
const filteredServices = React.useMemo(() => {
|
||||
return allServices.filter((service) => {
|
||||
const matchesSearch =
|
||||
!searchTerm ||
|
||||
service.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
service.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesCategory = !selectedCategory || service.category === selectedCategory;
|
||||
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
}, [allServices, searchTerm, selectedCategory]);
|
||||
|
||||
if (loading && allServices.length === 0) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-full" style={{ width: '100vw', position: 'relative', left: '50%', right: '50%', marginLeft: '-50vw', marginRight: '-50vw', marginTop: '-1.5rem', marginBottom: '-1.5rem' }}>
|
||||
{/* Hero Section */}
|
||||
<div className="w-full bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a] border-b border-[#d4af37]/10 pt-6 sm:pt-7 md:pt-8 overflow-hidden relative">
|
||||
{/* Background Effects */}
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-10 left-10 w-32 sm:w-48 h-32 sm:h-48 bg-[#d4af37] rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-10 right-10 w-40 sm:w-64 h-40 sm:h-64 bg-[#c9a227] rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[#d4af37]/50 to-transparent"></div>
|
||||
|
||||
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20 py-4 sm:py-5 md:py-6 relative z-10">
|
||||
<div className="max-w-2xl mx-auto text-center px-2">
|
||||
<div className="flex justify-center mb-2 sm:mb-3 md:mb-4">
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37] via-[#f5d76e] to-[#c9a227] rounded-xl blur-lg opacity-40 group-hover:opacity-60 transition-opacity duration-500"></div>
|
||||
<div className="relative p-2 sm:p-2.5 md:p-3 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg border-2 border-[#d4af37]/40 backdrop-blur-sm shadow-xl shadow-[#d4af37]/20 group-hover:border-[#d4af37]/60 transition-all duration-300">
|
||||
<Award className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-[#d4af37] drop-shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl xs:text-3xl sm:text-4xl md:text-5xl font-serif font-semibold mb-2 sm:mb-3 tracking-tight leading-tight px-2">
|
||||
<span className="bg-gradient-to-r from-white via-[#d4af37] to-white bg-clip-text text-transparent">
|
||||
{pageContent?.luxury_services_section_title || 'Our Services'}
|
||||
</span>
|
||||
</h1>
|
||||
<div className="w-12 sm:w-16 md:w-20 h-0.5 bg-gradient-to-r from-transparent via-[#d4af37] to-transparent mx-auto mb-2 sm:mb-3"></div>
|
||||
<p className="text-sm sm:text-base md:text-lg text-gray-300 font-light leading-relaxed max-w-xl mx-auto tracking-wide px-2 sm:px-4">
|
||||
{pageContent?.luxury_services_section_subtitle || 'Discover our premium services designed to enhance your stay'}
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-center gap-2 text-[#d4af37]/60">
|
||||
<Sparkles className="w-4 h-4 animate-pulse" />
|
||||
<span className="text-xs sm:text-sm font-light tracking-wider uppercase">Premium Services</span>
|
||||
<Sparkles className="w-4 h-4 animate-pulse" style={{ animationDelay: '0.5s' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[#d4af37]/30 to-transparent"></div>
|
||||
</div>
|
||||
|
||||
{/* Main Content - Full Width */}
|
||||
<div className="w-full py-12 sm:py-16 lg:py-20">
|
||||
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20">
|
||||
{/* Search Section */}
|
||||
<div className="mb-12 sm:mb-16">
|
||||
<div className="flex flex-col lg:flex-row gap-6 mb-8">
|
||||
<div className="flex-1 relative group">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/10 to-transparent rounded-xl blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-5 top-1/2 transform -translate-y-1/2 text-[#d4af37] w-5 h-5 z-10" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search services..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-14 pr-5 py-4 bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] border border-[#d4af37]/20 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37]/50 transition-all duration-300 backdrop-blur-sm font-light"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories Filter - Top Center */}
|
||||
{allCategories.length > 0 && (
|
||||
<div className="mb-12 flex justify-center">
|
||||
<div className="inline-flex flex-wrap items-center gap-3 bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#0a0a0a] rounded-2xl border-2 border-[#d4af37]/20 p-4 backdrop-blur-xl shadow-2xl">
|
||||
<button
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
className={`group relative px-6 py-3 rounded-xl text-sm font-medium transition-all duration-300 overflow-hidden ${
|
||||
selectedCategory === null
|
||||
? 'bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] shadow-lg shadow-[#d4af37]/40'
|
||||
: 'bg-gradient-to-br from-[#0f0f0f] to-[#0a0a0a] text-gray-300 border-2 border-[#d4af37]/20 hover:border-[#d4af37]/50 hover:text-[#d4af37]'
|
||||
}`}
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-2">
|
||||
<Tag className="w-4 h-4" />
|
||||
All Services
|
||||
</span>
|
||||
</button>
|
||||
{allCategories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={`group relative px-6 py-3 rounded-xl text-sm font-medium transition-all duration-300 overflow-hidden ${
|
||||
selectedCategory === category
|
||||
? 'bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] shadow-lg shadow-[#d4af37]/40'
|
||||
: 'bg-gradient-to-br from-[#0f0f0f] to-[#0a0a0a] text-gray-300 border-2 border-[#d4af37]/20 hover:border-[#d4af37]/50 hover:text-[#d4af37]'
|
||||
}`}
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-2">
|
||||
<Tag className="w-4 h-4" />
|
||||
{category}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Services Section - Centered */}
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Results Count */}
|
||||
{!loading && filteredServices.length > 0 && (
|
||||
<div className="mb-8 text-gray-400 font-light text-sm text-center">
|
||||
Showing {filteredServices.length} of {allServices.length} services
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Services Grid - Luxury Design - Centered */}
|
||||
{filteredServices.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-[#d4af37]/10 mb-6">
|
||||
<Award className="w-10 h-10 text-[#d4af37]" />
|
||||
</div>
|
||||
<p className="text-gray-400 text-xl font-light">No services found</p>
|
||||
<p className="text-gray-500 text-sm mt-2">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 lg:gap-10 mb-12 max-w-7xl w-full">
|
||||
{filteredServices.map((service, index) => {
|
||||
// Use the slug from the service object, or generate a fallback from title
|
||||
const serviceSlug = service.slug || service.title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
|
||||
if (!serviceSlug) {
|
||||
console.warn('Service missing slug:', service);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={service.id}
|
||||
to={`/services/${serviceSlug}`}
|
||||
className="group relative bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#0a0a0a] rounded-3xl border-2 border-[#d4af37]/20 overflow-hidden hover:border-[#d4af37]/60 transition-all duration-700 hover:shadow-2xl hover:shadow-[#d4af37]/30 hover:-translate-y-3 block"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
{/* Premium Glow Effects */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37]/0 via-[#d4af37]/0 to-[#d4af37]/0 group-hover:from-[#d4af37]/10 group-hover:via-[#d4af37]/5 group-hover:to-[#d4af37]/10 transition-all duration-700 rounded-3xl blur-2xl opacity-0 group-hover:opacity-100"></div>
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-[#d4af37]/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10">
|
||||
{service.image ? (
|
||||
<div className="relative h-72 sm:h-80 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent z-10"></div>
|
||||
<img
|
||||
src={service.image}
|
||||
alt={service.title}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-1000 ease-out"
|
||||
/>
|
||||
{/* Premium Badge Overlay */}
|
||||
{service.type === 'luxury' && (
|
||||
<div className="absolute top-6 left-6 z-20">
|
||||
<div className="bg-gradient-to-br from-[#d4af37]/20 to-[#c9a227]/10 backdrop-blur-md rounded-2xl px-4 py-2 border border-[#d4af37]/40 shadow-xl">
|
||||
<div className="flex items-center gap-2 text-[#d4af37]">
|
||||
<Star className="w-4 h-4" />
|
||||
<span className="text-sm font-semibold">Premium</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Luxury Corner Accent */}
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-[#d4af37]/20 to-transparent rounded-bl-full opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-48 sm:h-56 bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] flex items-center justify-center p-8">
|
||||
{service.icon && (LucideIcons as any)[service.icon] ? (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-[#d4af37]/20 rounded-full blur-2xl"></div>
|
||||
{React.createElement((LucideIcons as any)[service.icon], {
|
||||
className: 'w-16 h-16 sm:w-20 sm:h-20 text-[#d4af37] relative z-10 drop-shadow-lg'
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-[#d4af37]/20 rounded-full blur-2xl"></div>
|
||||
<Award className="w-16 h-16 sm:w-20 sm:h-20 text-[#d4af37] relative z-10 drop-shadow-lg" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-8">
|
||||
{service.category && (
|
||||
<div className="flex flex-wrap gap-2 mb-5">
|
||||
<span className="inline-flex items-center gap-1.5 px-4 py-1.5 bg-gradient-to-br from-[#d4af37]/15 to-[#d4af37]/5 text-[#d4af37] rounded-full text-xs font-semibold border border-[#d4af37]/30 backdrop-blur-sm shadow-lg">
|
||||
<Tag className="w-3 h-3" />
|
||||
{service.category}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-2xl sm:text-3xl font-serif font-bold text-white mb-4 group-hover:text-[#d4af37] transition-colors duration-500 line-clamp-2 leading-tight tracking-tight">
|
||||
{service.title}
|
||||
</h2>
|
||||
{service.description && (
|
||||
<p className="text-gray-300 text-base mb-6 line-clamp-3 font-light leading-relaxed">
|
||||
{service.description}
|
||||
</p>
|
||||
)}
|
||||
{service.price !== undefined && (
|
||||
<div className="mb-6 pb-6 border-b border-[#d4af37]/20">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl sm:text-3xl font-serif font-semibold bg-gradient-to-r from-[#d4af37] to-[#f5d76e] bg-clip-text text-transparent">
|
||||
{formatCurrency(service.price)}
|
||||
</span>
|
||||
{service.unit && (
|
||||
<span className="text-sm text-gray-400 font-light">
|
||||
/ {service.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-[#d4af37]/10">
|
||||
<div className="flex items-center gap-3 text-[#d4af37] group-hover:gap-4 transition-all duration-300">
|
||||
<span className="text-sm font-semibold tracking-wide uppercase">Learn More</span>
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-2 transition-transform duration-300" />
|
||||
</div>
|
||||
<div className="w-12 h-0.5 bg-gradient-to-r from-[#d4af37] to-transparent group-hover:w-20 transition-all duration-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServicesPage;
|
||||
|
||||
Reference in New Issue
Block a user