updates
This commit is contained in:
@@ -58,6 +58,8 @@ const ResetPasswordPage = lazy(() => import('./pages/auth/ResetPasswordPage'));
|
||||
|
||||
// Lazy load admin pages
|
||||
const AdminDashboardPage = lazy(() => import('./pages/admin/DashboardPage'));
|
||||
const InvoiceManagementPage = lazy(() => import('./pages/admin/InvoiceManagementPage'));
|
||||
const PaymentManagementPage = lazy(() => import('./pages/admin/PaymentManagementPage'));
|
||||
const UserManagementPage = lazy(() => import('./pages/admin/UserManagementPage'));
|
||||
const PageContentDashboardPage = lazy(() => import('./pages/admin/PageContentDashboard'));
|
||||
const AnalyticsDashboardPage = lazy(() => import('./pages/admin/AnalyticsDashboardPage'));
|
||||
@@ -321,6 +323,18 @@ function App() {
|
||||
path="settings"
|
||||
element={<SettingsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="invoices"
|
||||
element={<InvoiceManagementPage />}
|
||||
/>
|
||||
<Route
|
||||
path="invoices/:id"
|
||||
element={<InvoicePage />}
|
||||
/>
|
||||
<Route
|
||||
path="payments"
|
||||
element={<PaymentManagementPage />}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{/* 404 Route */}
|
||||
|
||||
225
Frontend/src/components/admin/IconPicker.tsx
Normal file
225
Frontend/src/components/admin/IconPicker.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
|
||||
// Popular icons for hotel/luxury content
|
||||
const popularIcons = [
|
||||
'Sparkles', 'Star', 'Award', 'Shield', 'Heart', 'Crown', 'Gem',
|
||||
'Zap', 'Wifi', 'Coffee', 'Utensils', 'Bed', 'Home', 'MapPin',
|
||||
'Phone', 'Mail', 'Calendar', 'Clock', 'Users', 'UserCheck',
|
||||
'Car', 'Plane', 'Ship', 'Train', 'Bike', 'Umbrella', 'Sun',
|
||||
'Moon', 'Cloud', 'Droplet', 'Flame', 'TreePine', 'Mountain',
|
||||
'Palette', 'Music', 'Camera', 'Video', 'Gamepad2', 'Book',
|
||||
'Briefcase', 'ShoppingBag', 'Gift', 'Trophy', 'Medal', 'Ribbon',
|
||||
'CheckCircle', 'XCircle', 'AlertCircle', 'Info', 'HelpCircle',
|
||||
'Lock', 'Key', 'Eye', 'EyeOff', 'Bell', 'Settings', 'Menu',
|
||||
'Grid', 'List', 'Layout', 'Maximize', 'Minimize', 'ArrowRight',
|
||||
'ArrowLeft', 'ArrowUp', 'ArrowDown', 'ChevronRight', 'ChevronLeft',
|
||||
'ChevronUp', 'ChevronDown', 'Plus', 'Minus', 'X', 'Check',
|
||||
'Trash2', 'Edit', 'Save', 'Download', 'Upload', 'Share',
|
||||
'Copy', 'Scissors', 'FileText', 'Image', 'Film', 'Headphones',
|
||||
'Mic', 'Radio', 'Tv', 'Monitor', 'Laptop', 'Smartphone',
|
||||
'Tablet', 'Watch', 'Printer', 'HardDrive', 'Database', 'Server',
|
||||
'Cloud', 'Globe', 'Compass', 'Navigation', 'Map', 'Route',
|
||||
'Building', 'Building2', 'Hotel', 'Home', 'Store', 'ShoppingCart',
|
||||
'CreditCard', 'DollarSign', 'Euro', 'PoundSterling', 'Yen',
|
||||
'Bitcoin', 'TrendingUp', 'TrendingDown', 'BarChart', 'LineChart',
|
||||
'PieChart', 'Activity', 'Target', 'Flag', 'Tag', 'Bookmark',
|
||||
'Folder', 'File', 'Archive', 'Inbox', 'Send', 'Inbox',
|
||||
'MessageSquare', 'MessageCircle', 'MessageCircleMore', 'PhoneCall',
|
||||
'Video', 'Voicemail', 'AtSign', 'Hash', 'Link', 'ExternalLink',
|
||||
'Unlink', 'Code', 'Terminal', 'Command', 'Slash', 'Brackets',
|
||||
'Braces', 'Parentheses', 'Percent', 'Infinity', 'Pi', 'Sigma',
|
||||
'Omega', 'Alpha', 'Beta', 'Gamma', 'Delta', 'Theta', 'Lambda',
|
||||
'Mu', 'Nu', 'Xi', 'Omicron', 'Rho', 'Tau', 'Upsilon', 'Phi',
|
||||
'Chi', 'Psi'
|
||||
];
|
||||
|
||||
interface IconPickerProps {
|
||||
value?: string;
|
||||
onChange: (iconName: string) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const IconPicker: React.FC<IconPickerProps> = ({ value, onChange, label = 'Icon' }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Get all available Lucide icons
|
||||
const allIcons = useMemo(() => {
|
||||
const icons: string[] = [];
|
||||
const excludedNames = new Set([
|
||||
'createLucideIcon',
|
||||
'Icon',
|
||||
'default',
|
||||
'lucideReact',
|
||||
'lucide',
|
||||
'createElement',
|
||||
'Fragment',
|
||||
'forwardRef',
|
||||
'memo'
|
||||
]);
|
||||
|
||||
for (const iconName in LucideIcons) {
|
||||
// Skip non-icon exports
|
||||
if (
|
||||
excludedNames.has(iconName) ||
|
||||
iconName.startsWith('_') ||
|
||||
iconName[0] !== iconName[0].toUpperCase() // Lucide icons start with uppercase
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const iconComponent = (LucideIcons as any)[iconName];
|
||||
// Check if it's a React component (function)
|
||||
if (typeof iconComponent === 'function') {
|
||||
icons.push(iconName);
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = icons.sort();
|
||||
return sorted;
|
||||
}, []);
|
||||
|
||||
// Filter icons based on search
|
||||
const filteredIcons = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
// Show popular icons first, then others
|
||||
const popular = popularIcons.filter(icon => allIcons.includes(icon));
|
||||
const others = allIcons.filter(icon => !popularIcons.includes(icon));
|
||||
return [...popular, ...others];
|
||||
}
|
||||
return allIcons.filter((icon) =>
|
||||
icon.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}, [searchQuery, allIcons]);
|
||||
|
||||
const selectedIcon = value && (LucideIcons as any)[value] ? (LucideIcons as any)[value] : null;
|
||||
|
||||
const handleIconSelect = (iconName: string) => {
|
||||
onChange(iconName);
|
||||
setIsOpen(false);
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">{label}</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg bg-white hover:border-purple-400 focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{selectedIcon ? (
|
||||
<>
|
||||
{React.createElement(selectedIcon, { className: 'w-5 h-5 text-gray-700' })}
|
||||
<span className="text-gray-700 font-medium">{value}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-400">Select an icon</span>
|
||||
)}
|
||||
</div>
|
||||
<Search className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[9998]"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<div className="absolute z-[9999] mt-2 w-full bg-white border-2 border-gray-200 rounded-xl shadow-2xl max-h-96 overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-gray-200 sticky top-0 bg-white z-10">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search icons..."
|
||||
className="w-full pl-10 pr-4 py-2 border-2 border-gray-200 rounded-lg focus:border-purple-400 focus:ring-4 focus:ring-purple-100 outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto p-4">
|
||||
{filteredIcons.length > 0 ? (
|
||||
<>
|
||||
{!searchQuery.trim() && (
|
||||
<div className="mb-3">
|
||||
<p className="text-sm text-gray-600">
|
||||
Showing {filteredIcons.length} icons. Popular icons appear first.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-6 sm:grid-cols-8 md:grid-cols-10 gap-2">
|
||||
{filteredIcons.slice(0, searchQuery.trim() ? 500 : 300).map((iconName) => {
|
||||
const IconComponent = (LucideIcons as any)[iconName];
|
||||
if (!IconComponent) return null;
|
||||
|
||||
const isSelected = value === iconName;
|
||||
const isPopular = !searchQuery.trim() && popularIcons.includes(iconName);
|
||||
|
||||
try {
|
||||
return (
|
||||
<button
|
||||
key={iconName}
|
||||
type="button"
|
||||
onClick={() => handleIconSelect(iconName)}
|
||||
className={`
|
||||
p-3 rounded-lg border-2 transition-all duration-200
|
||||
flex items-center justify-center relative
|
||||
${
|
||||
isSelected
|
||||
? 'border-purple-500 bg-purple-50 text-purple-600'
|
||||
: isPopular
|
||||
? 'border-amber-200 hover:border-amber-300 hover:bg-amber-50 text-gray-700'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:bg-purple-50 text-gray-700'
|
||||
}
|
||||
`}
|
||||
title={iconName}
|
||||
>
|
||||
{React.createElement(IconComponent, { className: 'w-5 h-5' })}
|
||||
{isPopular && !isSelected && (
|
||||
<span className="absolute top-0 right-0 w-2 h-2 bg-amber-400 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to render icon: ${iconName}`, error);
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
{filteredIcons.length > (searchQuery.trim() ? 500 : 300) && (
|
||||
<div className="text-center mt-4 text-sm text-gray-500">
|
||||
Showing first {searchQuery.trim() ? 500 : 300} of {filteredIcons.length} icons. {!searchQuery.trim() && 'Use search to find more.'}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>No icons found matching "{searchQuery}"</p>
|
||||
<p className="text-xs mt-2">Try a different search term</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconPicker;
|
||||
|
||||
@@ -37,7 +37,7 @@ const Footer: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const fetchPageContent = async () => {
|
||||
try {
|
||||
const response = await pageContentService.getPageContent('footer');
|
||||
const response = await pageContentService.getFooterContent();
|
||||
if (response.status === 'success' && response.data?.page_content) {
|
||||
setPageContent(response.data.page_content);
|
||||
}
|
||||
@@ -344,7 +344,12 @@ const Footer: React.FC = () => {
|
||||
{/* Copyright - Enhanced */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
|
||||
<div className="text-sm text-gray-500 font-light tracking-wide">
|
||||
© {new Date().getFullYear()} Luxury Hotel. All rights reserved.
|
||||
{(() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const copyrightText = pageContent?.copyright_text || '© {YEAR} Luxury Hotel. All rights reserved.';
|
||||
// Replace {YEAR} placeholder with current year
|
||||
return copyrightText.replace(/{YEAR}/g, currentYear.toString());
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex items-center space-x-6 text-xs text-gray-600">
|
||||
<span className="hover:text-[#d4af37]/80 transition-colors cursor-pointer font-light tracking-wide">Privacy</span>
|
||||
|
||||
@@ -82,11 +82,11 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
||||
index === currentIndex ? 'opacity-100 z-0 pointer-events-auto' : 'opacity-0 z-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
{banner.link ? (
|
||||
{banner.link_url ? (
|
||||
<a
|
||||
href={banner.link}
|
||||
target={banner.link.startsWith('http') ? '_blank' : '_self'}
|
||||
rel={banner.link.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
href={banner.link_url}
|
||||
target={banner.link_url.startsWith('http') ? '_blank' : '_self'}
|
||||
rel={banner.link_url.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
className="block w-full h-full"
|
||||
>
|
||||
<img
|
||||
@@ -128,11 +128,11 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Title - Positioned at top when search form is present */}
|
||||
{/* Title - Positioned higher up on the banner */}
|
||||
{currentBanner.title && (
|
||||
<div
|
||||
key={currentIndex}
|
||||
className={`absolute ${children ? 'top-12 sm:top-16 md:top-20 lg:top-24' : 'bottom-16 sm:bottom-20 md:bottom-24'}
|
||||
className={`absolute ${children ? 'top-[25%] sm:top-[28%] md:top-[30%] lg:top-[32%]' : 'top-[30%] sm:top-[35%] md:top-[38%] lg:top-[40%]'}
|
||||
left-1/2 -translate-x-1/2
|
||||
text-white z-10 flex flex-col items-center justify-center
|
||||
w-full max-w-5xl px-4 sm:px-6 md:px-8 lg:px-12
|
||||
@@ -194,12 +194,32 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
{/* Animated decorative line below title */}
|
||||
{/* Description text - centered below title */}
|
||||
{currentBanner.description && (
|
||||
<p
|
||||
className="text-sm sm:text-base md:text-lg lg:text-xl xl:text-2xl
|
||||
font-light text-white/95 text-center
|
||||
max-w-3xl mx-auto
|
||||
leading-relaxed sm:leading-relaxed md:leading-relaxed
|
||||
drop-shadow-[0_2px_6px_rgba(0,0,0,0.5)]
|
||||
[text-shadow:_0_1px_10px_rgba(0,0,0,0.7)]
|
||||
mt-2 sm:mt-3 md:mt-4
|
||||
px-2 sm:px-4 md:px-6
|
||||
opacity-0 animate-textReveal"
|
||||
style={{
|
||||
animation: 'textReveal 1s cubic-bezier(0.4, 0, 0.2, 1) 0.7s forwards',
|
||||
}}
|
||||
>
|
||||
{currentBanner.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Animated decorative line below title/description */}
|
||||
<div
|
||||
className="w-0 h-0.5 bg-gradient-to-r from-transparent via-[#d4af37] to-transparent mt-3 sm:mt-4 opacity-90
|
||||
animate-lineExpand"
|
||||
style={{
|
||||
animation: 'lineExpand 1.2s cubic-bezier(0.4, 0, 0.2, 1) 0.6s forwards',
|
||||
animation: 'lineExpand 1.2s cubic-bezier(0.4, 0, 0.2, 1) 0.9s forwards',
|
||||
maxWidth: '120px',
|
||||
}}
|
||||
/>
|
||||
|
||||
65
Frontend/src/data/luxuryContentSeed.ts
Normal file
65
Frontend/src/data/luxuryContentSeed.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// Seed data for luxury hotel content
|
||||
export const luxuryContentSeed = {
|
||||
home: {
|
||||
luxury_section_title: 'Experience Unparalleled Luxury',
|
||||
luxury_section_subtitle: 'Where elegance meets comfort in every detail',
|
||||
luxury_section_image: '',
|
||||
luxury_features: [
|
||||
{
|
||||
icon: 'Sparkles',
|
||||
title: 'Premium Amenities',
|
||||
description: 'World-class facilities designed for your comfort and relaxation'
|
||||
},
|
||||
{
|
||||
icon: 'Crown',
|
||||
title: 'Royal Service',
|
||||
description: 'Dedicated concierge service available 24/7 for all your needs'
|
||||
},
|
||||
{
|
||||
icon: 'Award',
|
||||
title: 'Award-Winning',
|
||||
description: 'Recognized for excellence in hospitality and guest satisfaction'
|
||||
},
|
||||
{
|
||||
icon: 'Shield',
|
||||
title: 'Secure & Private',
|
||||
description: 'Your privacy and security are our top priorities'
|
||||
},
|
||||
{
|
||||
icon: 'Heart',
|
||||
title: 'Personalized Care',
|
||||
description: 'Tailored experiences crafted just for you'
|
||||
},
|
||||
{
|
||||
icon: 'Gem',
|
||||
title: 'Luxury Design',
|
||||
description: 'Elegantly designed spaces with attention to every detail'
|
||||
}
|
||||
],
|
||||
luxury_gallery: [],
|
||||
luxury_testimonials: [
|
||||
{
|
||||
name: 'Sarah Johnson',
|
||||
title: 'Business Executive',
|
||||
quote: 'An absolutely stunning experience. The attention to detail and level of service exceeded all expectations.',
|
||||
image: ''
|
||||
},
|
||||
{
|
||||
name: 'Michael Chen',
|
||||
title: 'Travel Enthusiast',
|
||||
quote: 'The epitome of luxury. Every moment was perfect, from check-in to check-out.',
|
||||
image: ''
|
||||
},
|
||||
{
|
||||
name: 'Emma Williams',
|
||||
title: 'Luxury Traveler',
|
||||
quote: 'This hotel redefines what luxury means. I will definitely return.',
|
||||
image: ''
|
||||
}
|
||||
],
|
||||
about_preview_title: 'About Our Luxury Hotel',
|
||||
about_preview_content: 'Discover a world of refined elegance and exceptional service. Our hotel combines timeless luxury with modern amenities to create an unforgettable experience.',
|
||||
about_preview_image: ''
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Hotel,
|
||||
Award,
|
||||
Users,
|
||||
Heart,
|
||||
MapPin,
|
||||
Phone,
|
||||
Mail,
|
||||
Star,
|
||||
Shield,
|
||||
Clock
|
||||
Linkedin,
|
||||
Twitter
|
||||
} from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { pageContentService } from '../services/api';
|
||||
import type { PageContent } from '../services/api/pageContentService';
|
||||
@@ -23,7 +21,7 @@ const AboutPage: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const fetchPageContent = async () => {
|
||||
try {
|
||||
const response = await pageContentService.getPageContent('about');
|
||||
const response = await pageContentService.getAboutContent();
|
||||
if (response.status === 'success' && response.data?.page_content) {
|
||||
setPageContent(response.data.page_content);
|
||||
|
||||
@@ -58,22 +56,22 @@ const AboutPage: React.FC = () => {
|
||||
// Default values
|
||||
const defaultValues = [
|
||||
{
|
||||
icon: Heart,
|
||||
icon: 'Heart',
|
||||
title: 'Passion',
|
||||
description: 'We are passionate about hospitality and dedicated to creating exceptional experiences for every guest.'
|
||||
},
|
||||
{
|
||||
icon: Award,
|
||||
icon: 'Award',
|
||||
title: 'Excellence',
|
||||
description: 'We strive for excellence in every aspect of our service, from the smallest detail to the grandest gesture.'
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
icon: 'Shield',
|
||||
title: 'Integrity',
|
||||
description: 'We conduct our business with honesty, transparency, and respect for our guests and community.'
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
icon: 'Users',
|
||||
title: 'Service',
|
||||
description: 'Our guests are at the heart of everything we do. Your comfort and satisfaction are our top priorities.'
|
||||
}
|
||||
@@ -81,17 +79,17 @@ const AboutPage: React.FC = () => {
|
||||
|
||||
const defaultFeatures = [
|
||||
{
|
||||
icon: Star,
|
||||
icon: 'Star',
|
||||
title: 'Premium Accommodations',
|
||||
description: 'Luxuriously appointed rooms and suites designed for ultimate comfort and relaxation.'
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
icon: 'Clock',
|
||||
title: '24/7 Service',
|
||||
description: 'Round-the-clock concierge and room service to attend to your needs at any time.'
|
||||
},
|
||||
{
|
||||
icon: Award,
|
||||
icon: 'Award',
|
||||
title: 'Award-Winning',
|
||||
description: 'Recognized for excellence in hospitality and guest satisfaction.'
|
||||
}
|
||||
@@ -99,7 +97,7 @@ const AboutPage: React.FC = () => {
|
||||
|
||||
const values = pageContent?.values && pageContent.values.length > 0
|
||||
? pageContent.values.map((v: any) => ({
|
||||
icon: defaultValues.find(d => d.title === v.title)?.icon || Heart,
|
||||
icon: v.icon || defaultValues.find(d => d.title === v.title)?.icon || 'Heart',
|
||||
title: v.title,
|
||||
description: v.description
|
||||
}))
|
||||
@@ -107,60 +105,122 @@ const AboutPage: React.FC = () => {
|
||||
|
||||
const features = pageContent?.features && pageContent.features.length > 0
|
||||
? pageContent.features.map((f: any) => ({
|
||||
icon: defaultFeatures.find(d => d.title === f.title)?.icon || Star,
|
||||
icon: f.icon || defaultFeatures.find(d => d.title === f.title)?.icon || 'Star',
|
||||
title: f.title,
|
||||
description: f.description
|
||||
}))
|
||||
: defaultFeatures;
|
||||
|
||||
// Parse JSON fields
|
||||
const team = pageContent?.team && typeof pageContent.team === 'string'
|
||||
? JSON.parse(pageContent.team)
|
||||
: (Array.isArray(pageContent?.team) ? pageContent.team : []);
|
||||
const timeline = pageContent?.timeline && typeof pageContent.timeline === 'string'
|
||||
? JSON.parse(pageContent.timeline)
|
||||
: (Array.isArray(pageContent?.timeline) ? pageContent.timeline : []);
|
||||
const achievements = pageContent?.achievements && typeof pageContent.achievements === 'string'
|
||||
? JSON.parse(pageContent.achievements)
|
||||
: (Array.isArray(pageContent?.achievements) ? pageContent.achievements : []);
|
||||
|
||||
// Helper to get icon component
|
||||
const getIconComponent = (iconName?: string) => {
|
||||
if (!iconName) return Heart;
|
||||
const IconComponent = (LucideIcons as any)[iconName] || Heart;
|
||||
return IconComponent;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-white">
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 via-white to-slate-50">
|
||||
{/* Hero Section */}
|
||||
<div className="relative bg-gradient-to-b from-[#1a1a1a] to-[#0f0f0f] text-white py-20">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-3xl mx-auto text-center animate-fade-in">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37] to-[#f5d76e] rounded-full blur-2xl opacity-30"></div>
|
||||
<Hotel className="relative w-20 h-20 text-[#d4af37] drop-shadow-lg" />
|
||||
<div className={`relative ${pageContent?.about_hero_image ? '' : 'bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900'} text-white py-20 md:py-24 ${pageContent?.about_hero_image ? 'h-[400px] md:h-[450px] lg:h-[500px]' : ''} overflow-hidden`}>
|
||||
{pageContent?.about_hero_image && (
|
||||
<div className="absolute inset-0">
|
||||
<img
|
||||
src={pageContent.about_hero_image.startsWith('http') ? pageContent.about_hero_image : `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}${pageContent.about_hero_image}`}
|
||||
alt="About Hero"
|
||||
className="w-full h-full object-cover scale-105 transition-transform duration-[20s] ease-out hover:scale-100"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/50 to-black/70"></div>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/10 via-transparent to-[#d4af37]/10"></div>
|
||||
</div>
|
||||
)}
|
||||
{!pageContent?.about_hero_image && (
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(212,175,55,0.1),transparent_70%)]"></div>
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-[linear-gradient(45deg,transparent_30%,rgba(212,175,55,0.05)_50%,transparent_70%)]"></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
{!pageContent?.about_hero_image && (
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37] via-[#f5d76e] to-[#d4af37] rounded-full blur-3xl opacity-40 animate-pulse"></div>
|
||||
<div className="relative bg-gradient-to-br from-[#d4af37] to-[#c9a227] p-6 rounded-2xl shadow-2xl shadow-[#d4af37]/30">
|
||||
<Hotel className="w-12 h-12 md:w-16 md:h-16 text-white drop-shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-6">
|
||||
<div className="inline-block mb-4">
|
||||
<div className="h-px w-20 bg-gradient-to-r from-transparent via-[#d4af37] to-transparent mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-5xl md:text-6xl font-serif font-bold mb-6 tracking-tight">
|
||||
{pageContent?.title || 'About Luxury Hotel'}
|
||||
<h1 className="text-5xl md:text-6xl lg:text-7xl font-serif font-light mb-6 tracking-[0.02em] leading-tight">
|
||||
<span className="bg-gradient-to-b from-white via-[#f5d76e] to-[#d4af37] bg-clip-text text-transparent drop-shadow-2xl">
|
||||
{pageContent?.title || 'About Luxury Hotel'}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xl text-gray-300 font-light leading-relaxed">
|
||||
<p className="text-lg md:text-xl lg:text-2xl text-gray-200 font-light leading-relaxed max-w-2xl mx-auto tracking-wide">
|
||||
{pageContent?.subtitle || pageContent?.description || 'Where Excellence Meets Unforgettable Experiences'}
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<div className="inline-block">
|
||||
<div className="h-px w-20 bg-gradient-to-r from-transparent via-[#d4af37] to-transparent mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Our Story Section */}
|
||||
<section className="py-16 bg-white">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-12 animate-fade-in">
|
||||
<h2 className="text-4xl font-serif font-bold text-gray-900 mb-4">
|
||||
<section className="py-20 md:py-28 bg-white relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[#d4af37]/30 to-transparent"></div>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-block mb-4">
|
||||
<span className="text-sm font-semibold text-[#d4af37] tracking-[0.2em] uppercase">Our Heritage</span>
|
||||
</div>
|
||||
<h2 className="text-4xl md:text-5xl lg:text-6xl font-serif font-light text-gray-900 mb-6 tracking-tight">
|
||||
Our Story
|
||||
</h2>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
|
||||
<div className="flex items-center justify-center gap-4 mb-6">
|
||||
<div className="h-px w-12 bg-gradient-to-r from-transparent to-[#d4af37]"></div>
|
||||
<div className="w-2 h-2 bg-[#d4af37] rounded-full"></div>
|
||||
<div className="h-px w-12 bg-gradient-to-l from-transparent to-[#d4af37]"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="prose prose-lg max-w-none text-gray-700 leading-relaxed space-y-6 animate-slide-up">
|
||||
<div className="prose prose-lg md:prose-xl max-w-none text-gray-700 leading-relaxed space-y-8">
|
||||
{pageContent?.story_content ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: pageContent.story_content.replace(/\n/g, '<br />') }} />
|
||||
<div
|
||||
className="text-lg md:text-xl leading-relaxed font-light tracking-wide"
|
||||
dangerouslySetInnerHTML={{ __html: pageContent.story_content.replace(/\n/g, '<br />') }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
<p className="text-lg md:text-xl leading-relaxed font-light tracking-wide first-letter:text-5xl first-letter:font-serif first-letter:text-[#d4af37] first-letter:float-left first-letter:mr-2 first-letter:leading-none">
|
||||
Welcome to Luxury Hotel, where timeless elegance meets modern sophistication.
|
||||
Since our founding, we have been dedicated to providing exceptional hospitality
|
||||
and creating unforgettable memories for our guests.
|
||||
</p>
|
||||
<p>
|
||||
<p className="text-lg md:text-xl leading-relaxed font-light tracking-wide">
|
||||
Nestled in the heart of the city, our hotel combines classic architecture with
|
||||
contemporary amenities, offering a perfect blend of comfort and luxury. Every
|
||||
detail has been carefully curated to ensure your stay exceeds expectations.
|
||||
</p>
|
||||
<p>
|
||||
<p className="text-lg md:text-xl leading-relaxed font-light tracking-wide">
|
||||
Our commitment to excellence extends beyond our beautiful rooms and facilities.
|
||||
We believe in creating meaningful connections with our guests, understanding
|
||||
their needs, and delivering personalized service that makes each visit special.
|
||||
@@ -170,34 +230,49 @@ const AboutPage: React.FC = () => {
|
||||
</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>
|
||||
</section>
|
||||
|
||||
{/* Values Section */}
|
||||
<section className="py-16 bg-gray-50">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-12 animate-fade-in">
|
||||
<h2 className="text-4xl font-serif font-bold text-gray-900 mb-4">
|
||||
<section className="py-20 md:py-28 bg-gradient-to-b from-slate-50 via-white to-slate-50 relative">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_50%,rgba(212,175,55,0.03),transparent_50%)]"></div>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-block mb-4">
|
||||
<span className="text-sm font-semibold text-[#d4af37] tracking-[0.2em] uppercase">Core Principles</span>
|
||||
</div>
|
||||
<h2 className="text-4xl md:text-5xl lg:text-6xl font-serif font-light text-gray-900 mb-6 tracking-tight">
|
||||
Our Values
|
||||
</h2>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
|
||||
<div className="flex items-center justify-center gap-4 mb-6">
|
||||
<div className="h-px w-12 bg-gradient-to-r from-transparent to-[#d4af37]"></div>
|
||||
<div className="w-2 h-2 bg-[#d4af37] rounded-full"></div>
|
||||
<div className="h-px w-12 bg-gradient-to-l from-transparent to-[#d4af37]"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8">
|
||||
{values.map((value, index) => (
|
||||
<div
|
||||
key={value.title}
|
||||
className="bg-white p-6 rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 animate-slide-up"
|
||||
className="group relative bg-white/80 backdrop-blur-sm p-8 rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-500 border border-gray-100 hover:border-[#d4af37]/30 hover:-translate-y-2"
|
||||
style={{ animationDelay: `${index * 0.1}s` }}
|
||||
>
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-lg flex items-center justify-center mb-4">
|
||||
<value.icon className="w-6 h-6 text-white" />
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-[#d4af37]/5 to-transparent rounded-bl-full opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-[#d4af37] via-[#f5d76e] to-[#d4af37] rounded-xl flex items-center justify-center mb-6 shadow-lg shadow-[#d4af37]/20 group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500">
|
||||
{(() => {
|
||||
const ValueIcon = getIconComponent(value.icon);
|
||||
return <ValueIcon className="w-8 h-8 text-white drop-shadow-md" />;
|
||||
})()}
|
||||
</div>
|
||||
<h3 className="text-xl md:text-2xl font-serif font-semibold text-gray-900 mb-3 group-hover:text-[#d4af37] transition-colors duration-300">
|
||||
{value.title}
|
||||
</h3>
|
||||
<p className="text-gray-600 leading-relaxed font-light text-sm md:text-base">
|
||||
{value.description}
|
||||
</p>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
{value.title}
|
||||
</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
{value.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -206,60 +281,303 @@ const AboutPage: React.FC = () => {
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="py-16 bg-white">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-12 animate-fade-in">
|
||||
<h2 className="text-4xl font-serif font-bold text-gray-900 mb-4">
|
||||
<section className="py-20 md:py-28 bg-white relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(135deg,transparent_0%,rgba(212,175,55,0.02)_50%,transparent_100%)]"></div>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-block mb-4">
|
||||
<span className="text-sm font-semibold text-[#d4af37] tracking-[0.2em] uppercase">Excellence Defined</span>
|
||||
</div>
|
||||
<h2 className="text-4xl md:text-5xl lg:text-6xl font-serif font-light text-gray-900 mb-6 tracking-tight">
|
||||
Why Choose Us
|
||||
</h2>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
|
||||
<div className="flex items-center justify-center gap-4 mb-6">
|
||||
<div className="h-px w-12 bg-gradient-to-r from-transparent to-[#d4af37]"></div>
|
||||
<div className="w-2 h-2 bg-[#d4af37] rounded-full"></div>
|
||||
<div className="h-px w-12 bg-gradient-to-l from-transparent to-[#d4af37]"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{features.map((feature, index) => (
|
||||
<div
|
||||
key={feature.title}
|
||||
className="text-center p-6 animate-slide-up"
|
||||
style={{ animationDelay: `${index * 0.1}s` }}
|
||||
>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<feature.icon className="w-8 h-8 text-white" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 lg:gap-12">
|
||||
{features.map((feature, index) => {
|
||||
const FeatureIcon = getIconComponent(feature.icon);
|
||||
return (
|
||||
<div
|
||||
key={feature.title || index}
|
||||
className="group text-center p-8 relative"
|
||||
style={{ animationDelay: `${index * 0.1}s` }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37]/5 to-transparent rounded-3xl opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
<div className="relative">
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-[#d4af37] via-[#f5d76e] to-[#d4af37] rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-xl shadow-[#d4af37]/30 group-hover:scale-110 group-hover:shadow-2xl group-hover:shadow-[#d4af37]/40 transition-all duration-500">
|
||||
<FeatureIcon className="w-10 h-10 text-white drop-shadow-lg" />
|
||||
</div>
|
||||
<h3 className="text-xl md:text-2xl font-serif font-semibold text-gray-900 mb-4 group-hover:text-[#d4af37] transition-colors duration-300">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-gray-600 leading-relaxed font-light text-sm md:text-base">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mission & Vision Section */}
|
||||
{(pageContent?.mission || pageContent?.vision) && (
|
||||
<section className="py-20 md:py-28 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 relative overflow-hidden">
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(212,175,55,0.1),transparent_70%)]"></div>
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-[linear-gradient(45deg,transparent_30%,rgba(212,175,55,0.05)_50%,transparent_70%)]"></div>
|
||||
</div>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-2 gap-8 lg:gap-12">
|
||||
{pageContent.mission && (
|
||||
<div className="group relative bg-white/95 backdrop-blur-sm p-10 md:p-12 rounded-2xl shadow-2xl border border-[#d4af37]/20 hover:border-[#d4af37]/40 transition-all duration-500 hover:shadow-[#d4af37]/20 hover:-translate-y-1">
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-[#d4af37]/10 to-transparent rounded-bl-full opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-1 h-12 bg-gradient-to-b from-[#d4af37] to-[#f5d76e] rounded-full"></div>
|
||||
<h2 className="text-3xl md:text-4xl font-serif font-light text-gray-900">Our Mission</h2>
|
||||
</div>
|
||||
<p className="text-gray-700 leading-relaxed text-base md:text-lg font-light tracking-wide">{pageContent.mission}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pageContent.vision && (
|
||||
<div className="group relative bg-white/95 backdrop-blur-sm p-10 md:p-12 rounded-2xl shadow-2xl border border-[#d4af37]/20 hover:border-[#d4af37]/40 transition-all duration-500 hover:shadow-[#d4af37]/20 hover:-translate-y-1">
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-[#d4af37]/10 to-transparent rounded-bl-full opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-1 h-12 bg-gradient-to-b from-[#d4af37] to-[#f5d76e] rounded-full"></div>
|
||||
<h2 className="text-3xl md:text-4xl font-serif font-light text-gray-900">Our Vision</h2>
|
||||
</div>
|
||||
<p className="text-gray-700 leading-relaxed text-base md:text-lg font-light tracking-wide">{pageContent.vision}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Team Section */}
|
||||
{team && team.length > 0 && (
|
||||
<section className="py-20 md:py-28 bg-gradient-to-b from-white via-slate-50 to-white relative">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-block mb-4">
|
||||
<span className="text-sm font-semibold text-[#d4af37] tracking-[0.2em] uppercase">Meet The Experts</span>
|
||||
</div>
|
||||
<h2 className="text-4xl md:text-5xl lg:text-6xl font-serif font-light text-gray-900 mb-6 tracking-tight">
|
||||
Our Team
|
||||
</h2>
|
||||
<div className="flex items-center justify-center gap-4 mb-6">
|
||||
<div className="h-px w-12 bg-gradient-to-r from-transparent to-[#d4af37]"></div>
|
||||
<div className="w-2 h-2 bg-[#d4af37] rounded-full"></div>
|
||||
<div className="h-px w-12 bg-gradient-to-l from-transparent to-[#d4af37]"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 lg:gap-10">
|
||||
{team.map((member: any, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className="group relative bg-white rounded-2xl shadow-xl overflow-hidden hover:shadow-2xl transition-all duration-500 border border-gray-100 hover:border-[#d4af37]/30 hover:-translate-y-2"
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37]/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-10"></div>
|
||||
{member.image && (
|
||||
<div className="relative overflow-hidden h-72">
|
||||
<img
|
||||
src={member.image.startsWith('http') ? member.image : `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}${member.image}`}
|
||||
alt={member.name}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-8 relative z-10">
|
||||
<h3 className="text-2xl font-serif font-semibold text-gray-900 mb-2 group-hover:text-[#d4af37] transition-colors duration-300">{member.name}</h3>
|
||||
<p className="text-[#d4af37] font-medium mb-4 text-sm tracking-wide uppercase">{member.role}</p>
|
||||
{member.bio && <p className="text-gray-600 text-sm mb-6 leading-relaxed font-light">{member.bio}</p>}
|
||||
{member.social_links && (
|
||||
<div className="flex gap-4 pt-4 border-t border-gray-100">
|
||||
{member.social_links.linkedin && (
|
||||
<a
|
||||
href={member.social_links.linkedin}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 hover:bg-[#d4af37] hover:text-white transition-all duration-300 group-hover:scale-110"
|
||||
>
|
||||
<Linkedin className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
{member.social_links.twitter && (
|
||||
<a
|
||||
href={member.social_links.twitter}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 hover:bg-[#d4af37] hover:text-white transition-all duration-300 group-hover:scale-110"
|
||||
>
|
||||
<Twitter className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Timeline Section */}
|
||||
{timeline && timeline.length > 0 && (
|
||||
<section className="py-20 md:py-28 bg-gradient-to-b from-slate-50 via-white to-slate-50 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_70%_50%,rgba(212,175,55,0.03),transparent_50%)]"></div>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-block mb-4">
|
||||
<span className="text-sm font-semibold text-[#d4af37] tracking-[0.2em] uppercase">Our Journey</span>
|
||||
</div>
|
||||
<h2 className="text-4xl md:text-5xl lg:text-6xl font-serif font-light text-gray-900 mb-6 tracking-tight">
|
||||
Our History
|
||||
</h2>
|
||||
<div className="flex items-center justify-center gap-4 mb-6">
|
||||
<div className="h-px w-12 bg-gradient-to-r from-transparent to-[#d4af37]"></div>
|
||||
<div className="w-2 h-2 bg-[#d4af37] rounded-full"></div>
|
||||
<div className="h-px w-12 bg-gradient-to-l from-transparent to-[#d4af37]"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute left-8 md:left-1/2 transform md:-translate-x-1/2 w-1 h-full bg-gradient-to-b from-[#d4af37] via-[#f5d76e] to-[#d4af37] shadow-lg"></div>
|
||||
<div className="space-y-12 md:space-y-16">
|
||||
{timeline.map((event: any, index: number) => (
|
||||
<div key={index} className={`relative flex items-center ${index % 2 === 0 ? 'md:flex-row' : 'md:flex-row-reverse'}`}>
|
||||
<div className="absolute left-6 md:left-1/2 transform md:-translate-x-1/2 w-6 h-6 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full border-4 border-white shadow-xl z-10 group-hover:scale-125 transition-transform duration-300"></div>
|
||||
<div className={`ml-20 md:ml-0 md:w-5/12 ${index % 2 === 0 ? 'md:mr-auto md:pr-8' : 'md:ml-auto md:pl-8'}`}>
|
||||
<div className="group bg-white/90 backdrop-blur-sm p-8 rounded-2xl shadow-xl hover:shadow-2xl transition-all duration-500 border border-gray-100 hover:border-[#d4af37]/30">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="text-[#d4af37] font-bold text-2xl md:text-3xl font-serif">{event.year}</div>
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-[#d4af37] to-transparent"></div>
|
||||
</div>
|
||||
<h3 className="text-2xl md:text-3xl font-serif font-semibold text-gray-900 mb-3 group-hover:text-[#d4af37] transition-colors duration-300">{event.title}</h3>
|
||||
<p className="text-gray-600 leading-relaxed font-light mb-4">{event.description}</p>
|
||||
{event.image && (
|
||||
<div className="mt-6 overflow-hidden rounded-xl">
|
||||
<img
|
||||
src={event.image.startsWith('http') ? event.image : `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}${event.image}`}
|
||||
alt={event.title}
|
||||
className="w-full h-56 md:h-64 object-cover group-hover:scale-110 transition-transform duration-700"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Achievements Section */}
|
||||
{achievements && achievements.length > 0 && (
|
||||
<section className="py-20 md:py-28 bg-white relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(135deg,transparent_0%,rgba(212,175,55,0.02)_50%,transparent_100%)]"></div>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-block mb-4">
|
||||
<span className="text-sm font-semibold text-[#d4af37] tracking-[0.2em] uppercase">Recognition</span>
|
||||
</div>
|
||||
<h2 className="text-4xl md:text-5xl lg:text-6xl font-serif font-light text-gray-900 mb-6 tracking-tight">
|
||||
Achievements & Awards
|
||||
</h2>
|
||||
<div className="flex items-center justify-center gap-4 mb-6">
|
||||
<div className="h-px w-12 bg-gradient-to-r from-transparent to-[#d4af37]"></div>
|
||||
<div className="w-2 h-2 bg-[#d4af37] rounded-full"></div>
|
||||
<div className="h-px w-12 bg-gradient-to-l from-transparent to-[#d4af37]"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 lg:gap-10">
|
||||
{achievements.map((achievement: any, index: number) => {
|
||||
const AchievementIcon = getIconComponent(achievement.icon);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="group relative bg-gradient-to-br from-white to-slate-50 p-8 rounded-2xl shadow-xl hover:shadow-2xl transition-all duration-500 border border-gray-100 hover:border-[#d4af37]/40 hover:-translate-y-2"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-[#d4af37]/10 to-transparent rounded-bl-full opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-[#d4af37] via-[#f5d76e] to-[#d4af37] rounded-xl flex items-center justify-center shadow-lg shadow-[#d4af37]/20 group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500">
|
||||
<AchievementIcon className="w-8 h-8 text-white drop-shadow-md" />
|
||||
</div>
|
||||
{achievement.year && (
|
||||
<div className="text-[#d4af37] font-bold text-2xl font-serif">{achievement.year}</div>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-xl md:text-2xl font-serif font-semibold text-gray-900 mb-3 group-hover:text-[#d4af37] transition-colors duration-300">{achievement.title}</h3>
|
||||
<p className="text-gray-600 text-sm md:text-base leading-relaxed font-light mb-4">{achievement.description}</p>
|
||||
{achievement.image && (
|
||||
<div className="mt-6 overflow-hidden rounded-xl">
|
||||
<img
|
||||
src={achievement.image.startsWith('http') ? achievement.image : `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}${achievement.image}`}
|
||||
alt={achievement.title}
|
||||
className="w-full h-40 object-cover group-hover:scale-110 transition-transform duration-700"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Contact Section */}
|
||||
<section className="py-16 bg-gradient-to-b from-gray-50 to-white">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-12 animate-fade-in">
|
||||
<h2 className="text-4xl font-serif font-bold text-gray-900 mb-4">
|
||||
<section className="py-20 md:py-28 bg-gradient-to-br from-slate-50 via-white to-slate-50 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(212,175,55,0.03),transparent_70%)]"></div>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-block mb-4">
|
||||
<span className="text-sm font-semibold text-[#d4af37] tracking-[0.2em] uppercase">Connect With Us</span>
|
||||
</div>
|
||||
<h2 className="text-4xl md:text-5xl lg:text-6xl font-serif font-light text-gray-900 mb-6 tracking-tight">
|
||||
Get In Touch
|
||||
</h2>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
|
||||
<p className="text-gray-600 mt-4">
|
||||
<div className="flex items-center justify-center gap-4 mb-6">
|
||||
<div className="h-px w-12 bg-gradient-to-r from-transparent to-[#d4af37]"></div>
|
||||
<div className="w-2 h-2 bg-[#d4af37] rounded-full"></div>
|
||||
<div className="h-px w-12 bg-gradient-to-l from-transparent to-[#d4af37]"></div>
|
||||
</div>
|
||||
<p className="text-gray-600 mt-6 text-lg font-light max-w-2xl mx-auto">
|
||||
We'd love to hear from you. Contact us for reservations or inquiries.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="text-center p-6 bg-white rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 animate-slide-up">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<MapPin className="w-6 h-6 text-white" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 lg:gap-10 mb-16">
|
||||
<div className="group text-center p-8 bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl hover:shadow-2xl transition-all duration-500 border border-gray-100 hover:border-[#d4af37]/40 hover:-translate-y-2">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-[#d4af37] via-[#f5d76e] to-[#d4af37] rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg shadow-[#d4af37]/20 group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500">
|
||||
<MapPin className="w-8 h-8 text-white drop-shadow-md" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
<h3 className="text-xl font-serif font-semibold text-gray-900 mb-4 group-hover:text-[#d4af37] transition-colors duration-300">
|
||||
Address
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
<p className="text-gray-600 leading-relaxed font-light">
|
||||
{displayAddress
|
||||
.split('\n').map((line, i) => (
|
||||
<React.Fragment key={i}>
|
||||
@@ -269,40 +587,41 @@ const AboutPage: React.FC = () => {
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-6 bg-white rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 animate-slide-up" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Phone className="w-6 h-6 text-white" />
|
||||
<div className="group text-center p-8 bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl hover:shadow-2xl transition-all duration-500 border border-gray-100 hover:border-[#d4af37]/40 hover:-translate-y-2" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-[#d4af37] via-[#f5d76e] to-[#d4af37] rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg shadow-[#d4af37]/20 group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500">
|
||||
<Phone className="w-8 h-8 text-white drop-shadow-md" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
<h3 className="text-xl font-serif font-semibold text-gray-900 mb-4 group-hover:text-[#d4af37] transition-colors duration-300">
|
||||
Phone
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
<a href={`tel:${displayPhone.replace(/\s+/g, '').replace(/[()]/g, '')}`} className="hover:text-[#d4af37] transition-colors">
|
||||
<p className="text-gray-600 font-light">
|
||||
<a href={`tel:${displayPhone.replace(/\s+/g, '').replace(/[()]/g, '')}`} className="hover:text-[#d4af37] transition-colors duration-300">
|
||||
{displayPhone}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-6 bg-white rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 animate-slide-up" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Mail className="w-6 h-6 text-white" />
|
||||
<div className="group text-center p-8 bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl hover:shadow-2xl transition-all duration-500 border border-gray-100 hover:border-[#d4af37]/40 hover:-translate-y-2" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-[#d4af37] via-[#f5d76e] to-[#d4af37] rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg shadow-[#d4af37]/20 group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500">
|
||||
<Mail className="w-8 h-8 text-white drop-shadow-md" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
<h3 className="text-xl font-serif font-semibold text-gray-900 mb-4 group-hover:text-[#d4af37] transition-colors duration-300">
|
||||
Email
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
<a href={`mailto:${displayEmail}`} className="hover:text-[#d4af37] transition-colors">
|
||||
<p className="text-gray-600 font-light">
|
||||
<a href={`mailto:${displayEmail}`} className="hover:text-[#d4af37] transition-colors duration-300">
|
||||
{displayEmail}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center mt-12 animate-fade-in">
|
||||
<div className="text-center">
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="inline-flex items-center space-x-2 px-8 py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-white rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 font-medium shadow-lg shadow-[#d4af37]/20 hover:shadow-[#d4af37]/30"
|
||||
className="group inline-flex items-center space-x-3 px-10 py-4 bg-gradient-to-r from-[#d4af37] via-[#f5d76e] to-[#d4af37] text-white rounded-xl hover:shadow-2xl hover:shadow-[#d4af37]/40 transition-all duration-500 font-medium text-lg tracking-wide relative overflow-hidden"
|
||||
>
|
||||
<span>Explore Our Rooms</span>
|
||||
<Hotel className="w-5 h-5" />
|
||||
<span className="relative z-10">Explore Our Rooms</span>
|
||||
<Hotel className="w-5 h-5 relative z-10 group-hover:translate-x-1 transition-transform duration-300" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#f5d76e] to-[#d4af37] opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -99,7 +99,7 @@ const ContactPage: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const fetchPageContent = async () => {
|
||||
try {
|
||||
const response = await pageContentService.getPageContent('contact');
|
||||
const response = await pageContentService.getContactContent();
|
||||
if (response.status === 'success' && response.data?.page_content) {
|
||||
setPageContent(response.data.page_content);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,23 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Search, Eye, XCircle, CheckCircle, Loader2 } from 'lucide-react';
|
||||
import { bookingService, Booking } from '../../services/api';
|
||||
import { Search, Eye, XCircle, CheckCircle, Loader2, FileText } from 'lucide-react';
|
||||
import { bookingService, Booking, invoiceService } from '../../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import Pagination from '../../components/common/Pagination';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
import { parseDateLocal } from '../../utils/format';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const BookingManagementPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const navigate = useNavigate();
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [updatingBookingId, setUpdatingBookingId] = useState<number | null>(null);
|
||||
const [cancellingBookingId, setCancellingBookingId] = useState<number | null>(null);
|
||||
const [creatingInvoice, setCreatingInvoice] = useState(false);
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
status: '',
|
||||
@@ -80,6 +83,32 @@ const BookingManagementPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateInvoice = async (bookingId: number) => {
|
||||
try {
|
||||
setCreatingInvoice(true);
|
||||
// Ensure bookingId is a number
|
||||
const invoiceData = {
|
||||
booking_id: Number(bookingId),
|
||||
};
|
||||
|
||||
const response = await invoiceService.createInvoice(invoiceData);
|
||||
|
||||
if (response.status === 'success' && response.data?.invoice) {
|
||||
toast.success('Invoice created successfully!');
|
||||
setShowDetailModal(false);
|
||||
navigate(`/admin/invoices/${response.data.invoice.id}`);
|
||||
} else {
|
||||
throw new Error('Failed to create invoice');
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.detail || error.response?.data?.message || error.message || 'Unable to create invoice';
|
||||
toast.error(errorMessage);
|
||||
console.error('Invoice creation error:', error);
|
||||
} finally {
|
||||
setCreatingInvoice(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
|
||||
pending: {
|
||||
@@ -622,7 +651,24 @@ const BookingManagementPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="mt-8 pt-6 border-t border-slate-200 flex justify-end">
|
||||
<div className="mt-8 pt-6 border-t border-slate-200 flex justify-between items-center">
|
||||
<button
|
||||
onClick={() => handleCreateInvoice(selectedBooking.id)}
|
||||
disabled={creatingInvoice}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
{creatingInvoice ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Creating Invoice...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText className="w-5 h-5" />
|
||||
Create Invoice
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDetailModal(false)}
|
||||
className="px-8 py-3 bg-gradient-to-r from-slate-700 to-slate-800 text-white rounded-xl font-semibold hover:from-slate-800 hover:to-slate-900 transition-all duration-200 shadow-lg hover:shadow-xl border border-slate-600"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Search, User, Hotel, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Search, User, Hotel, CheckCircle, AlertCircle, Calendar, LogIn, LogOut } from 'lucide-react';
|
||||
import { bookingService, Booking } from '../../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
@@ -14,7 +14,14 @@ interface GuestInfo {
|
||||
|
||||
const CheckInPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const [bookingNumber, setBookingNumber] = useState('');
|
||||
const [selectedDate, setSelectedDate] = useState<string>(() => {
|
||||
const today = new Date();
|
||||
return today.toISOString().split('T')[0];
|
||||
});
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [checkInBookings, setCheckInBookings] = useState<Booking[]>([]);
|
||||
const [checkOutBookings, setCheckOutBookings] = useState<Booking[]>([]);
|
||||
const [loadingBookings, setLoadingBookings] = useState(false);
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searching, setSearching] = useState(false);
|
||||
@@ -24,19 +31,77 @@ const CheckInPage: React.FC = () => {
|
||||
const [children, setChildren] = useState(0);
|
||||
const [additionalFee, setAdditionalFee] = useState(0);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!bookingNumber.trim()) {
|
||||
// Fetch bookings for the selected date
|
||||
useEffect(() => {
|
||||
fetchBookingsForDate();
|
||||
}, [selectedDate, searchQuery]);
|
||||
|
||||
const fetchBookingsForDate = async () => {
|
||||
if (!selectedDate) return;
|
||||
|
||||
try {
|
||||
setLoadingBookings(true);
|
||||
const date = new Date(selectedDate);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
const nextDay = new Date(date);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
|
||||
// Fetch all bookings (we'll filter on client side for accuracy)
|
||||
const params: any = {
|
||||
limit: 200, // Fetch more to ensure we get all bookings for the date range
|
||||
};
|
||||
if (searchQuery) {
|
||||
params.search = searchQuery;
|
||||
}
|
||||
|
||||
// Fetch bookings around the selected date (3 days before and after to be safe)
|
||||
const startDate = new Date(date);
|
||||
startDate.setDate(startDate.getDate() - 3);
|
||||
const endDate = new Date(date);
|
||||
endDate.setDate(endDate.getDate() + 3);
|
||||
|
||||
params.startDate = startDate.toISOString();
|
||||
params.endDate = endDate.toISOString();
|
||||
|
||||
const response = await bookingService.getAllBookings(params);
|
||||
const allBookings = response.data.bookings || [];
|
||||
|
||||
// Filter check-ins: confirmed bookings with check_in_date matching selected date
|
||||
const filteredCheckIns = allBookings.filter((booking) => {
|
||||
if (!booking.check_in_date || booking.status !== 'confirmed') return false;
|
||||
const checkInDate = new Date(booking.check_in_date);
|
||||
checkInDate.setHours(0, 0, 0, 0);
|
||||
return checkInDate.getTime() === date.getTime();
|
||||
});
|
||||
setCheckInBookings(filteredCheckIns);
|
||||
|
||||
// Filter check-outs: checked_in bookings with check_out_date matching selected date
|
||||
const filteredCheckOuts = allBookings.filter((booking) => {
|
||||
if (!booking.check_out_date || booking.status !== 'checked_in') return false;
|
||||
const checkOutDate = new Date(booking.check_out_date);
|
||||
checkOutDate.setHours(0, 0, 0, 0);
|
||||
return checkOutDate.getTime() === date.getTime();
|
||||
});
|
||||
setCheckOutBookings(filteredCheckOuts);
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load bookings');
|
||||
} finally {
|
||||
setLoadingBookings(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchByNumber = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
toast.error('Please enter booking number');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSearching(true);
|
||||
const response = await bookingService.checkBookingByNumber(bookingNumber);
|
||||
const response = await bookingService.checkBookingByNumber(searchQuery);
|
||||
setBooking(response.data.booking);
|
||||
setActualRoomNumber(response.data.booking.room?.room_number || '');
|
||||
|
||||
// Show warning if there's remaining balance
|
||||
if ((response as any).warning) {
|
||||
const warning = (response as any).warning;
|
||||
toast.warning(
|
||||
@@ -54,6 +119,30 @@ const CheckInPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectBooking = async (bookingNumber: string) => {
|
||||
try {
|
||||
setSearching(true);
|
||||
const response = await bookingService.checkBookingByNumber(bookingNumber);
|
||||
setBooking(response.data.booking);
|
||||
setActualRoomNumber(response.data.booking.room?.room_number || '');
|
||||
setSearchQuery(bookingNumber);
|
||||
|
||||
if ((response as any).warning) {
|
||||
const warning = (response as any).warning;
|
||||
toast.warning(
|
||||
`⚠️ Payment Reminder: Guest has remaining balance of ${formatCurrency(warning.remaining_balance)} (${warning.payment_percentage.toFixed(1)}% paid)`,
|
||||
{ autoClose: 8000 }
|
||||
);
|
||||
} else {
|
||||
toast.success('Booking selected');
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load booking');
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddGuest = () => {
|
||||
setGuests([...guests, { name: '', id_number: '', phone: '' }]);
|
||||
};
|
||||
@@ -71,9 +160,8 @@ const CheckInPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const calculateAdditionalFee = () => {
|
||||
// Logic to calculate additional fees: children and extra person
|
||||
const extraPersonFee = extraPersons * 200000; // 200k/person
|
||||
const childrenFee = children * 100000; // 100k/child
|
||||
const extraPersonFee = extraPersons * 200000;
|
||||
const childrenFee = children * 100000;
|
||||
const total = extraPersonFee + childrenFee;
|
||||
setAdditionalFee(total);
|
||||
return total;
|
||||
@@ -82,7 +170,6 @@ const CheckInPage: React.FC = () => {
|
||||
const handleCheckIn = async () => {
|
||||
if (!booking) return;
|
||||
|
||||
// Validate
|
||||
if (!actualRoomNumber.trim()) {
|
||||
toast.error('Please enter actual room number');
|
||||
return;
|
||||
@@ -96,15 +183,12 @@ const CheckInPage: React.FC = () => {
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
// Calculate additional fee
|
||||
calculateAdditionalFee();
|
||||
|
||||
const response = await bookingService.updateBooking(booking.id, {
|
||||
status: 'checked_in',
|
||||
// Can send additional data about guests, room_number, additional_fee
|
||||
} as any);
|
||||
|
||||
// Show warning if there's remaining balance
|
||||
if ((response as any).warning) {
|
||||
const warning = (response as any).warning;
|
||||
toast.warning(
|
||||
@@ -115,14 +199,15 @@ const CheckInPage: React.FC = () => {
|
||||
toast.success('Check-in successful');
|
||||
}
|
||||
|
||||
// Reset form
|
||||
// Reset form and refresh bookings
|
||||
setBooking(null);
|
||||
setBookingNumber('');
|
||||
setSearchQuery('');
|
||||
setActualRoomNumber('');
|
||||
setGuests([{ name: '', id_number: '', phone: '' }]);
|
||||
setExtraPersons(0);
|
||||
setChildren(0);
|
||||
setAdditionalFee(0);
|
||||
await fetchBookingsForDate();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'An error occurred during check-in');
|
||||
} finally {
|
||||
@@ -130,6 +215,15 @@ const CheckInPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
@@ -144,39 +238,187 @@ const CheckInPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Booking */}
|
||||
{/* Date and Search Filters */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">1. Search booking</h2>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Calendar className="w-4 h-4 inline mr-2" />
|
||||
Select Date
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bookingNumber}
|
||||
onChange={(e) => setBookingNumber(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="Enter booking number"
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={searching}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 flex items-center gap-2"
|
||||
>
|
||||
{searching ? 'Searching...' : 'Search'}
|
||||
</button>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Search className="w-4 h-4 inline mr-2" />
|
||||
Search by Booking Number
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSearchByNumber()}
|
||||
placeholder="Enter booking number"
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearchByNumber}
|
||||
disabled={searching}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400"
|
||||
>
|
||||
{searching ? '...' : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
const today = new Date();
|
||||
setSelectedDate(today.toISOString().split('T')[0]);
|
||||
setSearchQuery('');
|
||||
}}
|
||||
className="w-full px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
Reset to Today
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Booking Info */}
|
||||
{/* Check-ins and Check-outs Lists */}
|
||||
{!booking && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Check-ins for Today */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<LogIn className="w-5 h-5 text-green-600" />
|
||||
Check-ins for {formatDate(selectedDate)}
|
||||
</h2>
|
||||
<span className="px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm font-medium">
|
||||
{checkInBookings.length}
|
||||
</span>
|
||||
</div>
|
||||
{loadingBookings ? (
|
||||
<div className="text-center py-8">
|
||||
<Loading />
|
||||
</div>
|
||||
) : checkInBookings.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<LogIn className="w-12 h-12 mx-auto mb-2 text-gray-300" />
|
||||
<p>No check-ins scheduled for this date</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{checkInBookings.map((b) => (
|
||||
<div
|
||||
key={b.id}
|
||||
onClick={() => handleSelectBooking(b.booking_number)}
|
||||
className="p-4 border border-gray-200 rounded-lg hover:border-blue-500 hover:shadow-md cursor-pointer transition-all"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-gray-900">{b.booking_number}</div>
|
||||
<div className="text-sm text-gray-600 mt-1">{b.user?.full_name}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{b.room?.room_type?.name} • {formatCurrency(b.total_price)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
b.status === 'confirmed' ? 'bg-green-100 text-green-800' :
|
||||
b.status === 'checked_in' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{b.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Check-outs for Today */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<LogOut className="w-5 h-5 text-orange-600" />
|
||||
Check-outs for {formatDate(selectedDate)}
|
||||
</h2>
|
||||
<span className="px-3 py-1 bg-orange-100 text-orange-800 rounded-full text-sm font-medium">
|
||||
{checkOutBookings.length}
|
||||
</span>
|
||||
</div>
|
||||
{loadingBookings ? (
|
||||
<div className="text-center py-8">
|
||||
<Loading />
|
||||
</div>
|
||||
) : checkOutBookings.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<LogOut className="w-12 h-12 mx-auto mb-2 text-gray-300" />
|
||||
<p>No check-outs scheduled for this date</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{checkOutBookings.map((b) => (
|
||||
<div
|
||||
key={b.id}
|
||||
onClick={() => handleSelectBooking(b.booking_number)}
|
||||
className="p-4 border border-gray-200 rounded-lg hover:border-orange-500 hover:shadow-md cursor-pointer transition-all"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-gray-900">{b.booking_number}</div>
|
||||
<div className="text-sm text-gray-600 mt-1">{b.user?.full_name}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{b.room?.room_type?.name} • {formatCurrency(b.total_price)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
b.status === 'checked_in' ? 'bg-blue-100 text-blue-800' :
|
||||
b.status === 'checked_out' ? 'bg-gray-100 text-gray-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{b.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Booking Info and Check-in Form */}
|
||||
{booking && (
|
||||
<>
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
2. Booking Information
|
||||
</h2>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
2. Booking Information
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setBooking(null);
|
||||
setSearchQuery('');
|
||||
}}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
@@ -299,7 +541,6 @@ const CheckInPage: React.FC = () => {
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
{(() => {
|
||||
// Use payment_balance from API if available, otherwise calculate from payments
|
||||
const paymentBalance = booking.payment_balance || (() => {
|
||||
const completedPayments = booking.payments?.filter(
|
||||
(p) => p.payment_status === 'completed'
|
||||
@@ -545,19 +786,6 @@ const CheckInPage: React.FC = () => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!booking && !searching && (
|
||||
<div className="bg-gray-50 rounded-lg p-12 text-center">
|
||||
<Search className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
No booking selected
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Please enter booking number above to start check-in process
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -47,7 +47,8 @@ const InvoiceManagementPage: React.FC = () => {
|
||||
invoiceList = invoiceList.filter((inv) =>
|
||||
inv.invoice_number.toLowerCase().includes(filters.search.toLowerCase()) ||
|
||||
inv.customer_name.toLowerCase().includes(filters.search.toLowerCase()) ||
|
||||
inv.customer_email.toLowerCase().includes(filters.search.toLowerCase())
|
||||
inv.customer_email.toLowerCase().includes(filters.search.toLowerCase()) ||
|
||||
(inv.promotion_code && inv.promotion_code.toLowerCase().includes(filters.search.toLowerCase()))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,11 +131,11 @@ const InvoiceManagementPage: React.FC = () => {
|
||||
<p className="text-slate-600 mt-3 text-lg font-light">Manage and track all invoices</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/admin/invoices/create')}
|
||||
onClick={() => navigate('/admin/bookings')}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Create Invoice
|
||||
Create Invoice from Booking
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -190,6 +191,9 @@ const InvoiceManagementPage: React.FC = () => {
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
|
||||
Amount
|
||||
</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
|
||||
Promotion
|
||||
</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
|
||||
Status
|
||||
</th>
|
||||
@@ -235,6 +239,25 @@ const InvoiceManagementPage: React.FC = () => {
|
||||
Due: {formatCurrency(invoice.balance_due)}
|
||||
</div>
|
||||
)}
|
||||
{invoice.discount_amount > 0 && (
|
||||
<div className="text-xs text-green-600 font-medium mt-1">
|
||||
Discount: -{formatCurrency(invoice.discount_amount)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
{invoice.promotion_code ? (
|
||||
<span className="px-3 py-1 text-xs font-semibold rounded-full bg-gradient-to-r from-purple-50 to-pink-50 text-purple-700 border border-purple-200">
|
||||
{invoice.promotion_code}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">—</span>
|
||||
)}
|
||||
{invoice.is_proforma && (
|
||||
<div className="text-xs text-blue-600 font-medium mt-1">
|
||||
Proforma
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<span className={`px-4 py-1.5 text-xs font-semibold rounded-full border shadow-sm ${statusBadge.bg} ${statusBadge.text} ${statusBadge.border || ''}`}>
|
||||
@@ -274,7 +297,7 @@ const InvoiceManagementPage: React.FC = () => {
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-8 py-12 text-center">
|
||||
<td colSpan={8} className="px-8 py-12 text-center">
|
||||
<div className="text-slate-500">
|
||||
<FileText className="w-16 h-16 mx-auto mb-4 text-slate-300" />
|
||||
<p className="text-lg font-semibold">No invoices found</p>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -125,8 +125,17 @@ const InvoicePage: React.FC = () => {
|
||||
{/* Invoice Header */}
|
||||
<div className="flex justify-between items-start mb-8 pb-8 border-b-2 border-gray-200">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Invoice</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
{invoice.is_proforma ? 'Proforma Invoice' : 'Invoice'}
|
||||
</h1>
|
||||
<p className="text-gray-600">#{invoice.invoice_number}</p>
|
||||
{invoice.promotion_code && (
|
||||
<div className="mt-2">
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold bg-gradient-to-r from-purple-100 to-pink-100 text-purple-700 border border-purple-200">
|
||||
Promotion Code: {invoice.promotion_code}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`flex items-center gap-2 px-4 py-2 rounded-lg border ${getStatusColor(invoice.status)}`}>
|
||||
{getStatusIcon(invoice.status)}
|
||||
@@ -252,8 +261,10 @@ const InvoicePage: React.FC = () => {
|
||||
<span>{formatCurrency(invoice.subtotal)}</span>
|
||||
</div>
|
||||
{invoice.discount_amount > 0 && (
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Discount:</span>
|
||||
<div className="flex justify-between text-green-600 font-medium">
|
||||
<span>
|
||||
Discount{invoice.promotion_code ? ` (${invoice.promotion_code})` : ''}:
|
||||
</span>
|
||||
<span>-{formatCurrency(invoice.discount_amount)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -62,6 +62,11 @@ apiClient.interceptors.request.use(
|
||||
config.url = config.url.replace(/\/\/+/, '/');
|
||||
}
|
||||
|
||||
// Handle FormData - remove Content-Type header to let browser set it with boundary
|
||||
if (config.data instanceof FormData) {
|
||||
delete config.headers['Content-Type'];
|
||||
}
|
||||
|
||||
// Add authorization token
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
|
||||
@@ -212,14 +212,10 @@ const authService = {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
// Don't set Content-Type header - let the browser set it with the correct boundary
|
||||
const response = await apiClient.post<AuthResponse>(
|
||||
'/api/auth/avatar/upload',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}
|
||||
formData
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -10,8 +10,9 @@ import apiClient from './apiClient';
|
||||
export interface Banner {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
image_url: string;
|
||||
link?: string;
|
||||
link_url?: string;
|
||||
position: string;
|
||||
display_order: number;
|
||||
is_active: boolean;
|
||||
@@ -157,13 +158,20 @@ export const createBanner = async (
|
||||
description?: string;
|
||||
image_url: string;
|
||||
link?: string;
|
||||
link_url?: string;
|
||||
position?: string;
|
||||
display_order?: number;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
): Promise<{ success: boolean; data: { banner: Banner }; message: string }> => {
|
||||
const response = await apiClient.post('/banners', data);
|
||||
// Map link_url to link for backend compatibility
|
||||
const requestData = {
|
||||
...data,
|
||||
link: data.link_url || data.link,
|
||||
};
|
||||
delete requestData.link_url;
|
||||
const response = await apiClient.post('/banners', requestData);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -177,6 +185,7 @@ export const updateBanner = async (
|
||||
description?: string;
|
||||
image_url?: string;
|
||||
link?: string;
|
||||
link_url?: string;
|
||||
position?: string;
|
||||
display_order?: number;
|
||||
is_active?: boolean;
|
||||
@@ -184,7 +193,13 @@ export const updateBanner = async (
|
||||
end_date?: string;
|
||||
}
|
||||
): Promise<{ success: boolean; data: { banner: Banner }; message: string }> => {
|
||||
const response = await apiClient.put(`/banners/${id}`, data);
|
||||
// Map link_url to link for backend compatibility
|
||||
const requestData = {
|
||||
...data,
|
||||
link: data.link_url || data.link,
|
||||
};
|
||||
delete requestData.link_url;
|
||||
const response = await apiClient.put(`/banners/${id}`, requestData);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -207,11 +222,8 @@ export const uploadBannerImage = async (
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
const response = await apiClient.post('/banners/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
// Don't set Content-Type header - let the browser set it with the correct boundary
|
||||
const response = await apiClient.post('/banners/upload', formData);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface BookingData {
|
||||
service_id: number;
|
||||
quantity: number;
|
||||
}>;
|
||||
promotion_code?: string;
|
||||
}
|
||||
|
||||
export interface Booking {
|
||||
@@ -205,6 +206,8 @@ export const getAllBookings = async (
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
): Promise<BookingsResponse> => {
|
||||
const response = await apiClient.get<BookingsResponse>('/bookings', { params });
|
||||
@@ -286,14 +289,10 @@ export const notifyPayment = async (
|
||||
formData.append('receipt', file);
|
||||
}
|
||||
|
||||
// Don't set Content-Type header - let the browser set it with the correct boundary
|
||||
const response = await apiClient.post(
|
||||
'/notify/payment',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}
|
||||
formData
|
||||
);
|
||||
|
||||
return response.data;
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface Invoice {
|
||||
notes?: string;
|
||||
terms_and_conditions?: string;
|
||||
payment_instructions?: string;
|
||||
is_proforma?: boolean;
|
||||
promotion_code?: string;
|
||||
items: InvoiceItem[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
@@ -34,12 +34,61 @@ export interface PageContent {
|
||||
support_links?: Array<{ label: string; url: string }>;
|
||||
};
|
||||
badges?: Array<{ text: string; icon: string }>;
|
||||
copyright_text?: string;
|
||||
hero_title?: string;
|
||||
hero_subtitle?: string;
|
||||
hero_image?: string;
|
||||
story_content?: string;
|
||||
values?: Array<{ icon?: string; title: string; description: string }>;
|
||||
features?: Array<{ icon?: string; title: string; description: string }>;
|
||||
features?: Array<{ icon?: string; title: string; description: string; image?: string }>;
|
||||
about_hero_image?: string;
|
||||
mission?: string;
|
||||
vision?: string;
|
||||
team?: Array<{ name: string; role: string; image?: string; bio?: string; social_links?: { linkedin?: string; twitter?: string; email?: string } }>;
|
||||
timeline?: Array<{ year: string; title: string; description: string; image?: string }>;
|
||||
achievements?: Array<{ icon?: string; title: string; description: string; year?: string; image?: string }>;
|
||||
// Home page luxury sections
|
||||
luxury_section_title?: string;
|
||||
luxury_section_subtitle?: string;
|
||||
luxury_section_image?: string;
|
||||
luxury_features?: Array<{ icon?: string; title: string; description: string }>;
|
||||
luxury_gallery_section_title?: string;
|
||||
luxury_gallery_section_subtitle?: string;
|
||||
luxury_gallery?: Array<string>;
|
||||
luxury_testimonials_section_title?: string;
|
||||
luxury_testimonials_section_subtitle?: string;
|
||||
luxury_testimonials?: Array<{ name: string; title?: string; quote: string; image?: string }>;
|
||||
amenities_section_title?: string;
|
||||
amenities_section_subtitle?: string;
|
||||
amenities?: Array<{ icon?: string; title: string; description: string; image?: string }>;
|
||||
testimonials_section_title?: string;
|
||||
testimonials_section_subtitle?: string;
|
||||
testimonials?: Array<{ name: string; role: string; image?: string; rating: number; comment: string }>;
|
||||
gallery_section_title?: string;
|
||||
gallery_section_subtitle?: string;
|
||||
gallery_images?: Array<string>;
|
||||
about_preview_title?: string;
|
||||
about_preview_subtitle?: string;
|
||||
about_preview_content?: string;
|
||||
about_preview_image?: string;
|
||||
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_experiences_section_title?: string;
|
||||
luxury_experiences_section_subtitle?: string;
|
||||
luxury_experiences?: Array<{ icon?: string; title: string; description: string; image?: string }>;
|
||||
awards_section_title?: string;
|
||||
awards_section_subtitle?: string;
|
||||
awards?: Array<{ icon?: string; title: string; description: string; image?: string; year?: string }>;
|
||||
cta_title?: string;
|
||||
cta_subtitle?: string;
|
||||
cta_button_text?: string;
|
||||
cta_button_link?: string;
|
||||
cta_image?: string;
|
||||
partners_section_title?: string;
|
||||
partners_section_subtitle?: string;
|
||||
partners?: Array<{ name: string; logo: string; link?: string }>;
|
||||
is_active?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
@@ -84,12 +133,61 @@ export interface UpdatePageContentData {
|
||||
support_links?: Array<{ label: string; url: string }>;
|
||||
};
|
||||
badges?: Array<{ text: string; icon: string }>;
|
||||
copyright_text?: string;
|
||||
hero_title?: string;
|
||||
hero_subtitle?: string;
|
||||
hero_image?: string;
|
||||
story_content?: string;
|
||||
values?: Array<{ icon?: string; title: string; description: string }>;
|
||||
features?: Array<{ icon?: string; title: string; description: string }>;
|
||||
features?: Array<{ icon?: string; title: string; description: string; image?: string }>;
|
||||
about_hero_image?: string;
|
||||
mission?: string;
|
||||
vision?: string;
|
||||
team?: Array<{ name: string; role: string; image?: string; bio?: string; social_links?: { linkedin?: string; twitter?: string; email?: string } }>;
|
||||
timeline?: Array<{ year: string; title: string; description: string; image?: string }>;
|
||||
achievements?: Array<{ icon?: string; title: string; description: string; year?: string; image?: string }>;
|
||||
// Home page luxury sections
|
||||
luxury_section_title?: string;
|
||||
luxury_section_subtitle?: string;
|
||||
luxury_section_image?: string;
|
||||
luxury_features?: Array<{ icon?: string; title: string; description: string }>;
|
||||
luxury_gallery_section_title?: string;
|
||||
luxury_gallery_section_subtitle?: string;
|
||||
luxury_gallery?: Array<string>;
|
||||
luxury_testimonials_section_title?: string;
|
||||
luxury_testimonials_section_subtitle?: string;
|
||||
luxury_testimonials?: Array<{ name: string; title?: string; quote: string; image?: string }>;
|
||||
amenities_section_title?: string;
|
||||
amenities_section_subtitle?: string;
|
||||
amenities?: Array<{ icon?: string; title: string; description: string; image?: string }>;
|
||||
testimonials_section_title?: string;
|
||||
testimonials_section_subtitle?: string;
|
||||
testimonials?: Array<{ name: string; role: string; image?: string; rating: number; comment: string }>;
|
||||
gallery_section_title?: string;
|
||||
gallery_section_subtitle?: string;
|
||||
gallery_images?: Array<string>;
|
||||
about_preview_title?: string;
|
||||
about_preview_subtitle?: string;
|
||||
about_preview_content?: string;
|
||||
about_preview_image?: string;
|
||||
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_experiences_section_title?: string;
|
||||
luxury_experiences_section_subtitle?: string;
|
||||
luxury_experiences?: Array<{ icon?: string; title: string; description: string; image?: string }>;
|
||||
awards_section_title?: string;
|
||||
awards_section_subtitle?: string;
|
||||
awards?: Array<{ icon?: string; title: string; description: string; image?: string; year?: string }>;
|
||||
cta_title?: string;
|
||||
cta_subtitle?: string;
|
||||
cta_button_text?: string;
|
||||
cta_button_link?: string;
|
||||
cta_image?: string;
|
||||
partners_section_title?: string;
|
||||
partners_section_subtitle?: string;
|
||||
partners?: Array<{ name: string; logo: string; link?: string }>;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
@@ -103,13 +201,45 @@ const pageContentService = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Get content for a specific page
|
||||
* Get content for a specific page (legacy method - kept for backward compatibility)
|
||||
*/
|
||||
getPageContent: async (pageType: PageType): Promise<PageContentResponse> => {
|
||||
const response = await apiClient.get<PageContentResponse>(`/page-content/${pageType}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get homepage content
|
||||
*/
|
||||
getHomeContent: async (): Promise<PageContentResponse> => {
|
||||
const response = await apiClient.get<PageContentResponse>('/home');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get about page content
|
||||
*/
|
||||
getAboutContent: async (): Promise<PageContentResponse> => {
|
||||
const response = await apiClient.get<PageContentResponse>('/about');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get contact page content
|
||||
*/
|
||||
getContactContent: async (): Promise<PageContentResponse> => {
|
||||
const response = await apiClient.get<PageContentResponse>('/contact-content');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get footer content
|
||||
*/
|
||||
getFooterContent: async (): Promise<PageContentResponse> => {
|
||||
const response = await apiClient.get<PageContentResponse>('/footer');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update page content
|
||||
*/
|
||||
@@ -162,6 +292,50 @@ const pageContentService = {
|
||||
if (data.features) {
|
||||
updateData.features = data.features; // Send as array, backend will convert to JSON
|
||||
}
|
||||
|
||||
// Handle luxury content arrays
|
||||
if (data.luxury_features) {
|
||||
updateData.luxury_features = data.luxury_features; // Send as array, backend will convert to JSON
|
||||
}
|
||||
if (data.luxury_gallery) {
|
||||
updateData.luxury_gallery = data.luxury_gallery; // Send as array, backend will convert to JSON
|
||||
}
|
||||
if (data.luxury_testimonials) {
|
||||
updateData.luxury_testimonials = data.luxury_testimonials; // Send as array, backend will convert to JSON
|
||||
}
|
||||
if (data.amenities) {
|
||||
updateData.amenities = data.amenities; // Send as array, backend will convert to JSON
|
||||
}
|
||||
if (data.testimonials) {
|
||||
updateData.testimonials = data.testimonials; // Send as array, backend will convert to JSON
|
||||
}
|
||||
if (data.gallery_images) {
|
||||
updateData.gallery_images = data.gallery_images; // Send as array, backend will convert to JSON
|
||||
}
|
||||
if (data.stats) {
|
||||
updateData.stats = data.stats; // Send as array, backend will convert to JSON
|
||||
}
|
||||
if (data.luxury_services) {
|
||||
updateData.luxury_services = data.luxury_services; // Send as array, backend will convert to JSON
|
||||
}
|
||||
if (data.luxury_experiences) {
|
||||
updateData.luxury_experiences = data.luxury_experiences; // Send as array, backend will convert to JSON
|
||||
}
|
||||
if (data.awards) {
|
||||
updateData.awards = data.awards; // Send as array, backend will convert to JSON
|
||||
}
|
||||
if (data.partners) {
|
||||
updateData.partners = data.partners; // Send as array, backend will convert to JSON
|
||||
}
|
||||
if (data.team) {
|
||||
updateData.team = data.team; // Send as array, backend will convert to JSON
|
||||
}
|
||||
if (data.timeline) {
|
||||
updateData.timeline = data.timeline; // Send as array, backend will convert to JSON
|
||||
}
|
||||
if (data.achievements) {
|
||||
updateData.achievements = data.achievements; // Send as array, backend will convert to JSON
|
||||
}
|
||||
|
||||
const response = await apiClient.put<PageContentResponse>(
|
||||
`/page-content/${pageType}`,
|
||||
@@ -169,6 +343,20 @@ const pageContentService = {
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload page content image
|
||||
*/
|
||||
uploadImage: async (
|
||||
file: File
|
||||
): Promise<{ success: boolean; data: { image_url: string; full_url: string }; message: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
// Don't set Content-Type header - let the browser set it with the correct boundary
|
||||
const response = await apiClient.post('/page-content/upload', formData);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default pageContentService;
|
||||
|
||||
@@ -97,14 +97,10 @@ export const confirmBankTransfer = async (
|
||||
formData.append('receipt', receipt);
|
||||
}
|
||||
|
||||
// Don't set Content-Type header - let the browser set it with the correct boundary
|
||||
const response = await apiClient.post(
|
||||
'/payments/confirm',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}
|
||||
formData
|
||||
);
|
||||
|
||||
return response.data;
|
||||
|
||||
@@ -322,14 +322,10 @@ const systemSettingsService = {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
// Don't set Content-Type header - let the browser set it with the correct boundary
|
||||
const response = await apiClient.post<UploadLogoResponse>(
|
||||
'/api/admin/system-settings/company/logo',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}
|
||||
formData
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
@@ -343,14 +339,10 @@ const systemSettingsService = {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
// Don't set Content-Type header - let the browser set it with the correct boundary
|
||||
const response = await apiClient.post<UploadFaviconResponse>(
|
||||
'/api/admin/system-settings/company/favicon',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}
|
||||
formData
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user