This commit is contained in:
Iliyan Angelov
2025-11-17 18:26:30 +02:00
parent 48353cde9c
commit 0c59fe1173
2535 changed files with 278997 additions and 2480 deletions

View File

@@ -9,6 +9,8 @@
"version": "1.0.0",
"dependencies": {
"@hookform/resolvers": "^3.3.2",
"@stripe/react-stripe-js": "^2.9.0",
"@stripe/stripe-js": "^2.4.0",
"@types/react-datepicker": "^6.2.0",
"axios": "^1.6.2",
"date-fns": "^2.30.0",
@@ -1436,6 +1438,27 @@
"win32"
]
},
"node_modules/@stripe/react-stripe-js": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-2.9.0.tgz",
"integrity": "sha512-+/j2g6qKAKuWSurhgRMfdlIdKM+nVVJCy/wl0US2Ccodlqx0WqfIIBhUkeONkCG+V/b+bZzcj4QVa3E/rXtT4Q==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.7.2"
},
"peerDependencies": {
"@stripe/stripe-js": "^1.44.1 || ^2.0.0 || ^3.0.0 || ^4.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@stripe/stripe-js": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-2.4.0.tgz",
"integrity": "sha512-WFkQx1mbs2b5+7looI9IV1BLa3bIApuN3ehp9FP58xGg7KL9hCHDECgW3BwO9l9L+xBPVAD7Yjn1EhGe6EDTeA==",
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -3609,7 +3632,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -3990,6 +4012,17 @@
"node": ">= 0.8.0"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/property-expr": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
@@ -4126,6 +4159,12 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",

View File

@@ -11,6 +11,8 @@
},
"dependencies": {
"@hookform/resolvers": "^3.3.2",
"@stripe/react-stripe-js": "^2.9.0",
"@stripe/stripe-js": "^2.4.0",
"@types/react-datepicker": "^6.2.0",
"axios": "^1.6.2",
"date-fns": "^2.30.0",

View File

@@ -9,6 +9,7 @@ import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { GlobalLoadingProvider } from './contexts/GlobalLoadingContext';
import { CookieConsentProvider } from './contexts/CookieConsentContext';
import { CurrencyProvider } from './contexts/CurrencyContext';
import OfflineIndicator from './components/common/OfflineIndicator';
import CookieConsentBanner from './components/common/CookieConsentBanner';
import AnalyticsLoader from './components/common/AnalyticsLoader';
@@ -40,8 +41,10 @@ const BookingPage = lazy(() => import('./pages/customer/BookingPage'));
const BookingSuccessPage = lazy(() => import('./pages/customer/BookingSuccessPage'));
const BookingDetailPage = lazy(() => import('./pages/customer/BookingDetailPage'));
const DepositPaymentPage = lazy(() => import('./pages/customer/DepositPaymentPage'));
const FullPaymentPage = lazy(() => import('./pages/customer/FullPaymentPage'));
const PaymentConfirmationPage = lazy(() => import('./pages/customer/PaymentConfirmationPage'));
const PaymentResultPage = lazy(() => import('./pages/customer/PaymentResultPage'));
const InvoicePage = lazy(() => import('./pages/customer/InvoicePage'));
const ProfilePage = lazy(() => import('./pages/customer/ProfilePage'));
const AboutPage = lazy(() => import('./pages/AboutPage'));
const LoginPage = lazy(() => import('./pages/auth/LoginPage'));
@@ -55,12 +58,15 @@ const RoomManagementPage = lazy(() => import('./pages/admin/RoomManagementPage')
const UserManagementPage = lazy(() => import('./pages/admin/UserManagementPage'));
const BookingManagementPage = lazy(() => import('./pages/admin/BookingManagementPage'));
const PaymentManagementPage = lazy(() => import('./pages/admin/PaymentManagementPage'));
const InvoiceManagementPage = lazy(() => import('./pages/admin/InvoiceManagementPage'));
const ServiceManagementPage = lazy(() => import('./pages/admin/ServiceManagementPage'));
const ReviewManagementPage = lazy(() => import('./pages/admin/ReviewManagementPage'));
const PromotionManagementPage = lazy(() => import('./pages/admin/PromotionManagementPage'));
const BannerManagementPage = lazy(() => import('./pages/admin/BannerManagementPage'));
const ReportsPage = lazy(() => import('./pages/admin/ReportsPage'));
const CookieSettingsPage = lazy(() => import('./pages/admin/CookieSettingsPage'));
const CurrencySettingsPage = lazy(() => import('./pages/admin/CurrencySettingsPage'));
const StripeSettingsPage = lazy(() => import('./pages/admin/StripeSettingsPage'));
const AuditLogsPage = lazy(() => import('./pages/admin/AuditLogsPage'));
const CheckInPage = lazy(() => import('./pages/admin/CheckInPage'));
const CheckOutPage = lazy(() => import('./pages/admin/CheckOutPage'));
@@ -123,6 +129,7 @@ function App() {
return (
<GlobalLoadingProvider>
<CookieConsentProvider>
<CurrencyProvider>
<BrowserRouter
future={{
v7_startTransition: true,
@@ -152,7 +159,7 @@ function App() {
element={<SearchResultsPage />}
/>
<Route
path="rooms/:id"
path="rooms/:room_number"
element={<RoomDetailPage />}
/>
<Route
@@ -163,6 +170,14 @@ function App() {
path="payment-result"
element={<PaymentResultPage />}
/>
<Route
path="invoices/:id"
element={
<ProtectedRoute>
<InvoicePage />
</ProtectedRoute>
}
/>
<Route
path="about"
element={<AboutPage />}
@@ -218,7 +233,15 @@ function App() {
}
/>
<Route
path="payment/:id"
path="payment/:bookingId"
element={
<ProtectedRoute>
<FullPaymentPage />
</ProtectedRoute>
}
/>
<Route
path="payment-confirmation/:id"
element={
<ProtectedRoute>
<PaymentConfirmationPage />
@@ -283,6 +306,10 @@ function App() {
path="payments"
element={<PaymentManagementPage />}
/>
<Route
path="invoices"
element={<InvoiceManagementPage />}
/>
<Route
path="services"
element={<ServiceManagementPage />}
@@ -319,6 +346,14 @@ function App() {
path="settings"
element={<CookieSettingsPage />}
/>
<Route
path="settings/currency"
element={<CurrencySettingsPage />}
/>
<Route
path="settings/stripe"
element={<StripeSettingsPage />}
/>
</Route>
{/* 404 Route */}
@@ -347,6 +382,7 @@ function App() {
<AnalyticsLoader />
</Suspense>
</BrowserRouter>
</CurrencyProvider>
</CookieConsentProvider>
</GlobalLoadingProvider>
);

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { useCurrency } from '../../contexts/CurrencyContext';
import { getCurrencySymbol } from '../../utils/format';
interface CurrencyIconProps {
className?: string;
size?: number;
currency?: string; // Optional: if not provided, uses currency from context
}
/**
* Dynamic currency icon component that displays the currency symbol
* instead of a hardcoded dollar sign icon
*/
const CurrencyIcon: React.FC<CurrencyIconProps> = ({
className = '',
size = 24,
currency
}) => {
const { currency: contextCurrency } = useCurrency();
const currencyToUse = currency || contextCurrency || 'VND';
const symbol = getCurrencySymbol(currencyToUse);
return (
<div
className={`flex items-center justify-center font-semibold ${className}`}
style={{
width: `${size}px`,
height: `${size}px`,
fontSize: `${size * 0.6}px`,
lineHeight: 1
}}
title={`${currencyToUse} currency symbol`}
>
{symbol}
</div>
);
};
export default CurrencyIcon;

View File

@@ -0,0 +1,126 @@
import React, { useState } from 'react';
import { useCurrency } from '../../contexts/CurrencyContext';
import { Globe, Save } from 'lucide-react';
import { toast } from 'react-toastify';
import systemSettingsService from '../../services/api/systemSettingsService';
import useAuthStore from '../../store/useAuthStore';
import { getCurrencySymbol } from '../../utils/format';
interface CurrencySelectorProps {
className?: string;
showLabel?: boolean;
variant?: 'dropdown' | 'select';
adminMode?: boolean; // If true, allows updating platform currency
}
const CurrencySelector: React.FC<CurrencySelectorProps> = ({
className = '',
showLabel = true,
variant = 'select',
adminMode = false,
}) => {
const { currency, supportedCurrencies, isLoading, refreshCurrency } = useCurrency();
const { userInfo } = useAuthStore();
const [saving, setSaving] = useState(false);
const isAdmin = userInfo?.role === 'admin';
const currencyNames: Record<string, string> = {
VND: 'Vietnamese Dong',
USD: 'US Dollar',
EUR: 'Euro',
GBP: 'British Pound',
JPY: 'Japanese Yen',
CNY: 'Chinese Yuan',
KRW: 'South Korean Won',
SGD: 'Singapore Dollar',
THB: 'Thai Baht',
AUD: 'Australian Dollar',
CAD: 'Canadian Dollar',
};
const getCurrencyDisplayName = (code: string): string => {
const name = currencyNames[code] || code;
const symbol = getCurrencySymbol(code);
return `${name} (${symbol})`;
};
const handleChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
const newCurrency = e.target.value;
// If admin mode, update platform currency
if (adminMode && isAdmin) {
try {
setSaving(true);
await systemSettingsService.updatePlatformCurrency(newCurrency);
await refreshCurrency();
toast.success('Platform currency updated successfully');
} catch (error: any) {
toast.error(error.message || 'Failed to update platform currency');
} finally {
setSaving(false);
}
}
};
if (isLoading) {
return (
<div className={`flex items-center gap-2 ${className}`}>
{showLabel && <span className="text-sm text-gray-600">Currency:</span>}
<div className="h-8 w-24 bg-gray-200 animate-pulse rounded"></div>
</div>
);
}
if (variant === 'dropdown') {
return (
<div className={`relative ${className}`}>
{showLabel && (
<label className="block text-sm font-medium text-gray-700 mb-1">
Currency
</label>
)}
<div className="relative">
<select
value={currency}
onChange={handleChange}
className="appearance-none bg-white border border-gray-300 rounded-lg px-4 py-2 pr-8 text-sm font-medium text-gray-900 focus:outline-none focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors cursor-pointer"
>
{supportedCurrencies.map((curr) => (
<option key={curr} value={curr}>
{curr} - {getCurrencyDisplayName(curr)}
</option>
))}
</select>
<div className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<Globe className="w-4 h-4 text-gray-400" />
</div>
</div>
</div>
);
}
return (
<div className={`flex items-center gap-2 ${className}`}>
{showLabel && (
<span className="text-sm font-medium text-gray-700 flex items-center gap-1">
<Globe className="w-4 h-4" />
Currency:
</span>
)}
<select
value={currency}
onChange={handleChange}
className="px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37] transition-colors cursor-pointer"
>
{supportedCurrencies.map((curr) => (
<option key={curr} value={curr}>
{curr}
</option>
))}
</select>
</div>
);
};
export default CurrencySelector;

View File

@@ -1,9 +1,9 @@
import React from 'react';
import { CreditCard, Building2 } from 'lucide-react';
import { CreditCard } from 'lucide-react';
interface PaymentMethodSelectorProps {
value: 'cash' | 'bank_transfer';
onChange: (value: 'cash' | 'bank_transfer') => void;
value: 'cash' | 'stripe';
onChange: (value: 'cash' | 'stripe') => void;
error?: string;
disabled?: boolean;
}
@@ -62,12 +62,12 @@ const PaymentMethodSelector: React.FC<
</div>
</label>
{/* Bank Transfer */}
{/* Stripe Payment */}
<label
className={`flex items-start p-4 border-2
rounded-lg cursor-pointer transition-all
${
value === 'bank_transfer'
value === 'stripe'
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-200 hover:border-indigo-300'
}
@@ -76,37 +76,37 @@ const PaymentMethodSelector: React.FC<
<input
type="radio"
name="payment_method"
value="bank_transfer"
checked={value === 'bank_transfer'}
value="stripe"
checked={value === 'stripe'}
onChange={(e) =>
onChange(e.target.value as 'bank_transfer')
onChange(e.target.value as 'stripe')
}
disabled={disabled}
className="mt-1 mr-3"
/>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Building2
className="w-5 h-5 text-gray-600"
<CreditCard
className="w-5 h-5 text-indigo-600"
/>
<span className="font-medium text-gray-900">
Bank Transfer
Pay with Card (Stripe)
</span>
<span className="text-xs bg-green-100
text-green-700 px-2 py-0.5 rounded-full
<span className="text-xs bg-indigo-100
text-indigo-700 px-2 py-0.5 rounded-full
font-medium"
>
Recommended
Instant
</span>
</div>
<p className="text-sm text-gray-600">
Transfer via QR code or account number.
Quick confirmation within 24 hours.
Secure payment with credit or debit card.
Instant confirmation.
</p>
<div className="mt-2 text-xs text-gray-500
bg-white rounded px-2 py-1 inline-block"
>
💳 Confirmation after booking
💳 Secure card payment
</div>
</div>
</label>
@@ -123,11 +123,10 @@ const PaymentMethodSelector: React.FC<
border-blue-200 rounded-lg"
>
<p className="text-xs text-blue-800">
💡 <strong>Note:</strong> You will not be
charged immediately. {' '}
💡 <strong>Note:</strong> {' '}
{value === 'cash'
? 'Payment when checking in.'
: 'Transfer after booking confirmation.'}
? 'You will pay when checking in. Cash and card accepted at the hotel.'
: 'Your payment will be processed securely through Stripe.'}
</p>
</div>
</div>

View File

@@ -11,6 +11,7 @@ import {
Heart,
Phone,
Mail,
Calendar,
} from 'lucide-react';
import { useClickOutside } from '../../hooks/useClickOutside';
@@ -121,25 +122,6 @@ const Header: React.FC<HeaderProps> = ({
<span className="relative z-10">Rooms</span>
<span className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/0 via-[#d4af37]/10 to-[#d4af37]/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
</Link>
<Link
to="/bookings"
className="text-white/90 hover:text-[#d4af37]
transition-all duration-300 font-light px-4 py-2
relative group tracking-wide"
>
<span className="relative z-10">Bookings</span>
<span className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/0 via-[#d4af37]/10 to-[#d4af37]/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
</Link>
<Link
to="/favorites"
className="text-white/90 hover:text-[#d4af37]
transition-all duration-300 font-light px-4 py-2
relative group tracking-wide flex items-center gap-2"
>
<Heart className="w-4 h-4 relative z-10" />
<span className="relative z-10">Favorites</span>
<span className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/0 via-[#d4af37]/10 to-[#d4af37]/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
</Link>
<Link
to="/about"
className="text-white/90 hover:text-[#d4af37]
@@ -235,6 +217,30 @@ const Header: React.FC<HeaderProps> = ({
<User className="w-4 h-4" />
<span className="font-light tracking-wide">Profile</span>
</Link>
<Link
to="/favorites"
onClick={() => setIsUserMenuOpen(false)}
className="flex items-center space-x-3
px-4 py-2.5 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
transition-all duration-300 border-l-2 border-transparent
hover:border-[#d4af37]"
>
<Heart className="w-4 h-4" />
<span className="font-light tracking-wide">Favorites</span>
</Link>
<Link
to="/bookings"
onClick={() => setIsUserMenuOpen(false)}
className="flex items-center space-x-3
px-4 py-2.5 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
transition-all duration-300 border-l-2 border-transparent
hover:border-[#d4af37]"
>
<Calendar className="w-4 h-4" />
<span className="font-light tracking-wide">My Bookings</span>
</Link>
{userInfo?.role === 'admin' && (
<Link
to="/admin"
@@ -313,30 +319,6 @@ const Header: React.FC<HeaderProps> = ({
>
Rooms
</Link>
<Link
to="/bookings"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
Bookings
</Link>
<Link
to="/favorites"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide
flex items-center gap-2"
>
<Heart className="w-4 h-4" />
Favorites
</Link>
<Link
to="/about"
onClick={() => setIsMobileMenuOpen(false)}
@@ -408,6 +390,36 @@ const Header: React.FC<HeaderProps> = ({
<User className="w-4 h-4" />
<span>Profile</span>
</Link>
<Link
to="/favorites"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
<Heart className="w-4 h-4" />
<span>Favorites</span>
</Link>
<Link
to="/bookings"
onClick={() =>
setIsMobileMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
<Calendar className="w-4 h-4" />
<span>My Bookings</span>
</Link>
{userInfo?.role === 'admin' && (
<Link
to="/admin"

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
import {
LayoutDashboard,
@@ -16,7 +16,10 @@ import {
Star,
LogIn,
LogOut,
ClipboardList
ClipboardList,
DollarSign,
Menu,
X
} from 'lucide-react';
interface SidebarAdminProps {
@@ -28,10 +31,25 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
isCollapsed: controlledCollapsed,
onToggle
}) => {
const [internalCollapsed, setInternalCollapsed] =
useState(false);
const [internalCollapsed, setInternalCollapsed] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [isMobileOpen, setIsMobileOpen] = useState(false);
const location = useLocation();
// Check if mobile on mount and resize
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 1024); // lg breakpoint
if (window.innerWidth >= 1024) {
setIsMobileOpen(false);
}
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
const isCollapsed =
controlledCollapsed !== undefined
? controlledCollapsed
@@ -45,6 +63,16 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
}
};
const handleMobileToggle = () => {
setIsMobileOpen(!isMobileOpen);
};
const handleLinkClick = () => {
if (isMobile) {
setIsMobileOpen(false);
}
};
const menuItems = [
{
path: '/admin/dashboard',
@@ -71,6 +99,11 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
icon: CreditCard,
label: 'Payments'
},
{
path: '/admin/invoices',
icon: FileText,
label: 'Invoices'
},
{
path: '/admin/services',
icon: Settings,
@@ -114,97 +147,171 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
{
path: '/admin/settings',
icon: FileText,
label: 'Settings'
label: 'Cookie Settings'
},
{
path: '/admin/settings/currency',
icon: DollarSign,
label: 'Currency Settings'
},
{
path: '/admin/settings/stripe',
icon: CreditCard,
label: 'Stripe Settings'
},
];
const isActive = (path: string) => {
return location.pathname === path ||
location.pathname.startsWith(`${path}/`);
// Exact match
if (location.pathname === path) return true;
// For settings paths, only match if it's an exact match or a direct child
if (path === '/admin/settings') {
return location.pathname === '/admin/settings';
}
// For other paths, check if it starts with the path followed by /
return location.pathname.startsWith(`${path}/`);
};
return (
<aside
className={`bg-gray-900 text-white
transition-all duration-300 flex flex-col
${isCollapsed ? 'w-20' : 'w-64'}`}
>
{/* Sidebar Header */}
<div className="p-4 border-b border-gray-800
flex items-center justify-between"
>
{!isCollapsed && (
<h2 className="text-xl font-bold">
Admin Panel
</h2>
)}
<>
{/* Mobile Menu Button */}
{isMobile && (
<button
onClick={handleToggle}
className="p-2 rounded-lg hover:bg-gray-800
transition-colors ml-auto"
aria-label="Toggle sidebar"
onClick={handleMobileToggle}
className="fixed top-4 left-4 z-50 lg:hidden p-3 bg-gradient-to-r from-slate-900 to-slate-800 text-white rounded-xl shadow-2xl border border-slate-700 hover:from-slate-800 hover:to-slate-700 transition-all duration-200"
aria-label="Toggle menu"
>
{isCollapsed ? (
<ChevronRight className="w-5 h-5" />
{isMobileOpen ? (
<X className="w-6 h-6" />
) : (
<ChevronLeft className="w-5 h-5" />
<Menu className="w-6 h-6" />
)}
</button>
</div>
)}
{/* Menu Items */}
<nav className="flex-1 overflow-y-auto py-4">
<ul className="space-y-1 px-2">
{menuItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.path);
return (
<li key={item.path}>
<Link
to={item.path}
className={`flex items-center
space-x-3 px-3 py-3 rounded-lg
transition-colors group
${active
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
}`}
title={isCollapsed ? item.label : undefined}
>
<Icon className={`flex-shrink-0
${isCollapsed ? 'w-6 h-6' : 'w-5 h-5'}`}
/>
{!isCollapsed && (
<span className="font-medium">
{item.label}
</span>
)}
</Link>
</li>
);
})}
</ul>
</nav>
{/* Mobile Overlay */}
{isMobile && isMobileOpen && (
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 lg:hidden"
onClick={handleMobileToggle}
/>
)}
{/* Sidebar Footer */}
<div className="p-4 border-t border-gray-800">
{!isCollapsed ? (
<div className="text-xs text-gray-400 text-center">
<p>Admin Dashboard v1.0</p>
<p className="mt-1">
© {new Date().getFullYear()}
</p>
</div>
) : (
<div className="flex justify-center">
<div className="w-2 h-2 bg-green-500
rounded-full"
></div>
</div>
)}
</div>
</aside>
{/* Sidebar */}
<aside
className={`
fixed lg:static inset-y-0 left-0 z-40
bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900
text-white shadow-2xl
transition-all duration-300 ease-in-out flex flex-col
${isMobile
? (isMobileOpen ? 'translate-x-0' : '-translate-x-full')
: ''
}
${!isMobile && (isCollapsed ? 'w-20' : 'w-72')}
${isMobile ? 'w-72' : ''}
border-r border-slate-700/50
`}
>
{/* Luxury Sidebar Header */}
<div className="p-6 border-b border-slate-700/50 flex items-center justify-between bg-gradient-to-r from-slate-800/50 to-slate-900/50 backdrop-blur-sm">
{!isCollapsed && (
<div className="flex items-center gap-3">
<div className="h-1 w-12 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h2 className="text-xl font-bold bg-gradient-to-r from-amber-100 to-amber-200 bg-clip-text text-transparent">
Admin Panel
</h2>
</div>
)}
{isCollapsed && !isMobile && (
<div className="w-full flex justify-center">
<div className="h-8 w-8 bg-gradient-to-br from-amber-400 to-amber-600 rounded-xl flex items-center justify-center shadow-lg">
<span className="text-slate-900 font-bold text-sm">A</span>
</div>
</div>
)}
{!isMobile && (
<button
onClick={handleToggle}
className="p-2.5 rounded-xl bg-slate-800/50 hover:bg-slate-700/50 border border-slate-700/50 hover:border-amber-500/50 transition-all duration-200 ml-auto shadow-lg hover:shadow-xl"
aria-label="Toggle sidebar"
>
{isCollapsed ? (
<ChevronRight className="w-5 h-5 text-amber-200" />
) : (
<ChevronLeft className="w-5 h-5 text-amber-200" />
)}
</button>
)}
</div>
{/* Menu Items */}
<nav className="flex-1 overflow-y-auto py-4 px-3 custom-scrollbar">
<ul className="space-y-2">
{menuItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.path);
return (
<li key={item.path}>
<Link
to={item.path}
onClick={handleLinkClick}
className={`
flex items-center
space-x-3 px-4 py-3.5 rounded-xl
transition-all duration-200 group relative
${active
? 'bg-gradient-to-r from-amber-500/20 to-amber-600/20 text-amber-100 shadow-lg border border-amber-500/30'
: 'text-slate-300 hover:bg-slate-800/50 hover:text-amber-100 border border-transparent hover:border-slate-700/50'
}
${isCollapsed && !isMobile ? 'justify-center' : ''}
`}
title={isCollapsed && !isMobile ? item.label : undefined}
>
{active && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-gradient-to-b from-amber-400 to-amber-600 rounded-r-full"></div>
)}
<Icon className={`
flex-shrink-0 transition-transform duration-200
${active ? 'text-amber-400' : 'text-slate-400 group-hover:text-amber-400'}
${isCollapsed && !isMobile ? 'w-6 h-6' : 'w-5 h-5'}
`} />
{(!isCollapsed || isMobile) && (
<span className={`
font-semibold transition-all duration-200
${active ? 'text-amber-100' : 'group-hover:text-amber-100'}
`}>
{item.label}
</span>
)}
{active && !isCollapsed && (
<div className="ml-auto w-2 h-2 bg-amber-400 rounded-full animate-pulse"></div>
)}
</Link>
</li>
);
})}
</ul>
</nav>
{/* Luxury Sidebar Footer */}
<div className="p-4 border-t border-slate-700/50 bg-gradient-to-r from-slate-800/50 to-slate-900/50 backdrop-blur-sm">
{(!isCollapsed || isMobile) ? (
<div className="text-xs text-slate-400 text-center space-y-1">
<p className="font-semibold text-amber-200/80">Admin Dashboard</p>
<p className="text-slate-500">
© {new Date().getFullYear()} Luxury Hotel
</p>
</div>
) : (
<div className="flex justify-center">
<div className="w-3 h-3 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-full shadow-lg animate-pulse"></div>
</div>
)}
</div>
</aside>
</>
);
};

View File

@@ -0,0 +1,174 @@
import React, { useState, useEffect } from 'react';
import {
PaymentElement,
useStripe,
useElements,
} from '@stripe/react-stripe-js';
import { CreditCard, Loader2, AlertCircle, CheckCircle } from 'lucide-react';
import { toast } from 'react-toastify';
interface StripePaymentFormProps {
clientSecret: string;
amount: number;
onSuccess: (paymentIntentId: string) => void;
onError: (error: string) => void;
}
const StripePaymentForm: React.FC<StripePaymentFormProps> = ({
clientSecret,
amount,
onSuccess,
onError,
}) => {
const stripe = useStripe();
const elements = useElements();
const [isProcessing, setIsProcessing] = useState(false);
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
if (!stripe || !clientSecret) {
return;
}
}, [stripe, clientSecret]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!stripe || !elements) {
return;
}
setIsProcessing(true);
setMessage(null);
try {
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: window.location.origin + '/payment-success',
},
redirect: 'if_required',
});
if (error) {
setMessage(error.message || 'An error occurred during payment');
onError(error.message || 'Payment failed');
toast.error(error.message || 'Payment failed');
} else if (paymentIntent && paymentIntent.status === 'succeeded') {
setMessage('Payment succeeded!');
toast.success('Payment successful!');
onSuccess(paymentIntent.id);
} else {
setMessage('Payment processing...');
}
} catch (err: any) {
const errorMessage = err.message || 'An unexpected error occurred';
setMessage(errorMessage);
onError(errorMessage);
toast.error(errorMessage);
} finally {
setIsProcessing(false);
}
};
if (!stripe || !elements) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="w-8 h-8 animate-spin text-indigo-600" />
<span className="ml-2 text-gray-600">Loading payment form...</span>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="bg-white rounded-lg border border-gray-200 p-6">
<div className="flex items-center gap-2 mb-4">
<CreditCard className="w-5 h-5 text-indigo-600" />
<h3 className="text-lg font-semibold text-gray-900">
Payment Details
</h3>
</div>
<div className="mb-4 p-4 bg-indigo-50 border border-indigo-200 rounded-lg">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-indigo-900">
Amount to Pay
</span>
<span className="text-xl font-bold text-indigo-600">
${amount.toFixed(2)}
</span>
</div>
</div>
<div className="mb-4">
<PaymentElement
options={{
layout: 'tabs',
}}
/>
</div>
{message && (
<div
className={`mb-4 p-4 rounded-lg flex items-start gap-3 ${
message.includes('succeeded')
? 'bg-green-50 border border-green-200'
: message.includes('error') || message.includes('failed')
? 'bg-red-50 border border-red-200'
: 'bg-yellow-50 border border-yellow-200'
}`}
>
{message.includes('succeeded') ? (
<CheckCircle className="w-5 h-5 text-green-600 mt-0.5" />
) : message.includes('error') || message.includes('failed') ? (
<AlertCircle className="w-5 h-5 text-red-600 mt-0.5" />
) : (
<Loader2 className="w-5 h-5 text-yellow-600 mt-0.5 animate-spin" />
)}
<p
className={`text-sm ${
message.includes('succeeded')
? 'text-green-800'
: message.includes('error') || message.includes('failed')
? 'text-red-800'
: 'text-yellow-800'
}`}
>
{message}
</p>
</div>
)}
<button
type="submit"
disabled={isProcessing || !stripe || !elements}
className="w-full bg-indigo-600 text-white py-3 px-4 rounded-lg
hover:bg-indigo-700 transition-colors font-semibold
disabled:bg-gray-400 disabled:cursor-not-allowed
flex items-center justify-center gap-2"
>
{isProcessing ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Processing...
</>
) : (
<>
<CreditCard className="w-5 h-5" />
Pay ${amount.toFixed(2)}
</>
)}
</button>
<p className="text-xs text-gray-500 mt-3 text-center">
Your payment is secure and encrypted
</p>
</div>
</form>
);
};
export default StripePaymentForm;

View File

@@ -0,0 +1,197 @@
import React, { useState, useEffect } from 'react';
import { loadStripe, StripeElementsOptions } from '@stripe/stripe-js';
import { Elements } from '@stripe/react-stripe-js';
import StripePaymentForm from './StripePaymentForm';
import { createStripePaymentIntent, confirmStripePayment } from '../../services/api/paymentService';
import { Loader2, AlertCircle } from 'lucide-react';
import Loading from '../common/Loading';
interface StripePaymentWrapperProps {
bookingId: number;
amount: number;
currency?: string;
onSuccess: () => void;
onError?: (error: string) => void;
}
const StripePaymentWrapper: React.FC<StripePaymentWrapperProps> = ({
bookingId,
amount,
currency = 'usd',
onSuccess,
onError,
}) => {
const [stripePromise, setStripePromise] = useState<Promise<any> | null>(null);
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [publishableKey, setPublishableKey] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [paymentCompleted, setPaymentCompleted] = useState(false);
// Initialize Stripe payment intent
useEffect(() => {
// Don't initialize if payment is already completed
if (paymentCompleted) {
return;
}
// First, create payment intent to get publishable key
const initializeStripe = async () => {
try {
setLoading(true);
setError(null);
const response = await createStripePaymentIntent(
bookingId,
amount,
currency
);
if (response.success && response.data) {
const { publishable_key, client_secret } = response.data;
console.log('Payment intent response:', { publishable_key: publishable_key ? 'present' : 'missing', client_secret: client_secret ? 'present' : 'missing' });
if (!client_secret) {
throw new Error('Client secret not received from server');
}
if (!publishable_key) {
throw new Error('Publishable key not configured. Please configure Stripe settings in Admin Panel.');
}
setPublishableKey(publishable_key);
setClientSecret(client_secret);
// Initialize Stripe with publishable key
// loadStripe returns a Promise, so we don't need to wrap it
const stripePromise = loadStripe(publishable_key);
setStripePromise(stripePromise);
// Wait for Stripe to load before proceeding
const stripe = await stripePromise;
if (!stripe) {
throw new Error('Failed to load Stripe');
}
} else {
throw new Error(response.message || 'Failed to initialize payment');
}
} catch (err: any) {
console.error('Error initializing Stripe:', err);
const errorMessage = err.response?.data?.message || err.message || 'Failed to initialize payment';
setError(errorMessage);
if (onError) {
onError(errorMessage);
}
} finally {
setLoading(false);
}
};
initializeStripe();
}, [bookingId, amount, currency, onError, paymentCompleted]);
// Debug logging - must be before any conditional returns
useEffect(() => {
if (clientSecret && stripePromise) {
console.log('Stripe initialized successfully', { hasClientSecret: !!clientSecret, hasStripePromise: !!stripePromise });
} else {
console.log('Stripe not ready', { hasClientSecret: !!clientSecret, hasStripePromise: !!stripePromise, error });
}
}, [clientSecret, stripePromise, error]);
const handlePaymentSuccess = async (paymentIntentId: string) => {
try {
// Mark payment as completed to prevent re-initialization
setPaymentCompleted(true);
const response = await confirmStripePayment(paymentIntentId, bookingId);
if (response.success) {
onSuccess();
} else {
// Reset payment completed flag if confirmation failed
setPaymentCompleted(false);
throw new Error(response.message || 'Payment confirmation failed');
}
} catch (err: any) {
console.error('Error confirming payment:', err);
// Reset payment completed flag on error
setPaymentCompleted(false);
const errorMessage = err.response?.data?.message || err.message || 'Payment confirmation failed';
setError(errorMessage);
if (onError) {
onError(errorMessage);
}
}
};
const handlePaymentError = (errorMessage: string) => {
setError(errorMessage);
if (onError) {
onError(errorMessage);
}
};
// Don't show error if payment is completed
if (paymentCompleted) {
return null; // Component will be unmounted by parent
}
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="w-8 h-8 animate-spin text-indigo-600" />
<span className="ml-2 text-gray-600">Initializing payment...</span>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-600 mt-0.5" />
<div>
<h3 className="text-lg font-semibold text-red-900 mb-1">
Payment Initialization Failed
</h3>
<p className="text-sm text-red-800">
{error || 'Unable to initialize payment. Please try again.'}
</p>
</div>
</div>
</div>
);
}
if (!clientSecret || !stripePromise) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="w-8 h-8 animate-spin text-indigo-600" />
<span className="ml-2 text-gray-600">Loading payment form...</span>
</div>
);
}
const options: StripeElementsOptions = {
clientSecret,
appearance: {
theme: 'stripe',
},
};
return (
<Elements stripe={stripePromise} options={options}>
<StripePaymentForm
clientSecret={clientSecret}
amount={amount}
onSuccess={handlePaymentSuccess}
onError={handlePaymentError}
/>
</Elements>
);
};
export default StripePaymentWrapper;

View File

@@ -77,10 +77,11 @@ const Pagination: React.FC<PaginationProps> = ({
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="px-3 py-2 border border-gray-300
rounded-lg hover:bg-gray-100
className="px-4 py-2 border border-[#d4af37]/30
rounded-lg bg-[#0a0a0a] text-gray-300
hover:bg-[#1a1a1a] hover:border-[#d4af37] hover:text-[#d4af37]
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
transition-all duration-300 font-medium tracking-wide"
aria-label="Previous page"
>
<ChevronLeft size={18} />
@@ -92,7 +93,7 @@ const Pagination: React.FC<PaginationProps> = ({
return (
<span
key={`ellipsis-${index}`}
className="px-3 py-2 text-gray-500"
className="px-4 py-2 text-gray-500 font-light"
>
...
</span>
@@ -106,11 +107,11 @@ const Pagination: React.FC<PaginationProps> = ({
<button
key={pageNum}
onClick={() => handlePageChange(pageNum)}
className={`px-4 py-2 rounded-lg transition-colors
font-medium ${
className={`px-5 py-2 rounded-lg transition-all duration-300
font-medium tracking-wide ${
isActive
? 'bg-blue-600 text-white'
: 'border border-gray-300 hover:bg-gray-100 text-gray-700'
? 'bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] shadow-lg shadow-[#d4af37]/30'
: 'border border-[#d4af37]/30 bg-[#0a0a0a] text-gray-300 hover:bg-[#1a1a1a] hover:border-[#d4af37] hover:text-[#d4af37]'
}`}
aria-label={`Page ${pageNum}`}
aria-current={isActive ? 'page' : undefined}
@@ -124,10 +125,11 @@ const Pagination: React.FC<PaginationProps> = ({
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-3 py-2 border border-gray-300
rounded-lg hover:bg-gray-100
className="px-4 py-2 border border-[#d4af37]/30
rounded-lg bg-[#0a0a0a] text-gray-300
hover:bg-[#1a1a1a] hover:border-[#d4af37] hover:text-[#d4af37]
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
transition-all duration-300 font-medium tracking-wide"
aria-label="Next page"
>
<ChevronRight size={18} />

View File

@@ -12,6 +12,42 @@ import {
Shield,
Cigarette,
Bath,
Home,
Bed,
Sofa,
Key,
Phone,
Zap,
Gamepad2,
Music,
Sparkles,
Flame,
Lock,
Baby,
Heart,
MapPin,
Building,
Users,
Laptop,
Smartphone,
Monitor,
Radio,
Gamepad,
Headphones,
UtensilsCrossed as Restaurant,
Briefcase,
Printer,
Mail,
Clock,
Sunrise,
Moon,
Eye,
Ear,
Accessibility,
Baby as BabyIcon,
PawPrint,
Radio as RadioIcon,
Flame as Fireplace,
} from 'lucide-react';
interface RoomAmenitiesProps {
@@ -59,80 +95,565 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
const safeAmenities = normalizeAmenities(amenities);
// Icon mapping for common amenities
// Icon mapping for comprehensive amenities
const amenityIcons: Record<string, React.ReactNode> = {
// Basic & Internet
wifi: <Wifi className="w-5 h-5" />,
'wi-fi': <Wifi className="w-5 h-5" />,
'free wifi': <Wifi className="w-5 h-5" />,
'wifi in room': <Wifi className="w-5 h-5" />,
'high-speed internet': <Wifi className="w-5 h-5" />,
// Entertainment
tv: <Tv className="w-5 h-5" />,
television: <Tv className="w-5 h-5" />,
'flat-screen tv': <Tv className="w-5 h-5" />,
'cable tv': <Tv className="w-5 h-5" />,
'satellite tv': <Tv className="w-5 h-5" />,
'smart tv': <Tv className="w-5 h-5" />,
netflix: <Tv className="w-5 h-5" />,
'streaming services': <Tv className="w-5 h-5" />,
'dvd player': <Monitor className="w-5 h-5" />,
'stereo system': <Music className="w-5 h-5" />,
radio: <RadioIcon className="w-5 h-5" />,
'ipod dock': <Smartphone className="w-5 h-5" />,
'blu-ray player': <Monitor className="w-5 h-5" />,
'gaming console': <Gamepad2 className="w-5 h-5" />,
playstation: <Gamepad2 className="w-5 h-5" />,
xbox: <Gamepad2 className="w-5 h-5" />,
'sound system': <Headphones className="w-5 h-5" />,
'surround sound': <Headphones className="w-5 h-5" />,
'music system': <Music className="w-5 h-5" />,
// Climate
'air-conditioning': <Wind className="w-5 h-5" />,
'air conditioning': <Wind className="w-5 h-5" />,
ac: <Wind className="w-5 h-5" />,
heating: <Flame className="w-5 h-5" />,
'climate control': <Wind className="w-5 h-5" />,
'ceiling fan': <Wind className="w-5 h-5" />,
'air purifier': <Wind className="w-5 h-5" />,
// Bathroom
'private bathroom': <Bath className="w-5 h-5" />,
'ensuite bathroom': <Bath className="w-5 h-5" />,
bathtub: <Bath className="w-5 h-5" />,
'jacuzzi bathtub': <Bath className="w-5 h-5" />,
'hot tub': <Waves className="w-5 h-5" />,
shower: <Bath className="w-5 h-5" />,
'rain shower': <Bath className="w-5 h-5" />,
'walk-in shower': <Bath className="w-5 h-5" />,
'steam shower': <Bath className="w-5 h-5" />,
bidet: <Bath className="w-5 h-5" />,
'hair dryer': <Sparkles className="w-5 h-5" />,
hairdryer: <Sparkles className="w-5 h-5" />,
bathrobes: <Bath className="w-5 h-5" />,
slippers: <Bath className="w-5 h-5" />,
toiletries: <Bath className="w-5 h-5" />,
'premium toiletries': <Bath className="w-5 h-5" />,
towels: <Bath className="w-5 h-5" />,
// Food & Beverage
'mini bar': <Coffee className="w-5 h-5" />,
minibar: <Coffee className="w-5 h-5" />,
restaurant: <Utensils className="w-5 h-5" />,
refrigerator: <Coffee className="w-5 h-5" />,
fridge: <Coffee className="w-5 h-5" />,
microwave: <Coffee className="w-5 h-5" />,
'coffee maker': <Coffee className="w-5 h-5" />,
'electric kettle': <Coffee className="w-5 h-5" />,
kettle: <Coffee className="w-5 h-5" />,
'tea making facilities': <Coffee className="w-5 h-5" />,
'coffee machine': <Coffee className="w-5 h-5" />,
'nespresso machine': <Coffee className="w-5 h-5" />,
kitchenette: <Utensils className="w-5 h-5" />,
'dining table': <Utensils className="w-5 h-5" />,
'room service': <UtensilsCrossed className="w-5 h-5" />,
'breakfast included': <Coffee className="w-5 h-5" />,
breakfast: <Coffee className="w-5 h-5" />,
'complimentary water': <Coffee className="w-5 h-5" />,
'bottled water': <Coffee className="w-5 h-5" />,
// Furniture
desk: <Briefcase className="w-5 h-5" />,
'writing desk': <Briefcase className="w-5 h-5" />,
'office desk': <Briefcase className="w-5 h-5" />,
'work desk': <Briefcase className="w-5 h-5" />,
sofa: <Sofa className="w-5 h-5" />,
'sitting area': <Sofa className="w-5 h-5" />,
'lounge area': <Sofa className="w-5 h-5" />,
'dining area': <Utensils className="w-5 h-5" />,
'separate living area': <Home className="w-5 h-5" />,
wardrobe: <Home className="w-5 h-5" />,
closet: <Home className="w-5 h-5" />,
dresser: <Home className="w-5 h-5" />,
mirror: <Sparkles className="w-5 h-5" />,
'full-length mirror': <Sparkles className="w-5 h-5" />,
'seating area': <Sofa className="w-5 h-5" />,
// Bed & Sleep
'king size bed': <Bed className="w-5 h-5" />,
'queen size bed': <Bed className="w-5 h-5" />,
'double bed': <Bed className="w-5 h-5" />,
'twin beds': <Bed className="w-5 h-5" />,
'single bed': <Bed className="w-5 h-5" />,
'extra bedding': <Bed className="w-5 h-5" />,
'pillow menu': <Bed className="w-5 h-5" />,
'premium bedding': <Bed className="w-5 h-5" />,
'blackout curtains': <Moon className="w-5 h-5" />,
soundproofing: <Shield className="w-5 h-5" />,
// Safety & Security
safe: <Shield className="w-5 h-5" />,
'in-room safe': <Shield className="w-5 h-5" />,
'safety deposit box': <Shield className="w-5 h-5" />,
'smoke detector': <Shield className="w-5 h-5" />,
'fire extinguisher': <Flame className="w-5 h-5" />,
'security system': <Shield className="w-5 h-5" />,
'key card access': <Key className="w-5 h-5" />,
'door lock': <Lock className="w-5 h-5" />,
// Technology
'usb charging ports': <Zap className="w-5 h-5" />,
'usb ports': <Zap className="w-5 h-5" />,
'usb outlets': <Zap className="w-5 h-5" />,
'power outlets': <Zap className="w-5 h-5" />,
'charging station': <Zap className="w-5 h-5" />,
'laptop safe': <Laptop className="w-5 h-5" />,
'hdmi port': <Monitor className="w-5 h-5" />,
phone: <Phone className="w-5 h-5" />,
'desk phone': <Phone className="w-5 h-5" />,
'wake-up service': <Sunrise className="w-5 h-5" />,
'alarm clock': <Clock className="w-5 h-5" />,
'digital clock': <Clock className="w-5 h-5" />,
// View & Outdoor
balcony: <MapPin className="w-5 h-5" />,
'private balcony': <MapPin className="w-5 h-5" />,
terrace: <MapPin className="w-5 h-5" />,
patio: <MapPin className="w-5 h-5" />,
'city view': <MapPin className="w-5 h-5" />,
'ocean view': <Waves className="w-5 h-5" />,
'sea view': <Waves className="w-5 h-5" />,
'mountain view': <MapPin className="w-5 h-5" />,
'garden view': <MapPin className="w-5 h-5" />,
'pool view': <Waves className="w-5 h-5" />,
'park view': <MapPin className="w-5 h-5" />,
window: <Eye className="w-5 h-5" />,
'large windows': <Eye className="w-5 h-5" />,
'floor-to-ceiling windows': <Eye className="w-5 h-5" />,
// Services
'24-hour front desk': <Building className="w-5 h-5" />,
'24 hour front desk': <Building className="w-5 h-5" />,
'24/7 front desk': <Building className="w-5 h-5" />,
'front desk': <Building className="w-5 h-5" />,
'concierge service': <Users className="w-5 h-5" />,
'butler service': <Users className="w-5 h-5" />,
butler: <Users className="w-5 h-5" />,
housekeeping: <Home className="w-5 h-5" />,
'daily housekeeping': <Home className="w-5 h-5" />,
'turndown service': <Moon className="w-5 h-5" />,
'laundry service': <Home className="w-5 h-5" />,
laundry: <Home className="w-5 h-5" />,
'dry cleaning': <Home className="w-5 h-5" />,
'ironing service': <Sparkles className="w-5 h-5" />,
'luggage storage': <Home className="w-5 h-5" />,
'bell service': <Shield className="w-5 h-5" />,
'valet parking': <Car className="w-5 h-5" />,
parking: <Car className="w-5 h-5" />,
'free parking': <Car className="w-5 h-5" />,
'airport shuttle': <Car className="w-5 h-5" />,
'shuttle service': <Car className="w-5 h-5" />,
shuttle: <Car className="w-5 h-5" />,
'car rental': <Car className="w-5 h-5" />,
'taxi service': <Car className="w-5 h-5" />,
// Fitness & Wellness
'gym access': <Dumbbell className="w-5 h-5" />,
'fitness center': <Dumbbell className="w-5 h-5" />,
'fitness room': <Dumbbell className="w-5 h-5" />,
gym: <Dumbbell className="w-5 h-5" />,
fitness: <Dumbbell className="w-5 h-5" />,
pool: <Waves className="w-5 h-5" />,
'swimming pool': <Waves className="w-5 h-5" />,
'room service': <UtensilsCrossed className="w-5 h-5" />,
safe: <Shield className="w-5 h-5" />,
'no smoking': <Cigarette className="w-5 h-5" />,
bathtub: <Bath className="w-5 h-5" />,
shower: <Bath className="w-5 h-5" />,
breakfast: <Coffee className="w-5 h-5" />,
'breakfast included': <Coffee className="w-5 h-5" />,
kettle: <Coffee className="w-5 h-5" />,
'hair dryer': <Shield className="w-5 h-5" />,
hairdryer: <Shield className="w-5 h-5" />,
iron: <Shield className="w-5 h-5" />,
fridge: <Utensils className="w-5 h-5" />,
microwave: <Utensils className="w-5 h-5" />,
'private bathroom': <Bath className="w-5 h-5" />,
balcony: <Wind className="w-5 h-5" />,
'24-hour front desk': <Shield className="w-5 h-5" />,
'front desk': <Shield className="w-5 h-5" />,
'spa access': <Waves className="w-5 h-5" />,
spa: <Waves className="w-5 h-5" />,
sauna: <Waves className="w-5 h-5" />,
'steam room': <Waves className="w-5 h-5" />,
'massage service': <Heart className="w-5 h-5" />,
'beauty services': <Sparkles className="w-5 h-5" />,
// Recreation
'swimming pool': <Waves className="w-5 h-5" />,
pool: <Waves className="w-5 h-5" />,
'indoor pool': <Waves className="w-5 h-5" />,
'outdoor pool': <Waves className="w-5 h-5" />,
'infinity pool': <Waves className="w-5 h-5" />,
'pool access': <Waves className="w-5 h-5" />,
'golf course': <Car className="w-5 h-5" />,
'tennis court': <Dumbbell className="w-5 h-5" />,
'beach access': <Waves className="w-5 h-5" />,
'water sports': <Waves className="w-5 h-5" />,
// Business
'business center': <Briefcase className="w-5 h-5" />,
'meeting room': <Briefcase className="w-5 h-5" />,
'conference room': <Briefcase className="w-5 h-5" />,
'fax service': <Printer className="w-5 h-5" />,
photocopying: <Printer className="w-5 h-5" />,
'printing service': <Printer className="w-5 h-5" />,
'secretarial services': <Briefcase className="w-5 h-5" />,
// Accessibility
'wheelchair accessible': <Accessibility className="w-5 h-5" />,
'accessible room': <Accessibility className="w-5 h-5" />,
'elevator access': <Accessibility className="w-5 h-5" />,
'ramp access': <Accessibility className="w-5 h-5" />,
'accessible bathroom': <Accessibility className="w-5 h-5" />,
'lowered sink': <Accessibility className="w-5 h-5" />,
'grab bars': <Accessibility className="w-5 h-5" />,
'hearing accessible': <Ear className="w-5 h-5" />,
'visual alarm': <Eye className="w-5 h-5" />,
// Family & Pets
'family room': <Users className="w-5 h-5" />,
'kids welcome': <BabyIcon className="w-5 h-5" />,
'baby crib': <BabyIcon className="w-5 h-5" />,
crib: <BabyIcon className="w-5 h-5" />,
'extra bed': <Bed className="w-5 h-5" />,
'childcare services': <BabyIcon className="w-5 h-5" />,
'pets allowed': <PawPrint className="w-5 h-5" />,
pets: <PawPrint className="w-5 h-5" />,
'pet friendly': <PawPrint className="w-5 h-5" />,
// Additional
'smoking room': <Cigarette className="w-5 h-5" />,
'non-smoking room': <Shield className="w-5 h-5" />,
'no smoking': <Cigarette className="w-5 h-5" />,
'interconnecting rooms': <Home className="w-5 h-5" />,
'adjoining rooms': <Home className="w-5 h-5" />,
suite: <Home className="w-5 h-5" />,
'separate bedroom': <Bed className="w-5 h-5" />,
kitchen: <Utensils className="w-5 h-5" />,
'full kitchen': <Utensils className="w-5 h-5" />,
dishwasher: <Utensils className="w-5 h-5" />,
oven: <Flame className="w-5 h-5" />,
stove: <Flame className="w-5 h-5" />,
'washing machine': <Home className="w-5 h-5" />,
dryer: <Home className="w-5 h-5" />,
iron: <Sparkles className="w-5 h-5" />,
'ironing board': <Sparkles className="w-5 h-5" />,
'clothes rack': <Home className="w-5 h-5" />,
umbrella: <Home className="w-5 h-5" />,
'shoe shine service': <Sparkles className="w-5 h-5" />,
// Luxury
fireplace: <Fireplace className="w-5 h-5" />,
jacuzzi: <Waves className="w-5 h-5" />,
'airport shuttle': <Car className="w-5 h-5" />,
shuttle: <Car className="w-5 h-5" />,
laundry: <Shield className="w-5 h-5" />,
pets: <Car className="w-5 h-5" />,
'spa bath': <Bath className="w-5 h-5" />,
'bidet toilet': <Bath className="w-5 h-5" />,
'smart home system': <Home className="w-5 h-5" />,
'lighting control': <Zap className="w-5 h-5" />,
'curtain control': <Eye className="w-5 h-5" />,
'automated systems': <Zap className="w-5 h-5" />,
'personalized service': <Users className="w-5 h-5" />,
'vip treatment': <Sparkles className="w-5 h-5" />,
'private entrance': <Key className="w-5 h-5" />,
'private elevator': <Building className="w-5 h-5" />,
'panic button': <Shield className="w-5 h-5" />,
// Restaurant
restaurant: <Restaurant className="w-5 h-5" />,
// Special
library: <Briefcase className="w-5 h-5" />,
'reading room': <Briefcase className="w-5 h-5" />,
'study room': <Briefcase className="w-5 h-5" />,
'private pool': <Waves className="w-5 h-5" />,
'private garden': <MapPin className="w-5 h-5" />,
yard: <MapPin className="w-5 h-5" />,
courtyard: <MapPin className="w-5 h-5" />,
'outdoor furniture': <Sofa className="w-5 h-5" />,
'bbq facilities': <Flame className="w-5 h-5" />,
'picnic area': <Utensils className="w-5 h-5" />,
};
const amenityLabels: Record<string, string> = {
// Basic & Internet
wifi: 'WiFi',
'wi-fi': 'WiFi',
'free wifi': 'Free WiFi',
'wifi in room': 'WiFi in Room',
'high-speed internet': 'High-Speed Internet',
// Entertainment
tv: 'TV',
ac: 'Air Conditioning',
television: 'TV',
'flat-screen tv': 'Flat-Screen TV',
'cable tv': 'Cable TV',
'satellite tv': 'Satellite TV',
'smart tv': 'Smart TV',
netflix: 'Netflix',
'streaming services': 'Streaming Services',
'dvd player': 'DVD Player',
'stereo system': 'Stereo System',
radio: 'Radio',
'ipod dock': 'iPod Dock',
'blu-ray player': 'Blu-ray Player',
'gaming console': 'Gaming Console',
playstation: 'PlayStation',
xbox: 'Xbox',
'sound system': 'Sound System',
'surround sound': 'Surround Sound',
'music system': 'Music System',
// Climate
'air-conditioning': 'Air Conditioning',
minibar: 'Mini Bar',
'mini bar': 'Mini Bar',
restaurant: 'Restaurant',
parking: 'Parking',
gym: 'Gym',
pool: 'Swimming Pool',
'room service': 'Room Service',
safe: 'Safe',
'no smoking': 'No Smoking',
'air conditioning': 'Air Conditioning',
ac: 'Air Conditioning',
heating: 'Heating',
'climate control': 'Climate Control',
'ceiling fan': 'Ceiling Fan',
'air purifier': 'Air Purifier',
// Bathroom
'private bathroom': 'Private Bathroom',
'ensuite bathroom': 'Ensuite Bathroom',
bathtub: 'Bathtub',
'jacuzzi bathtub': 'Jacuzzi Bathtub',
'hot tub': 'Hot Tub',
shower: 'Shower',
breakfast: 'Breakfast Included',
kettle: 'Electric Kettle',
'rain shower': 'Rain Shower',
'walk-in shower': 'Walk-in Shower',
'steam shower': 'Steam Shower',
bidet: 'Bidet',
'hair dryer': 'Hair Dryer',
hairdryer: 'Hair Dryer',
iron: 'Iron',
bathrobes: 'Bathrobes',
slippers: 'Slippers',
toiletries: 'Toiletries',
'premium toiletries': 'Premium Toiletries',
towels: 'Towels',
// Food & Beverage
'mini bar': 'Mini Bar',
minibar: 'Mini Bar',
refrigerator: 'Refrigerator',
fridge: 'Refrigerator',
microwave: 'Microwave',
'private bathroom': 'Private Bathroom',
'coffee maker': 'Coffee Maker',
'electric kettle': 'Electric Kettle',
kettle: 'Electric Kettle',
'tea making facilities': 'Tea Making Facilities',
'coffee machine': 'Coffee Machine',
'nespresso machine': 'Nespresso Machine',
kitchenette: 'Kitchenette',
'dining table': 'Dining Table',
'room service': 'Room Service',
'breakfast included': 'Breakfast Included',
breakfast: 'Breakfast Included',
'complimentary water': 'Complimentary Water',
'bottled water': 'Bottled Water',
// Furniture
desk: 'Desk',
'writing desk': 'Writing Desk',
'office desk': 'Office Desk',
'work desk': 'Work Desk',
sofa: 'Sofa',
'sitting area': 'Sitting Area',
'lounge area': 'Lounge Area',
'dining area': 'Dining Area',
'separate living area': 'Separate Living Area',
wardrobe: 'Wardrobe',
closet: 'Closet',
dresser: 'Dresser',
mirror: 'Mirror',
'full-length mirror': 'Full-Length Mirror',
'seating area': 'Seating Area',
// Bed & Sleep
'king size bed': 'King Size Bed',
'queen size bed': 'Queen Size Bed',
'double bed': 'Double Bed',
'twin beds': 'Twin Beds',
'single bed': 'Single Bed',
'extra bedding': 'Extra Bedding',
'pillow menu': 'Pillow Menu',
'premium bedding': 'Premium Bedding',
'blackout curtains': 'Blackout Curtains',
soundproofing: 'Soundproofing',
// Safety & Security
safe: 'Safe',
'in-room safe': 'In-Room Safe',
'safety deposit box': 'Safety Deposit Box',
'smoke detector': 'Smoke Detector',
'fire extinguisher': 'Fire Extinguisher',
'security system': 'Security System',
'key card access': 'Key Card Access',
'door lock': 'Door Lock',
// Technology
'usb charging ports': 'USB Charging Ports',
'usb ports': 'USB Ports',
'usb outlets': 'USB Outlets',
'power outlets': 'Power Outlets',
'charging station': 'Charging Station',
'laptop safe': 'Laptop Safe',
'hdmi port': 'HDMI Port',
phone: 'Phone',
'desk phone': 'Desk Phone',
'wake-up service': 'Wake-Up Service',
'alarm clock': 'Alarm Clock',
'digital clock': 'Digital Clock',
// View & Outdoor
balcony: 'Balcony',
'private balcony': 'Private Balcony',
terrace: 'Terrace',
patio: 'Patio',
'city view': 'City View',
'ocean view': 'Ocean View',
'sea view': 'Sea View',
'mountain view': 'Mountain View',
'garden view': 'Garden View',
'pool view': 'Pool View',
'park view': 'Park View',
window: 'Window',
'large windows': 'Large Windows',
'floor-to-ceiling windows': 'Floor-to-Ceiling Windows',
// Services
'24-hour front desk': '24/7 Front Desk',
'24 hour front desk': '24/7 Front Desk',
'24/7 front desk': '24/7 Front Desk',
'front desk': 'Front Desk',
'concierge service': 'Concierge Service',
'butler service': 'Butler Service',
butler: 'Butler',
housekeeping: 'Housekeeping',
'daily housekeeping': 'Daily Housekeeping',
'turndown service': 'Turndown Service',
'laundry service': 'Laundry Service',
laundry: 'Laundry Service',
'dry cleaning': 'Dry Cleaning',
'ironing service': 'Ironing Service',
'luggage storage': 'Luggage Storage',
'bell service': 'Bell Service',
'valet parking': 'Valet Parking',
parking: 'Parking',
'free parking': 'Free Parking',
'airport shuttle': 'Airport Shuttle',
'shuttle service': 'Shuttle Service',
shuttle: 'Shuttle Service',
'car rental': 'Car Rental',
'taxi service': 'Taxi Service',
// Fitness & Wellness
'gym access': 'Gym Access',
'fitness center': 'Fitness Center',
'fitness room': 'Fitness Room',
gym: 'Gym',
fitness: 'Fitness Center',
'spa access': 'Spa Access',
spa: 'Spa',
sauna: 'Sauna',
jacuzzi: 'Jacuzzi',
laundry: 'Laundry Service',
'24-hour front desk': '24/7 Front Desk',
'airport shuttle': 'Airport Shuttle',
'steam room': 'Steam Room',
'massage service': 'Massage Service',
'beauty services': 'Beauty Services',
// Recreation
'swimming pool': 'Swimming Pool',
pool: 'Swimming Pool',
'indoor pool': 'Indoor Pool',
'outdoor pool': 'Outdoor Pool',
'infinity pool': 'Infinity Pool',
'pool access': 'Pool Access',
'golf course': 'Golf Course',
'tennis court': 'Tennis Court',
'beach access': 'Beach Access',
'water sports': 'Water Sports',
// Business
'business center': 'Business Center',
'meeting room': 'Meeting Room',
'conference room': 'Conference Room',
'fax service': 'Fax Service',
photocopying: 'Photocopying',
'printing service': 'Printing Service',
'secretarial services': 'Secretarial Services',
// Accessibility
'wheelchair accessible': 'Wheelchair Accessible',
'accessible room': 'Accessible Room',
'elevator access': 'Elevator Access',
'ramp access': 'Ramp Access',
'accessible bathroom': 'Accessible Bathroom',
'lowered sink': 'Lowered Sink',
'grab bars': 'Grab Bars',
'hearing accessible': 'Hearing Accessible',
'visual alarm': 'Visual Alarm',
// Family & Pets
'family room': 'Family Room',
'kids welcome': 'Kids Welcome',
'baby crib': 'Baby Crib',
crib: 'Baby Crib',
'extra bed': 'Extra Bed',
'childcare services': 'Childcare Services',
'pets allowed': 'Pets Allowed',
pets: 'Pets Allowed',
'pet friendly': 'Pet Friendly',
// Additional
'smoking room': 'Smoking Room',
'non-smoking room': 'Non-Smoking Room',
'no smoking': 'No Smoking',
'interconnecting rooms': 'Interconnecting Rooms',
'adjoining rooms': 'Adjoining Rooms',
suite: 'Suite',
'separate bedroom': 'Separate Bedroom',
kitchen: 'Kitchen',
'full kitchen': 'Full Kitchen',
dishwasher: 'Dishwasher',
oven: 'Oven',
stove: 'Stove',
'washing machine': 'Washing Machine',
dryer: 'Dryer',
iron: 'Iron',
'ironing board': 'Ironing Board',
'clothes rack': 'Clothes Rack',
umbrella: 'Umbrella',
'shoe shine service': 'Shoe Shine Service',
// Luxury
fireplace: 'Fireplace',
jacuzzi: 'Jacuzzi',
'spa bath': 'Spa Bath',
'bidet toilet': 'Bidet Toilet',
'smart home system': 'Smart Home System',
'lighting control': 'Lighting Control',
'curtain control': 'Curtain Control',
'automated systems': 'Automated Systems',
'personalized service': 'Personalized Service',
'vip treatment': 'VIP Treatment',
'private entrance': 'Private Entrance',
'private elevator': 'Private Elevator',
'panic button': 'Panic Button',
// Restaurant
restaurant: 'Restaurant',
// Special
library: 'Library',
'reading room': 'Reading Room',
'study room': 'Study Room',
'private pool': 'Private Pool',
'private garden': 'Private Garden',
yard: 'Yard',
courtyard: 'Courtyard',
'outdoor furniture': 'Outdoor Furniture',
'bbq facilities': 'BBQ Facilities',
'picnic area': 'Picnic Area',
};
const amenityDescriptions: Record<string, string> = {

View File

@@ -11,6 +11,7 @@ import {
} from 'lucide-react';
import type { Room } from '../../services/api/roomService';
import FavoriteButton from './FavoriteButton';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
interface RoomCardProps {
room: Room;
@@ -18,20 +19,19 @@ interface RoomCardProps {
const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
const roomType = room.room_type;
const { formatCurrency } = useFormatCurrency();
if (!roomType) {
return null;
}
// Get first image or use placeholder
const imageUrl = roomType.images?.[0] ||
'/images/room-placeholder.jpg';
// Get first image - prioritize room-specific images over room type images
const imageUrl = (room.images && room.images.length > 0)
? room.images[0]
: (roomType.images?.[0] || '/images/room-placeholder.jpg');
// Format price
const formattedPrice = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(roomType.base_price);
// Format price using currency context - use room price if available, otherwise room type base price
const formattedPrice = formatCurrency(room.price || roomType.base_price);
// Prefer room-level amenities when available, otherwise use room type
const normalizeAmenities = (input: any): string[] => {
@@ -154,18 +154,20 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
</span>
</div>
{/* Description (truncated) */}
<p className="text-gray-600 text-sm mb-4 line-clamp-2
leading-relaxed font-light">
{roomType.description}
</p>
{/* Description (truncated) - Show room-specific description first */}
{(room.description || roomType.description) && (
<p className="text-gray-600 text-sm mb-4 line-clamp-2
leading-relaxed font-light">
{room.description || roomType.description}
</p>
)}
{/* Capacity & Rating */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center text-gray-700">
<Users className="w-4 h-4 mr-1.5 text-[#d4af37]" />
<span className="text-sm font-light tracking-wide">
{roomType.capacity} guests
{room.capacity || roomType.capacity} guests
</span>
</div>
@@ -221,7 +223,7 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
</div>
<Link
to={`/rooms/${room.id}`}
to={`/rooms/${room.room_number}`}
className="btn-luxury-primary flex items-center gap-2
text-sm px-5 py-2.5 relative"
>

View File

@@ -3,50 +3,51 @@ import React from 'react';
const RoomCardSkeleton: React.FC = () => {
return (
<div
className="bg-white rounded-lg shadow-md
overflow-hidden animate-pulse"
className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
rounded-xl border border-[#d4af37]/20
overflow-hidden animate-pulse shadow-lg shadow-[#d4af37]/5"
>
{/* Image Skeleton */}
<div className="h-48 bg-gray-300" />
<div className="h-52 bg-gradient-to-br from-gray-800 to-gray-900" />
{/* Content Skeleton */}
<div className="p-5">
<div className="p-6">
{/* Title */}
<div className="h-6 bg-gray-300 rounded w-3/4 mb-2" />
<div className="h-6 bg-gray-800 rounded w-3/4 mb-3" />
{/* Room Number */}
<div className="h-4 bg-gray-200 rounded w-1/2 mb-3" />
<div className="h-4 bg-gray-800 rounded w-1/2 mb-4" />
{/* Description */}
<div className="space-y-2 mb-3">
<div className="h-3 bg-gray-200 rounded w-full" />
<div className="h-3 bg-gray-200 rounded w-5/6" />
<div className="space-y-2 mb-4">
<div className="h-3 bg-gray-800 rounded w-full" />
<div className="h-3 bg-gray-800 rounded w-5/6" />
</div>
{/* Capacity & Rating */}
<div className="flex items-center justify-between mb-3">
<div className="h-4 bg-gray-200 rounded w-20" />
<div className="h-4 bg-gray-200 rounded w-16" />
<div className="flex items-center justify-between mb-4">
<div className="h-4 bg-gray-800 rounded w-20" />
<div className="h-4 bg-gray-800 rounded w-16" />
</div>
{/* Amenities */}
<div className="flex gap-2 mb-4">
<div className="h-6 bg-gray-200 rounded w-16" />
<div className="h-6 bg-gray-200 rounded w-16" />
<div className="h-6 bg-gray-200 rounded w-16" />
<div className="flex gap-2 mb-5">
<div className="h-6 bg-gray-800 rounded w-16" />
<div className="h-6 bg-gray-800 rounded w-16" />
<div className="h-6 bg-gray-800 rounded w-16" />
</div>
{/* Price & Button */}
<div
className="flex items-center justify-between
pt-3 border-t"
pt-4 border-t border-[#d4af37]/20"
>
<div>
<div className="h-3 bg-gray-200 rounded w-12 mb-1" />
<div className="h-7 bg-gray-300 rounded w-24 mb-1" />
<div className="h-3 bg-gray-200 rounded w-10" />
<div className="h-3 bg-gray-800 rounded w-12 mb-2" />
<div className="h-7 bg-gray-800 rounded w-24 mb-2" />
<div className="h-3 bg-gray-800 rounded w-10" />
</div>
<div className="h-10 bg-gray-300 rounded w-28" />
<div className="h-10 bg-gray-800 rounded w-28" />
</div>
</div>
</div>

View File

@@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import { useSearchParams } from 'react-router-dom';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { Calendar, DollarSign, Users, X } from 'lucide-react';
// no debounce needed when apply-on-submit is used
interface RoomFilterProps {
@@ -19,6 +21,7 @@ export interface FilterValues {
const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
const [searchParams, setSearchParams] = useSearchParams();
const { formatCurrency: formatCurrencyUtil } = useFormatCurrency();
const [filters, setFilters] = useState<FilterValues>({
type: searchParams.get('type') || '',
@@ -69,10 +72,25 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
setFilters({ type, minPrice, maxPrice, capacity, from, to });
// Sync local date state
setCheckInDate(from ? new Date(from) : null);
setCheckOutDate(to ? new Date(to) : null);
const checkIn = from ? new Date(from) : null;
const checkOut = to ? new Date(to) : null;
setCheckInDate(checkIn);
// Validate checkout date - if it's before or equal to check-in, reset it
if (checkIn && checkOut && checkOut <= checkIn) {
setCheckOutDate(null);
} else {
setCheckOutDate(checkOut);
}
}, [searchParams]);
// Reset checkout date if it's invalid when check-in changes
useEffect(() => {
if (checkInDate && checkOutDate && checkOutDate <= checkInDate) {
setCheckOutDate(null);
}
}, [checkInDate, checkOutDate]);
// Load amenities from API
useEffect(() => {
let mounted = true;
@@ -97,12 +115,10 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
}
};
// Use formatCurrency from hook which uses platform currency
const formatCurrency = (n?: number): string => {
if (n == null) return '';
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(n);
return formatCurrencyUtil(n);
};
const handleInputChange = (
@@ -234,10 +250,19 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
};
return (
<div className="luxury-card rounded-sm shadow-lg p-6 mb-6 border border-gray-100">
<div className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
rounded-xl border border-[#d4af37]/30
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/10
p-6"
>
<div className="flex items-center gap-3 mb-6">
<div className="w-1 h-8 bg-gradient-to-b from-[#d4af37] to-[#c9a227]"></div>
<h2 className="text-xl font-serif font-semibold mb-0 text-gray-900 tracking-tight">
<div className="p-2 bg-[#d4af37]/10 rounded-lg
border border-[#d4af37]/30">
<svg className="w-5 h-5 text-[#d4af37]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
</div>
<h2 className="text-xl font-serif font-semibold mb-0 text-white tracking-tight">
Room Filters
</h2>
</div>
@@ -247,79 +272,168 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
<label
htmlFor="type"
className="block text-sm font-medium
text-gray-700 mb-2 tracking-wide"
text-gray-200 mb-2 tracking-wide"
>
Room Type
</label>
<div className="relative">
<select
id="type"
name="type"
value={filters.type || ''}
onChange={handleInputChange}
className="luxury-input"
className="w-full px-4 py-3.5 bg-[#1a1a1a] border-2
border-[#d4af37]/30 rounded-lg text-white text-base
placeholder-gray-400 focus:ring-2
focus:ring-[#d4af37]/60 focus:border-[#d4af37]
transition-all duration-300 font-normal tracking-wide
appearance-none cursor-pointer
hover:border-[#d4af37]/50"
>
<option value="">All</option>
<option value="Standard Room">Standard Room</option>
<option value="Deluxe Room">Deluxe Room</option>
<option value="Luxury Room">Luxury Room</option>
<option value="Family Room">Family Room</option>
<option value="Twin Room">Twin Room</option>
<option value="" className="bg-[#1a1a1a] text-white">All Room Types</option>
<option value="Standard Room" className="bg-[#1a1a1a] text-white">Standard Room</option>
<option value="Deluxe Room" className="bg-[#1a1a1a] text-white">Deluxe Room</option>
<option value="Luxury Room" className="bg-[#1a1a1a] text-white">Luxury Room</option>
<option value="Family Room" className="bg-[#1a1a1a] text-white">Family Room</option>
<option value="Twin Room" className="bg-[#1a1a1a] text-white">Twin Room</option>
</select>
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
<svg className="w-5 h-5 text-[#d4af37]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</div>
{/* Date Range */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-4">
<div>
<label
htmlFor="from"
className="block text-sm font-medium text-gray-700 mb-2 tracking-wide"
className="block text-sm font-medium text-gray-200 mb-2 tracking-wide flex items-center gap-2"
>
<Calendar className="w-4 h-4 text-[#d4af37]" />
Check-in Date
</label>
<div className="relative">
<DatePicker
selected={checkInDate}
onChange={(date: Date | null) => setCheckInDate(date)}
onChange={(date: Date | null) => {
setCheckInDate(date);
// Reset checkout if it becomes invalid
if (date && checkOutDate && checkOutDate <= date) {
setCheckOutDate(null);
}
}}
selectsStart
startDate={checkInDate}
endDate={checkOutDate}
minDate={new Date()}
dateFormat="dd/MM/yyyy"
placeholderText=""
className="luxury-input"
/>
placeholderText="Select check-in"
className="w-full px-4 py-3.5 pl-11 bg-[#1a1a1a] border-2
border-[#d4af37]/30 rounded-lg text-white text-base
placeholder-gray-400 focus:ring-2
focus:ring-[#d4af37]/60 focus:border-[#d4af37]
transition-all duration-300 font-normal tracking-wide
hover:border-[#d4af37]/50"
wrapperClassName="w-full"
/>
{checkInDate && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setCheckInDate(null);
if (checkOutDate) {
setCheckOutDate(null);
}
}}
className="absolute right-3 top-1/2 -translate-y-1/2
text-gray-400 hover:text-[#d4af37] transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2
w-5 h-5 text-[#d4af37] pointer-events-none" />
</div>
</div>
<div>
<label
htmlFor="to"
className="block text-sm font-medium text-gray-700 mb-2 tracking-wide"
className="block text-sm font-medium text-gray-200 mb-2 tracking-wide flex items-center gap-2"
>
<Calendar className="w-4 h-4 text-[#d4af37]" />
Check-out Date
</label>
<div className="relative">
<DatePicker
selected={checkOutDate}
onChange={(date: Date | null) => setCheckOutDate(date)}
selectsEnd
startDate={checkInDate}
endDate={checkOutDate}
minDate={checkInDate || new Date()}
minDate={
checkInDate
? new Date(checkInDate.getTime() + 24 * 60 * 60 * 1000) // Next day after check-in
: new Date()
}
disabled={!checkInDate}
dateFormat="dd/MM/yyyy"
placeholderText=""
className="luxury-input"
/>
placeholderText={checkInDate ? "Select check-out" : "Select check-in first"}
className="w-full px-4 py-3.5 pl-11 bg-[#1a1a1a] border-2
border-[#d4af37]/30 rounded-lg text-white text-base
placeholder-gray-400 focus:ring-2
focus:ring-[#d4af37]/60 focus:border-[#d4af37]
transition-all duration-300 font-normal tracking-wide
hover:border-[#d4af37]/50
disabled:opacity-50 disabled:cursor-not-allowed"
wrapperClassName="w-full"
/>
{checkOutDate && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setCheckOutDate(null);
}}
className="absolute right-3 top-1/2 -translate-y-1/2
text-gray-400 hover:text-[#d4af37] transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2
w-5 h-5 text-[#d4af37] pointer-events-none" />
</div>
{checkInDate && !checkOutDate && (
<p className="mt-1.5 text-xs text-gray-400 font-light">
Select check-out date (minimum 1 night stay)
</p>
)}
</div>
</div>
{/* Price Range */}
<div>
<label className="block text-sm font-medium text-gray-200 mb-3 tracking-wide flex items-center gap-2">
<DollarSign className="w-4 h-4 text-[#d4af37]" />
Price Range
</label>
<div className="grid grid-cols-2 gap-4">
<div>
<label
htmlFor="minPrice"
className="block text-sm font-medium
text-gray-700 mb-2 tracking-wide"
className="block text-xs font-normal
text-gray-400 mb-1.5 tracking-wide"
>
Min Price
Minimum
</label>
<div className="relative">
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2
w-4 h-4 text-[#d4af37] pointer-events-none" />
<input
type="text"
id="minPrice"
@@ -333,17 +447,26 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
placeholder="0"
inputMode="numeric"
pattern="[0-9.]*"
className="luxury-input"
className="w-full px-4 py-3.5 pl-10 bg-[#1a1a1a] border-2
border-[#d4af37]/30 rounded-lg text-white text-base
placeholder-gray-400 focus:ring-2
focus:ring-[#d4af37]/60 focus:border-[#d4af37]
transition-all duration-300 font-normal tracking-wide
hover:border-[#d4af37]/50"
/>
</div>
</div>
<div>
<label
htmlFor="maxPrice"
className="block text-sm font-medium
text-gray-700 mb-2 tracking-wide"
className="block text-xs font-normal
text-gray-400 mb-1.5 tracking-wide"
>
Max Price
Maximum
</label>
<div className="relative">
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2
w-4 h-4 text-[#d4af37] pointer-events-none" />
<input
type="text"
id="maxPrice"
@@ -354,11 +477,18 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
: ''
}
onChange={handleInputChange}
placeholder="10.000.000"
placeholder="No limit"
inputMode="numeric"
pattern="[0-9.]*"
className="luxury-input"
className="w-full px-4 py-3.5 pl-10 bg-[#1a1a1a] border-2
border-[#d4af37]/30 rounded-lg text-white text-base
placeholder-gray-400 focus:ring-2
focus:ring-[#d4af37]/60 focus:border-[#d4af37]
transition-all duration-300 font-normal tracking-wide
hover:border-[#d4af37]/50"
/>
</div>
</div>
</div>
</div>
@@ -367,10 +497,14 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
<label
htmlFor="capacity"
className="block text-sm font-medium
text-gray-700 mb-2 tracking-wide"
text-gray-200 mb-2 tracking-wide flex items-center gap-2"
>
<Users className="w-4 h-4 text-[#d4af37]" />
Number of Guests
</label>
<div className="relative">
<Users className="absolute left-3 top-1/2 -translate-y-1/2
w-5 h-5 text-[#d4af37] pointer-events-none" />
<input
type="number"
id="capacity"
@@ -380,51 +514,80 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
placeholder="1"
min="1"
max="10"
className="luxury-input"
className="w-full px-4 py-3.5 pl-11 bg-[#1a1a1a] border-2
border-[#d4af37]/30 rounded-lg text-white text-base
placeholder-gray-400 focus:ring-2
focus:ring-[#d4af37]/60 focus:border-[#d4af37]
transition-all duration-300 font-normal tracking-wide
hover:border-[#d4af37]/50
[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none
[&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</div>
{/* Amenities */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2 tracking-wide">
<label className="block text-sm font-medium text-gray-200 mb-3 tracking-wide">
Amenities
</label>
{availableAmenities.length === 0 ? (
<div className="text-sm text-gray-500 font-light">Loading amenities...</div>
<div className="text-sm text-gray-400 font-light bg-[#1a1a1a]/50
border border-[#d4af37]/20 rounded-lg px-4 py-3">
Loading amenities...
</div>
) : (
<div className="flex flex-col gap-2 max-h-40 overflow-auto pr-2">
<div className="bg-[#1a1a1a]/50 border border-[#d4af37]/20 rounded-lg p-3
max-h-48 overflow-y-auto custom-scrollbar space-y-2">
{availableAmenities.map((amenity) => (
<label
key={amenity}
className="flex items-center gap-2 text-sm w-full font-light tracking-wide hover:text-[#d4af37] transition-colors cursor-pointer"
className="flex items-center gap-3 text-sm w-full font-normal tracking-wide
hover:text-[#d4af37] transition-colors cursor-pointer
text-gray-200 group"
>
<input
type="checkbox"
checked={selectedAmenities.includes(amenity)}
onChange={() => toggleAmenity(amenity)}
className="h-4 w-4 accent-[#d4af37] cursor-pointer"
className="h-5 w-5 accent-[#d4af37] cursor-pointer
border-2 border-[#d4af37]/30 rounded
checked:bg-[#d4af37] checked:border-[#d4af37]
group-hover:border-[#d4af37] transition-all"
/>
<span className="text-gray-700">{amenity}</span>
<span className="flex-1 group-hover:text-[#d4af37] transition-colors">
{amenity}
</span>
</label>
))}
</div>
)}
{selectedAmenities.length > 0 && (
<p className="mt-2 text-xs text-[#d4af37] font-light">
{selectedAmenities.length} amenit{selectedAmenities.length === 1 ? 'y' : 'ies'} selected
</p>
)}
</div>
{/* Buttons */}
<div className="flex gap-3 pt-2">
<button
type="submit"
className="btn-luxury-primary flex-1 py-2.5 px-4 relative"
className="flex-1 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] py-3 px-4 rounded-sm font-medium tracking-wide
hover:from-[#f5d76e] hover:to-[#d4af37]
transition-all duration-300 shadow-lg shadow-[#d4af37]/30
relative overflow-hidden group"
>
<span className="relative z-10">Apply</span>
<span className="absolute inset-0 bg-gradient-to-r from-white/0 via-white/20 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700"></span>
</button>
<button
type="button"
onClick={handleReset}
className="flex-1 bg-white/80 backdrop-blur-sm text-gray-700
py-2.5 px-4 rounded-sm border border-gray-300
hover:bg-white hover:border-[#d4af37]/30 hover:text-[#d4af37]
className="flex-1 bg-[#0a0a0a] backdrop-blur-sm text-gray-300
py-3 px-4 rounded-sm border border-[#d4af37]/30
hover:bg-[#1a1a1a] hover:border-[#d4af37] hover:text-[#d4af37]
transition-all font-medium tracking-wide"
>
Reset

View File

@@ -0,0 +1,102 @@
import React, {
createContext,
useContext,
useEffect,
useState,
ReactNode,
} from 'react';
import { CURRENCY } from '../utils/constants';
import systemSettingsService from '../services/api/systemSettingsService';
type CurrencyContextValue = {
currency: string;
isLoading: boolean;
supportedCurrencies: string[];
refreshCurrency: () => Promise<void>;
};
const CurrencyContext = createContext<CurrencyContextValue | undefined>(undefined);
export const useCurrency = () => {
const context = useContext(CurrencyContext);
if (!context) {
throw new Error('useCurrency must be used within CurrencyProvider');
}
return context;
};
interface CurrencyProviderProps {
children: ReactNode;
}
export const CurrencyProvider: React.FC<CurrencyProviderProps> = ({ children }) => {
const [currency, setCurrencyState] = useState<string>(CURRENCY.VND);
const [isLoading, setIsLoading] = useState<boolean>(true);
const supportedCurrencies = [
CURRENCY.VND,
CURRENCY.USD,
CURRENCY.EUR,
'GBP',
'JPY',
'CNY',
'KRW',
'SGD',
'THB',
'AUD',
'CAD',
];
// Load platform currency from system settings
const loadCurrency = async () => {
try {
setIsLoading(true);
const response = await systemSettingsService.getPlatformCurrency();
if (response.data?.currency && supportedCurrencies.includes(response.data.currency)) {
const platformCurrency = response.data.currency;
setCurrencyState(platformCurrency);
// Store in localStorage so formatCurrency can access it
localStorage.setItem('currency', platformCurrency);
} else {
// Fallback to default
setCurrencyState(CURRENCY.VND);
localStorage.setItem('currency', CURRENCY.VND);
}
} catch (error) {
console.error('Error loading platform currency:', error);
// Fallback to default
setCurrencyState(CURRENCY.VND);
localStorage.setItem('currency', CURRENCY.VND);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadCurrency();
}, []);
const refreshCurrency = async () => {
await loadCurrency();
// Dispatch a custom event to notify all components of currency change
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('currencyChanged', {
detail: { currency: localStorage.getItem('currency') || CURRENCY.VND }
}));
}
};
return (
<CurrencyContext.Provider
value={{
currency,
isLoading,
supportedCurrencies,
refreshCurrency,
}}
>
{children}
</CurrencyContext.Provider>
);
};

View File

@@ -0,0 +1,20 @@
import { useMemo } from 'react';
import { useCurrency } from '../contexts/CurrencyContext';
import { formatCurrency as formatCurrencyUtil } from '../utils/format';
/**
* Hook to format currency using the current currency from CurrencyContext
*/
export const useFormatCurrency = () => {
const { currency } = useCurrency();
const formatCurrency = useMemo(
() => (amount: number | string, overrideCurrency?: string) => {
return formatCurrencyUtil(amount, overrideCurrency || currency);
},
[currency]
);
return { formatCurrency, currency };
};

View File

@@ -4,13 +4,13 @@ import { SidebarAdmin } from '../components/layout';
const AdminLayout: React.FC = () => {
return (
<div className="flex h-screen bg-gray-100">
<div className="flex h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
{/* Admin Sidebar */}
<SidebarAdmin />
{/* Admin Content Area */}
<div className="flex-1 overflow-auto">
<div className="p-6">
<div className="flex-1 overflow-auto lg:ml-0">
<div className="min-h-screen pt-20 lg:pt-0">
<Outlet />
</div>
</div>

View File

@@ -1,15 +1,19 @@
import React, { useEffect, useState } from 'react';
import { Search, Eye, XCircle, CheckCircle } from 'lucide-react';
import { Search, Eye, XCircle, CheckCircle, Loader2 } from 'lucide-react';
import { bookingService, Booking } 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';
const BookingManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
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 [filters, setFilters] = useState({
search: '',
status: '',
@@ -49,11 +53,14 @@ const BookingManagementPage: React.FC = () => {
const handleUpdateStatus = async (id: number, status: string) => {
try {
setUpdatingBookingId(id);
await bookingService.updateBooking(id, { status } as any);
toast.success('Status updated successfully');
fetchBookings();
await fetchBookings();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to update status');
} finally {
setUpdatingBookingId(null);
}
};
@@ -61,64 +68,93 @@ const BookingManagementPage: React.FC = () => {
if (!window.confirm('Are you sure you want to cancel this booking?')) return;
try {
setCancellingBookingId(id);
await bookingService.cancelBooking(id);
toast.success('Booking cancelled successfully');
fetchBookings();
await fetchBookings();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to cancel booking');
} finally {
setCancellingBookingId(null);
}
};
const getStatusBadge = (status: string) => {
const badges: Record<string, { bg: string; text: string; label: string }> = {
pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'Pending confirmation' },
confirmed: { bg: 'bg-blue-100', text: 'text-blue-800', label: 'Confirmed' },
checked_in: { bg: 'bg-green-100', text: 'text-green-800', label: 'Checked in' },
checked_out: { bg: 'bg-gray-100', text: 'text-gray-800', label: 'Checked out' },
cancelled: { bg: 'bg-red-100', text: 'text-red-800', label: 'Cancelled' },
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
pending: {
bg: 'bg-gradient-to-r from-amber-50 to-yellow-50',
text: 'text-amber-800',
label: 'Pending confirmation',
border: 'border-amber-200'
},
confirmed: {
bg: 'bg-gradient-to-r from-blue-50 to-indigo-50',
text: 'text-blue-800',
label: 'Confirmed',
border: 'border-blue-200'
},
checked_in: {
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
text: 'text-emerald-800',
label: 'Checked in',
border: 'border-emerald-200'
},
checked_out: {
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
text: 'text-slate-700',
label: 'Checked out',
border: 'border-slate-200'
},
cancelled: {
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
text: 'text-rose-800',
label: 'Cancelled',
border: 'border-rose-200'
},
};
const badge = badges[status] || badges.pending;
return (
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
<span className={`px-4 py-1.5 rounded-full text-xs font-semibold border ${badge.bg} ${badge.text} ${badge.border} shadow-sm`}>
{badge.label}
</span>
);
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(amount);
};
if (loading) {
return <Loading />;
}
return (
<div className="space-y-8">
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{/* Luxury Header */}
<div className="animate-fade-in">
<h1 className="enterprise-section-title">Booking Management</h1>
<p className="enterprise-section-subtitle mt-2">Manage and track all hotel bookings</p>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Booking Management
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Manage and track all hotel bookings with precision</p>
</div>
<div className="enterprise-card p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
{/* Luxury Filter Card */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
<input
type="text"
placeholder="Search by booking number, guest name..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="enterprise-input pl-10"
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="enterprise-input"
className="w-full px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All statuses</option>
<option value="pending">Pending confirmation</option>
@@ -130,93 +166,121 @@ const BookingManagementPage: React.FC = () => {
</div>
</div>
<div className="enterprise-card overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
<table className="enterprise-table">
<thead>
<tr>
<th>Booking Number</th>
<th>Customer</th>
<th>Room</th>
<th>Check-in/out</th>
<th>Total Price</th>
<th>Status</th>
<th className="text-right">Actions</th>
</tr>
</thead>
<tbody>
{bookings.map((booking) => (
<tr key={booking.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-blue-600">{booking.booking_number}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{booking.guest_info?.full_name || booking.user?.name}</div>
<div className="text-xs text-gray-500">{booking.guest_info?.email || booking.user?.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
Room {booking.room?.room_number} - {booking.room?.room_type?.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{new Date(booking.check_in_date).toLocaleDateString('en-US')}
</div>
<div className="text-xs text-gray-500">
to {new Date(booking.check_out_date).toLocaleDateString('en-US')}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-gray-900">
{formatCurrency(booking.total_price)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(booking.status)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => {
setSelectedBooking(booking);
setShowDetailModal(true);
}}
className="text-blue-600 hover:text-blue-900 mr-2"
title="View details"
>
<Eye className="w-5 h-5" />
</button>
{booking.status === 'pending' && (
<>
<button
onClick={() => handleUpdateStatus(booking.id, 'confirmed')}
className="text-green-600 hover:text-green-900 mr-2"
title="Confirm"
>
<CheckCircle className="w-5 h-5" />
</button>
<button
onClick={() => handleCancelBooking(booking.id)}
className="text-red-600 hover:text-red-900"
title="Cancel"
>
<XCircle className="w-5 h-5" />
</button>
</>
)}
{booking.status === 'confirmed' && (
<button
onClick={() => handleUpdateStatus(booking.id, 'checked_in')}
className="text-green-600 hover:text-green-900"
title="Check-in"
>
<CheckCircle className="w-5 h-5" />
</button>
)}
</td>
{/* Luxury Table Card */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900">
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Booking Number</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Customer</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Room</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Check-in/out</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Total Price</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>
<th className="px-8 py-5 text-right text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Actions</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="bg-white divide-y divide-slate-100">
{bookings.map((booking, index) => (
<tr
key={booking.id}
className="hover:bg-gradient-to-r hover:from-amber-50/30 hover:to-yellow-50/30 transition-all duration-200 group border-b border-slate-100"
style={{ animationDelay: `${index * 0.05}s` }}
>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-bold text-slate-900 group-hover:text-amber-700 transition-colors font-mono">
{booking.booking_number}
</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-semibold text-slate-900">{booking.guest_info?.full_name || booking.user?.name}</div>
<div className="text-xs text-slate-500 mt-0.5">{booking.guest_info?.email || booking.user?.email}</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-medium text-slate-800">
<span className="text-amber-600 font-semibold">Room {booking.room?.room_number}</span>
<span className="text-slate-400 mx-2"></span>
<span className="text-slate-600">{booking.room?.room_type?.name}</span>
</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-medium text-slate-900">
{new Date(booking.check_in_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</div>
<div className="text-xs text-slate-500 mt-0.5">
{new Date(booking.check_out_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-bold text-slate-900 bg-gradient-to-r from-amber-600 to-amber-700 bg-clip-text text-transparent">
{formatCurrency(booking.total_price)}
</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
{getStatusBadge(booking.status)}
</td>
<td className="px-8 py-5 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => {
setSelectedBooking(booking);
setShowDetailModal(true);
}}
className="p-2 rounded-lg text-slate-600 hover:text-amber-600 hover:bg-amber-50 transition-all duration-200 shadow-sm hover:shadow-md border border-slate-200 hover:border-amber-300"
title="View details"
>
<Eye className="w-5 h-5" />
</button>
{booking.status === 'pending' && (
<>
<button
onClick={() => handleUpdateStatus(booking.id, 'confirmed')}
disabled={updatingBookingId === booking.id || cancellingBookingId === booking.id}
className="p-2 rounded-lg text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-sm hover:shadow-md border border-emerald-200 hover:border-emerald-300"
title="Confirm"
>
{updatingBookingId === booking.id ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<CheckCircle className="w-5 h-5" />
)}
</button>
<button
onClick={() => handleCancelBooking(booking.id)}
disabled={updatingBookingId === booking.id || cancellingBookingId === booking.id}
className="p-2 rounded-lg text-rose-600 hover:text-rose-700 hover:bg-rose-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-sm hover:shadow-md border border-rose-200 hover:border-rose-300"
title="Cancel"
>
{cancellingBookingId === booking.id ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<XCircle className="w-5 h-5" />
)}
</button>
</>
)}
{booking.status === 'confirmed' && (
<button
onClick={() => handleUpdateStatus(booking.id, 'checked_in')}
disabled={updatingBookingId === booking.id || cancellingBookingId === booking.id}
className="p-2 rounded-lg text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-sm hover:shadow-md border border-emerald-200 hover:border-emerald-300"
title="Check-in"
>
{updatingBookingId === booking.id ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<CheckCircle className="w-5 h-5" />
)}
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
@@ -226,72 +290,110 @@ const BookingManagementPage: React.FC = () => {
/>
</div>
{/* Detail Modal */}
{/* Luxury Detail Modal */}
{showDetailModal && selectedBooking && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in">
<div className="enterprise-card p-8 w-full max-w-2xl max-h-[90vh] overflow-y-auto animate-scale-in">
<div className="flex justify-between items-center mb-6 pb-4 border-b border-gray-200">
<h2 className="text-2xl font-bold text-gray-900">Booking Details</h2>
<button
onClick={() => setShowDetailModal(false)}
className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
>
</button>
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4 animate-fade-in">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden animate-scale-in border border-slate-200">
{/* Modal Header */}
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-8 py-6 border-b border-slate-700">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-bold text-amber-100 mb-1">Booking Details</h2>
<p className="text-amber-200/80 text-sm font-light">Comprehensive booking information</p>
</div>
<button
onClick={() => setShowDetailModal(false)}
className="w-10 h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400"
>
</button>
</div>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-gray-500">Booking Number</label>
<p className="text-lg font-semibold">{selectedBooking.booking_number}</p>
{/* Modal Content */}
<div className="p-8 overflow-y-auto max-h-[calc(90vh-120px)]">
<div className="space-y-6">
{/* Booking Number & Status */}
<div className="grid grid-cols-2 gap-6">
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Booking Number</label>
<p className="text-xl font-bold text-slate-900 font-mono">{selectedBooking.booking_number}</p>
</div>
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Status</label>
<div className="mt-1">{getStatusBadge(selectedBooking.status)}</div>
</div>
</div>
<div>
<label className="text-sm font-medium text-gray-500">Status</label>
<div className="mt-1">{getStatusBadge(selectedBooking.status)}</div>
{/* Customer Information */}
<div className="bg-gradient-to-br from-amber-50/50 to-yellow-50/50 p-6 rounded-xl border border-amber-100">
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
<div className="w-1 h-4 bg-gradient-to-b from-amber-400 to-amber-600 rounded-full"></div>
Customer Information
</label>
<div className="space-y-2">
<p className="text-lg font-bold text-slate-900">{selectedBooking.guest_info?.full_name || selectedBooking.user?.name}</p>
<p className="text-slate-600">{selectedBooking.guest_info?.email || selectedBooking.user?.email}</p>
<p className="text-slate-600">{selectedBooking.guest_info?.phone || selectedBooking.user?.phone}</p>
</div>
</div>
</div>
<div>
<label className="text-sm font-medium text-gray-500">Customer Information</label>
<p className="text-gray-900">{selectedBooking.guest_info?.full_name || selectedBooking.user?.name}</p>
<p className="text-gray-600">{selectedBooking.guest_info?.email || selectedBooking.user?.email}</p>
<p className="text-gray-600">{selectedBooking.guest_info?.phone || selectedBooking.user?.phone}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-500">Room Information</label>
<p className="text-gray-900">Room {selectedBooking.room?.room_number} - {selectedBooking.room?.room_type?.name}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-gray-500">Check-in Date</label>
<p className="text-gray-900">{new Date(selectedBooking.check_in_date).toLocaleDateString('en-US')}</p>
{/* Room Information */}
<div className="bg-gradient-to-br from-blue-50/50 to-indigo-50/50 p-6 rounded-xl border border-blue-100">
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
<div className="w-1 h-4 bg-gradient-to-b from-blue-400 to-blue-600 rounded-full"></div>
Room Information
</label>
<p className="text-lg font-semibold text-slate-900">
<span className="text-amber-600">Room {selectedBooking.room?.room_number}</span>
<span className="text-slate-400 mx-2"></span>
<span className="text-slate-700">{selectedBooking.room?.room_type?.name}</span>
</p>
</div>
<div>
<label className="text-sm font-medium text-gray-500">Check-out Date</label>
<p className="text-gray-900">{new Date(selectedBooking.check_out_date).toLocaleDateString('en-US')}</p>
{/* Dates & Guests */}
<div className="grid grid-cols-2 gap-6">
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Check-in Date</label>
<p className="text-base font-semibold text-slate-900">{new Date(selectedBooking.check_in_date).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}</p>
</div>
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Check-out Date</label>
<p className="text-base font-semibold text-slate-900">{new Date(selectedBooking.check_out_date).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}</p>
</div>
</div>
</div>
<div>
<label className="text-sm font-medium text-gray-500">Number of Guests</label>
<p className="text-gray-900">{selectedBooking.guest_count} guest(s)</p>
</div>
<div>
<label className="text-sm font-medium text-gray-500">Total Price</label>
<p className="text-2xl font-bold text-green-600">{formatCurrency(selectedBooking.total_price)}</p>
</div>
{selectedBooking.notes && (
<div>
<label className="text-sm font-medium text-gray-500">Notes</label>
<p className="text-gray-900">{selectedBooking.notes}</p>
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Number of Guests</label>
<p className="text-lg font-semibold text-slate-900">{selectedBooking.guest_count} guest{selectedBooking.guest_count !== 1 ? 's' : ''}</p>
</div>
)}
</div>
<div className="mt-8 flex justify-end">
<button
onClick={() => setShowDetailModal(false)}
className="btn-enterprise-secondary"
>
Close
</button>
{/* Total Price - Highlighted */}
<div className="bg-gradient-to-br from-amber-50 via-yellow-50 to-amber-50 p-6 rounded-xl border-2 border-amber-200 shadow-lg">
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wider mb-2 block">Total Price</label>
<p className="text-4xl font-bold bg-gradient-to-r from-amber-600 via-amber-700 to-amber-600 bg-clip-text text-transparent">
{formatCurrency(selectedBooking.total_price)}
</p>
</div>
{/* Notes */}
{selectedBooking.notes && (
<div className="bg-gradient-to-br from-slate-50 to-white p-6 rounded-xl border border-slate-200">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3 block">Special Notes</label>
<p className="text-slate-700 leading-relaxed">{selectedBooking.notes}</p>
</div>
)}
</div>
{/* Modal Footer */}
<div className="mt-8 pt-6 border-t border-slate-200 flex justify-end">
<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"
>
Close
</button>
</div>
</div>
</div>
</div>

View File

@@ -3,6 +3,7 @@ import { Search, User, Hotel, CheckCircle, AlertCircle } from 'lucide-react';
import { bookingService, Booking } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
interface GuestInfo {
name: string;
@@ -11,6 +12,7 @@ interface GuestInfo {
}
const CheckInPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [bookingNumber, setBookingNumber] = useState('');
const [booking, setBooking] = useState<Booking | null>(null);
const [loading, setLoading] = useState(false);
@@ -108,9 +110,6 @@ const CheckInPage: React.FC = () => {
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(amount);
};
if (loading) {
return <Loading />;

View File

@@ -1,8 +1,10 @@
import React, { useState } from 'react';
import { Search, FileText, DollarSign, CreditCard, Printer, CheckCircle } from 'lucide-react';
import { Search, FileText, CreditCard, Printer, CheckCircle } from 'lucide-react';
import { bookingService, Booking } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import CurrencyIcon from '../../components/common/CurrencyIcon';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
interface ServiceItem {
service_name: string;
@@ -12,12 +14,13 @@ interface ServiceItem {
}
const CheckOutPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [bookingNumber, setBookingNumber] = useState('');
const [booking, setBooking] = useState<Booking | null>(null);
const [loading, setLoading] = useState(false);
const [searching, setSearching] = useState(false);
const [services, setServices] = useState<ServiceItem[]>([]);
const [paymentMethod, setPaymentMethod] = useState<'cash' | 'bank_transfer' | 'credit_card'>('cash');
const [paymentMethod, setPaymentMethod] = useState<'cash' | 'stripe'>('cash');
const [discount, setDiscount] = useState(0);
const [showInvoice, setShowInvoice] = useState(false);
@@ -88,9 +91,6 @@ const CheckOutPage: React.FC = () => {
return calculateTotal() - calculateDeposit();
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(amount);
};
const handleCheckOut = async () => {
if (!booking) return;
@@ -323,30 +323,19 @@ const CheckOutPage: React.FC = () => {
: 'border-gray-300 hover:border-gray-400'
}`}
>
<DollarSign className="w-8 h-8 mx-auto mb-2" />
<CurrencyIcon className="mx-auto mb-2" size={32} />
<div className="font-medium">Cash</div>
</button>
<button
onClick={() => setPaymentMethod('bank_transfer')}
onClick={() => setPaymentMethod('stripe')}
className={`p-4 border-2 rounded-lg text-center transition-all ${
paymentMethod === 'bank_transfer'
? 'border-blue-600 bg-blue-50 text-blue-600'
paymentMethod === 'stripe'
? 'border-indigo-600 bg-indigo-50 text-indigo-600'
: 'border-gray-300 hover:border-gray-400'
}`}
>
<CreditCard className="w-8 h-8 mx-auto mb-2" />
<div className="font-medium">Bank transfer</div>
</button>
<button
onClick={() => setPaymentMethod('credit_card')}
className={`p-4 border-2 rounded-lg text-center transition-all ${
paymentMethod === 'credit_card'
? 'border-blue-600 bg-blue-50 text-blue-600'
: 'border-gray-300 hover:border-gray-400'
}`}
>
<CreditCard className="w-8 h-8 mx-auto mb-2" />
<div className="font-medium">Credit card</div>
<div className="font-medium">Stripe</div>
</button>
</div>
</div>
@@ -398,7 +387,7 @@ const CheckOutPage: React.FC = () => {
<div>
<p className="text-sm text-gray-600">Payment method:</p>
<p className="font-semibold">
{paymentMethod === 'cash' ? 'Cash' : paymentMethod === 'bank_transfer' ? 'Bank transfer' : 'Credit card'}
{paymentMethod === 'cash' ? 'Cash' : 'Stripe'}
</p>
</div>
</div>

View File

@@ -112,15 +112,17 @@ const CookieSettingsPage: React.FC = () => {
}
return (
<div className="space-y-8">
<div className="space-y-10 pb-8 animate-fade-in">
{/* Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Shield className="w-6 h-6 text-amber-500" />
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-6 pb-6 border-b border-gray-200/60">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-lg bg-gradient-to-br from-[#d4af37]/10 to-[#d4af37]/5 border border-[#d4af37]/20 shadow-sm">
<Shield className="w-6 h-6 text-[#d4af37]" />
</div>
<h1 className="enterprise-section-title">Cookie & Privacy Controls</h1>
</div>
<p className="enterprise-section-subtitle max-w-2xl">
<p className="enterprise-section-subtitle max-w-2xl text-gray-600">
Define which cookie categories are allowed in the application. These
settings control which types of cookies your users can consent to.
</p>
@@ -130,51 +132,65 @@ const CookieSettingsPage: React.FC = () => {
type="button"
onClick={handleSave}
disabled={saving}
className="btn-enterprise-primary inline-flex items-center gap-2"
className="btn-enterprise-primary inline-flex items-center gap-2 whitespace-nowrap"
>
<Save className={`w-4 h-4 ${saving ? 'animate-pulse' : ''}`} />
{saving ? 'Saving...' : 'Save changes'}
{saving ? 'Saving...' : 'Save All Changes'}
</button>
</div>
{/* Info card */}
<div className="enterprise-card flex gap-4 p-4 sm:p-5">
<div className="mt-1">
<Info className="w-5 h-5 text-amber-500" />
<div className="enterprise-card flex gap-5 p-6 bg-gradient-to-br from-blue-50/50 to-indigo-50/30 border-blue-100/60">
<div className="mt-0.5 flex-shrink-0">
<div className="p-2.5 rounded-lg bg-gradient-to-br from-blue-500/10 to-indigo-500/10 border border-blue-200/40">
<Info className="w-5 h-5 text-blue-600" />
</div>
</div>
<div className="space-y-1 text-sm text-gray-700">
<p className="font-semibold text-gray-900">
How these settings affect the guest experience
</p>
<p>
<div className="space-y-2.5 flex-1">
<p className="font-semibold text-gray-900 text-base">
How these settings affect the guest experience
</p>
<p className="text-sm text-gray-700 leading-relaxed">
Disabling a category here prevents it from being offered to guests as
part of the cookie consent flow. For example, if marketing cookies are
disabled, the website should not load marketing pixels even if a guest
previously opted in.
</p>
{policyMeta?.updated_at && (
<p className="text-xs text-gray-500">
Last updated on{' '}
{new Date(policyMeta.updated_at).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
})}{' '}
{policyMeta.updated_by ? `by ${policyMeta.updated_by}` : ''}
</p>
<div className="pt-2 border-t border-gray-200/60">
<p className="text-xs text-gray-500 font-medium">
Last updated on{' '}
<span className="text-gray-700">
{new Date(policyMeta.updated_at).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
})}
</span>
{policyMeta.updated_by && (
<>
{' '}by <span className="text-gray-700 font-semibold">{policyMeta.updated_by}</span>
</>
)}
</p>
</div>
)}
</div>
</div>
{/* Toggles */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="enterprise-card p-5 space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-semibold text-gray-900 flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4 text-emerald-500" />
Analytics cookies
</p>
<p className="text-xs text-gray-500 mt-1">
<div className="enterprise-card p-6 space-y-4 group">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2.5">
<div className="p-1.5 rounded-md bg-emerald-50 border border-emerald-100">
<SlidersHorizontal className="w-4 h-4 text-emerald-600" />
</div>
<p className="font-bold text-gray-900 text-base">
Analytics Cookies
</p>
</div>
<p className="text-sm text-gray-600 leading-relaxed">
Anonymous traffic and performance measurement (e.g. page views,
conversion funnels).
</p>
@@ -182,31 +198,39 @@ const CookieSettingsPage: React.FC = () => {
<button
type="button"
onClick={() => handleToggle('analytics_enabled')}
className={`relative inline-flex h-7 w-12 items-center rounded-full transition ${
policy.analytics_enabled ? 'bg-emerald-500' : 'bg-gray-300'
className={`relative inline-flex h-8 w-14 items-center rounded-full transition-all duration-300 shadow-lg ${
policy.analytics_enabled
? 'bg-gradient-to-r from-emerald-500 to-emerald-600 shadow-emerald-500/30'
: 'bg-gray-300 shadow-gray-300/20'
}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
policy.analytics_enabled ? 'translate-x-5' : 'translate-x-1'
className={`inline-block h-6 w-6 transform rounded-full bg-white shadow-lg transition-all duration-300 ${
policy.analytics_enabled ? 'translate-x-7' : 'translate-x-1'
}`}
/>
</button>
</div>
<p className="text-[11px] text-gray-500">
When disabled, analytics tracking scripts should not be executed,
regardless of user consent.
</p>
<div className="pt-3 border-t border-gray-100">
<p className="text-xs text-gray-500 leading-relaxed">
When disabled, analytics tracking scripts should not be executed,
regardless of user consent.
</p>
</div>
</div>
<div className="enterprise-card p-5 space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-semibold text-gray-900 flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4 text-pink-500" />
Marketing cookies
</p>
<p className="text-xs text-gray-500 mt-1">
<div className="enterprise-card p-6 space-y-4 group">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2.5">
<div className="p-1.5 rounded-md bg-pink-50 border border-pink-100">
<SlidersHorizontal className="w-4 h-4 text-pink-600" />
</div>
<p className="font-bold text-gray-900 text-base">
Marketing Cookies
</p>
</div>
<p className="text-sm text-gray-600 leading-relaxed">
Personalised offers, remarketing campaigns, and external ad
networks.
</p>
@@ -214,97 +238,119 @@ const CookieSettingsPage: React.FC = () => {
<button
type="button"
onClick={() => handleToggle('marketing_enabled')}
className={`relative inline-flex h-7 w-12 items-center rounded-full transition ${
policy.marketing_enabled ? 'bg-pink-500' : 'bg-gray-300'
className={`relative inline-flex h-8 w-14 items-center rounded-full transition-all duration-300 shadow-lg ${
policy.marketing_enabled
? 'bg-gradient-to-r from-pink-500 to-pink-600 shadow-pink-500/30'
: 'bg-gray-300 shadow-gray-300/20'
}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
policy.marketing_enabled ? 'translate-x-5' : 'translate-x-1'
className={`inline-block h-6 w-6 transform rounded-full bg-white shadow-lg transition-all duration-300 ${
policy.marketing_enabled ? 'translate-x-7' : 'translate-x-1'
}`}
/>
</button>
</div>
<p className="text-[11px] text-gray-500">
When disabled, do not load any marketing pixels or share data with ad
platforms.
</p>
<div className="pt-3 border-t border-gray-100">
<p className="text-xs text-gray-500 leading-relaxed">
When disabled, do not load any marketing pixels or share data with ad
platforms.
</p>
</div>
</div>
<div className="enterprise-card p-5 space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-semibold text-gray-900 flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4 text-indigo-500" />
Preference cookies
</p>
<p className="text-xs text-gray-500 mt-1">
<div className="enterprise-card p-6 space-y-4 group">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2.5">
<div className="p-1.5 rounded-md bg-indigo-50 border border-indigo-100">
<SlidersHorizontal className="w-4 h-4 text-indigo-600" />
</div>
<p className="font-bold text-gray-900 text-base">
Preference Cookies
</p>
</div>
<p className="text-sm text-gray-600 leading-relaxed">
Remember guest choices like language, currency, and layout.
</p>
</div>
<button
type="button"
onClick={() => handleToggle('preferences_enabled')}
className={`relative inline-flex h-7 w-12 items-center rounded-full transition ${
policy.preferences_enabled ? 'bg-indigo-500' : 'bg-gray-300'
className={`relative inline-flex h-8 w-14 items-center rounded-full transition-all duration-300 shadow-lg ${
policy.preferences_enabled
? 'bg-gradient-to-r from-indigo-500 to-indigo-600 shadow-indigo-500/30'
: 'bg-gray-300 shadow-gray-300/20'
}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
policy.preferences_enabled ? 'translate-x-5' : 'translate-x-1'
className={`inline-block h-6 w-6 transform rounded-full bg-white shadow-lg transition-all duration-300 ${
policy.preferences_enabled ? 'translate-x-7' : 'translate-x-1'
}`}
/>
</button>
</div>
<p className="text-[11px] text-gray-500">
When disabled, the application should avoid persisting non-essential
preferences client-side.
</p>
<div className="pt-3 border-t border-gray-100">
<p className="text-xs text-gray-500 leading-relaxed">
When disabled, the application should avoid persisting non-essential
preferences client-side.
</p>
</div>
</div>
</div>
{/* Integration IDs */}
<div className="enterprise-card p-5 space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-blue-500" />
<div>
<p className="font-semibold text-gray-900">
Third-party integrations (IDs only)
<div className="enterprise-card p-6 space-y-6">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between pb-4 border-b border-gray-200/60">
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-gradient-to-br from-blue-500/10 to-indigo-500/10 border border-blue-200/40 mt-0.5">
<Globe className="w-5 h-5 text-blue-600" />
</div>
<div className="space-y-1.5">
<p className="font-bold text-gray-900 text-lg">
Third-Party Integrations
</p>
<p className="text-xs text-gray-500">
<p className="text-sm text-gray-600 leading-relaxed max-w-xl">
Configure IDs for supported analytics and marketing platforms. The
application will only load these when both the policy and user consent
allow it.
</p>
</div>
</div>
<div className="flex flex-col items-start gap-2 md:items-end">
<div className="flex flex-col items-start gap-3 md:items-end md:min-w-[200px]">
{integrationMeta?.updated_at && (
<p className="text-[11px] text-gray-500">
Last changed{' '}
{new Date(integrationMeta.updated_at).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
})}{' '}
{integrationMeta.updated_by ? `by ${integrationMeta.updated_by}` : ''}
</p>
<div className="text-right">
<p className="text-xs text-gray-500 font-medium">
Last changed
</p>
<p className="text-xs text-gray-700 mt-0.5">
{new Date(integrationMeta.updated_at).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
})}
</p>
{integrationMeta.updated_by && (
<p className="text-xs text-gray-600 mt-0.5">
by <span className="font-semibold">{integrationMeta.updated_by}</span>
</p>
)}
</div>
)}
<button
type="button"
onClick={handleSaveIntegrations}
disabled={saving}
className="btn-enterprise-secondary inline-flex items-center gap-1.5 px-3 py-1.5 text-xs"
className="btn-enterprise-secondary inline-flex items-center gap-2 whitespace-nowrap"
>
<Save className="w-3.5 h-3.5" />
{saving ? 'Saving IDs...' : 'Save integration IDs'}
{saving ? 'Saving...' : 'Save Integration IDs'}
</button>
</div>
</div>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-800">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-3">
<label className="block text-sm font-semibold text-gray-900 tracking-wide">
Google Analytics 4 Measurement ID
</label>
<input
@@ -319,14 +365,14 @@ const CookieSettingsPage: React.FC = () => {
placeholder="G-XXXXXXXXXX"
className="enterprise-input text-sm"
/>
<p className="text-[11px] text-gray-500">
Example: <code className="font-mono">G-ABCDE12345</code>. This is used to
<p className="text-xs text-gray-500 leading-relaxed">
Example: <code className="font-mono text-gray-700 bg-gray-50 px-1.5 py-0.5 rounded border border-gray-200">G-ABCDE12345</code>. This is used to
load GA4 via gtag.js when analytics cookies are allowed.
</p>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-800">
<div className="space-y-3">
<label className="block text-sm font-semibold text-gray-900 tracking-wide">
Meta (Facebook) Pixel ID
</label>
<input
@@ -341,7 +387,7 @@ const CookieSettingsPage: React.FC = () => {
placeholder="123456789012345"
className="enterprise-input text-sm"
/>
<p className="text-[11px] text-gray-500">
<p className="text-xs text-gray-500 leading-relaxed">
Numeric ID from your Meta Pixel. The application will only fire pixel
events when marketing cookies are allowed.
</p>

View File

@@ -0,0 +1,187 @@
import React, { useEffect, useState } from 'react';
import { DollarSign, Save, Info, Globe } from 'lucide-react';
import { toast } from 'react-toastify';
import systemSettingsService, { PlatformCurrencyResponse } from '../../services/api/systemSettingsService';
import { useCurrency } from '../../contexts/CurrencyContext';
import { Loading } from '../../components/common';
import { getCurrencySymbol } from '../../utils/format';
const CurrencySettingsPage: React.FC = () => {
const { currency, supportedCurrencies, refreshCurrency } = useCurrency();
const [selectedCurrency, setSelectedCurrency] = useState<string>(currency);
const [currencyInfo, setCurrencyInfo] = useState<PlatformCurrencyResponse['data'] | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const currencyNames: Record<string, string> = {
VND: 'Vietnamese Dong',
USD: 'US Dollar',
EUR: 'Euro',
GBP: 'British Pound',
JPY: 'Japanese Yen',
CNY: 'Chinese Yuan',
KRW: 'South Korean Won',
SGD: 'Singapore Dollar',
THB: 'Thai Baht',
AUD: 'Australian Dollar',
CAD: 'Canadian Dollar',
};
const getCurrencyDisplayName = (code: string): string => {
const name = currencyNames[code] || code;
const symbol = getCurrencySymbol(code);
return `${name} (${symbol})`;
};
const loadCurrencySettings = async () => {
try {
setLoading(true);
const response = await systemSettingsService.getPlatformCurrency();
setCurrencyInfo(response.data);
setSelectedCurrency(response.data.currency);
} catch (error: any) {
toast.error(error.message || 'Failed to load currency settings');
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadCurrencySettings();
}, []);
useEffect(() => {
setSelectedCurrency(currency);
}, [currency]);
const handleSave = async () => {
try {
setSaving(true);
await systemSettingsService.updatePlatformCurrency(selectedCurrency);
await refreshCurrency();
await loadCurrencySettings();
toast.success('Platform currency updated successfully');
} catch (error: any) {
toast.error(error.message || 'Failed to update platform currency');
} finally {
setSaving(false);
}
};
if (loading) {
return <Loading fullScreen={false} text="Loading currency settings..." />;
}
return (
<div className="space-y-10 pb-8 animate-fade-in">
{/* Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-6 pb-6 border-b border-gray-200/60">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-lg bg-gradient-to-br from-[#d4af37]/10 to-[#d4af37]/5 border border-[#d4af37]/20 shadow-sm">
<DollarSign className="w-6 h-6 text-[#d4af37]" />
</div>
<h1 className="enterprise-section-title">Platform Currency Settings</h1>
</div>
<p className="enterprise-section-subtitle max-w-2xl text-gray-600">
Set the default currency that will be displayed across all dashboards and pages
throughout the platform. This setting applies to all users.
</p>
</div>
<button
type="button"
onClick={handleSave}
disabled={saving || selectedCurrency === currency}
className="btn-enterprise-primary inline-flex items-center gap-2 whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className={`w-4 h-4 ${saving ? 'animate-pulse' : ''}`} />
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
{/* Info card */}
<div className="enterprise-card flex gap-5 p-6 bg-gradient-to-br from-blue-50/50 to-indigo-50/30 border-blue-100/60">
<div className="mt-0.5 flex-shrink-0">
<div className="p-2.5 rounded-lg bg-gradient-to-br from-blue-500/10 to-indigo-500/10 border border-blue-200/40">
<Info className="w-5 h-5 text-blue-600" />
</div>
</div>
<div className="space-y-2.5 flex-1">
<p className="font-semibold text-gray-900 text-base">
How platform currency works
</p>
<p className="text-sm text-gray-700 leading-relaxed">
The platform currency you select here will be used to display all prices, amounts,
and financial information across the entire application. This includes customer-facing
pages, admin dashboards, reports, and booking pages. All users will see prices in
the selected currency.
</p>
{currencyInfo?.updated_at && (
<div className="pt-2 border-t border-gray-200/60">
<p className="text-xs text-gray-500 font-medium">
Last updated on{' '}
<span className="text-gray-700">
{new Date(currencyInfo.updated_at).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
})}
</span>
{currencyInfo.updated_by && (
<>
{' '}by <span className="text-gray-700 font-semibold">{currencyInfo.updated_by}</span>
</>
)}
</p>
</div>
)}
</div>
</div>
{/* Currency Selection */}
<div className="enterprise-card p-6 space-y-6">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between pb-4 border-b border-gray-200/60">
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-gradient-to-br from-green-500/10 to-emerald-500/10 border border-green-200/40 mt-0.5">
<Globe className="w-5 h-5 text-green-600" />
</div>
<div className="space-y-1.5">
<p className="font-bold text-gray-900 text-lg">
Select Platform Currency
</p>
<p className="text-sm text-gray-600 leading-relaxed max-w-xl">
Choose the currency that will be used throughout the platform for displaying
all monetary values.
</p>
</div>
</div>
</div>
<div className="space-y-4">
<div className="space-y-3">
<label className="block text-sm font-semibold text-gray-900 tracking-wide">
Currency
</label>
<select
value={selectedCurrency}
onChange={(e) => setSelectedCurrency(e.target.value)}
className="enterprise-input text-sm max-w-md"
>
{supportedCurrencies.map((curr) => (
<option key={curr} value={curr}>
{curr} - {getCurrencyDisplayName(curr)}
</option>
))}
</select>
<p className="text-xs text-gray-500 leading-relaxed">
Current platform currency: <span className="font-semibold text-gray-700">{currency}</span>
</p>
</div>
</div>
</div>
</div>
);
};
export default CurrencySettingsPage;

View File

@@ -3,23 +3,30 @@ import {
BarChart3,
Users,
Hotel,
DollarSign,
Calendar,
TrendingUp,
RefreshCw,
TrendingDown
TrendingDown,
CreditCard
} from 'lucide-react';
import { reportService, ReportData } from '../../services/api';
import { reportService, ReportData, paymentService, Payment } from '../../services/api';
import { toast } from 'react-toastify';
import { Loading, EmptyState } from '../../components/common';
import { formatCurrency, formatDate } from '../../utils/format';
import CurrencyIcon from '../../components/common/CurrencyIcon';
import { formatDate } from '../../utils/format';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { useAsync } from '../../hooks/useAsync';
import { useNavigate } from 'react-router-dom';
const DashboardPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const navigate = useNavigate();
const [dateRange, setDateRange] = useState({
from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
to: new Date().toISOString().split('T')[0],
});
const [recentPayments, setRecentPayments] = useState<Payment[]>([]);
const [loadingPayments, setLoadingPayments] = useState(false);
const fetchDashboardData = async () => {
const response = await reportService.getReports({
@@ -43,10 +50,56 @@ const DashboardPage: React.FC = () => {
execute();
}, [dateRange]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const fetchPayments = async () => {
try {
setLoadingPayments(true);
const response = await paymentService.getPayments({ page: 1, limit: 5 });
if (response.success && response.data?.payments) {
setRecentPayments(response.data.payments);
}
} catch (err: any) {
console.error('Error fetching payments:', err);
} finally {
setLoadingPayments(false);
}
};
fetchPayments();
}, []);
const handleRefresh = () => {
execute();
};
const getPaymentStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-gradient-to-r from-emerald-50 to-green-50 text-emerald-800 border-emerald-200';
case 'pending':
return 'bg-gradient-to-r from-amber-50 to-yellow-50 text-amber-800 border-amber-200';
case 'failed':
return 'bg-gradient-to-r from-rose-50 to-red-50 text-rose-800 border-rose-200';
case 'refunded':
return 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200';
default:
return 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200';
}
};
const getPaymentMethodLabel = (method: string) => {
switch (method) {
case 'stripe':
case 'credit_card':
return 'Card';
case 'bank_transfer':
return 'Bank Transfer';
case 'cash':
return 'Cash';
default:
return method;
}
};
if (loading) {
return <Loading fullScreen text="Loading dashboard..." />;
}
@@ -67,12 +120,17 @@ const DashboardPage: React.FC = () => {
}
return (
<div className="space-y-8">
{/* Header */}
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{/* Luxury Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-6 animate-fade-in">
<div>
<h1 className="enterprise-section-title">Dashboard</h1>
<p className="enterprise-section-subtitle mt-2">Hotel operations overview and analytics</p>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Dashboard
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Hotel operations overview and analytics</p>
</div>
{/* Date Range Filter */}
@@ -82,20 +140,20 @@ const DashboardPage: React.FC = () => {
type="date"
value={dateRange.from}
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
className="enterprise-input text-sm"
className="px-4 py-2.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md"
/>
<span className="text-gray-500 font-medium">to</span>
<span className="text-slate-500 font-medium">to</span>
<input
type="date"
value={dateRange.to}
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
className="enterprise-input text-sm"
className="px-4 py-2.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md"
/>
</div>
<button
onClick={handleRefresh}
disabled={loading}
className="btn-enterprise-primary flex items-center gap-2 text-sm"
className="px-6 py-2.5 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 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 text-sm"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
@@ -103,84 +161,83 @@ const DashboardPage: React.FC = () => {
</div>
</div>
{/* Stats Cards */}
{/* Luxury Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Total Revenue */}
<div className="enterprise-stat-card border-l-4 border-green-500 animate-slide-up" style={{ animationDelay: '0.1s' }}>
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 border-l-4 border-l-emerald-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.1s' }}>
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm font-medium">Total Revenue</p>
<p className="text-2xl font-bold text-gray-900 mt-2">
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-2">Total Revenue</p>
<p className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
{formatCurrency(stats?.total_revenue || 0)}
</p>
</div>
<div className="bg-green-100 p-3 rounded-full">
<DollarSign className="w-6 h-6 text-green-600" />
<div className="bg-gradient-to-br from-emerald-100 to-emerald-200 p-4 rounded-2xl shadow-lg">
<CurrencyIcon className="text-emerald-600" size={28} />
</div>
</div>
{/* Trend indicator - can be enhanced with actual comparison data */}
<div className="flex items-center mt-4 text-sm">
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
<span className="text-green-600 font-medium">Active</span>
<span className="text-gray-500 ml-2">All time revenue</span>
<div className="flex items-center mt-5 pt-4 border-t border-slate-100">
<TrendingUp className="w-4 h-4 text-emerald-500 mr-2" />
<span className="text-emerald-600 font-semibold text-sm">Active</span>
<span className="text-slate-500 ml-2 text-sm">All time revenue</span>
</div>
</div>
{/* Total Bookings */}
<div className="enterprise-stat-card border-l-4 border-blue-500 animate-slide-up" style={{ animationDelay: '0.2s' }}>
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 border-l-4 border-l-blue-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.2s' }}>
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm font-medium">Total Bookings</p>
<p className="text-2xl font-bold text-gray-900 mt-2">
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-2">Total Bookings</p>
<p className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-blue-700 bg-clip-text text-transparent">
{stats?.total_bookings || 0}
</p>
</div>
<div className="bg-blue-100 p-3 rounded-full">
<Calendar className="w-6 h-6 text-blue-600" />
<div className="bg-gradient-to-br from-blue-100 to-blue-200 p-4 rounded-2xl shadow-lg">
<Calendar className="w-7 h-7 text-blue-600" />
</div>
</div>
<div className="flex items-center mt-4 text-sm">
<span className="text-gray-500">
<div className="flex items-center mt-5 pt-4 border-t border-slate-100">
<span className="text-slate-500 text-sm">
{stats.total_bookings > 0 ? 'Total bookings recorded' : 'No bookings yet'}
</span>
</div>
</div>
{/* Available Rooms */}
<div className="enterprise-stat-card border-l-4 border-purple-500 animate-slide-up" style={{ animationDelay: '0.3s' }}>
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 border-l-4 border-l-purple-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.3s' }}>
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm font-medium">Available Rooms</p>
<p className="text-2xl font-bold text-gray-900 mt-2">
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-2">Available Rooms</p>
<p className="text-3xl font-bold bg-gradient-to-r from-purple-600 to-purple-700 bg-clip-text text-transparent">
{stats?.available_rooms || 0}
</p>
</div>
<div className="bg-purple-100 p-3 rounded-full">
<Hotel className="w-6 h-6 text-purple-600" />
<div className="bg-gradient-to-br from-purple-100 to-purple-200 p-4 rounded-2xl shadow-lg">
<Hotel className="w-7 h-7 text-purple-600" />
</div>
</div>
<div className="flex items-center mt-4 text-sm">
<span className="text-gray-500">
<div className="flex items-center mt-5 pt-4 border-t border-slate-100">
<span className="text-slate-500 text-sm">
{stats?.occupied_rooms || 0} rooms in use
</span>
</div>
</div>
{/* Total Customers */}
<div className="enterprise-stat-card border-l-4 border-orange-500 animate-slide-up" style={{ animationDelay: '0.4s' }}>
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 border-l-4 border-l-amber-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.4s' }}>
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm font-medium">Customers</p>
<p className="text-2xl font-bold text-gray-900 mt-2">
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-2">Customers</p>
<p className="text-3xl font-bold bg-gradient-to-r from-amber-600 to-amber-700 bg-clip-text text-transparent">
{stats?.total_customers || 0}
</p>
</div>
<div className="bg-orange-100 p-3 rounded-full">
<Users className="w-6 h-6 text-orange-600" />
<div className="bg-gradient-to-br from-amber-100 to-amber-200 p-4 rounded-2xl shadow-lg">
<Users className="w-7 h-7 text-amber-600" />
</div>
</div>
<div className="flex items-center mt-4 text-sm">
<span className="text-gray-500">
<div className="flex items-center mt-5 pt-4 border-t border-slate-100">
<span className="text-slate-500 text-sm">
Unique customers with bookings
</span>
</div>
@@ -190,10 +247,10 @@ const DashboardPage: React.FC = () => {
{/* Charts Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Revenue Chart */}
<div className="enterprise-card p-6 animate-fade-in">
<div className="flex items-center justify-between mb-6 pb-3 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">Daily Revenue</h2>
<div className="p-2 bg-blue-100 rounded-lg">
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
<h2 className="text-xl font-bold text-slate-900">Daily Revenue</h2>
<div className="p-3 bg-gradient-to-br from-blue-100 to-blue-200 rounded-xl shadow-md">
<BarChart3 className="w-5 h-5 text-blue-600" />
</div>
</div>
@@ -202,22 +259,22 @@ const DashboardPage: React.FC = () => {
{stats.revenue_by_date.slice(0, 7).map((item, index) => {
const maxRevenue = Math.max(...stats.revenue_by_date!.map(r => r.revenue));
return (
<div key={index} className="flex items-center">
<span className="text-sm text-gray-600 w-24">
<div key={index} className="flex items-center py-2">
<span className="text-sm text-slate-600 w-24 font-medium">
{formatDate(item.date, 'short')}
</span>
<div className="flex-1 mx-3">
<div className="bg-gray-200 rounded-full h-4 overflow-hidden">
<div className="flex-1 mx-4">
<div className="bg-slate-200 rounded-full h-5 overflow-hidden shadow-inner">
<div
className="bg-blue-500 h-4 rounded-full transition-all"
className="bg-gradient-to-r from-emerald-500 to-emerald-600 h-5 rounded-full transition-all shadow-md"
style={{
width: `${Math.min((item.revenue / (maxRevenue || 1)) * 100, 100)}%`,
}}
/>
</div>
</div>
<span className="text-sm font-semibold text-gray-900 w-32 text-right">
{formatCurrency(item.revenue, 'VND')}
<span className="text-sm font-bold text-slate-900 w-32 text-right bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
{formatCurrency(item.revenue)}
</span>
</div>
);
@@ -232,9 +289,9 @@ const DashboardPage: React.FC = () => {
</div>
{/* Bookings by Status */}
<div className="enterprise-card p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
<div className="flex items-center justify-between mb-6 pb-3 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">Booking Status</h2>
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
<h2 className="text-xl font-bold text-slate-900">Booking Status</h2>
</div>
{stats?.bookings_by_status && Object.keys(stats.bookings_by_status).length > 0 ? (
<div className="space-y-4">
@@ -242,11 +299,11 @@ const DashboardPage: React.FC = () => {
.filter(([_, count]) => count > 0)
.map(([status, count]) => {
const statusColors: Record<string, string> = {
pending: 'bg-yellow-500',
pending: 'bg-amber-500',
confirmed: 'bg-blue-500',
checked_in: 'bg-green-500',
checked_out: 'bg-gray-500',
cancelled: 'bg-red-500',
checked_in: 'bg-emerald-500',
checked_out: 'bg-slate-500',
cancelled: 'bg-rose-500',
};
const statusLabels: Record<string, string> = {
pending: 'Pending confirmation',
@@ -256,12 +313,12 @@ const DashboardPage: React.FC = () => {
cancelled: 'Cancelled',
};
return (
<div key={status} className="flex items-center justify-between">
<div key={status} className="flex items-center justify-between p-3 bg-gradient-to-r from-slate-50 to-white rounded-xl hover:shadow-md transition-all duration-200 border border-slate-100">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${statusColors[status] || 'bg-gray-500'}`} />
<span className="text-gray-700">{statusLabels[status] || status}</span>
<div className={`w-4 h-4 rounded-full shadow-md ${statusColors[status] || 'bg-slate-500'}`} />
<span className="text-slate-700 font-medium">{statusLabels[status] || status}</span>
</div>
<span className="font-semibold text-gray-900">{count}</span>
<span className="font-bold text-slate-900 text-lg">{count}</span>
</div>
);
})}
@@ -275,26 +332,31 @@ const DashboardPage: React.FC = () => {
</div>
</div>
{/* Top Rooms and Services */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Top Rooms, Services & Recent Payments */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Top Rooms */}
<div className="enterprise-card p-6 animate-fade-in">
<h2 className="text-xl font-bold text-gray-900 mb-6 pb-3 border-b border-gray-200">Top Booked Rooms</h2>
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
<h2 className="text-xl font-bold text-slate-900">Top Booked Rooms</h2>
<div className="p-2 bg-gradient-to-br from-amber-100 to-amber-200 rounded-xl">
<Hotel className="w-5 h-5 text-amber-600" />
</div>
</div>
{stats?.top_rooms && stats.top_rooms.length > 0 ? (
<div className="space-y-3">
{stats.top_rooms.map((room, index) => (
<div key={room.room_id} className="flex items-center justify-between p-4 bg-gradient-to-r from-gray-50 to-gray-100/50 rounded-xl hover:from-blue-50 hover:to-indigo-50 transition-all duration-300 border border-gray-200 hover:border-blue-200 hover:shadow-md">
<div key={room.room_id} className="flex items-center justify-between p-4 bg-gradient-to-r from-slate-50 to-white rounded-xl hover:from-amber-50 hover:to-yellow-50 transition-all duration-300 border border-slate-200 hover:border-amber-300 hover:shadow-lg">
<div className="flex items-center gap-4">
<span className="flex items-center justify-center w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 text-white rounded-xl font-bold shadow-lg shadow-blue-500/30">
<span className="flex items-center justify-center w-12 h-12 bg-gradient-to-br from-amber-500 to-amber-600 text-white rounded-xl font-bold shadow-lg shadow-amber-500/40 text-lg">
{index + 1}
</span>
<div>
<p className="font-medium text-gray-900">Room {room.room_number}</p>
<p className="text-sm text-gray-500">{room.bookings} booking{room.bookings !== 1 ? 's' : ''}</p>
<p className="font-semibold text-slate-900">Room {room.room_number}</p>
<p className="text-sm text-slate-500">{room.bookings} booking{room.bookings !== 1 ? 's' : ''}</p>
</div>
</div>
<span className="font-semibold text-green-600">
{formatCurrency(room.revenue, 'VND')}
<span className="font-bold text-emerald-600 bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
{formatCurrency(room.revenue)}
</span>
</div>
))}
@@ -308,18 +370,23 @@ const DashboardPage: React.FC = () => {
</div>
{/* Service Usage */}
<div className="enterprise-card p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
<h2 className="text-xl font-bold text-gray-900 mb-6 pb-3 border-b border-gray-200">Services Used</h2>
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
<h2 className="text-xl font-bold text-slate-900">Services Used</h2>
<div className="p-2 bg-gradient-to-br from-purple-100 to-purple-200 rounded-xl">
<BarChart3 className="w-5 h-5 text-purple-600" />
</div>
</div>
{stats?.service_usage && stats.service_usage.length > 0 ? (
<div className="space-y-3">
{stats.service_usage.map((service) => (
<div key={service.service_id} className="flex items-center justify-between p-4 bg-gradient-to-r from-gray-50 to-gray-100/50 rounded-xl hover:from-purple-50 hover:to-indigo-50 transition-all duration-300 border border-gray-200 hover:border-purple-200 hover:shadow-md">
<div key={service.service_id} className="flex items-center justify-between p-4 bg-gradient-to-r from-slate-50 to-white rounded-xl hover:from-purple-50 hover:to-indigo-50 transition-all duration-300 border border-slate-200 hover:border-purple-300 hover:shadow-lg">
<div>
<p className="font-medium text-gray-900">{service.service_name}</p>
<p className="text-sm text-gray-500">{service.usage_count} time{service.usage_count !== 1 ? 's' : ''} used</p>
<p className="font-semibold text-slate-900">{service.service_name}</p>
<p className="text-sm text-slate-500">{service.usage_count} time{service.usage_count !== 1 ? 's' : ''} used</p>
</div>
<span className="font-semibold text-purple-600">
{formatCurrency(service.total_revenue, 'VND')}
<span className="font-bold text-purple-600 bg-gradient-to-r from-purple-600 to-purple-700 bg-clip-text text-transparent">
{formatCurrency(service.total_revenue)}
</span>
</div>
))}
@@ -331,6 +398,67 @@ const DashboardPage: React.FC = () => {
/>
)}
</div>
{/* Recent Payments */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.3s' }}>
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
<h2 className="text-xl font-bold text-slate-900">Recent Payments</h2>
<button
onClick={() => navigate('/admin/payments')}
className="text-sm text-amber-600 hover:text-amber-700 font-semibold hover:underline transition-colors"
>
View All
</button>
</div>
{loadingPayments ? (
<div className="flex items-center justify-center py-8">
<Loading text="Loading payments..." />
</div>
) : recentPayments && recentPayments.length > 0 ? (
<div className="space-y-3">
{recentPayments.map((payment) => (
<div
key={payment.id}
className="flex items-center justify-between p-4 bg-gradient-to-r from-slate-50 to-white rounded-xl hover:from-amber-50 hover:to-yellow-50 border border-slate-200 hover:border-amber-300 hover:shadow-lg cursor-pointer transition-all duration-200"
onClick={() => navigate(`/admin/payments`)}
>
<div className="flex items-center space-x-4 flex-1">
<div className="p-3 bg-gradient-to-br from-blue-100 to-blue-200 rounded-xl shadow-md">
<CreditCard className="w-5 h-5 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-slate-900 truncate text-lg">
{formatCurrency(payment.amount)}
</p>
<div className="flex items-center gap-2 mt-1">
<p className="text-sm text-slate-600 font-medium">
{getPaymentMethodLabel(payment.payment_method)}
</p>
{payment.payment_date && (
<span className="text-xs text-slate-400">
{formatDate(payment.payment_date, 'short')}
</span>
)}
</div>
</div>
</div>
<span className={`px-3 py-1.5 text-xs font-semibold rounded-full border shadow-sm ${getPaymentStatusColor(payment.payment_status)}`}>
{payment.payment_status.charAt(0).toUpperCase() + payment.payment_status.slice(1)}
</span>
</div>
))}
</div>
) : (
<EmptyState
title="No Recent Payments"
description="Recent payment transactions will appear here"
action={{
label: 'View All Payments',
onClick: () => navigate('/admin/payments')
}}
/>
)}
</div>
</div>
</div>
);

View File

@@ -0,0 +1,306 @@
import React, { useEffect, useState } from 'react';
import { Search, Plus, Edit, Trash2, Eye, Download, FileText, Filter } from 'lucide-react';
import { invoiceService, Invoice } 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 { useNavigate } from 'react-router-dom';
import { formatDate } from '../../utils/format';
const InvoiceManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const navigate = useNavigate();
const [invoices, setInvoices] = useState<Invoice[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState({
search: '',
status: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 10;
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchInvoices();
}, [filters, currentPage]);
const fetchInvoices = async () => {
try {
setLoading(true);
const response = await invoiceService.getInvoices({
status: filters.status || undefined,
page: currentPage,
limit: itemsPerPage,
});
if (response.status === 'success' && response.data) {
let invoiceList = response.data.invoices || [];
// Apply search filter
if (filters.search) {
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())
);
}
setInvoices(invoiceList);
setTotalPages(response.data.total_pages || 1);
setTotalItems(response.data.total || 0);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load invoices');
} finally {
setLoading(false);
}
};
const getStatusBadge = (status: string) => {
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
draft: {
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
text: 'text-slate-700',
label: 'Draft',
border: 'border-slate-200'
},
sent: {
bg: 'bg-gradient-to-r from-blue-50 to-indigo-50',
text: 'text-blue-800',
label: 'Sent',
border: 'border-blue-200'
},
paid: {
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
text: 'text-emerald-800',
label: 'Paid',
border: 'border-emerald-200'
},
overdue: {
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
text: 'text-rose-800',
label: 'Overdue',
border: 'border-rose-200'
},
cancelled: {
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
text: 'text-slate-700',
label: 'Cancelled',
border: 'border-slate-200'
},
};
return badges[status] || badges.draft;
};
const handleDelete = async (id: number) => {
if (!window.confirm('Are you sure you want to delete this invoice?')) {
return;
}
try {
await invoiceService.deleteInvoice(id);
toast.success('Invoice deleted successfully');
fetchInvoices();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to delete invoice');
}
};
if (loading && invoices.length === 0) {
return <Loading fullScreen text="Loading invoices..." />;
}
return (
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{/* Luxury Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 animate-fade-in">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Invoice Management
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Manage and track all invoices</p>
</div>
<button
onClick={() => navigate('/admin/invoices/create')}
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
</button>
</div>
{/* Luxury Filters */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
<input
type="text"
placeholder="Search invoices..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Statuses</option>
<option value="draft">Draft</option>
<option value="sent">Sent</option>
<option value="paid">Paid</option>
<option value="overdue">Overdue</option>
<option value="cancelled">Cancelled</option>
</select>
<div className="flex items-center gap-3 px-4 py-3.5 bg-gradient-to-r from-slate-50 to-white border-2 border-slate-200 rounded-xl">
<Filter className="w-5 h-5 text-amber-600" />
<span className="text-sm font-semibold text-slate-700">
{totalItems} invoice{totalItems !== 1 ? 's' : ''}
</span>
</div>
</div>
</div>
{/* Luxury Invoices Table */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900">
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
Invoice #
</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
Customer
</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
Booking
</th>
<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">
Status
</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
Due Date
</th>
<th className="px-8 py-5 text-right text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-100">
{invoices.length > 0 ? (
invoices.map((invoice, index) => {
const statusBadge = getStatusBadge(invoice.status);
return (
<tr
key={invoice.id}
className="hover:bg-gradient-to-r hover:from-amber-50/30 hover:to-yellow-50/30 transition-all duration-200 group border-b border-slate-100"
style={{ animationDelay: `${index * 0.05}s` }}
>
<td className="px-8 py-5 whitespace-nowrap">
<div className="flex items-center">
<FileText className="w-5 h-5 text-amber-600 mr-3" />
<span className="text-sm font-bold text-slate-900 font-mono">
{invoice.invoice_number}
</span>
</div>
</td>
<td className="px-8 py-5">
<div className="text-sm font-semibold text-slate-900">{invoice.customer_name}</div>
<div className="text-sm text-slate-500">{invoice.customer_email}</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<span className="text-sm font-medium text-amber-600">#{invoice.booking_id}</span>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
{formatCurrency(invoice.total_amount)}
</div>
{invoice.balance_due > 0 && (
<div className="text-xs text-rose-600 font-medium mt-1">
Due: {formatCurrency(invoice.balance_due)}
</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 || ''}`}>
{statusBadge.label}
</span>
</td>
<td className="px-8 py-5 whitespace-nowrap text-sm text-slate-600">
{formatDate(invoice.due_date, 'short')}
</td>
<td className="px-8 py-5 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => navigate(`/admin/invoices/${invoice.id}`)}
className="p-2 rounded-lg text-blue-600 hover:text-blue-700 hover:bg-blue-50 transition-all duration-200 shadow-sm hover:shadow-md border border-blue-200 hover:border-blue-300"
title="View"
>
<Eye className="w-5 h-5" />
</button>
<button
onClick={() => navigate(`/admin/invoices/${invoice.id}/edit`)}
className="p-2 rounded-lg text-indigo-600 hover:text-indigo-700 hover:bg-indigo-50 transition-all duration-200 shadow-sm hover:shadow-md border border-indigo-200 hover:border-indigo-300"
title="Edit"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => handleDelete(invoice.id)}
className="p-2 rounded-lg text-rose-600 hover:text-rose-700 hover:bg-rose-50 transition-all duration-200 shadow-sm hover:shadow-md border border-rose-200 hover:border-rose-300"
title="Delete"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</td>
</tr>
);
})
) : (
<tr>
<td colSpan={7} 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>
<p className="text-sm mt-1">Create your first invoice to get started</p>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="px-6 py-4 border-t border-gray-200">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
</div>
</div>
);
};
export default InvoiceManagementPage;

View File

@@ -4,8 +4,10 @@ import { paymentService, Payment } 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';
const PaymentManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [payments, setPayments] = useState<Payment[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState({
@@ -47,22 +49,37 @@ const PaymentManagementPage: React.FC = () => {
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(amount);
};
const getMethodBadge = (method: string) => {
const badges: Record<string, { bg: string; text: string; label: string }> = {
cash: { bg: 'bg-green-100', text: 'text-green-800', label: 'Cash' },
bank_transfer: { bg: 'bg-blue-100', text: 'text-blue-800', label: 'Bank transfer' },
credit_card: { bg: 'bg-purple-100', text: 'text-purple-800', label: 'Credit card' },
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
cash: {
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
text: 'text-emerald-800',
label: 'Cash',
border: 'border-emerald-200'
},
bank_transfer: {
bg: 'bg-gradient-to-r from-blue-50 to-indigo-50',
text: 'text-blue-800',
label: 'Bank transfer',
border: 'border-blue-200'
},
stripe: {
bg: 'bg-gradient-to-r from-indigo-50 to-purple-50',
text: 'text-indigo-800',
label: 'Stripe',
border: 'border-indigo-200'
},
credit_card: {
bg: 'bg-gradient-to-r from-purple-50 to-pink-50',
text: 'text-purple-800',
label: 'Credit card',
border: 'border-purple-200'
},
};
const badge = badges[method] || badges.cash;
return (
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
<span className={`px-4 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${badge.bg} ${badge.text} ${badge.border}`}>
{badge.label}
</span>
);
@@ -73,104 +90,106 @@ const PaymentManagementPage: React.FC = () => {
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Payment Management</h1>
<p className="text-gray-500 mt-1">Track payment transactions</p>
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{/* Luxury Header */}
<div className="animate-fade-in">
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Payment Management
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Track payment transactions</p>
</div>
<div className="bg-white rounded-lg shadow-md p-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
{/* Luxury Filter Card */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
<div className="grid grid-cols-1 md:grid-cols-4 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
<input
type="text"
placeholder="Search..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
<select
value={filters.method}
onChange={(e) => setFilters({ ...filters, method: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All methods</option>
<option value="cash">Cash</option>
<option value="bank_transfer">Bank transfer</option>
<option value="stripe">Stripe</option>
<option value="credit_card">Credit card</option>
</select>
<input
type="date"
value={filters.from}
onChange={(e) => setFilters({ ...filters, from: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md"
placeholder="From date"
/>
<input
type="date"
value={filters.to}
onChange={(e) => setFilters({ ...filters, to: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md"
placeholder="To date"
/>
</div>
</div>
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Transaction ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Booking Number
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Customer
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Method
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Amount
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Payment Date
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{payments.map((payment) => (
<tr key={payment.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{payment.transaction_id || `PAY-${payment.id}`}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-blue-600">{payment.booking?.booking_number}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{payment.booking?.user?.name}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getMethodBadge(payment.payment_method)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-green-600">
{formatCurrency(payment.amount)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{new Date(payment.payment_date || payment.createdAt).toLocaleDateString('en-US')}
</div>
</td>
{/* Luxury Table Card */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900">
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Transaction ID</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Booking Number</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Customer</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Method</th>
<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">Payment Date</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="bg-white divide-y divide-slate-100">
{payments.map((payment, index) => (
<tr
key={payment.id}
className="hover:bg-gradient-to-r hover:from-amber-50/30 hover:to-yellow-50/30 transition-all duration-200 group border-b border-slate-100"
style={{ animationDelay: `${index * 0.05}s` }}
>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-bold text-slate-900 font-mono">{payment.transaction_id || `PAY-${payment.id}`}</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-semibold text-amber-600">{payment.booking?.booking_number}</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-medium text-slate-900">{payment.booking?.user?.name}</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
{getMethodBadge(payment.payment_method)}
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
{formatCurrency(payment.amount)}
</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm text-slate-600">
{new Date(payment.payment_date || payment.createdAt).toLocaleDateString('en-US')}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
@@ -180,13 +199,20 @@ const PaymentManagementPage: React.FC = () => {
/>
</div>
{/* Summary Card */}
<div className="bg-gradient-to-r from-blue-500 to-blue-600 rounded-lg shadow-lg p-6 text-white">
<h3 className="text-lg font-semibold mb-2">Total Revenue</h3>
<p className="text-3xl font-bold">
{formatCurrency(payments.reduce((sum, p) => sum + p.amount, 0))}
</p>
<p className="text-sm mt-2 opacity-90">Total {payments.length} transactions</p>
{/* Luxury Summary Card */}
<div className="bg-gradient-to-r from-amber-500 via-amber-600 to-amber-700 rounded-2xl shadow-2xl p-8 text-white animate-fade-in" style={{ animationDelay: '0.3s' }}>
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold mb-2 text-amber-100">Total Revenue</h3>
<p className="text-4xl font-bold">
{formatCurrency(payments.reduce((sum, p) => sum + p.amount, 0))}
</p>
<p className="text-sm mt-3 text-amber-100/90">Total {payments.length} transaction{payments.length !== 1 ? 's' : ''}</p>
</div>
<div className="bg-white/20 backdrop-blur-sm p-6 rounded-2xl">
<div className="text-5xl font-bold text-white/80">{payments.length}</div>
</div>
</div>
</div>
</div>
);

View File

@@ -4,8 +4,12 @@ import { promotionService, Promotion } 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 { useCurrency } from '../../contexts/CurrencyContext';
const PromotionManagementPage: React.FC = () => {
const { currency } = useCurrency();
const { formatCurrency } = useFormatCurrency();
const [promotions, setPromotions] = useState<Promotion[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
@@ -127,18 +131,31 @@ const PromotionManagementPage: React.FC = () => {
});
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(amount);
};
const getStatusBadge = (status: string) => {
const badges: Record<string, { bg: string; text: string; label: string }> = {
active: { bg: 'bg-green-100', text: 'text-green-800', label: 'Active' },
inactive: { bg: 'bg-gray-100', text: 'text-gray-800', label: 'Inactive' },
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
active: {
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
text: 'text-emerald-800',
label: 'Active',
border: 'border-emerald-200'
},
inactive: {
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
text: 'text-slate-700',
label: 'Inactive',
border: 'border-slate-200'
},
expired: {
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
text: 'text-rose-800',
label: 'Expired',
border: 'border-rose-200'
},
};
const badge = badges[status] || badges.active;
return (
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
<span className={`px-4 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${badge.bg} ${badge.text} ${badge.border}`}>
{badge.label}
</span>
);
@@ -149,43 +166,47 @@ const PromotionManagementPage: React.FC = () => {
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{/* Luxury Header */}
<div className="flex justify-between items-center animate-fade-in">
<div>
<h1 className="text-3xl font-bold text-gray-900">Promotion Management</h1>
<p className="text-gray-500 mt-1">Manage discount codes and promotion programs</p>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Promotion Management
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Manage discount codes and promotion programs</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-2"
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" />
Add Promotion
</button>
</div>
{/* Filters */}
<div className="bg-white p-4 rounded-lg shadow-sm">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Search by code or name..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Luxury Filters */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
<div className="grid grid-cols-1 md:grid-cols-4 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
<input
type="text"
placeholder="Search by code or name..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
<select
value={filters.type}
onChange={(e) => setFilters({ ...filters, type: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Types</option>
<option value="percentage">Percentage</option>
@@ -194,7 +215,7 @@ const PromotionManagementPage: React.FC = () => {
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Statuses</option>
<option value="active">Active</option>
@@ -203,281 +224,294 @@ const PromotionManagementPage: React.FC = () => {
</div>
</div>
{/* Table */}
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Code
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Program Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Value
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Period
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Used
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{promotions.map((promotion) => (
<tr key={promotion.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<Tag className="w-4 h-4 text-blue-600" />
<span className="text-sm font-mono font-bold text-blue-600">{promotion.code}</span>
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm font-medium text-gray-900">{promotion.name}</div>
<div className="text-xs text-gray-500">{promotion.description}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{promotion.discount_type === 'percentage'
? `${promotion.discount_value}%`
: formatCurrency(promotion.discount_value)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-xs text-gray-500">
{promotion.start_date ? new Date(promotion.start_date).toLocaleDateString('en-US') : 'N/A'}
{' → '}
{promotion.end_date ? new Date(promotion.end_date).toLocaleDateString('en-US') : 'N/A'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{promotion.used_count || 0} / {promotion.usage_limit || '∞'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(promotion.status)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleEdit(promotion)}
className="text-blue-600 hover:text-blue-900 mr-3"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => handleDelete(promotion.id)}
className="text-red-600 hover:text-red-900"
>
<Trash2 className="w-5 h-5" />
</button>
</td>
{/* Luxury Table */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900">
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Code</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Program Name</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Value</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Period</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Used</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>
<th className="px-8 py-5 text-right text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Actions</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="bg-white divide-y divide-slate-100">
{promotions.map((promotion, index) => (
<tr
key={promotion.id}
className="hover:bg-gradient-to-r hover:from-amber-50/30 hover:to-yellow-50/30 transition-all duration-200 group border-b border-slate-100"
style={{ animationDelay: `${index * 0.05}s` }}
>
<td className="px-8 py-5 whitespace-nowrap">
<div className="flex items-center gap-3">
<div className="p-2 bg-gradient-to-br from-amber-100 to-amber-200 rounded-lg">
<Tag className="w-4 h-4 text-amber-600" />
</div>
<span className="text-sm font-mono font-bold bg-gradient-to-r from-amber-600 to-amber-700 bg-clip-text text-transparent">{promotion.code}</span>
</div>
</td>
<td className="px-8 py-5">
<div className="text-sm font-semibold text-slate-900">{promotion.name}</div>
<div className="text-xs text-slate-500 mt-0.5">{promotion.description}</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
{promotion.discount_type === 'percentage'
? `${promotion.discount_value}%`
: formatCurrency(promotion.discount_value)}
</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-xs text-slate-600">
{promotion.start_date ? new Date(promotion.start_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : 'N/A'}
<span className="text-slate-400 mx-1"></span>
{promotion.end_date ? new Date(promotion.end_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : 'N/A'}
</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-medium text-slate-700">
<span className="text-amber-600 font-semibold">{promotion.used_count || 0}</span>
<span className="text-slate-400 mx-1">/</span>
<span className="text-slate-600">{promotion.usage_limit || '∞'}</span>
</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
{getStatusBadge(promotion.status)}
</td>
<td className="px-8 py-5 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleEdit(promotion)}
className="p-2 rounded-lg text-blue-600 hover:text-blue-700 hover:bg-blue-50 transition-all duration-200 shadow-sm hover:shadow-md border border-blue-200 hover:border-blue-300"
title="Edit"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => handleDelete(promotion.id)}
className="p-2 rounded-lg text-rose-600 hover:text-rose-700 hover:bg-rose-50 transition-all duration-200 shadow-sm hover:shadow-md border border-rose-200 hover:border-rose-300"
title="Delete"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
/>
</div>
{/* Pagination */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
/>
{/* Modal */}
{/* Luxury Modal */}
{showModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">
{editingPromotion ? 'Update Promotion' : 'Add New Promotion'}
</h2>
<button onClick={() => setShowModal(false)}>
<X className="w-6 h-6 text-gray-500 hover:text-gray-700" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4 animate-fade-in">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden border border-slate-200 animate-scale-in">
{/* Modal Header */}
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-8 py-6 border-b border-slate-700">
<div className="flex justify-between items-center">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Code <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase() })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="e.g: SUMMER2024"
required
/>
<h2 className="text-3xl font-bold text-amber-100 mb-1">
{editingPromotion ? 'Update Promotion' : 'Add New Promotion'}
</h2>
<p className="text-amber-200/80 text-sm font-light">
{editingPromotion ? 'Modify promotion details' : 'Create a new promotion program'}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Program Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="e.g: Summer Sale"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
rows={3}
placeholder="Detailed description of the program..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Discount Type <span className="text-red-500">*</span>
</label>
<select
value={formData.discount_type}
onChange={(e) => setFormData({ ...formData, discount_type: e.target.value as 'percentage' | 'fixed' })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="percentage">Percentage (%)</option>
<option value="fixed">Fixed Amount (EUR)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Discount Value <span className="text-red-500">*</span>
</label>
<input
type="number"
value={formData.discount_value}
onChange={(e) => setFormData({ ...formData, discount_value: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
min="0"
max={formData.discount_type === 'percentage' ? 100 : undefined}
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Minimum Order Value (EUR)
</label>
<input
type="number"
value={formData.min_booking_amount}
onChange={(e) => setFormData({ ...formData, min_booking_amount: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
min="0"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Maximum Discount (EUR)
</label>
<input
type="number"
value={formData.max_discount_amount}
onChange={(e) => setFormData({ ...formData, max_discount_amount: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
min="0"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Start Date <span className="text-red-500">*</span>
</label>
<input
type="date"
value={formData.start_date}
onChange={(e) => setFormData({ ...formData, start_date: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
End Date <span className="text-red-500">*</span>
</label>
<input
type="date"
value={formData.end_date}
onChange={(e) => setFormData({ ...formData, end_date: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Usage Limit (0 = unlimited)
</label>
<input
type="number"
value={formData.usage_limit}
onChange={(e) => setFormData({ ...formData, usage_limit: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
min="0"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as 'active' | 'inactive' })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<button
type="button"
<button
onClick={() => setShowModal(false)}
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
className="w-10 h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
{editingPromotion ? 'Update' : 'Add'}
<X className="w-6 h-6" />
</button>
</div>
</form>
</div>
{/* Modal Content */}
<div className="p-8 overflow-y-auto max-h-[calc(90vh-120px)] custom-scrollbar">
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Code <span className="text-rose-500">*</span>
</label>
<input
type="text"
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase() })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm font-mono"
placeholder="e.g: SUMMER2024"
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Program Name <span className="text-rose-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
placeholder="e.g: Summer Sale"
required
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
rows={3}
placeholder="Detailed description of the program..."
/>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Discount Type <span className="text-rose-500">*</span>
</label>
<select
value={formData.discount_type}
onChange={(e) => setFormData({ ...formData, discount_type: e.target.value as 'percentage' | 'fixed' })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm cursor-pointer"
>
<option value="percentage">Percentage (%)</option>
<option value="fixed">Fixed Amount ({currency})</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Discount Value <span className="text-rose-500">*</span>
</label>
<input
type="number"
value={formData.discount_value}
onChange={(e) => setFormData({ ...formData, discount_value: parseFloat(e.target.value) })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
min="0"
max={formData.discount_type === 'percentage' ? 100 : undefined}
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Minimum Order Value ({currency})
</label>
<input
type="number"
value={formData.min_booking_amount}
onChange={(e) => setFormData({ ...formData, min_booking_amount: parseFloat(e.target.value) })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
min="0"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Maximum Discount ({currency})
</label>
<input
type="number"
value={formData.max_discount_amount}
onChange={(e) => setFormData({ ...formData, max_discount_amount: parseFloat(e.target.value) })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
min="0"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Start Date <span className="text-rose-500">*</span>
</label>
<input
type="date"
value={formData.start_date}
onChange={(e) => setFormData({ ...formData, start_date: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
End Date <span className="text-rose-500">*</span>
</label>
<input
type="date"
value={formData.end_date}
onChange={(e) => setFormData({ ...formData, end_date: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Usage Limit (0 = unlimited)
</label>
<input
type="number"
value={formData.usage_limit}
onChange={(e) => setFormData({ ...formData, usage_limit: parseInt(e.target.value) })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
min="0"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Status
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as 'active' | 'inactive' })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm cursor-pointer"
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</div>
<div className="flex justify-end gap-3 pt-6 border-t border-slate-200">
<button
type="button"
onClick={() => setShowModal(false)}
className="px-8 py-3 border-2 border-slate-300 rounded-xl text-slate-700 font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md"
>
Cancel
</button>
<button
type="submit"
className="px-8 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"
>
{editingPromotion ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
</div>
)}

View File

@@ -1,7 +1,6 @@
import React, { useState, useEffect } from 'react';
import {
Calendar,
DollarSign,
Users,
Hotel,
TrendingUp,
@@ -12,11 +11,14 @@ import {
} from 'lucide-react';
import { toast } from 'react-toastify';
import { Loading, EmptyState } from '../../components/common';
import CurrencyIcon from '../../components/common/CurrencyIcon';
import { useAsync } from '../../hooks/useAsync';
import { reportService, ReportData } from '../../services/api/reportService';
import { formatCurrency, formatDate } from '../../utils/format';
import { reportService, ReportData } from '../../services/api';
import { formatDate } from '../../utils/format';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
const ReportsPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [dateRange, setDateRange] = useState({
from: '',
to: '',
@@ -184,7 +186,7 @@ const ReportsPage: React.FC = () => {
<div className="bg-white p-6 rounded-lg shadow-md border-l-4 border-green-500">
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-green-100 rounded-lg">
<DollarSign className="w-6 h-6 text-green-600" />
<CurrencyIcon className="text-green-600" size={24} />
</div>
<TrendingUp className="w-5 h-5 text-green-500" />
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -4,8 +4,10 @@ import { serviceService, Service } 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';
const ServiceManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [services, setServices] = useState<Service[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
@@ -108,52 +110,53 @@ const ServiceManagementPage: React.FC = () => {
});
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(amount);
};
if (loading) {
return <Loading />;
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{/* Luxury Header */}
<div className="flex justify-between items-center animate-fade-in">
<div>
<h1 className="text-3xl font-bold text-gray-900">Service Management</h1>
<p className="text-gray-500 mt-1">Manage hotel services</p>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
Service Management
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Manage hotel services</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
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" />
Add Service
</button>
</div>
<div className="bg-white rounded-lg shadow-md p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
{/* Luxury Filter Card */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
<input
type="text"
placeholder="Search services..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All Statuses</option>
<option value="active">Active</option>
@@ -162,72 +165,71 @@ const ServiceManagementPage: React.FC = () => {
</div>
</div>
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Service Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Description
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Price
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Unit
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{services.map((service) => (
<tr key={service.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{service.name}</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900 max-w-xs truncate">{service.description}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-gray-900">{formatCurrency(service.price)}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{service.unit}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${
service.status === 'active'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{service.status === 'active' ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleEdit(service)}
className="text-blue-600 hover:text-blue-900 mr-3"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => handleDelete(service.id)}
className="text-red-600 hover:text-red-900"
>
<Trash2 className="w-5 h-5" />
</button>
</td>
{/* Luxury Table Card */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900">
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Service Name</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Description</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Price</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Unit</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>
<th className="px-8 py-5 text-right text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Actions</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="bg-white divide-y divide-slate-100">
{services.map((service, index) => (
<tr
key={service.id}
className="hover:bg-gradient-to-r hover:from-amber-50/30 hover:to-yellow-50/30 transition-all duration-200 group border-b border-slate-100"
style={{ animationDelay: `${index * 0.05}s` }}
>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-semibold text-slate-900">{service.name}</div>
</td>
<td className="px-8 py-5">
<div className="text-sm text-slate-700 max-w-xs truncate">{service.description}</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">{formatCurrency(service.price)}</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm text-slate-600">{service.unit}</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<span className={`px-4 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${
service.status === 'active'
? 'bg-gradient-to-r from-emerald-50 to-green-50 text-emerald-800 border-emerald-200'
: 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200'
}`}>
{service.status === 'active' ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-8 py-5 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleEdit(service)}
className="p-2 rounded-lg text-blue-600 hover:text-blue-700 hover:bg-blue-50 transition-all duration-200 shadow-sm hover:shadow-md border border-blue-200 hover:border-blue-300"
title="Edit"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => handleDelete(service.id)}
className="p-2 rounded-lg text-rose-600 hover:text-rose-700 hover:bg-rose-50 transition-all duration-200 shadow-sm hover:shadow-md border border-rose-200 hover:border-rose-300"
title="Delete"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
@@ -237,95 +239,111 @@ const ServiceManagementPage: React.FC = () => {
/>
</div>
{/* Luxury Modal */}
{showModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">
{editingService ? 'Update Service' : 'Add New Service'}
</h2>
<button onClick={() => setShowModal(false)}>
<X className="w-6 h-6 text-gray-500 hover:text-gray-700" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Service Name
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
rows={3}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Price
</label>
<input
type="number"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
min="0"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Unit
</label>
<input
type="text"
value={formData.unit}
onChange={(e) => setFormData({ ...formData, unit: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="e.g: time, hour, day..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
<div className="flex gap-3 mt-6">
<button
type="button"
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4 animate-fade-in">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-hidden border border-slate-200 animate-scale-in">
{/* Modal Header */}
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-6 py-5 border-b border-slate-700">
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold text-amber-100">
{editingService ? 'Update Service' : 'Add New Service'}
</h2>
<p className="text-amber-200/80 text-sm font-light mt-1">
{editingService ? 'Modify service information' : 'Create a new service'}
</p>
</div>
<button
onClick={() => setShowModal(false)}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
className="w-10 h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
{editingService ? 'Update' : 'Add'}
<X className="w-6 h-6" />
</button>
</div>
</form>
</div>
{/* Modal Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-120px)]">
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Service Name
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
rows={3}
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Price
</label>
<input
type="number"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
required
min="0"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Unit
</label>
<input
type="text"
value={formData.unit}
onChange={(e) => setFormData({ ...formData, unit: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
placeholder="e.g: time, hour, day..."
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Status
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm cursor-pointer"
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
<div className="flex gap-3 pt-4 border-t border-slate-200">
<button
type="button"
onClick={() => setShowModal(false)}
className="flex-1 px-6 py-3 border-2 border-slate-300 rounded-xl text-slate-700 font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-6 py-3 bg-gradient-to-r from-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"
>
{editingService ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
</div>
)}

View File

@@ -0,0 +1,366 @@
import React, { useEffect, useState } from 'react';
import { CreditCard, Save, Info, Eye, EyeOff, Lock, Key, Globe } from 'lucide-react';
import { toast } from 'react-toastify';
import systemSettingsService, {
StripeSettingsResponse,
UpdateStripeSettingsRequest,
} from '../../services/api/systemSettingsService';
import { Loading } from '../../components/common';
const StripeSettingsPage: React.FC = () => {
const [settings, setSettings] = useState<StripeSettingsResponse['data'] | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState<UpdateStripeSettingsRequest>({
stripe_secret_key: '',
stripe_publishable_key: '',
stripe_webhook_secret: '',
});
const [showSecretKey, setShowSecretKey] = useState(false);
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
useEffect(() => {
loadStripeSettings();
}, []);
const loadStripeSettings = async () => {
try {
setLoading(true);
const response = await systemSettingsService.getStripeSettings();
setSettings(response.data);
// Pre-fill form with current values (empty for security)
setFormData({
stripe_secret_key: '',
stripe_publishable_key: response.data.stripe_publishable_key || '',
stripe_webhook_secret: '',
});
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to load Stripe settings');
} finally {
setLoading(false);
}
};
const handleSave = async () => {
try {
setSaving(true);
// Only send keys that were actually changed (not empty)
const updateData: UpdateStripeSettingsRequest = {};
if (formData.stripe_secret_key && formData.stripe_secret_key.trim()) {
updateData.stripe_secret_key = formData.stripe_secret_key.trim();
}
if (formData.stripe_publishable_key && formData.stripe_publishable_key.trim()) {
updateData.stripe_publishable_key = formData.stripe_publishable_key.trim();
}
if (formData.stripe_webhook_secret && formData.stripe_webhook_secret.trim()) {
updateData.stripe_webhook_secret = formData.stripe_webhook_secret.trim();
}
await systemSettingsService.updateStripeSettings(updateData);
await loadStripeSettings();
// Clear sensitive fields after saving
setFormData({
...formData,
stripe_secret_key: '',
stripe_webhook_secret: '',
});
toast.success('Stripe settings updated successfully');
} catch (error: any) {
toast.error(
error.response?.data?.message ||
error.response?.data?.detail ||
'Failed to update Stripe settings'
);
} finally {
setSaving(false);
}
};
if (loading) {
return <Loading fullScreen={false} text="Loading Stripe settings..." />;
}
return (
<div className="space-y-10 pb-8 animate-fade-in">
{/* Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-6 pb-6 border-b border-gray-200/60">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-lg bg-gradient-to-br from-indigo-500/10 to-purple-500/5 border border-indigo-200/20 shadow-sm">
<CreditCard className="w-6 h-6 text-indigo-600" />
</div>
<h1 className="enterprise-section-title">Stripe Payment Settings</h1>
</div>
<p className="enterprise-section-subtitle max-w-2xl text-gray-600">
Configure your Stripe account credentials to enable card payments.
All payments will be processed through your Stripe account.
</p>
</div>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="btn-enterprise-primary inline-flex items-center gap-2 whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className={`w-4 h-4 ${saving ? 'animate-pulse' : ''}`} />
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
{/* Info card */}
<div className="enterprise-card flex gap-5 p-6 bg-gradient-to-br from-blue-50/50 to-indigo-50/30 border-blue-100/60">
<div className="mt-0.5 flex-shrink-0">
<div className="p-2.5 rounded-lg bg-gradient-to-br from-blue-500/10 to-indigo-500/10 border border-blue-200/40">
<Info className="w-5 h-5 text-blue-600" />
</div>
</div>
<div className="space-y-2.5 flex-1">
<p className="font-semibold text-gray-900 text-base">
How Stripe payments work
</p>
<p className="text-sm text-gray-700 leading-relaxed">
Stripe handles all card payments securely. You need to provide your Stripe API keys
from your Stripe Dashboard. The secret key is used to process payments on the backend,
while the publishable key is used in the frontend payment forms. The webhook secret
is required to verify webhook events from Stripe.
</p>
<div className="text-xs text-gray-600 pt-2 border-t border-gray-200/60 space-y-2">
<p>
<strong>Note:</strong> Leave fields empty to keep existing values. Only enter new values
when you want to update them.
</p>
<div className="bg-blue-50 border border-blue-200 rounded p-3 mt-2">
<p className="font-semibold text-blue-900 mb-2">📝 How to Get Stripe Test Keys:</p>
<ol className="list-decimal list-inside space-y-1 text-blue-800">
<li>Go to <a href="https://dashboard.stripe.com/register" target="_blank" rel="noopener noreferrer" className="underline font-medium">Stripe Dashboard</a> (create account if needed)</li>
<li>Make sure you're in <strong>Test mode</strong> (toggle in top right)</li>
<li>Navigate to <a href="https://dashboard.stripe.com/test/apikeys" target="_blank" rel="noopener noreferrer" className="underline font-medium">API Keys</a></li>
<li>Copy your <strong>Publishable key</strong> (starts with <code className="bg-blue-100 px-1 rounded">pk_test_</code>)</li>
<li>Click "Reveal test key" and copy your <strong>Secret key</strong> (starts with <code className="bg-blue-100 px-1 rounded">sk_test_</code>)</li>
<li>Paste them in the fields below and click "Save Changes"</li>
</ol>
<p className="mt-2 text-blue-800">
<strong>💳 Test Card Numbers:</strong> Use <code className="bg-blue-100 px-1 rounded">4242 4242 4242 4242</code> with any future expiry date, any CVC, and any ZIP code.
</p>
</div>
</div>
{settings?.updated_at && (
<div className="pt-2 border-t border-gray-200/60">
<p className="text-xs text-gray-500 font-medium">
Last updated on{' '}
<span className="text-gray-700">
{new Date(settings.updated_at).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
})}
</span>
{settings.updated_by && (
<>
{' '}by <span className="text-gray-700 font-semibold">{settings.updated_by}</span>
</>
)}
</p>
</div>
)}
</div>
</div>
{/* Stripe Settings Form */}
<div className="enterprise-card p-6 space-y-6">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between pb-4 border-b border-gray-200/60">
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-gradient-to-br from-purple-500/10 to-pink-500/10 border border-purple-200/40 mt-0.5">
<Key className="w-5 h-5 text-purple-600" />
</div>
<div className="space-y-1.5">
<p className="font-bold text-gray-900 text-lg">
Stripe API Keys
</p>
<p className="text-sm text-gray-600 leading-relaxed max-w-xl">
Get these keys from your{' '}
<a
href="https://dashboard.stripe.com/test/apikeys"
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:text-indigo-700 underline font-medium"
>
Stripe Dashboard (Test Mode)
</a>
{' '}or{' '}
<a
href="https://dashboard.stripe.com/apikeys"
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:text-indigo-700 underline font-medium"
>
Live Mode
</a>
</p>
</div>
</div>
</div>
<div className="space-y-6">
{/* Secret Key */}
<div className="space-y-3">
<label className="block text-sm font-semibold text-gray-900 tracking-wide">
<Lock className="w-4 h-4 inline mr-1" />
Stripe Secret Key
<span className="text-red-500 ml-1">*</span>
</label>
<div className="relative">
<input
type={showSecretKey ? 'text' : 'password'}
value={formData.stripe_secret_key}
onChange={(e) =>
setFormData({ ...formData, stripe_secret_key: e.target.value })
}
placeholder={
settings?.has_secret_key
? `Current: ${settings.stripe_secret_key_masked || '****'}`
: 'sk_test_... or sk_live_...'
}
className="enterprise-input text-sm pr-10"
/>
<button
type="button"
onClick={() => setShowSecretKey(!showSecretKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
>
{showSecretKey ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
<p className="text-xs text-gray-500 leading-relaxed">
Used to process payments on the backend. Must start with{' '}
<code className="px-1.5 py-0.5 bg-gray-100 rounded text-gray-700">sk_</code>
{settings?.has_secret_key && (
<span className="text-green-600 font-medium ml-2">
✓ Currently configured
</span>
)}
</p>
</div>
{/* Publishable Key */}
<div className="space-y-3">
<label className="block text-sm font-semibold text-gray-900 tracking-wide">
<Globe className="w-4 h-4 inline mr-1" />
Stripe Publishable Key
<span className="text-red-500 ml-1">*</span>
</label>
<input
type="text"
value={formData.stripe_publishable_key}
onChange={(e) =>
setFormData({
...formData,
stripe_publishable_key: e.target.value,
})
}
placeholder="pk_test_... or pk_live_..."
className="enterprise-input text-sm"
/>
<p className="text-xs text-gray-500 leading-relaxed">
Used in frontend payment forms. Must start with{' '}
<code className="px-1.5 py-0.5 bg-gray-100 rounded text-gray-700">pk_</code>
{settings?.has_publishable_key && (
<span className="text-green-600 font-medium ml-2">
✓ Currently configured
</span>
)}
</p>
</div>
{/* Webhook Secret */}
<div className="space-y-3">
<label className="block text-sm font-semibold text-gray-900 tracking-wide">
<Lock className="w-4 h-4 inline mr-1" />
Stripe Webhook Secret
</label>
<div className="relative">
<input
type={showWebhookSecret ? 'text' : 'password'}
value={formData.stripe_webhook_secret}
onChange={(e) =>
setFormData({
...formData,
stripe_webhook_secret: e.target.value,
})
}
placeholder={
settings?.has_webhook_secret
? `Current: ${settings.stripe_webhook_secret_masked || '****'}`
: 'whsec_...'
}
className="enterprise-input text-sm pr-10"
/>
<button
type="button"
onClick={() => setShowWebhookSecret(!showWebhookSecret)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
>
{showWebhookSecret ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
<p className="text-xs text-gray-500 leading-relaxed">
Used to verify webhook events from Stripe. Must start with{' '}
<code className="px-1.5 py-0.5 bg-gray-100 rounded text-gray-700">whsec_</code>
{settings?.has_webhook_secret && (
<span className="text-green-600 font-medium ml-2">
✓ Currently configured
</span>
)}
</p>
</div>
</div>
{/* Webhook URL Info */}
<div className="mt-6 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm font-semibold text-yellow-900 mb-2">
Webhook Endpoint URL
</p>
<p className="text-sm text-yellow-800 mb-2">
Configure this URL in your{' '}
<a
href="https://dashboard.stripe.com/webhooks"
target="_blank"
rel="noopener noreferrer"
className="underline font-medium"
>
Stripe Webhooks Dashboard
</a>
:
</p>
<code className="block text-xs bg-yellow-100 px-3 py-2 rounded text-yellow-900 break-all">
{window.location.origin}/api/payments/stripe/webhook
</code>
<p className="text-xs text-yellow-700 mt-2">
Make sure to subscribe to <code className="px-1 bg-yellow-100 rounded">payment_intent.succeeded</code> and{' '}
<code className="px-1 bg-yellow-100 rounded">payment_intent.payment_failed</code> events.
</p>
</div>
</div>
</div>
);
};
export default StripeSettingsPage;

View File

@@ -156,14 +156,29 @@ const UserManagementPage: React.FC = () => {
};
const getRoleBadge = (role: string) => {
const badges: Record<string, { bg: string; text: string; label: string }> = {
admin: { bg: 'bg-red-100', text: 'text-red-800', label: 'Admin' },
staff: { bg: 'bg-blue-100', text: 'text-blue-800', label: 'Staff' },
customer: { bg: 'bg-green-100', text: 'text-green-800', label: 'Customer' },
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
admin: {
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
text: 'text-rose-800',
label: 'Admin',
border: 'border-rose-200'
},
staff: {
bg: 'bg-gradient-to-r from-blue-50 to-indigo-50',
text: 'text-blue-800',
label: 'Staff',
border: 'border-blue-200'
},
customer: {
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
text: 'text-emerald-800',
label: 'Customer',
border: 'border-emerald-200'
},
};
const badge = badges[role] || badges.customer;
return (
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
<span className={`px-4 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${badge.bg} ${badge.text} ${badge.border}`}>
{badge.label}
</span>
);
@@ -174,40 +189,47 @@ const UserManagementPage: React.FC = () => {
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
{/* Luxury Header */}
<div className="flex justify-between items-center animate-fade-in">
<div>
<h1 className="text-3xl font-bold text-gray-900">User Management</h1>
<p className="text-gray-500 mt-1">Manage accounts and permissions</p>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
User Management
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Manage accounts and permissions</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
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" />
Add User
</button>
</div>
<div className="bg-white rounded-lg shadow-md p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
{/* Luxury Filter Card */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
<div className="relative group">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5 group-focus-within:text-amber-500 transition-colors" />
<input
type="text"
placeholder="Search by name, email..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 placeholder-slate-400 font-medium shadow-sm hover:shadow-md"
/>
</div>
<select
value={filters.role}
onChange={(e) => setFilters({ ...filters, role: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All roles</option>
<option value="admin">Admin</option>
@@ -217,7 +239,7 @@ const UserManagementPage: React.FC = () => {
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
className="px-4 py-3.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
>
<option value="">All statuses</option>
<option value="active">Active</option>
@@ -226,69 +248,80 @@ const UserManagementPage: React.FC = () => {
</div>
</div>
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Phone
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Created Date
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{user.full_name}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{user.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{user.phone_number || 'N/A'}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getRoleBadge(user.role)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{user.created_at ? new Date(user.created_at).toLocaleDateString('en-US') : 'N/A'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleEdit(user)}
className="text-blue-600 hover:text-blue-900 mr-3"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => handleDelete(user.id)}
className="text-red-600 hover:text-red-900"
disabled={userInfo?.id === user.id}
>
<Trash2 className="w-5 h-5" />
</button>
</td>
{/* Luxury Table Card */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900">
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
Name
</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
Email
</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
Phone
</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
Role
</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
Created Date
</th>
<th className="px-8 py-5 text-right text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
Actions
</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="bg-white divide-y divide-slate-100">
{users.map((user, index) => (
<tr
key={user.id}
className="hover:bg-gradient-to-r hover:from-amber-50/30 hover:to-yellow-50/30 transition-all duration-200 group border-b border-slate-100"
style={{ animationDelay: `${index * 0.05}s` }}
>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm font-semibold text-slate-900">{user.full_name}</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm text-slate-700">{user.email}</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm text-slate-600">{user.phone_number || 'N/A'}</div>
</td>
<td className="px-8 py-5 whitespace-nowrap">
{getRoleBadge(user.role)}
</td>
<td className="px-8 py-5 whitespace-nowrap">
<div className="text-sm text-slate-500">
{user.created_at ? new Date(user.created_at).toLocaleDateString('en-US') : 'N/A'}
</div>
</td>
<td className="px-8 py-5 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleEdit(user)}
className="p-2 rounded-lg text-blue-600 hover:text-blue-700 hover:bg-blue-50 transition-all duration-200 shadow-sm hover:shadow-md border border-blue-200 hover:border-blue-300"
title="Edit"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => handleDelete(user.id)}
className="p-2 rounded-lg text-rose-600 hover:text-rose-700 hover:bg-rose-50 transition-all duration-200 shadow-sm hover:shadow-md border border-rose-200 hover:border-rose-300 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={userInfo?.id === user.id}
title="Delete"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
@@ -298,110 +331,126 @@ const UserManagementPage: React.FC = () => {
/>
</div>
{/* Luxury Modal */}
{showModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">
{editingUser ? 'Update User' : 'Add New User'}
</h2>
<button onClick={() => setShowModal(false)}>
<X className="w-6 h-6 text-gray-500 hover:text-gray-700" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name
</label>
<input
type="text"
value={formData.full_name}
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Phone Number
</label>
<input
type="tel"
value={formData.phone_number}
onChange={(e) => setFormData({ ...formData, phone_number: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password {editingUser && '(leave blank if not changing)'}
</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required={!editingUser}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Role
</label>
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
>
<option value="customer">Customer</option>
<option value="staff">Staff</option>
<option value="admin">Admin</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
required
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
<div className="flex gap-3 mt-6">
<button
type="button"
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4 animate-fade-in">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-hidden border border-slate-200 animate-scale-in">
{/* Modal Header */}
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-6 py-5 border-b border-slate-700">
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold text-amber-100">
{editingUser ? 'Update User' : 'Add New User'}
</h2>
<p className="text-amber-200/80 text-sm font-light mt-1">
{editingUser ? 'Modify user information' : 'Create a new user account'}
</p>
</div>
<button
onClick={() => setShowModal(false)}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
className="w-10 h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
{editingUser ? 'Update' : 'Add'}
<X className="w-6 h-6" />
</button>
</div>
</form>
</div>
{/* Modal Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-120px)]">
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Name
</label>
<input
type="text"
value={formData.full_name}
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Phone Number
</label>
<input
type="tel"
value={formData.phone_number}
onChange={(e) => setFormData({ ...formData, phone_number: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Password {editingUser && <span className="text-slate-400 normal-case">(leave blank if not changing)</span>}
</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
required={!editingUser}
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Role
</label>
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm cursor-pointer"
required
>
<option value="customer">Customer</option>
<option value="staff">Staff</option>
<option value="admin">Admin</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Status
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm cursor-pointer"
required
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
<div className="flex gap-3 pt-4 border-t border-slate-200">
<button
type="button"
onClick={() => setShowModal(false)}
className="flex-1 px-6 py-3 border-2 border-slate-300 rounded-xl text-slate-700 font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-6 py-3 bg-gradient-to-r from-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"
>
{editingUser ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
</div>
)}

View File

@@ -9,3 +9,6 @@ export { default as PromotionManagementPage } from './PromotionManagementPage';
export { default as CheckInPage } from './CheckInPage';
export { default as CheckOutPage } from './CheckOutPage';
export { default as AuditLogsPage } from './AuditLogsPage';
export { default as CurrencySettingsPage } from './CurrencySettingsPage';
export { default as CookieSettingsPage } from './CookieSettingsPage';
export { default as StripeSettingsPage } from './StripeSettingsPage';

View File

@@ -33,11 +33,13 @@ import useAuthStore from '../../store/useAuthStore';
import Loading from '../../components/common/Loading';
import PaymentStatusBadge from
'../../components/common/PaymentStatusBadge';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
const BookingDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAuthenticated } = useAuthStore();
const { formatCurrency } = useFormatCurrency();
const [booking, setBooking] = useState<Booking | null>(
null
@@ -95,35 +97,51 @@ const BookingDetailPage: React.FC = () => {
};
const handleCancelBooking = async () => {
if (!booking) return;
console.log('=== handleCancelBooking called ===');
if (!booking) {
console.error('No booking found');
toast.error('Booking not found');
return;
}
console.log('Cancel booking clicked', { bookingId: booking.id, status: booking.status });
// Use a more user-friendly confirmation
const confirmed = window.confirm(
`Are you sure you want to cancel booking ` +
`${booking.booking_number}?\n\n` +
`⚠️ Note:\n` +
`- You will be charged 20% of the order value\n` +
`- The remaining 80% will be refunded\n` +
`- Room status will be updated to "available"`
`⚠️ CANCEL BOOKING CONFIRMATION ⚠️\n\n` +
`Booking Number: ${booking.booking_number}\n\n` +
`Are you sure you want to cancel this booking?\n\n` +
`IMPORTANT NOTES:\n` +
`• You will be charged 20% cancellation fee\n` +
`• The remaining 80% will be refunded\n` +
`• This action cannot be undone\n\n` +
`Click OK to confirm cancellation\n` +
`Click Cancel to keep your booking`
);
if (!confirmed) return;
if (!confirmed) {
console.log('User chose to keep the booking');
toast.info('Booking cancellation cancelled. Your booking remains active.');
return;
}
console.log('User confirmed cancellation, proceeding...');
try {
setCancelling(true);
console.log('Calling cancelBooking API...', booking.id);
const response = await cancelBooking(booking.id);
console.log('Cancel booking response:', response);
if (response.success) {
// Check both success and status fields
if (response.success || response.status === 'success') {
toast.success(
`✅ Booking ${booking.booking_number} cancelled successfully!`
);
// Update local state
setBooking((prev) =>
prev
? { ...prev, status: 'cancelled' }
: null
);
// Refresh booking details to get updated status
await fetchBookingDetails(booking.id);
} else {
throw new Error(
response.message ||
@@ -133,7 +151,9 @@ const BookingDetailPage: React.FC = () => {
} catch (err: any) {
console.error('Error cancelling booking:', err);
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
err.message ||
'Unable to cancel booking. Please try again.';
toast.error(message);
} finally {
@@ -150,12 +170,7 @@ const BookingDetailPage: React.FC = () => {
});
};
const formatPrice = (price: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(price);
};
const formatPrice = (price: number) => formatCurrency(price);
const getStatusConfig = (status: string) => {
switch (status) {
@@ -199,10 +214,15 @@ const BookingDetailPage: React.FC = () => {
};
const canCancelBooking = (booking: Booking) => {
return (
booking.status === 'pending' ||
booking.status === 'confirmed'
);
// Only allow cancellation of pending bookings
const canCancel = booking.status === 'pending';
console.log('Can cancel booking?', {
status: booking.status,
canCancel,
bookingId: booking.id,
bookingNumber: booking.booking_number
});
return canCancel;
};
if (loading) {
@@ -307,10 +327,14 @@ const BookingDetailPage: React.FC = () => {
gap-6"
>
{/* Room Image */}
{roomType.images?.[0] && (
{((room?.images && room.images.length > 0)
? room.images[0]
: roomType.images?.[0]) && (
<div className="md:w-64 flex-shrink-0">
<img
src={roomType.images[0]}
src={(room?.images && room.images.length > 0)
? room.images[0]
: (roomType.images?.[0] || '')}
alt={roomType.name}
className="w-full h-48 md:h-full
object-cover rounded-lg"
@@ -337,7 +361,7 @@ const BookingDetailPage: React.FC = () => {
Capacity
</p>
<p className="font-medium text-gray-900">
Max {roomType.capacity} guests
Max {room?.capacity || roomType.capacity} guests
</p>
</div>
<div>
@@ -345,7 +369,7 @@ const BookingDetailPage: React.FC = () => {
Room Price
</p>
<p className="font-medium text-indigo-600">
{formatPrice(roomType.base_price)}/night
{formatPrice(room?.price || roomType.base_price)}/night
</p>
</div>
</div>
@@ -422,7 +446,9 @@ const BookingDetailPage: React.FC = () => {
<p className="font-medium text-gray-900 mb-2">
{booking.payment_method === 'cash'
? '💵 Pay at hotel'
: '🏦 Bank transfer'}
: booking.payment_method === 'stripe'
? '💳 Pay with Card (Stripe)'
: booking.payment_method || 'N/A'}
</p>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">
@@ -497,8 +523,8 @@ const BookingDetailPage: React.FC = () => {
</div>
)}
{/* Bank Transfer Info */}
{booking.payment_method === 'bank_transfer' &&
{/* Stripe Payment Info - if needed */}
{booking.payment_method === 'stripe' &&
booking.payment_status === 'unpaid' && (
<div
className="bg-blue-50 border border-blue-200
@@ -510,41 +536,22 @@ const BookingDetailPage: React.FC = () => {
mt-1 flex-shrink-0"
/>
<div className="flex-1">
<h3 className="font-bold text-blue-900 mb-2">
Bank Transfer Information
<h3 className="font-bold text-indigo-900 mb-2">
Payment Required
</h3>
<div className="bg-white rounded p-4
space-y-2 text-sm"
<p className="text-sm text-indigo-700 mb-4">
Please complete your payment to confirm your booking.
</p>
<Link
to={`/payment/${booking.id}`}
className="inline-flex items-center gap-2 px-4 py-2
bg-indigo-600 text-white rounded-lg
hover:bg-indigo-700 transition-colors
font-semibold"
>
<p>
<strong>Bank:</strong>
Vietcombank (VCB)
</p>
<p>
<strong>Account Number:</strong>
0123456789
</p>
<p>
<strong>Account Holder:</strong>
KHACH SAN ABC
</p>
<p>
<strong>Amount:</strong>{' '}
<span className="text-indigo-600
font-bold"
>
{formatPrice(booking.total_price)}
</span>
</p>
<p>
<strong>Content:</strong>{' '}
<span className="font-mono
text-indigo-600"
>
{booking.booking_number}
</span>
</p>
</div>
<CreditCard className="w-4 h-4" />
Complete Payment
</Link>
</div>
</div>
</div>
@@ -581,8 +588,8 @@ const BookingDetailPage: React.FC = () => {
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-4">
{/* Payment Button for unpaid bank transfer */}
{booking.payment_method === 'bank_transfer' &&
{/* Payment Button for unpaid stripe payment */}
{booking.payment_method === 'stripe' &&
booking.payment_status === 'unpaid' && (
<Link
to={`/payment/${booking.id}`}
@@ -597,16 +604,28 @@ const BookingDetailPage: React.FC = () => {
</Link>
)}
{canCancelBooking(booking) && (
{canCancelBooking(booking) ? (
<button
onClick={handleCancelBooking}
type="button"
onClick={(e) => {
console.log('=== BUTTON CLICKED ===', {
bookingId: booking.id,
status: booking.status,
cancelling
});
e.stopPropagation();
handleCancelBooking();
}}
disabled={cancelling}
className="flex-1 flex items-center
justify-center gap-2 px-6 py-3
bg-red-600 text-white rounded-lg
hover:bg-red-700 transition-colors
hover:bg-red-700 active:bg-red-800
transition-colors
font-semibold disabled:bg-gray-400
disabled:cursor-not-allowed"
disabled:cursor-not-allowed cursor-pointer
relative z-10"
aria-label="Cancel booking"
>
{cancelling ? (
<>
@@ -622,6 +641,19 @@ const BookingDetailPage: React.FC = () => {
</>
)}
</button>
) : (
<div className="flex-1 text-center text-sm text-gray-500 py-3">
Cannot cancel {booking.status} booking
<br />
<small className="text-xs">(Only pending bookings can be cancelled)</small>
</div>
)}
{/* Debug info - remove in production */}
{import.meta.env.DEV && (
<div className="text-xs text-gray-400 mt-2 p-2 bg-gray-100 rounded">
Debug: Status={booking.status}, CanCancel={canCancelBooking(booking) ? 'true' : 'false'}, Cancelling={cancelling ? 'true' : 'false'}
</div>
)}
<Link

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Calendar, Clock, DollarSign } from 'lucide-react';
import { Calendar, Clock } from 'lucide-react';
import CurrencyIcon from '../../components/common/CurrencyIcon';
const BookingListPage: React.FC = () => {
return (
@@ -82,13 +83,12 @@ const BookingListPage: React.FC = () => {
<div className="flex items-center
space-x-2"
>
<DollarSign className="w-5 h-5
text-green-600"
/>
<CurrencyIcon className="text-green-600" size={20} />
<span className="text-2xl font-bold
text-gray-800"
>
${booking * 150}
{/* TODO: Replace with actual booking price using formatCurrency */}
{booking * 150}
</span>
</div>
<button className="px-4 py-2

View File

@@ -17,6 +17,10 @@ import {
AlertCircle,
Loader2,
CheckCircle,
Shield,
Sparkles,
Star,
MapPin,
} from 'lucide-react';
import { toast } from 'react-toastify';
import { getRoomById, type Room } from
@@ -32,11 +36,13 @@ import {
type BookingFormData
} from '../../validators/bookingValidator';
import Loading from '../../components/common/Loading';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
const BookingPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAuthenticated, userInfo } = useAuthStore();
const { formatCurrency } = useFormatCurrency();
const [room, setRoom] = useState<Room | null>(null);
const [loading, setLoading] = useState(true);
@@ -125,17 +131,15 @@ const BookingPage: React.FC = () => {
)
: 0;
// Prioritize room-specific price over room type base price
const roomPrice =
room?.room_type?.base_price || 0;
(room?.price && room.price > 0)
? room.price
: (room?.room_type?.base_price || 0);
const totalPrice = numberOfNights * roomPrice;
// Format price
const formatPrice = (price: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(price);
};
// Format price using currency context
const formatPrice = (price: number) => formatCurrency(price);
// Handle form submission
const onSubmit = async (data: BookingFormData) => {
@@ -230,32 +234,42 @@ const BookingPage: React.FC = () => {
};
if (loading) {
return <Loading fullScreen text="Loading..." />;
return (
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] py-8">
<div className="max-w-7xl mx-auto px-4">
<Loading fullScreen text="Loading room details..." />
</div>
</div>
);
}
if (error || !room) {
return (
<div className="min-h-screen bg-gray-50">
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] py-8">
<div className="max-w-7xl mx-auto px-4 py-8">
<div
className="bg-red-50 border border-red-200
rounded-lg p-8 text-center"
className="bg-gradient-to-br from-red-900/20 to-red-800/10
border border-red-500/30 rounded-xl p-12 text-center
backdrop-blur-xl shadow-2xl shadow-red-500/10"
>
<AlertCircle
className="w-12 h-12 text-red-500
mx-auto mb-3"
className="w-12 h-12 text-red-400
mx-auto mb-4"
/>
<p className="text-red-700 font-medium mb-4">
<p className="text-red-300 font-light text-lg mb-6 tracking-wide">
{error || 'Room not found'}
</p>
<button
onClick={() => navigate('/rooms')}
className="inline-flex items-center gap-2 bg-indigo-600
text-white px-3 py-2 rounded-md hover:bg-indigo-700
disabled:bg-gray-400 mb-6 transition-colors"
<Link
to="/rooms"
className="inline-flex items-center gap-2 bg-gradient-to-r
from-[#d4af37] to-[#c9a227] text-[#0f0f0f]
px-6 py-3 rounded-sm hover:from-[#f5d76e]
hover:to-[#d4af37] transition-all duration-300
font-medium tracking-wide shadow-lg shadow-[#d4af37]/30"
>
<ArrowLeft className="w-5 h-5" />
Back to Room List
</button>
</Link>
</div>
</div>
</div>
@@ -266,64 +280,83 @@ const BookingPage: React.FC = () => {
if (!roomType) return null;
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-7xl mx-auto px-4">
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] py-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Back Button */}
<Link
to={`/rooms/${room.id}`}
to={`/rooms/${room.room_number}`}
className="inline-flex items-center gap-2
text-gray-600 hover:text-gray-900
mb-6 transition-colors"
text-[#d4af37]/80 hover:text-[#d4af37]
mb-8 transition-all duration-300
group font-light tracking-wide"
>
<ArrowLeft className="w-5 h-5" />
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
<span>Back to room details</span>
</Link>
{/* Page Title */}
<div className="mb-10">
<h1
className="text-3xl font-bold text-gray-900 mb-8"
className="text-5xl font-serif font-semibold
text-white mb-4 tracking-tight leading-tight
bg-gradient-to-r from-white via-[#d4af37] to-white
bg-clip-text text-transparent"
>
Book Room
Book Your Room
</h1>
<p className="text-gray-400 font-light tracking-wide text-lg">
Complete your reservation in just a few steps
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
{/* Booking Form */}
<div className="lg:col-span-2">
<form
onSubmit={handleSubmit(onSubmit)}
className="bg-white rounded-lg shadow-md
p-6 space-y-6"
className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
rounded-xl border border-[#d4af37]/20
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/5
p-8 space-y-8"
>
{/* Guest Information */}
<div>
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-[#d4af37]/10 rounded-lg
border border-[#d4af37]/30">
<Users className="w-5 h-5 text-[#d4af37]" />
</div>
<h2
className="text-xl font-bold
text-gray-900 mb-4"
className="text-2xl font-serif font-semibold
text-white tracking-wide"
>
Customer Information
</h2>
</div>
<div className="space-y-4">
<div className="space-y-5">
{/* Full Name */}
<div>
<label
className="block text-sm font-medium
text-gray-700 mb-1"
className="block text-sm font-light tracking-wide
text-gray-300 mb-2"
>
Full Name
<span className="text-red-500">*</span>
<span className="text-[#d4af37] ml-1">*</span>
</label>
<input
{...register('fullName')}
type="text"
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2 focus:ring-indigo-500
focus:border-indigo-500"
className="w-full px-4 py-3 bg-[#0a0a0a] border
border-[#d4af37]/20 rounded-lg
text-white placeholder-gray-500
focus:ring-2 focus:ring-[#d4af37]/50
focus:border-[#d4af37] transition-all duration-300
font-light tracking-wide"
placeholder="John Doe"
/>
{errors.fullName && (
<p className="text-sm text-red-600 mt-1">
<p className="text-sm text-red-400 mt-2 font-light">
{errors.fullName.message}
</p>
)}
@@ -331,29 +364,30 @@ const BookingPage: React.FC = () => {
{/* Email & Phone */}
<div className="grid grid-cols-1
md:grid-cols-2 gap-4"
md:grid-cols-2 gap-5"
>
<div>
<label
className="block text-sm
font-medium text-gray-700 mb-1"
className="block text-sm font-light tracking-wide
text-gray-300 mb-2"
>
Email
<span className="text-red-500">*</span>
<span className="text-[#d4af37] ml-1">*</span>
</label>
<input
{...register('email')}
type="email"
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2
focus:ring-indigo-500
focus:border-indigo-500"
className="w-full px-4 py-3 bg-[#0a0a0a] border
border-[#d4af37]/20 rounded-lg
text-white placeholder-gray-500
focus:ring-2 focus:ring-[#d4af37]/50
focus:border-[#d4af37] transition-all duration-300
font-light tracking-wide"
placeholder="email@example.com"
/>
{errors.email && (
<p className="text-sm text-red-600
mt-1"
<p className="text-sm text-red-400
mt-2 font-light"
>
{errors.email.message}
</p>
@@ -362,25 +396,26 @@ const BookingPage: React.FC = () => {
<div>
<label
className="block text-sm
font-medium text-gray-700 mb-1"
className="block text-sm font-light tracking-wide
text-gray-300 mb-2"
>
Phone Number
<span className="text-red-500">*</span>
<span className="text-[#d4af37] ml-1">*</span>
</label>
<input
{...register('phone')}
type="tel"
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2
focus:ring-indigo-500
focus:border-indigo-500"
className="w-full px-4 py-3 bg-[#0a0a0a] border
border-[#d4af37]/20 rounded-lg
text-white placeholder-gray-500
focus:ring-2 focus:ring-[#d4af37]/50
focus:border-[#d4af37] transition-all duration-300
font-light tracking-wide"
placeholder="0123456789"
/>
{errors.phone && (
<p className="text-sm text-red-600
mt-1"
<p className="text-sm text-red-400
mt-2 font-light"
>
{errors.phone.message}
</p>
@@ -391,30 +426,36 @@ const BookingPage: React.FC = () => {
</div>
{/* Booking Details */}
<div className="border-t pt-6">
<div className="border-t border-[#d4af37]/20 pt-8">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-[#d4af37]/10 rounded-lg
border border-[#d4af37]/30">
<Calendar className="w-5 h-5 text-[#d4af37]" />
</div>
<h2
className="text-xl font-bold
text-gray-900 mb-4"
className="text-2xl font-serif font-semibold
text-white tracking-wide"
>
Booking Details
</h2>
</div>
<div className="space-y-4">
<div className="space-y-5">
{/* Date Range */}
<div className="grid grid-cols-1
md:grid-cols-2 gap-4"
md:grid-cols-2 gap-5"
>
{/* Check-in Date */}
<div>
<label
className="block text-sm
font-medium text-gray-700 mb-1"
className="block text-sm font-light tracking-wide
text-gray-300 mb-2"
>
<Calendar
className="w-4 h-4 inline mr-1"
className="w-4 h-4 inline mr-1 text-[#d4af37]"
/>
Check-in Date
<span className="text-red-500">*</span>
<span className="text-[#d4af37] ml-1">*</span>
</label>
<Controller
control={control}
@@ -431,18 +472,19 @@ const BookingPage: React.FC = () => {
endDate={checkOutDate}
dateFormat="dd/MM/yyyy"
placeholderText="Select check-in date"
className="w-full px-4 py-2
border border-gray-300
rounded-lg focus:ring-2
focus:ring-indigo-500
focus:border-indigo-500"
className="w-full px-4 py-3
bg-[#0a0a0a] border border-[#d4af37]/20
rounded-lg text-white placeholder-gray-500
focus:ring-2 focus:ring-[#d4af37]/50
focus:border-[#d4af37] transition-all duration-300
font-light tracking-wide"
wrapperClassName="w-full"
/>
)}
/>
{errors.checkInDate && (
<p className="text-sm text-red-600
mt-1"
<p className="text-sm text-red-400
mt-2 font-light"
>
{errors.checkInDate.message}
</p>
@@ -452,14 +494,14 @@ const BookingPage: React.FC = () => {
{/* Check-out Date */}
<div>
<label
className="block text-sm
font-medium text-gray-700 mb-1"
className="block text-sm font-light tracking-wide
text-gray-300 mb-2"
>
<Calendar
className="w-4 h-4 inline mr-1"
className="w-4 h-4 inline mr-1 text-[#d4af37]"
/>
Check-out Date
<span className="text-red-500">*</span>
<span className="text-[#d4af37] ml-1">*</span>
</label>
<Controller
control={control}
@@ -478,18 +520,19 @@ const BookingPage: React.FC = () => {
endDate={checkOutDate}
dateFormat="dd/MM/yyyy"
placeholderText="Select check-out date"
className="w-full px-4 py-2
border border-gray-300
rounded-lg focus:ring-2
focus:ring-indigo-500
focus:border-indigo-500"
className="w-full px-4 py-3
bg-[#0a0a0a] border border-[#d4af37]/20
rounded-lg text-white placeholder-gray-500
focus:ring-2 focus:ring-[#d4af37]/50
focus:border-[#d4af37] transition-all duration-300
font-light tracking-wide"
wrapperClassName="w-full"
/>
)}
/>
{errors.checkOutDate && (
<p className="text-sm text-red-600
mt-1"
<p className="text-sm text-red-400
mt-2 font-light"
>
{errors.checkOutDate.message}
</p>
@@ -500,31 +543,33 @@ const BookingPage: React.FC = () => {
{/* Guest Count */}
<div>
<label
className="block text-sm font-medium
text-gray-700 mb-1"
className="block text-sm font-light tracking-wide
text-gray-300 mb-2"
>
<Users
className="w-4 h-4 inline mr-1"
className="w-4 h-4 inline mr-1 text-[#d4af37]"
/>
Number of Guests
<span className="text-red-500">*</span>
<span className="text-[#d4af37] ml-1">*</span>
</label>
<input
{...register('guestCount')}
type="number"
min="1"
max={roomType.capacity}
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2 focus:ring-indigo-500
focus:border-indigo-500"
max={room?.capacity || roomType.capacity}
className="w-full px-4 py-3 bg-[#0a0a0a] border
border-[#d4af37]/20 rounded-lg
text-white placeholder-gray-500
focus:ring-2 focus:ring-[#d4af37]/50
focus:border-[#d4af37] transition-all duration-300
font-light tracking-wide"
placeholder="1"
/>
<p className="text-sm text-gray-500 mt-1">
Maximum capacity: {roomType.capacity} guests
<p className="text-sm text-gray-400 mt-2 font-light tracking-wide">
Maximum capacity: <span className="text-[#d4af37]">{room?.capacity || roomType.capacity}</span> guests
</p>
{errors.guestCount && (
<p className="text-sm text-red-600 mt-1">
<p className="text-sm text-red-400 mt-2 font-light">
{errors.guestCount.message}
</p>
)}
@@ -533,25 +578,27 @@ const BookingPage: React.FC = () => {
{/* Notes */}
<div>
<label
className="block text-sm font-medium
text-gray-700 mb-1"
className="block text-sm font-light tracking-wide
text-gray-300 mb-2"
>
<FileText
className="w-4 h-4 inline mr-1"
className="w-4 h-4 inline mr-1 text-[#d4af37]"
/>
Notes (optional)
</label>
<textarea
{...register('notes')}
rows={3}
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2 focus:ring-indigo-500
focus:border-indigo-500"
className="w-full px-4 py-3 bg-[#0a0a0a] border
border-[#d4af37]/20 rounded-lg
text-white placeholder-gray-500
focus:ring-2 focus:ring-[#d4af37]/50
focus:border-[#d4af37] transition-all duration-300
font-light tracking-wide resize-none"
placeholder="Special requests..."
/>
{errors.notes && (
<p className="text-sm text-red-600 mt-1">
<p className="text-sm text-red-400 mt-2 font-light">
{errors.notes.message}
</p>
)}
@@ -560,57 +607,67 @@ const BookingPage: React.FC = () => {
</div>
{/* Payment Method */}
<div className="border-t pt-6">
<div className="border-t border-[#d4af37]/20 pt-8">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-[#d4af37]/10 rounded-lg
border border-[#d4af37]/30">
<CreditCard className="w-5 h-5 text-[#d4af37]" />
</div>
<h2
className="text-xl font-bold
text-gray-900 mb-4"
className="text-2xl font-serif font-semibold
text-white tracking-wide"
>
Payment Method
</h2>
</div>
<div className="space-y-3">
<div className="space-y-4">
{/* Cash */}
<label
className="flex items-start p-4
border-2 border-gray-200
className="flex items-start p-5
bg-gradient-to-br from-[#0a0a0a] to-[#1a1a1a]
border-2 border-[#d4af37]/20
rounded-lg cursor-pointer
hover:border-indigo-500
transition-colors"
hover:border-[#d4af37]/40 hover:shadow-lg hover:shadow-[#d4af37]/10
transition-all duration-300"
>
<input
{...register('paymentMethod')}
type="radio"
value="cash"
className="mt-1 mr-3"
className="mt-1 mr-4 w-5 h-5 text-[#d4af37]
border-[#d4af37]/30 focus:ring-[#d4af37]/50"
/>
<div className="flex-1">
<div className="flex items-center
gap-2 mb-1"
gap-3 mb-2"
>
<CreditCard
className="w-5 h-5
text-gray-600"
text-[#d4af37]"
/>
<span className="font-medium
text-gray-900"
text-white tracking-wide"
>
Pay on arrival
</span>
<span className="text-xs bg-orange-100
text-orange-700 px-2 py-0.5 rounded"
<span className="text-xs bg-gradient-to-r
from-orange-900/30 to-orange-800/20
text-orange-300 border border-orange-500/30
px-3 py-1 rounded font-medium tracking-wide"
>
Requires 20% deposit
</span>
</div>
<p className="text-sm text-gray-600 mb-2">
<p className="text-sm text-gray-400 mb-3 font-light tracking-wide">
Pay the remaining balance on arrival
</p>
<div className="bg-orange-50 border
border-orange-200 rounded p-2"
<div className="bg-gradient-to-br from-orange-900/20 to-orange-800/10
border border-orange-500/30 rounded-lg p-4"
>
<p className="text-xs text-orange-800">
<strong>Note:</strong> You need to pay
<strong> 20% deposit</strong> via
<p className="text-xs text-orange-300 font-light tracking-wide leading-relaxed">
<strong className="text-orange-200">Note:</strong> You need to pay
<strong className="text-[#d4af37]"> 20% deposit</strong> via
bank transfer immediately after booking to
secure the room. Pay the remaining balance
on arrival.
@@ -619,62 +676,73 @@ const BookingPage: React.FC = () => {
</div>
</label>
{/* Bank Transfer */}
{/* Stripe Payment */}
<label
className="flex items-start p-4
border-2 border-gray-200
className="flex items-start p-5
bg-gradient-to-br from-[#0a0a0a] to-[#1a1a1a]
border-2 border-[#d4af37]/20
rounded-lg cursor-pointer
hover:border-indigo-500
transition-colors"
hover:border-[#d4af37]/40 hover:shadow-lg hover:shadow-[#d4af37]/10
transition-all duration-300"
>
<input
{...register('paymentMethod')}
type="radio"
value="bank_transfer"
className="mt-1 mr-3"
value="stripe"
className="mt-1 mr-4 w-5 h-5 text-[#d4af37]
border-[#d4af37]/30 focus:ring-[#d4af37]/50"
/>
<div className="flex-1">
<div className="flex items-center
gap-2 mb-1"
gap-3 mb-2"
>
<Building2
<CreditCard
className="w-5 h-5
text-gray-600"
text-[#d4af37]"
/>
<span className="font-medium
text-gray-900"
text-white tracking-wide"
>
Bank Transfer
Pay with Card (Stripe)
</span>
<span className="text-xs bg-gradient-to-r
from-[#d4af37]/20 to-[#c9a227]/20
text-[#d4af37] border border-[#d4af37]/40
px-3 py-1 rounded font-medium tracking-wide"
>
Instant
</span>
</div>
<p className="text-sm text-gray-600">
Transfer via QR code or
account number
<p className="text-sm text-gray-400 font-light tracking-wide">
Secure payment with credit or debit card
</p>
</div>
</label>
{errors.paymentMethod && (
<p className="text-sm text-red-600">
<p className="text-sm text-red-400 font-light">
{errors.paymentMethod.message}
</p>
)}
{/* Bank Transfer Info */}
{paymentMethod === 'bank_transfer' && (
{/* Stripe Payment Info */}
{paymentMethod === 'stripe' && (
<div
className="bg-blue-50 border
border-blue-200 rounded-lg
p-4 mt-3"
className="bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/5
border border-[#d4af37]/30 rounded-lg
p-5 mt-3 backdrop-blur-sm"
>
<p className="text-sm text-blue-800
font-medium mb-2"
<div className="flex items-center gap-3 mb-3">
<Shield className="w-5 h-5 text-[#d4af37]" />
<p className="text-sm text-[#d4af37]
font-medium tracking-wide"
>
📌 Bank Transfer Information
Secure Card Payment
</p>
<p className="text-sm text-blue-700">
Scan QR code or transfer according to
the information after confirming the booking.
</div>
<p className="text-sm text-gray-300 font-light tracking-wide leading-relaxed">
You will be redirected to a secure payment page
to complete your booking with your credit or debit card.
</p>
</div>
)}
@@ -682,28 +750,37 @@ const BookingPage: React.FC = () => {
</div>
{/* Submit Button */}
<div className="border-t pt-6">
<div className="border-t border-[#d4af37]/20 pt-8">
<button
type="submit"
disabled={submitting}
className="w-full bg-indigo-600
text-white py-4 rounded-lg
hover:bg-indigo-700
transition-colors font-semibold
text-lg disabled:bg-gray-400
disabled:cursor-not-allowed
className="w-full bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] py-4 rounded-sm
hover:from-[#f5d76e] hover:to-[#d4af37]
transition-all duration-300 font-medium
text-lg disabled:bg-gray-800 disabled:text-gray-500
disabled:cursor-not-allowed disabled:hover:from-gray-800
disabled:hover:to-gray-800
flex items-center justify-center
gap-2"
gap-3 relative overflow-hidden group
shadow-lg shadow-[#d4af37]/30 hover:shadow-[#d4af37]/50
tracking-wide"
>
{submitting ? (
<>
<Loader2
className="w-5 h-5 animate-spin"
className="w-5 h-5 animate-spin relative z-10"
/>
Processing...
<span className="relative z-10">Processing...</span>
</>
) : (
'Confirm Booking'
<>
<span className="relative z-10">Confirm Booking</span>
<CheckCircle className="w-5 h-5 relative z-10" />
</>
)}
{!submitting && (
<span className="absolute inset-0 bg-gradient-to-r from-white/0 via-white/20 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700"></span>
)}
</button>
</div>
@@ -713,67 +790,94 @@ const BookingPage: React.FC = () => {
{/* Booking Summary */}
<div className="lg:col-span-1">
<div
className="bg-white rounded-lg shadow-md
p-6 sticky top-8"
className="bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a]
rounded-xl border border-[#d4af37]/30
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/20
p-8 sticky top-6"
>
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-[#d4af37]/10 rounded-lg
border border-[#d4af37]/30">
<Sparkles className="w-5 h-5 text-[#d4af37]" />
</div>
<h2
className="text-xl font-bold
text-gray-900 mb-4"
className="text-2xl font-serif font-semibold
text-white tracking-wide"
>
Booking Summary
</h2>
</div>
{/* Room Info */}
<div className="mb-4">
{roomType.images?.[0] && (
<div className="mb-6">
{((room?.images && room.images.length > 0)
? room.images[0]
: roomType.images?.[0]) && (
<div className="relative overflow-hidden rounded-lg mb-4
border border-[#d4af37]/20 group"
>
<img
src={roomType.images[0]}
src={(room?.images && room.images.length > 0)
? room.images[0]
: (roomType.images?.[0] || '')}
alt={roomType.name}
className="w-full h-48 object-cover
rounded-lg mb-3"
group-hover:scale-110 transition-transform
duration-500"
/>
<div className="absolute inset-0 bg-gradient-to-t
from-black/60 via-transparent to-transparent
opacity-0 group-hover:opacity-100 transition-opacity
duration-300"
/>
</div>
)}
<h3 className="font-bold text-gray-900">
<h3 className="font-serif font-semibold text-white mb-2
text-xl tracking-tight"
>
{roomType.name}
</h3>
<p className="text-sm text-gray-600">
<div className="flex items-center gap-2 text-gray-400
font-light tracking-wide text-sm"
>
<MapPin className="w-4 h-4 text-[#d4af37]" />
<span>
Room {room.room_number} - Floor {room.floor}
</p>
</span>
</div>
</div>
{/* Pricing Breakdown */}
<div className="border-t pt-4 space-y-2">
<div className="flex justify-between
text-sm"
>
<span className="text-gray-600">
<div className="border-t border-[#d4af37]/20 pt-6 space-y-3">
<div className="flex justify-between items-center">
<span className="text-gray-400 font-light tracking-wide text-sm">
Room price/night
</span>
<span className="font-medium">
<span className="font-light text-white">
{formatPrice(roomPrice)}
</span>
</div>
{numberOfNights > 0 && (
<div className="flex justify-between
text-sm"
>
<span className="text-gray-600">
<div className="flex justify-between items-center">
<span className="text-gray-400 font-light tracking-wide text-sm">
Nights
</span>
<span className="font-medium">
<span className="font-light text-white">
{numberOfNights} night(s)
</span>
</div>
)}
<div
className="border-t pt-2 flex
justify-between text-lg
font-bold"
className="border-t border-[#d4af37]/20 pt-4 flex
justify-between items-center"
>
<span>Total</span>
<span className="text-indigo-600">
<span className="text-lg font-medium text-white tracking-wide">Total</span>
<span className="text-2xl font-serif font-semibold
bg-gradient-to-r from-[#d4af37] to-[#f5d76e]
bg-clip-text text-transparent tracking-tight"
>
{numberOfNights > 0
? formatPrice(totalPrice)
: '---'}
@@ -782,24 +886,24 @@ const BookingPage: React.FC = () => {
{/* Deposit amount for cash payment */}
{paymentMethod === 'cash' && numberOfNights > 0 && (
<div className="bg-orange-50 border
border-orange-200 rounded-lg p-3 mt-2"
<div className="bg-gradient-to-br from-orange-900/20 to-orange-800/10
border border-orange-500/30 rounded-lg p-4 mt-4"
>
<div className="flex justify-between
items-center mb-1"
items-center mb-2"
>
<span className="text-sm font-medium
text-orange-900"
text-orange-300 tracking-wide"
>
Deposit to pay (20%)
</span>
<span className="text-lg font-bold
text-orange-700"
<span className="text-lg font-serif font-semibold
text-[#d4af37] tracking-tight"
>
{formatPrice(totalPrice * 0.2)}
</span>
</div>
<p className="text-xs text-orange-700">
<p className="text-xs text-orange-300/80 font-light tracking-wide">
Pay via bank transfer to confirm booking
</p>
</div>
@@ -808,24 +912,30 @@ const BookingPage: React.FC = () => {
{/* Note */}
<div
className={`border rounded-lg p-3 mt-4 ${
className={`border rounded-lg p-4 mt-6 ${
paymentMethod === 'cash'
? 'bg-orange-50 border-orange-200'
: 'bg-yellow-50 border-yellow-200'
? 'bg-gradient-to-br from-orange-900/20 to-orange-800/10 border-orange-500/30'
: 'bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/5 border-[#d4af37]/30'
}`}
>
{paymentMethod === 'cash' ? (
<p className="text-xs text-orange-800">
🔒 <strong>Required:</strong> Pay 20% deposit
<div className="flex items-start gap-3">
<Shield className="w-5 h-5 text-orange-400 mt-0.5 flex-shrink-0" />
<p className="text-xs text-orange-300 font-light tracking-wide leading-relaxed">
<strong className="text-orange-200">Required:</strong> Pay 20% deposit
via bank transfer after booking.
Remaining balance ({formatPrice(totalPrice * 0.8)})
to be paid on arrival.
</p>
</div>
) : (
<p className="text-xs text-yellow-800">
💡 Scan QR code or transfer according to the information
after confirming the booking.
<div className="flex items-start gap-3">
<Sparkles className="w-5 h-5 text-[#d4af37] mt-0.5 flex-shrink-0" />
<p className="text-xs text-gray-300 font-light tracking-wide leading-relaxed">
Secure payment via Stripe. Complete your booking
with credit or debit card after confirming.
</p>
</div>
)}
</div>
</div>

View File

@@ -31,10 +31,12 @@ import {
import { confirmBankTransfer } from
'../../services/api/paymentService';
import Loading from '../../components/common/Loading';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
const BookingSuccessPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { formatCurrency } = useFormatCurrency();
const [booking, setBooking] = useState<Booking | null>(
null
@@ -72,6 +74,21 @@ const BookingSuccessPage: React.FC = () => {
const bookingData = response.data.booking;
setBooking(bookingData);
// Check for pending Stripe payment
if (bookingData.payment_method === 'stripe' && bookingData.payments) {
const pendingStripePayment = bookingData.payments.find(
(p: any) =>
p.payment_method === 'stripe' &&
p.payment_status === 'pending'
);
if (pendingStripePayment) {
// Redirect to payment page for Stripe payment
navigate(`/payment/${bookingId}`, { replace: true });
return;
}
}
// Redirect to deposit payment page if required and not yet paid
if (
bookingData.requires_deposit &&
@@ -106,12 +123,7 @@ const BookingSuccessPage: React.FC = () => {
});
};
const formatPrice = (price: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(price);
};
const formatPrice = (price: number) => formatCurrency(price);
const getStatusColor = (status: string) => {
switch (status) {
@@ -369,9 +381,13 @@ const BookingSuccessPage: React.FC = () => {
{roomType && (
<div className="border-b pb-4">
<div className="flex items-start gap-4">
{roomType.images?.[0] && (
{((room?.images && room.images.length > 0)
? room.images[0]
: roomType.images?.[0]) && (
<img
src={roomType.images[0]}
src={(room?.images && room.images.length > 0)
? room.images[0]
: (roomType.images?.[0] || '')}
alt={roomType.name}
className="w-24 h-24 object-cover
rounded-lg"
@@ -395,7 +411,7 @@ const BookingSuccessPage: React.FC = () => {
<p className="text-indigo-600
font-semibold mt-1"
>
{formatPrice(roomType.base_price)}/night
{formatPrice(room?.price || roomType.base_price)}/night
</p>
</div>
</div>

View File

@@ -1,21 +1,28 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import {
TrendingUp,
Hotel,
DollarSign,
Calendar,
Activity,
TrendingDown
TrendingDown,
CreditCard,
Receipt
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import dashboardService, { CustomerDashboardStats } from '../../services/api/dashboardService';
import { paymentService, Payment } from '../../services/api';
import { toast } from 'react-toastify';
import { formatCurrency, formatDate, formatRelativeTime } from '../../utils/format';
import { formatDate, formatRelativeTime } from '../../utils/format';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { Loading, EmptyState } from '../../components/common';
import CurrencyIcon from '../../components/common/CurrencyIcon';
import { useAsync } from '../../hooks/useAsync';
const DashboardPage: React.FC = () => {
const navigate = useNavigate();
const { formatCurrency } = useFormatCurrency();
const [recentPayments, setRecentPayments] = useState<Payment[]>([]);
const [loadingPayments, setLoadingPayments] = useState(false);
const fetchDashboardData = async (): Promise<CustomerDashboardStats> => {
const response = await dashboardService.getCustomerDashboardStats();
@@ -32,10 +39,56 @@ const DashboardPage: React.FC = () => {
}
);
useEffect(() => {
const fetchPayments = async () => {
try {
setLoadingPayments(true);
const response = await paymentService.getPayments({ page: 1, limit: 5 });
if (response.success && response.data?.payments) {
setRecentPayments(response.data.payments);
}
} catch (err: any) {
console.error('Error fetching payments:', err);
} finally {
setLoadingPayments(false);
}
};
fetchPayments();
}, []);
const handleRefresh = () => {
execute();
};
const getPaymentStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800';
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'failed':
return 'bg-red-100 text-red-800';
case 'refunded':
return 'bg-gray-100 text-gray-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getPaymentMethodLabel = (method: string) => {
switch (method) {
case 'stripe':
case 'credit_card':
return 'Card';
case 'bank_transfer':
return 'Bank Transfer';
case 'cash':
return 'Cash';
default:
return method;
}
};
if (loading) {
return <Loading fullScreen text="Loading dashboard..." />;
}
@@ -99,7 +152,7 @@ const DashboardPage: React.FC = () => {
<div className="enterprise-stat-card border-l-4 border-green-500 animate-slide-up" style={{ animationDelay: '0.2s' }}>
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-green-100 rounded-lg">
<DollarSign className="w-6 h-6 text-green-600" />
<CurrencyIcon className="text-green-600" size={24} />
</div>
{stats.spending_change_percentage !== 0 && (
<span className={`text-sm font-medium flex items-center gap-1 ${
@@ -118,7 +171,7 @@ const DashboardPage: React.FC = () => {
Total Spending
</h3>
<p className="text-3xl font-bold text-gray-800">
{formatCurrency(stats.total_spending, 'VND')}
{formatCurrency(stats.total_spending)}
</p>
</div>
@@ -158,8 +211,8 @@ const DashboardPage: React.FC = () => {
</div>
</div>
{/* Recent Activity & Upcoming Bookings */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Activity, Upcoming Bookings & Payment History */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Recent Activity */}
<div className="enterprise-card p-6 animate-fade-in">
<h2 className="text-xl font-bold text-gray-900 mb-6 pb-3 border-b border-gray-200">
@@ -223,7 +276,7 @@ const DashboardPage: React.FC = () => {
{formatDate(booking.check_in_date, 'medium')}
</p>
<p className="text-xs text-gray-400 mt-1">
{formatCurrency(booking.total_price, 'VND')}
{formatCurrency(booking.total_price)}
</p>
</div>
<span className={`enterprise-badge ${
@@ -249,6 +302,69 @@ const DashboardPage: React.FC = () => {
/>
)}
</div>
{/* Payment History */}
<div className="enterprise-card p-6 animate-fade-in" style={{ animationDelay: '0.3s' }}>
<div className="flex items-center justify-between mb-6 pb-3 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">
Payment History
</h2>
<button
onClick={() => navigate('/bookings')}
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
>
View All
</button>
</div>
{loadingPayments ? (
<div className="flex items-center justify-center py-8">
<Loading text="Loading payments..." />
</div>
) : recentPayments && recentPayments.length > 0 ? (
<div className="space-y-4">
{recentPayments.map((payment) => (
<div
key={payment.id}
className="flex items-center justify-between pb-4 border-b border-gray-200 last:border-0 hover:bg-gray-50 -mx-2 px-2 py-1 rounded cursor-pointer transition-colors"
onClick={() => navigate(`/bookings/${payment.booking_id}`)}
>
<div className="flex items-center space-x-3 flex-1">
<div className="p-2 bg-blue-100 rounded-lg">
<CreditCard className="w-5 h-5 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-800 truncate">
{formatCurrency(payment.amount)}
</p>
<div className="flex items-center gap-2 mt-1">
<p className="text-sm text-gray-500">
{getPaymentMethodLabel(payment.payment_method)}
</p>
{payment.payment_date && (
<span className="text-xs text-gray-400">
{formatDate(payment.payment_date, 'short')}
</span>
)}
</div>
</div>
</div>
<span className={`enterprise-badge text-xs ${getPaymentStatusColor(payment.payment_status)}`}>
{payment.payment_status.charAt(0).toUpperCase() + payment.payment_status.slice(1)}
</span>
</div>
))}
</div>
) : (
<EmptyState
title="No Payment History"
description="Your payment history will appear here"
action={{
label: 'View Bookings',
onClick: () => navigate('/bookings')
}}
/>
)}
</div>
</div>
</div>
);

View File

@@ -4,39 +4,29 @@ import {
CheckCircle,
AlertCircle,
CreditCard,
Building2,
Copy,
Check,
Loader2,
ArrowLeft,
Download,
} from 'lucide-react';
import { toast } from 'react-toastify';
import { getBookingById, type Booking } from
'../../services/api/bookingService';
import {
getPaymentsByBookingId,
getBankTransferInfo,
notifyPaymentCompletion,
type Payment,
type BankInfo,
} from '../../services/api/paymentService';
import Loading from '../../components/common/Loading';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import StripePaymentWrapper from '../../components/payments/StripePaymentWrapper';
const DepositPaymentPage: React.FC = () => {
const { bookingId } = useParams<{ bookingId: string }>();
const navigate = useNavigate();
const { formatCurrency } = useFormatCurrency();
const [booking, setBooking] = useState<Booking | null>(null);
const [depositPayment, setDepositPayment] = useState<Payment | null>(null);
const [bankInfo, setBankInfo] = useState<BankInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notifying, setNotifying] = useState(false);
const [copiedText, setCopiedText] = useState<string | null>(null);
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<
'bank_transfer' | null
>('bank_transfer');
const [paymentSuccess, setPaymentSuccess] = useState(false);
useEffect(() => {
if (bookingId) {
@@ -58,6 +48,13 @@ const DepositPaymentPage: React.FC = () => {
const bookingData = bookingResponse.data.booking;
setBooking(bookingData);
// Check if booking is already confirmed - redirect to booking details
if (bookingData.status === 'confirmed' || bookingData.status === 'checked_in') {
toast.success('Booking is already confirmed!');
navigate(`/bookings/${id}`);
return;
}
// Check if booking requires deposit
if (!bookingData.requires_deposit) {
toast.info('This booking does not require a deposit');
@@ -73,14 +70,6 @@ const DepositPaymentPage: React.FC = () => {
);
if (deposit) {
setDepositPayment(deposit);
// If payment is pending, fetch bank info
if (deposit.payment_status === 'pending') {
const bankInfoResponse = await getBankTransferInfo(deposit.id);
if (bankInfoResponse.success && bankInfoResponse.data.bank_info) {
setBankInfo(bankInfoResponse.data.bank_info);
}
}
}
}
} catch (err: any) {
@@ -94,57 +83,8 @@ const DepositPaymentPage: React.FC = () => {
}
};
const formatPrice = (price: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(price);
};
const formatPrice = (price: number) => formatCurrency(price);
const copyToClipboard = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedText(label);
toast.success(`Copied ${label}`);
setTimeout(() => setCopiedText(null), 2000);
} catch (err) {
toast.error('Unable to copy');
}
};
// No auto-redirect payment methods. Default to bank transfer.
const handleNotifyPayment = async () => {
if (!depositPayment) return;
try {
setNotifying(true);
const response = await notifyPaymentCompletion(
depositPayment.id,
'Customer has transferred deposit'
);
if (response.success) {
toast.success(
'✅ Payment notification sent! ' +
'We will confirm within 24 hours.'
);
navigate(`/bookings/${bookingId}`);
} else {
throw new Error(response.message || 'Unable to send notification');
}
} catch (err: any) {
console.error('Error notifying payment:', err);
const message =
err.response?.data?.message ||
'Unable to send notification. Please try again.';
toast.error(message);
} finally {
setNotifying(false);
}
};
// VNPay removed: no online redirect handler
if (loading) {
return <Loading fullScreen text="Loading..." />;
@@ -302,241 +242,61 @@ const DepositPaymentPage: React.FC = () => {
{!isDepositPaid && (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-6">
Select Payment Method
Payment Method
</h2>
{/* Payment Method Buttons */}
<div className="grid grid-cols-2 gap-4 mb-6">
{/* Bank Transfer Button */}
<button
onClick={() => setSelectedPaymentMethod('bank_transfer')}
className={`p-4 border-2 rounded-lg transition-all
${
selectedPaymentMethod === 'bank_transfer'
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-300 bg-white hover:border-indigo-300'
}`}
>
<Building2
className={`w-8 h-8 mx-auto mb-2 ${
selectedPaymentMethod === 'bank_transfer'
? 'text-indigo-600'
: 'text-gray-600'
}`}
/>
<div
className={`font-bold text-sm ${
selectedPaymentMethod === 'bank_transfer'
? 'text-indigo-900'
: 'text-gray-700'
}`}
>
Bank Transfer
</div>
<div className="text-xs text-gray-500 mt-1">
Bank transfer
</div>
</button>
{/* VNPay removed */}
</div>
<p className="text-sm text-gray-600 mb-4">
Pay with your credit or debit card
</p>
</div>
)}
{/* Bank Transfer Instructions or VNPay panel */}
{!isDepositPaid && selectedPaymentMethod === 'bank_transfer' &&
bankInfo && (
{/* Stripe Payment Panel */}
{!isDepositPaid && booking && depositPayment && (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">
<Building2 className="w-5 h-5 inline mr-2" />
Bank Transfer Information
<CreditCard className="w-5 h-5 inline mr-2" />
Card Payment
</h2>
<div className="space-y-4">
{/* Bank Info */}
<div className="space-y-3">
<div className="flex justify-between items-center p-3 bg-gray-50 rounded">
<div>
<div className="text-xs text-gray-500">Bank</div>
<div className="font-medium">{bankInfo.bank_name}</div>
</div>
<button
onClick={() =>
copyToClipboard(bankInfo.bank_name, 'bank name')
}
className="p-2 hover:bg-gray-200 rounded transition-colors"
>
{copiedText === 'bank name' ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-gray-600" />
)}
</button>
</div>
<div className="flex justify-between items-center p-3 bg-gray-50 rounded">
<div>
<div className="text-xs text-gray-500">Account Number</div>
<div className="font-medium font-mono">
{bankInfo.account_number}
</div>
</div>
<button
onClick={() =>
copyToClipboard(bankInfo.account_number, 'account number')
}
className="p-2 hover:bg-gray-200 rounded transition-colors"
>
{copiedText === 'account number' ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-gray-600" />
)}
</button>
</div>
<div className="flex justify-between items-center p-3 bg-gray-50 rounded">
<div>
<div className="text-xs text-gray-500">Account Holder</div>
<div className="font-medium">{bankInfo.account_name}</div>
</div>
<button
onClick={() =>
copyToClipboard(bankInfo.account_name, 'account holder')
}
className="p-2 hover:bg-gray-200 rounded transition-colors"
>
{copiedText === 'account holder' ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-gray-600" />
)}
</button>
</div>
<div className="flex justify-between items-center p-3 bg-orange-50 border border-orange-200 rounded">
<div>
<div className="text-xs text-orange-700">Amount</div>
<div className="text-lg font-bold text-orange-600">
{formatPrice(bankInfo.amount)}
</div>
</div>
<button
onClick={() =>
copyToClipboard(bankInfo.amount.toString(), 'amount')
}
className="p-2 hover:bg-orange-100 rounded transition-colors"
>
{copiedText === 'amount' ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-orange-600" />
)}
</button>
</div>
<div className="flex justify-between items-center p-3 bg-gray-50 rounded">
<div>
<div className="text-xs text-gray-500">Transfer Content</div>
<div className="font-medium font-mono text-red-600">
{bankInfo.content}
</div>
</div>
<button
onClick={() =>
copyToClipboard(bankInfo.content, 'content')
}
className="p-2 hover:bg-gray-200 rounded transition-colors"
>
{copiedText === 'content' ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-gray-600" />
)}
</button>
</div>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded p-3">
<p className="text-sm text-yellow-800">
<strong> Note:</strong> Please enter the correct transfer content so
the system can automatically confirm the payment.
{paymentSuccess ? (
<div className="bg-green-50 border border-green-200 rounded-lg p-6 text-center">
<CheckCircle className="w-12 h-12 text-green-600 mx-auto mb-3" />
<h3 className="text-lg font-bold text-green-900 mb-2">
Payment Successful!
</h3>
<p className="text-green-700 mb-4">
Your deposit payment has been confirmed.
</p>
<button
onClick={() => navigate(`/bookings/${booking.id}`)}
className="bg-green-600 text-white px-6 py-2 rounded-lg hover:bg-green-700 transition-colors"
>
View Booking
</button>
</div>
{/* Notify Button */}
<button
onClick={handleNotifyPayment}
disabled={notifying}
className="w-full bg-indigo-600 text-white py-3 rounded-lg
hover:bg-indigo-700 transition-colors font-semibold
disabled:bg-gray-400 disabled:cursor-not-allowed
flex items-center justify-center gap-2"
>
{notifying ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Sending...
</>
) : (
<>
<CheckCircle className="w-5 h-5" />
I have transferred
</>
)}
</button>
<p className="text-xs text-center text-gray-500 mt-2">
After transferring, click the button above to notify us
</p>
</div>
) : (
<StripePaymentWrapper
bookingId={booking.id}
amount={depositAmount}
onSuccess={() => {
setPaymentSuccess(true);
toast.success('✅ Payment successful! Your booking has been confirmed.');
// Navigate to booking details after successful payment
setTimeout(() => {
navigate(`/bookings/${booking.id}`);
}, 2000);
}}
onError={(error) => {
toast.error(error || 'Payment failed');
}}
/>
)}
</div>
)}
{/* VNPay removed */}
</div>
{/* QR Code Sidebar */}
{!isDepositPaid &&
bankInfo &&
selectedPaymentMethod === 'bank_transfer' && (
<div className="lg:col-span-1">
<div className="bg-white rounded-lg shadow-md p-6 sticky top-8">
<h3 className="text-lg font-bold text-gray-900 mb-4 text-center">
Scan QR Code to Pay
</h3>
<div className="bg-gray-50 p-4 rounded-lg mb-4">
<img
src={bankInfo.qr_url}
alt="QR Code"
className="w-full h-auto rounded"
/>
</div>
<div className="text-center space-y-2">
<p className="text-sm text-gray-600">
Scan QR code with your bank app
</p>
<p className="text-xs text-gray-500">
Transfer information has been automatically filled
</p>
</div>
<a
href={bankInfo.qr_url}
download={`deposit-qr-${booking.booking_number}.jpg`}
className="mt-4 w-full inline-flex items-center justify-center
gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200
text-gray-700 rounded-lg transition-colors"
>
<Download className="w-4 h-4" />
Download QR Code
</a>
</div>
</div>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,437 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import {
CheckCircle,
AlertCircle,
CreditCard,
ArrowLeft,
} from 'lucide-react';
import { toast } from 'react-toastify';
import { getBookingById, type Booking } from
'../../services/api/bookingService';
import {
getPaymentsByBookingId,
type Payment,
} from '../../services/api/paymentService';
import Loading from '../../components/common/Loading';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import StripePaymentWrapper from '../../components/payments/StripePaymentWrapper';
const FullPaymentPage: React.FC = () => {
const { bookingId } = useParams<{ bookingId: string }>();
const navigate = useNavigate();
const { formatCurrency } = useFormatCurrency();
const [booking, setBooking] = useState<Booking | null>(null);
const [stripePayment, setStripePayment] = useState<Payment | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [paymentSuccess, setPaymentSuccess] = useState(false);
useEffect(() => {
if (bookingId) {
fetchData(Number(bookingId));
}
}, [bookingId]);
const fetchData = async (id: number) => {
try {
setLoading(true);
setError(null);
// Fetch booking details
const bookingResponse = await getBookingById(id);
if (!bookingResponse.success || !bookingResponse.data?.booking) {
throw new Error('Booking not found');
}
const bookingData = bookingResponse.data.booking;
setBooking(bookingData);
// Check if booking is already confirmed - redirect to booking details
if (bookingData.status === 'confirmed' || bookingData.status === 'checked_in') {
toast.success('Booking is already confirmed!');
navigate(`/bookings/${id}`);
return;
}
// Check if booking uses Stripe payment method
if (bookingData.payment_method !== 'stripe') {
toast.info('This booking does not use Stripe payment');
navigate(`/bookings/${id}`);
return;
}
// Fetch payments
const paymentsResponse = await getPaymentsByBookingId(id);
console.log('Payments response:', paymentsResponse);
if (paymentsResponse.success && paymentsResponse.data?.payments) {
const payments = paymentsResponse.data.payments;
console.log('Payments found:', payments);
// Find pending Stripe payment (full payment)
const stripePaymentFound = payments.find(
(p: Payment) =>
(p.payment_method === 'stripe' || p.payment_method === 'credit_card') &&
p.payment_status === 'pending'
);
if (stripePaymentFound) {
console.log('Found pending Stripe payment:', stripePaymentFound);
setStripePayment(stripePaymentFound);
} else {
// Check if payment is already completed
const completedPayment = payments.find(
(p: Payment) =>
(p.payment_method === 'stripe' || p.payment_method === 'credit_card') &&
p.payment_status === 'completed'
);
if (completedPayment) {
console.log('Found completed Stripe payment:', completedPayment);
setStripePayment(completedPayment);
setPaymentSuccess(true);
// If payment is completed and booking is confirmed, redirect
if (bookingData.status === 'confirmed' || bookingData.status === 'checked_in') {
toast.info('Payment already completed. Booking is confirmed.');
setTimeout(() => {
navigate(`/bookings/${id}`);
}, 1500);
return;
}
} else {
// If no Stripe payment found, check if we can use booking data to create payment info
console.warn('No Stripe payment found in payments array:', payments);
console.warn('Booking payment method:', bookingData.payment_method);
// If booking uses Stripe but no payment record exists, this is an error
throw new Error('No Stripe payment record found for this booking. The payment may not have been created properly.');
}
}
} else {
// If payments endpoint fails or returns no payments, check booking payments array
console.warn('Payments response not successful or no payments data:', paymentsResponse);
if (bookingData.payments && bookingData.payments.length > 0) {
console.log('Using payments from booking data:', bookingData.payments);
const stripePaymentFromBooking = bookingData.payments.find(
(p: any) =>
(p.payment_method === 'stripe' || p.payment_method === 'credit_card') &&
p.payment_status === 'pending'
);
if (stripePaymentFromBooking) {
setStripePayment(stripePaymentFromBooking as Payment);
} else {
throw new Error('No pending Stripe payment found for this booking');
}
} else {
// If no payments found at all, this might be a timing issue - wait a moment and retry
console.error('No payments found for booking. This might be a timing issue.');
throw new Error('Payment information not found. Please wait a moment and refresh, or contact support if the issue persists.');
}
}
} catch (err: any) {
console.error('Error fetching data:', err);
const message =
err.response?.data?.message || err.message || 'Unable to load payment information';
setError(message);
toast.error(message);
} finally {
setLoading(false);
}
};
const formatPrice = (price: number) => formatCurrency(price);
if (loading) {
return <Loading fullScreen text="Loading..." />;
}
if (error || !booking || !stripePayment) {
return (
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] py-8">
<div className="max-w-4xl mx-auto px-4">
<div
className="bg-gradient-to-br from-red-900/20 to-red-800/10
border border-red-500/30 rounded-xl p-8 text-center
backdrop-blur-xl shadow-2xl shadow-red-500/10"
>
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
<p className="text-red-300 font-medium mb-6 tracking-wide">
{error || 'Payment information not found'}
</p>
<Link
to="/bookings"
className="inline-flex items-center gap-2
bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] px-6 py-3 rounded-sm
hover:from-[#f5d76e] hover:to-[#d4af37]
transition-all duration-300 font-medium
tracking-wide shadow-lg shadow-[#d4af37]/30"
>
<ArrowLeft className="w-4 h-4" />
Back to booking list
</Link>
</div>
</div>
</div>
);
}
// Get payment amount, but validate it's reasonable
let paymentAmount = parseFloat(stripePayment.amount.toString());
const isPaymentCompleted = stripePayment.payment_status === 'completed';
// Log payment amount for debugging
console.log('Payment amount from payment record:', paymentAmount);
console.log('Booking total price:', booking?.total_price);
// If payment amount seems incorrect (too large or doesn't match booking), use booking total
if (paymentAmount > 999999.99 || (booking && Math.abs(paymentAmount - booking.total_price) > 0.01)) {
console.warn('Payment amount seems incorrect, using booking total price instead');
if (booking) {
paymentAmount = parseFloat(booking.total_price.toString());
console.log('Using booking total price:', paymentAmount);
}
}
// Final validation - ensure amount is reasonable for Stripe
if (paymentAmount > 999999.99) {
const errorMsg = `Payment amount $${paymentAmount.toLocaleString()} exceeds Stripe's maximum. Please contact support.`;
console.error(errorMsg);
setError(errorMsg);
return null;
}
return (
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] py-8">
<div className="max-w-4xl mx-auto px-4">
{/* Back Button */}
<Link
to={`/bookings/${bookingId}`}
className="inline-flex items-center gap-2
text-[#d4af37]/80 hover:text-[#d4af37]
mb-6 transition-all duration-300
group font-light tracking-wide"
>
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
<span>Back to booking details</span>
</Link>
{/* Success Header (if paid) */}
{isPaymentCompleted && (
<div
className="bg-gradient-to-br from-green-900/20 to-green-800/10
border-2 border-green-500/30 rounded-xl p-6 mb-6
backdrop-blur-xl shadow-2xl shadow-green-500/10"
>
<div className="flex items-center gap-4">
<div
className="w-16 h-16 bg-green-500/20 rounded-full
flex items-center justify-center border border-green-500/30"
>
<CheckCircle className="w-10 h-10 text-green-400" />
</div>
<div className="flex-1">
<h1 className="text-2xl font-serif font-bold text-green-300 mb-1 tracking-wide">
Payment successful!
</h1>
<p className="text-green-400/80 font-light tracking-wide">
Your booking has been confirmed.
</p>
</div>
</div>
</div>
)}
{/* Pending Header */}
{!isPaymentCompleted && (
<div
className="bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/5
border-2 border-[#d4af37]/30 rounded-xl p-6 mb-6
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/10"
>
<div className="flex items-center gap-4">
<div
className="w-16 h-16 bg-[#d4af37]/20 rounded-full
flex items-center justify-center border border-[#d4af37]/30"
>
<CreditCard className="w-10 h-10 text-[#d4af37]" />
</div>
<div className="flex-1">
<h1 className="text-2xl font-serif font-bold text-[#d4af37] mb-1 tracking-wide">
Complete Payment
</h1>
<p className="text-gray-300 font-light tracking-wide">
Please complete your payment to confirm your booking
</p>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Payment Info */}
<div className="lg:col-span-2 space-y-6">
{/* Payment Summary */}
<div
className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
rounded-xl border border-[#d4af37]/20
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/5 p-6"
>
<h2 className="text-xl font-serif font-semibold text-[#d4af37] mb-4 flex items-center gap-2">
<div className="w-1 h-6 bg-gradient-to-b from-[#d4af37] to-[#c9a227]" />
Payment Information
</h2>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-400 font-light">Total Room Price</span>
<span className="font-medium text-white">
{formatPrice(booking.total_price)}
</span>
</div>
<div
className="flex justify-between border-t border-[#d4af37]/20 pt-3
text-[#d4af37]"
>
<span className="font-medium">
Amount to Pay
</span>
<span className="text-xl font-bold">
{formatPrice(paymentAmount)}
</span>
</div>
</div>
{isPaymentCompleted && (
<div className="mt-4 bg-green-500/10 border border-green-500/30 rounded-lg p-3">
<p className="text-sm text-green-400 font-light">
Payment completed on:{' '}
{stripePayment.payment_date
? new Date(stripePayment.payment_date).toLocaleString('en-US')
: 'N/A'}
</p>
{stripePayment.transaction_id && (
<p className="text-xs text-green-400/70 mt-1 font-light">
Transaction ID: {stripePayment.transaction_id}
</p>
)}
</div>
)}
</div>
{/* Stripe Payment Panel */}
{!isPaymentCompleted && booking && stripePayment && (
<div
className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
rounded-xl border border-[#d4af37]/20
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/5 p-6"
>
<h2 className="text-xl font-serif font-semibold text-[#d4af37] mb-4 flex items-center gap-2">
<div className="w-1 h-6 bg-gradient-to-b from-[#d4af37] to-[#c9a227]" />
<CreditCard className="w-5 h-5" />
Card Payment
</h2>
{paymentSuccess ? (
<div className="bg-green-500/10 border border-green-500/30 rounded-lg p-6 text-center">
<CheckCircle className="w-12 h-12 text-green-400 mx-auto mb-3" />
<h3 className="text-lg font-serif font-bold text-green-300 mb-2 tracking-wide">
Payment Successful!
</h3>
<p className="text-green-400/80 font-light mb-4 tracking-wide">
Your payment has been confirmed.
</p>
<button
onClick={() => navigate(`/bookings/${booking.id}`)}
className="bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] px-6 py-3 rounded-sm
hover:from-[#f5d76e] hover:to-[#d4af37]
transition-all duration-300 font-medium
tracking-wide shadow-lg shadow-[#d4af37]/30"
>
View Booking
</button>
</div>
) : (
<StripePaymentWrapper
bookingId={booking.id}
amount={paymentAmount}
onSuccess={() => {
setPaymentSuccess(true);
toast.success('✅ Payment successful! Your booking has been confirmed.');
// Navigate to booking details after successful payment
setTimeout(() => {
navigate(`/bookings/${booking.id}`);
}, 2000);
}}
onError={(error) => {
toast.error(error || 'Payment failed');
}}
/>
)}
</div>
)}
</div>
{/* Booking Summary Sidebar */}
<div className="lg:col-span-1">
<div
className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
rounded-xl border border-[#d4af37]/20
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/5 p-6
sticky top-6"
>
<h3 className="text-lg font-serif font-semibold text-[#d4af37] mb-4 flex items-center gap-2">
<div className="w-1 h-5 bg-gradient-to-b from-[#d4af37] to-[#c9a227]" />
Booking Summary
</h3>
<div className="space-y-3 text-sm">
<div>
<span className="text-gray-400 font-light">Booking Number</span>
<p className="text-white font-medium">{booking.booking_number}</p>
</div>
<div>
<span className="text-gray-400 font-light">Room</span>
<p className="text-white font-medium">
{booking.room?.room_number || 'N/A'}
</p>
</div>
<div>
<span className="text-gray-400 font-light">Check-in</span>
<p className="text-white font-medium">
{new Date(booking.check_in_date).toLocaleDateString('en-US')}
</p>
</div>
<div>
<span className="text-gray-400 font-light">Check-out</span>
<p className="text-white font-medium">
{new Date(booking.check_out_date).toLocaleDateString('en-US')}
</p>
</div>
<div className="pt-3 border-t border-[#d4af37]/20">
<span className="text-gray-400 font-light">Total Amount</span>
<p className="text-xl font-bold text-[#d4af37]">
{formatPrice(booking.total_price)}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default FullPaymentPage;

View File

@@ -0,0 +1,330 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { ArrowLeft, Download, FileText, CheckCircle, Clock, XCircle } from 'lucide-react';
import { invoiceService, Invoice } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { formatDate } from '../../utils/format';
const InvoicePage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { formatCurrency } = useFormatCurrency();
const [invoice, setInvoice] = useState<Invoice | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (id) {
fetchInvoice(Number(id));
}
}, [id]);
const fetchInvoice = async (invoiceId: number) => {
try {
setLoading(true);
const response = await invoiceService.getInvoiceById(invoiceId);
if (response.status === 'success' && response.data?.invoice) {
setInvoice(response.data.invoice);
} else {
throw new Error('Invoice not found');
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load invoice');
navigate('/bookings');
} finally {
setLoading(false);
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'paid':
return <CheckCircle className="w-5 h-5 text-green-600" />;
case 'sent':
case 'draft':
return <Clock className="w-5 h-5 text-yellow-600" />;
case 'overdue':
return <XCircle className="w-5 h-5 text-red-600" />;
default:
return <FileText className="w-5 h-5 text-gray-600" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'paid':
return 'bg-green-100 text-green-800 border-green-200';
case 'sent':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'draft':
return 'bg-gray-100 text-gray-800 border-gray-200';
case 'overdue':
return 'bg-red-100 text-red-800 border-red-200';
case 'cancelled':
return 'bg-gray-100 text-gray-800 border-gray-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
const handlePrint = () => {
window.print();
};
if (loading) {
return <Loading fullScreen text="Loading invoice..." />;
}
if (!invoice) {
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
<div className="bg-white rounded-lg shadow-md p-8 text-center">
<FileText className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-gray-900 mb-2">Invoice Not Found</h2>
<p className="text-gray-600 mb-6">The invoice you're looking for doesn't exist.</p>
<Link
to="/bookings"
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
Back to Bookings
</Link>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-8 print:bg-white">
<div className="max-w-4xl mx-auto px-4">
{/* Header Actions */}
<div className="mb-6 flex items-center justify-between print:hidden">
<Link
to="/bookings"
className="inline-flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
<span>Back to Bookings</span>
</Link>
<div className="flex items-center gap-3">
<button
onClick={handlePrint}
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
<Download className="w-5 h-5" />
Print/Download
</button>
</div>
</div>
{/* Invoice Card */}
<div className="bg-white rounded-lg shadow-lg p-8 print:shadow-none">
{/* 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>
<p className="text-gray-600">#{invoice.invoice_number}</p>
</div>
<div className={`flex items-center gap-2 px-4 py-2 rounded-lg border ${getStatusColor(invoice.status)}`}>
{getStatusIcon(invoice.status)}
<span className="font-medium capitalize">{invoice.status}</span>
</div>
</div>
{/* Company & Customer Info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
{/* Company Info */}
<div>
<h3 className="text-sm font-semibold text-gray-500 uppercase mb-3">From</h3>
{invoice.company_name && (
<div className="text-gray-900">
<p className="font-bold text-lg mb-1">{invoice.company_name}</p>
{invoice.company_address && (
<p className="text-gray-600 whitespace-pre-line">{invoice.company_address}</p>
)}
<div className="mt-2 space-y-1">
{invoice.company_phone && (
<p className="text-gray-600">Phone: {invoice.company_phone}</p>
)}
{invoice.company_email && (
<p className="text-gray-600">Email: {invoice.company_email}</p>
)}
{invoice.company_tax_id && (
<p className="text-gray-600">Tax ID: {invoice.company_tax_id}</p>
)}
</div>
</div>
)}
{!invoice.company_name && (
<p className="text-gray-500 italic">Company information not set</p>
)}
</div>
{/* Customer Info */}
<div>
<h3 className="text-sm font-semibold text-gray-500 uppercase mb-3">Bill To</h3>
<div className="text-gray-900">
<p className="font-bold text-lg mb-1">{invoice.customer_name}</p>
{invoice.customer_address && (
<p className="text-gray-600 whitespace-pre-line">{invoice.customer_address}</p>
)}
<div className="mt-2 space-y-1">
{invoice.customer_email && (
<p className="text-gray-600">Email: {invoice.customer_email}</p>
)}
{invoice.customer_phone && (
<p className="text-gray-600">Phone: {invoice.customer_phone}</p>
)}
{invoice.customer_tax_id && (
<p className="text-gray-600">Tax ID: {invoice.customer_tax_id}</p>
)}
</div>
</div>
</div>
</div>
{/* Invoice Details */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8 p-4 bg-gray-50 rounded-lg">
<div>
<p className="text-sm text-gray-500 mb-1">Issue Date</p>
<p className="font-medium text-gray-900">{formatDate(invoice.issue_date, 'medium')}</p>
</div>
<div>
<p className="text-sm text-gray-500 mb-1">Due Date</p>
<p className="font-medium text-gray-900">{formatDate(invoice.due_date, 'medium')}</p>
</div>
{invoice.paid_date && (
<div>
<p className="text-sm text-gray-500 mb-1">Paid Date</p>
<p className="font-medium text-gray-900">{formatDate(invoice.paid_date, 'medium')}</p>
</div>
)}
<div>
<p className="text-sm text-gray-500 mb-1">Booking ID</p>
<p className="font-medium text-gray-900">#{invoice.booking_id}</p>
</div>
</div>
{/* Invoice Items */}
<div className="mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Items</h3>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Description</th>
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-700">Quantity</th>
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-700">Unit Price</th>
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-700">Total</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{invoice.items && invoice.items.length > 0 ? (
invoice.items.map((item) => (
<tr key={item.id}>
<td className="px-4 py-3 text-sm text-gray-900">{item.description}</td>
<td className="px-4 py-3 text-sm text-gray-600 text-right">{item.quantity}</td>
<td className="px-4 py-3 text-sm text-gray-600 text-right">{formatCurrency(item.unit_price)}</td>
<td className="px-4 py-3 text-sm font-medium text-gray-900 text-right">{formatCurrency(item.line_total)}</td>
</tr>
))
) : (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-gray-500">
No items found
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Invoice Totals */}
<div className="flex justify-end mb-8">
<div className="w-full md:w-1/2">
<div className="space-y-2">
<div className="flex justify-between text-gray-600">
<span>Subtotal:</span>
<span>{formatCurrency(invoice.subtotal)}</span>
</div>
{invoice.discount_amount > 0 && (
<div className="flex justify-between text-gray-600">
<span>Discount:</span>
<span>-{formatCurrency(invoice.discount_amount)}</span>
</div>
)}
{invoice.tax_amount > 0 && (
<div className="flex justify-between text-gray-600">
<span>Tax ({invoice.tax_rate}%):</span>
<span>{formatCurrency(invoice.tax_amount)}</span>
</div>
)}
<div className="flex justify-between text-lg font-bold text-gray-900 pt-2 border-t-2 border-gray-200">
<span>Total:</span>
<span>{formatCurrency(invoice.total_amount)}</span>
</div>
{invoice.amount_paid > 0 && (
<div className="flex justify-between text-gray-600">
<span>Amount Paid:</span>
<span className="text-green-600">{formatCurrency(invoice.amount_paid)}</span>
</div>
)}
{invoice.balance_due > 0 && (
<div className="flex justify-between text-lg font-bold text-red-600 pt-2 border-t border-gray-200">
<span>Balance Due:</span>
<span>{formatCurrency(invoice.balance_due)}</span>
</div>
)}
{invoice.balance_due <= 0 && invoice.status === 'paid' && (
<div className="flex justify-between text-lg font-bold text-green-600 pt-2 border-t border-gray-200">
<span>Status:</span>
<span>Paid in Full</span>
</div>
)}
</div>
</div>
</div>
{/* Notes & Terms */}
{(invoice.notes || invoice.terms_and_conditions || invoice.payment_instructions) && (
<div className="mt-8 pt-8 border-t-2 border-gray-200 space-y-4">
{invoice.notes && (
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-2">Notes</h4>
<p className="text-gray-600 whitespace-pre-line">{invoice.notes}</p>
</div>
)}
{invoice.payment_instructions && (
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-2">Payment Instructions</h4>
<p className="text-gray-600 whitespace-pre-line">{invoice.payment_instructions}</p>
</div>
)}
{invoice.terms_and_conditions && (
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-2">Terms & Conditions</h4>
<p className="text-sm text-gray-600 whitespace-pre-line">{invoice.terms_and_conditions}</p>
</div>
)}
</div>
)}
{/* Footer */}
<div className="mt-8 pt-8 border-t border-gray-200 text-center text-sm text-gray-500">
<p>Thank you for your business!</p>
{invoice.company_name && (
<p className="mt-1">{invoice.company_name}</p>
)}
</div>
</div>
</div>
</div>
);
};
export default InvoicePage;

View File

@@ -25,10 +25,12 @@ import {
import useAuthStore from '../../store/useAuthStore';
import Loading from '../../components/common/Loading';
import EmptyState from '../../components/common/EmptyState';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
const MyBookingsPage: React.FC = () => {
const navigate = useNavigate();
const { isAuthenticated } = useAuthStore();
const { formatCurrency } = useFormatCurrency();
const [bookings, setBookings] = useState<Booking[]>([]);
const [filteredBookings, setFilteredBookings] =
@@ -174,12 +176,7 @@ const MyBookingsPage: React.FC = () => {
});
};
const formatPrice = (price: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(price);
};
const formatPrice = (price: number) => formatCurrency(price);
const getStatusConfig = (status: string) => {
switch (status) {
@@ -223,11 +220,8 @@ const MyBookingsPage: React.FC = () => {
};
const canCancelBooking = (booking: Booking) => {
// Can only cancel pending or confirmed bookings
return (
booking.status === 'pending' ||
booking.status === 'confirmed'
);
// Only allow cancellation of pending bookings
return booking.status === 'pending';
};
if (loading) {
@@ -406,10 +400,14 @@ const MyBookingsPage: React.FC = () => {
lg:flex-row gap-6"
>
{/* Room Image */}
{roomType?.images?.[0] && (
{((room?.images && room.images.length > 0)
? room.images[0]
: roomType?.images?.[0]) && (
<div className="lg:w-48 flex-shrink-0">
<img
src={roomType.images[0]}
src={(room?.images && room.images.length > 0)
? room.images[0]
: (roomType?.images?.[0] || '')}
alt={roomType.name}
className="w-full h-48 lg:h-full
object-cover rounded-lg"
@@ -587,12 +585,15 @@ const MyBookingsPage: React.FC = () => {
{/* Cancel Booking */}
{canCancelBooking(booking) && (
<button
onClick={() =>
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCancelBooking(
booking.id,
booking.booking_number
)
}
);
}}
disabled={
cancellingId === booking.id
}
@@ -602,7 +603,7 @@ const MyBookingsPage: React.FC = () => {
rounded-lg hover:bg-red-700
transition-colors font-medium
text-sm disabled:bg-gray-400
disabled:cursor-not-allowed"
disabled:cursor-not-allowed cursor-pointer"
>
{cancellingId === booking.id ? (
<>

View File

@@ -27,11 +27,13 @@ import useAuthStore from '../../store/useAuthStore';
import Loading from '../../components/common/Loading';
import PaymentStatusBadge from
'../../components/common/PaymentStatusBadge';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
const PaymentConfirmationPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAuthenticated } = useAuthStore();
const { formatCurrency } = useFormatCurrency();
const [booking, setBooking] = useState<Booking | null>(
null
@@ -109,12 +111,7 @@ const PaymentConfirmationPage: React.FC = () => {
}
};
const formatPrice = (price: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(price);
};
const formatPrice = (price: number) => formatCurrency(price);
const copyBookingNumber = async () => {
if (!booking?.booking_number) return;

View File

@@ -3,39 +3,54 @@ import { useParams, useNavigate, Link } from 'react-router-dom';
import {
Users,
MapPin,
DollarSign,
ArrowLeft,
Star,
Calendar,
Shield,
Award,
Sparkles,
} from 'lucide-react';
import { getRoomById, type Room } from
import { getRoomByNumber, type Room } from
'../../services/api/roomService';
import RoomGallery from '../../components/rooms/RoomGallery';
import RoomAmenities from '../../components/rooms/RoomAmenities';
import ReviewSection from '../../components/rooms/ReviewSection';
import RatingStars from '../../components/rooms/RatingStars';
import CurrencyIcon from '../../components/common/CurrencyIcon';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
const RoomDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const { room_number } = useParams<{ room_number: string }>();
const navigate = useNavigate();
const { formatCurrency } = useFormatCurrency();
const [room, setRoom] = useState<Room | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (id) {
fetchRoomDetail(Number(id));
if (room_number) {
fetchRoomDetail(room_number);
}
}, [id]);
}, [room_number]);
const fetchRoomDetail = async (roomId: number) => {
const fetchRoomDetail = async (roomNumber: string) => {
try {
setLoading(true);
setError(null);
const response = await getRoomById(roomId);
const response = await getRoomByNumber(roomNumber);
// backend uses `status: 'success'` (not `success`), accept both
if ((response as any).success || (response as any).status === 'success') {
if (response.data && response.data.room) {
setRoom(response.data.room);
const fetchedRoom = response.data.room;
// Verify the room number matches what we requested
if (fetchedRoom.room_number !== roomNumber) {
console.error(`Room number mismatch: requested ${roomNumber}, got ${fetchedRoom.room_number}`);
throw new Error(`Room data mismatch: expected room number ${roomNumber} but got ${fetchedRoom.room_number}`);
}
setRoom(fetchedRoom);
} else {
throw new Error('Failed to fetch room details');
}
@@ -46,6 +61,7 @@ const RoomDetailPage: React.FC = () => {
console.error('Error fetching room:', err);
const message =
err.response?.data?.message ||
err.message ||
'Unable to load room information';
setError(message);
} finally {
@@ -55,13 +71,12 @@ const RoomDetailPage: React.FC = () => {
if (loading) {
return (
<div className="min-h-screen bg-gray-50">
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]">
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-96 bg-gray-300 rounded-lg" />
<div className="h-8 bg-gray-300 rounded w-1/3" />
<div className="h-4 bg-gray-300 rounded w-2/3" />
<div className="h-32 bg-gray-300 rounded" />
<div className="h-[600px] bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-xl border border-[#d4af37]/20" />
<div className="h-12 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg w-1/3 border border-[#d4af37]/10" />
<div className="h-6 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg w-2/3 border border-[#d4af37]/10" />
</div>
</div>
</div>
@@ -70,20 +85,24 @@ const RoomDetailPage: React.FC = () => {
if (error || !room) {
return (
<div className="min-h-screen bg-gray-50">
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]">
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="bg-red-50 border border-red-200
rounded-lg p-8 text-center"
<div className="bg-gradient-to-br from-red-900/20 to-red-800/10
border border-red-500/30 rounded-xl p-12 text-center
backdrop-blur-xl shadow-2xl shadow-red-500/10"
>
<p className="text-red-800 font-medium mb-4">
<p className="text-red-300 font-light text-lg mb-6 tracking-wide">
{error || 'Room not found'}
</p>
<button
onClick={() => navigate('/rooms')}
className="inline-flex items-center gap-2 bg-indigo-600
text-white px-3 py-2 rounded-md hover:bg-indigo-700
disabled:bg-gray-400 mb-6 transition-colors"
className="inline-flex items-center gap-2 bg-gradient-to-r
from-[#d4af37] to-[#c9a227] text-[#0f0f0f]
px-6 py-3 rounded-sm hover:from-[#f5d76e]
hover:to-[#d4af37] transition-all duration-300
font-medium tracking-wide shadow-lg shadow-[#d4af37]/30"
>
<ArrowLeft className="w-5 h-5" />
Back to Room List
</button>
</div>
@@ -93,117 +112,192 @@ const RoomDetailPage: React.FC = () => {
}
const roomType = room.room_type;
const formattedPrice = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(roomType?.base_price || 0);
const formattedPrice = formatCurrency(room?.price || roomType?.base_price || 0);
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Back Button */}
<Link
to="/rooms"
className="inline-flex items-center gap-2 bg-indigo-600
text-white px-3 py-2 rounded-md hover:bg-indigo-700
disabled:bg-gray-400 mb-6 transition-colors"
className="inline-flex items-center gap-2
text-[#d4af37]/80 hover:text-[#d4af37]
mb-8 transition-all duration-300
group font-light tracking-wide"
>
<ArrowLeft className="w-5 h-5" />
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
<span>Back to room list</span>
</Link>
{/* Image Gallery */}
<div className="mb-8">
<div className="mb-12">
<RoomGallery
images={roomType?.images || []}
images={(room.images && room.images.length > 0)
? room.images
: (roomType?.images || [])}
roomName={roomType?.name || 'Room'}
/>
</div>
{/* Room Information */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 mb-12">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 mb-16">
{/* Main Info */}
<div className="lg:col-span-8 space-y-6">
<div className="lg:col-span-8 space-y-10">
{/* Title & Basic Info */}
<div>
<h1 className="text-4xl font-bold
text-gray-900 mb-4"
<div className="space-y-6">
{/* Room Name with Luxury Badge */}
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-4">
{room.featured && (
<div className="flex items-center gap-2
bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] px-4 py-1.5 rounded-sm
text-xs font-medium tracking-wide
shadow-lg shadow-[#d4af37]/30"
>
<Sparkles className="w-3.5 h-3.5" />
Featured
</div>
)}
<div
className={`px-4 py-1.5 rounded-sm
text-xs font-medium tracking-wide
backdrop-blur-sm shadow-lg
${
room.status === 'available'
? 'bg-green-500/90 text-white border border-green-400/50'
: room.status === 'occupied'
? 'bg-red-500/90 text-white border border-red-400/50'
: 'bg-gray-500/90 text-white border border-gray-400/50'
}`}
>
{room.status === 'available'
? 'Available Now'
: room.status === 'occupied'
? 'Booked'
: 'Maintenance'}
</div>
</div>
<h1 className="text-5xl font-serif font-semibold
text-white mb-6 tracking-tight leading-tight
bg-gradient-to-r from-white via-[#d4af37] to-white
bg-clip-text text-transparent"
>
{roomType?.name}
</h1>
</div>
</div>
<div className="flex flex-wrap items-center
gap-6 text-gray-600 mb-4"
{/* Basic Info Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div className="flex items-center gap-3
p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
rounded-lg border border-[#d4af37]/20
hover:border-[#d4af37]/40 transition-all duration-300"
>
<div className="flex items-center gap-2">
<MapPin className="w-5 h-5" />
<span>
<div className="p-2 bg-[#d4af37]/10 rounded-lg
border border-[#d4af37]/30">
<MapPin className="w-5 h-5 text-[#d4af37]" />
</div>
<div>
<p className="text-xs text-gray-400 font-light tracking-wide mb-0.5">
Location
</p>
<p className="text-white font-light tracking-wide">
Room {room.room_number} - Floor {room.floor}
</span>
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Users className="w-5 h-5" />
<span>
{roomType?.capacity || 0} guests
</span>
<div className="flex items-center gap-3
p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
rounded-lg border border-[#d4af37]/20"
>
<div className="p-2 bg-[#d4af37]/10 rounded-lg
border border-[#d4af37]/30">
<Users className="w-5 h-5 text-[#d4af37]" />
</div>
<div>
<p className="text-xs text-gray-400 font-light tracking-wide mb-0.5">
Capacity
</p>
<p className="text-white font-light tracking-wide">
{room?.capacity || roomType?.capacity || 0} guests
</p>
</div>
</div>
{room.average_rating != null && (
<div className="flex items-center gap-3
p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
rounded-lg border border-[#d4af37]/20
hover:border-[#d4af37]/40 transition-all duration-300"
>
<div className="p-2 bg-[#d4af37]/10 rounded-lg
border border-[#d4af37]/30">
<Star className="w-5 h-5 text-[#d4af37] fill-[#d4af37]" />
</div>
<div>
<p className="text-xs text-gray-400 font-light tracking-wide mb-0.5">
Rating
</p>
<div className="flex items-center gap-2">
<RatingStars
rating={Number(room.average_rating)}
size="sm"
showNumber
/>
<span className="text-sm text-gray-500">
({room.total_reviews || 0} đánh giá)
<p className="text-white font-semibold">
{Number(room.average_rating).toFixed(1)}
</p>
<span className="text-xs text-gray-500 font-light">
({room.total_reviews || 0})
</span>
</div>
</div>
</div>
)}
</div>
{/* Status Badge */}
<div
className={`inline-block px-4 py-2
rounded-full text-sm font-semibold
${
room.status === 'available'
? 'bg-green-100 text-green-800'
: room.status === 'occupied'
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{room.status === 'available'
? 'Available'
: room.status === 'occupied'
? 'Booked'
: 'Maintenance'}
</div>
</div>
{/* Description */}
{roomType?.description && (
<div>
<h2 className="text-2xl font-bold
text-gray-900 mb-4"
{/* Description - Show room-specific description first, fallback to room type */}
{(room?.description || roomType?.description) && (
<div className="p-8 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
rounded-xl border border-[#d4af37]/20
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/5"
>
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-[#d4af37]/10 rounded-lg
border border-[#d4af37]/30">
<Award className="w-5 h-5 text-[#d4af37]" />
</div>
<h2 className="text-2xl font-serif font-semibold
text-white tracking-wide"
>
Room Description
{room?.description ? 'Room Description' : 'Room Type Description'}
</h2>
<p className="text-gray-700 leading-relaxed">
{roomType.description}
</div>
<p className="text-gray-300 leading-relaxed
font-light tracking-wide text-lg"
>
{room?.description || roomType?.description}
</p>
</div>
)}
{/* Amenities */}
<div>
<h2 className="text-2xl font-bold
text-gray-900 mb-4"
<div className="p-8 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
rounded-xl border border-[#d4af37]/20
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/5"
>
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-[#d4af37]/10 rounded-lg
border border-[#d4af37]/30">
<Sparkles className="w-5 h-5 text-[#d4af37]" />
</div>
<h2 className="text-2xl font-serif font-semibold
text-white tracking-wide"
>
Amenities
Amenities & Features
</h2>
</div>
<RoomAmenities
amenities={
(room.amenities && room.amenities.length > 0)
@@ -216,61 +310,107 @@ const RoomDetailPage: React.FC = () => {
{/* Booking Card */}
<aside className="lg:col-span-4">
<div className="bg-white rounded-xl shadow-md p-6 sticky top-6">
<div className="flex items-baseline gap-3 mb-4">
<DollarSign className="w-5 h-5 text-gray-600" />
<div className="bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a]
rounded-xl border border-[#d4af37]/30
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/20
p-8 sticky top-6"
>
{/* Price Section */}
<div className="mb-8 pb-8 border-b border-[#d4af37]/20">
<p className="text-xs text-gray-400 font-light tracking-wide mb-2">
Starting from
</p>
<div className="flex items-baseline gap-3">
<CurrencyIcon className="text-[#d4af37]" size={24} />
<div>
<div className="text-3xl font-extrabold text-indigo-600">
<div className="text-4xl font-serif font-semibold
bg-gradient-to-r from-[#d4af37] to-[#f5d76e]
bg-clip-text text-transparent tracking-tight"
>
{formattedPrice}
</div>
<div className="text-sm text-gray-400 font-light tracking-wide mt-1">
/ night
</div>
</div>
<div className="text-sm text-gray-500">/ night</div>
</div>
</div>
<div className="mt-4">
{/* Booking Button */}
<div className="mb-6">
<Link
to={`/booking/${room.id}`}
className={`block w-full py-3 text-center font-semibold rounded-md transition-colors ${
className={`block w-full py-4 text-center
font-medium rounded-sm transition-all duration-300
tracking-wide relative overflow-hidden group
${
room.status === 'available'
? 'bg-indigo-600 text-white hover:bg-indigo-700'
: 'bg-gray-200 text-gray-500 cursor-not-allowed'
? 'bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] hover:from-[#f5d76e] hover:to-[#d4af37] shadow-lg shadow-[#d4af37]/30 hover:shadow-[#d4af37]/50'
: 'bg-gray-800 text-gray-500 cursor-not-allowed border border-gray-700'
}`}
onClick={(e) => {
if (room.status !== 'available') e.preventDefault();
}}
>
<span className="relative z-10 flex items-center justify-center gap-2">
<Calendar className="w-5 h-5" />
{room.status === 'available' ? 'Book Now' : 'Not Available'}
</span>
{room.status === 'available' && (
<span className="absolute inset-0 bg-gradient-to-r from-white/0 via-white/20 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700"></span>
)}
</Link>
</div>
{room.status === 'available' && (
<p className="text-sm text-gray-500 text-center mt-3">
No immediate charge pay at the hotel
<div className="flex items-start gap-3 p-4 bg-[#d4af37]/5
rounded-lg border border-[#d4af37]/20 mb-6"
>
<Shield className="w-5 h-5 text-[#d4af37] mt-0.5 flex-shrink-0" />
<p className="text-sm text-gray-300 font-light tracking-wide">
No immediate charge secure your booking now and pay at the hotel
</p>
</div>
)}
<hr className="my-4" />
<div className="text-sm text-gray-700 space-y-2">
<div className="flex items-center justify-between">
<span>Room Type</span>
<strong>{roomType?.name}</strong>
{/* Room Details */}
<div className="space-y-4">
<div className="flex items-center justify-between
py-3 border-b border-[#d4af37]/10"
>
<span className="text-gray-400 font-light tracking-wide">Room Type</span>
<strong className="text-white font-light">{roomType?.name}</strong>
</div>
<div className="flex items-center justify-between">
<span>Guests</span>
<span>{roomType?.capacity} guests</span>
</div>
<div className="flex items-center justify-between">
<span>Rooms</span>
<span>1</span>
<div className="flex items-center justify-between
py-3 border-b border-[#d4af37]/10"
>
<span className="text-gray-400 font-light tracking-wide">Max Guests</span>
<span className="text-white font-light">{(room?.capacity || roomType?.capacity || 0)} guests</span>
</div>
{room?.room_size && (
<div className="flex items-center justify-between
py-3 border-b border-[#d4af37]/10"
>
<span className="text-gray-400 font-light tracking-wide">Room Size</span>
<span className="text-white font-light">{room.room_size}</span>
</div>
)}
{room?.view && (
<div className={`flex items-center justify-between ${room?.room_size ? 'py-3 border-b border-[#d4af37]/10' : 'py-3'}`}>
<span className="text-gray-400 font-light tracking-wide">View</span>
<span className="text-white font-light">{room.view}</span>
</div>
)}
</div>
</div>
</aside>
</div>
{/* Reviews Section */}
<div className="mb-12">
<div className="mb-12 p-8 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
rounded-xl border border-[#d4af37]/20
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/5"
>
<ReviewSection roomId={room.id} />
</div>
</div>

View File

@@ -6,7 +6,7 @@ import RoomFilter from '../../components/rooms/RoomFilter';
import RoomCard from '../../components/rooms/RoomCard';
import RoomCardSkeleton from '../../components/rooms/RoomCardSkeleton';
import Pagination from '../../components/rooms/Pagination';
import { ArrowLeft } from 'lucide-react';
import { ArrowLeft, Sparkles, Hotel } from 'lucide-react';
const RoomListPage: React.FC = () => {
const [searchParams] = useSearchParams();
@@ -66,35 +66,51 @@ const RoomListPage: React.FC = () => {
}, [searchParams]);
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-blue-50/30 to-gray-50">
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Back Button */}
<Link
to="/"
className="inline-flex items-center gap-2 btn-enterprise-secondary mb-8 animate-fade-in"
className="inline-flex items-center gap-2
text-[#d4af37]/80 hover:text-[#d4af37]
mb-10 transition-all duration-300
group font-light tracking-wide"
>
<ArrowLeft className="w-5 h-5" />
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
<span>Back to home</span>
</Link>
<div className="mb-10 text-center animate-fade-in">
<h1 className="enterprise-section-title">
Room List
{/* Page Header */}
<div className="mb-12 text-center">
<div className="inline-flex items-center justify-center gap-3 mb-6">
<div className="p-3 bg-[#d4af37]/10 rounded-xl border border-[#d4af37]/30">
<Hotel className="w-8 h-8 text-[#d4af37]" />
</div>
</div>
<h1 className="text-5xl font-serif font-semibold
text-white mb-4 tracking-tight leading-tight
bg-gradient-to-r from-white via-[#d4af37] to-white
bg-clip-text text-transparent"
>
Our Rooms & Suites
</h1>
<p className="enterprise-section-subtitle mt-2">
Browse our available accommodations
<p className="text-gray-400 font-light tracking-wide text-lg max-w-2xl mx-auto">
Discover our collection of luxurious accommodations,
each designed to provide an exceptional stay
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-10">
<aside className="lg:col-span-1">
<div className="sticky top-6">
<RoomFilter />
</div>
</aside>
<main className="lg:col-span-3">
{loading && (
<div className="grid grid-cols-1 md:grid-cols-2
xl:grid-cols-3 gap-6"
xl:grid-cols-2 gap-8"
>
{Array.from({ length: 6 }).map((_, index) => (
<RoomCardSkeleton key={index} />
@@ -103,13 +119,15 @@ const RoomListPage: React.FC = () => {
)}
{error && !loading && (
<div className="enterprise-card p-8 text-center animate-fade-in
border-red-200 bg-gradient-to-br from-red-50 to-red-100/50"
<div className="bg-gradient-to-br from-red-900/20 to-red-800/10
border border-red-500/30 rounded-xl p-12 text-center
backdrop-blur-xl shadow-2xl shadow-red-500/10"
>
<div className="inline-flex items-center justify-center w-16 h-16
bg-red-100 rounded-full mb-4">
<div className="inline-flex items-center justify-center w-20 h-20
bg-red-500/20 rounded-full mb-6 border border-red-500/30"
>
<svg
className="w-8 h-8 text-red-600"
className="w-10 h-10 text-red-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -123,14 +141,13 @@ const RoomListPage: React.FC = () => {
/>
</svg>
</div>
<p className="text-red-800 font-semibold text-lg mb-2">{error}</p>
<p className="text-red-300 font-light text-lg mb-6 tracking-wide">{error}</p>
<button
onClick={() => window.location.reload()}
className="mt-4 px-6 py-2.5 bg-gradient-to-r from-red-600 to-red-700
text-white rounded-lg font-semibold
hover:from-red-700 hover:to-red-800
transition-all duration-300 shadow-lg shadow-red-500/30
hover:shadow-xl hover:shadow-red-500/40 hover:-translate-y-0.5"
className="px-8 py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] rounded-sm font-medium tracking-wide
hover:from-[#f5d76e] hover:to-[#d4af37]
transition-all duration-300 shadow-lg shadow-[#d4af37]/30"
>
Try Again
</button>
@@ -138,37 +155,29 @@ const RoomListPage: React.FC = () => {
)}
{!loading && !error && rooms.length === 0 && (
<div className="enterprise-card p-12 text-center animate-fade-in"
<div className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
border border-[#d4af37]/20 rounded-xl p-16 text-center
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/5"
>
<div className="inline-flex items-center justify-center w-24 h-24
bg-gradient-to-br from-gray-100 to-gray-200 rounded-2xl mb-6">
<svg
className="w-12 h-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14
0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1
4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
bg-[#d4af37]/10 rounded-2xl mb-8 border border-[#d4af37]/30"
>
<Hotel className="w-12 h-12 text-[#d4af37]" />
</div>
<h3 className="text-xl font-bold
text-gray-900 mb-2"
<h3 className="text-2xl font-serif font-semibold
text-white mb-4 tracking-wide"
>
No matching rooms found
</h3>
<p className="text-gray-600 mb-6">
<p className="text-gray-400 font-light tracking-wide mb-8 text-lg">
Please try adjusting the filters or search differently
</p>
<button
onClick={() => window.location.href = '/rooms'}
className="btn-enterprise-primary"
className="px-8 py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] rounded-sm font-medium tracking-wide
hover:from-[#f5d76e] hover:to-[#d4af37]
transition-all duration-300 shadow-lg shadow-[#d4af37]/30"
>
Clear Filters
</button>
@@ -177,18 +186,30 @@ const RoomListPage: React.FC = () => {
{!loading && !error && rooms.length > 0 && (
<>
{/* Results Count */}
<div className="mb-6 flex items-center justify-between">
<p className="text-gray-400 font-light tracking-wide">
Showing <span className="text-[#d4af37] font-medium">{rooms.length}</span> of{' '}
<span className="text-[#d4af37] font-medium">{pagination.total}</span> rooms
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2
xl:grid-cols-2 gap-6"
xl:grid-cols-2 gap-8 mb-10"
>
{rooms.map((room) => (
<RoomCard key={room.id} room={room} />
))}
</div>
{pagination.totalPages > 1 && (
<div className="mt-10 pt-8 border-t border-[#d4af37]/20">
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
/>
</div>
)}
</>
)}
</main>

View File

@@ -28,6 +28,7 @@ export interface AuthResponse {
email: string;
phone?: string;
avatar?: string;
currency?: string;
role: string;
createdAt?: string;
};
@@ -143,6 +144,7 @@ const authService = {
phone_number?: string;
password?: string;
currentPassword?: string;
currency?: string;
}
): Promise<AuthResponse> => {
const response = await apiClient.put<AuthResponse>(

View File

@@ -7,7 +7,7 @@ export interface BookingData {
check_out_date: string; // YYYY-MM-DD
guest_count: number;
notes?: string;
payment_method: 'cash' | 'bank_transfer';
payment_method: 'cash' | 'stripe';
total_price: number;
guest_info: {
full_name: string;
@@ -31,7 +31,7 @@ export interface Booking {
| 'cancelled'
| 'checked_in'
| 'checked_out';
payment_method: 'cash' | 'bank_transfer';
payment_method: 'cash' | 'stripe';
payment_status:
| 'unpaid'
| 'paid'

View File

@@ -20,6 +20,9 @@ export type * from './bookingService';
export { default as paymentService } from './paymentService';
export type * from './paymentService';
export { default as invoiceService } from './invoiceService';
export type * from './invoiceService';
export { default as userService } from './userService';
export type * from './userService';

View File

@@ -0,0 +1,175 @@
import apiClient from './apiClient';
export interface InvoiceItem {
id: number;
description: string;
quantity: number;
unit_price: number;
tax_rate: number;
discount_amount: number;
line_total: number;
room_id?: number;
service_id?: number;
}
export interface Invoice {
id: number;
invoice_number: string;
booking_id: number;
user_id: number;
issue_date: string;
due_date: string;
paid_date?: string;
subtotal: number;
tax_rate: number;
tax_amount: number;
discount_amount: number;
total_amount: number;
amount_paid: number;
balance_due: number;
status: 'draft' | 'sent' | 'paid' | 'overdue' | 'cancelled';
company_name?: string;
company_address?: string;
company_phone?: string;
company_email?: string;
company_tax_id?: string;
company_logo_url?: string;
customer_name: string;
customer_email: string;
customer_address?: string;
customer_phone?: string;
customer_tax_id?: string;
notes?: string;
terms_and_conditions?: string;
payment_instructions?: string;
items: InvoiceItem[];
created_at: string;
updated_at: string;
}
export interface InvoiceResponse {
status: string;
message?: string;
data: {
invoice?: Invoice;
invoices?: Invoice[];
total?: number;
page?: number;
limit?: number;
total_pages?: number;
};
}
export interface CreateInvoiceData {
booking_id: number;
tax_rate?: number;
discount_amount?: number;
due_days?: number;
company_name?: string;
company_address?: string;
company_phone?: string;
company_email?: string;
company_tax_id?: string;
company_logo_url?: string;
customer_tax_id?: string;
notes?: string;
terms_and_conditions?: string;
payment_instructions?: string;
}
export interface UpdateInvoiceData {
company_name?: string;
company_address?: string;
company_phone?: string;
company_email?: string;
company_tax_id?: string;
company_logo_url?: string;
notes?: string;
terms_and_conditions?: string;
payment_instructions?: string;
status?: string;
due_date?: string;
tax_rate?: number;
discount_amount?: number;
}
/**
* Get all invoices
* GET /api/invoices
*/
export const getInvoices = async (params?: {
booking_id?: number;
status?: string;
page?: number;
limit?: number;
}): Promise<InvoiceResponse> => {
const response = await apiClient.get<InvoiceResponse>('/invoices', { params });
return response.data;
};
/**
* Get invoice by ID
* GET /api/invoices/:id
*/
export const getInvoiceById = async (id: number): Promise<InvoiceResponse> => {
const response = await apiClient.get<InvoiceResponse>(`/invoices/${id}`);
return response.data;
};
/**
* Get invoices by booking ID
* GET /api/invoices/booking/:bookingId
*/
export const getInvoicesByBooking = async (bookingId: number): Promise<InvoiceResponse> => {
const response = await apiClient.get<InvoiceResponse>(`/invoices/booking/${bookingId}`);
return response.data;
};
/**
* Create invoice from booking
* POST /api/invoices
*/
export const createInvoice = async (data: CreateInvoiceData): Promise<InvoiceResponse> => {
const response = await apiClient.post<InvoiceResponse>('/invoices', data);
return response.data;
};
/**
* Update invoice
* PUT /api/invoices/:id
*/
export const updateInvoice = async (id: number, data: UpdateInvoiceData): Promise<InvoiceResponse> => {
const response = await apiClient.put<InvoiceResponse>(`/invoices/${id}`, data);
return response.data;
};
/**
* Mark invoice as paid
* POST /api/invoices/:id/mark-paid
*/
export const markInvoiceAsPaid = async (id: number, amount?: number): Promise<InvoiceResponse> => {
const response = await apiClient.post<InvoiceResponse>(`/invoices/${id}/mark-paid`, { amount });
return response.data;
};
/**
* Delete invoice
* DELETE /api/invoices/:id
*/
export const deleteInvoice = async (id: number): Promise<{ status: string; message: string }> => {
const response = await apiClient.delete<{ status: string; message: string }>(`/invoices/${id}`);
return response.data;
};
const invoiceService = {
getInvoices,
getInvoiceById,
getInvoicesByBooking,
createInvoice,
updateInvoice,
markInvoiceAsPaid,
deleteInvoice,
};
export default invoiceService;

View File

@@ -4,7 +4,7 @@ import apiClient from './apiClient';
export interface PaymentData {
booking_id: number;
amount: number;
payment_method: 'cash' | 'bank_transfer';
payment_method: 'cash' | 'bank_transfer' | 'stripe';
transaction_id?: string;
notes?: string;
}
@@ -13,7 +13,7 @@ export interface Payment {
id: number;
booking_id: number;
amount: number;
payment_method: 'cash' | 'bank_transfer' | 'credit_card' | 'debit_card' | 'e_wallet';
payment_method: 'cash' | 'bank_transfer' | 'credit_card' | 'debit_card' | 'e_wallet' | 'stripe';
payment_type: 'full' | 'deposit' | 'remaining';
deposit_percentage?: number;
payment_status: 'pending' | 'completed' | 'failed' | 'refunded';
@@ -159,6 +159,38 @@ export const notifyPaymentCompletion = async (
return response.data;
};
/**
* Get all payments (with optional filters)
* GET /api/payments
*/
export const getPayments = async (params?: {
booking_id?: number;
status?: string;
page?: number;
limit?: number;
}): Promise<{
success: boolean;
data: {
payments: Payment[];
pagination?: {
total: number;
page: number;
limit: number;
totalPages: number;
};
};
message?: string;
}> => {
const response = await apiClient.get('/payments', { params });
// Map backend response format (status: "success") to frontend format (success: true)
const data = response.data;
return {
success: data.status === "success" || data.success === true,
data: data.data || { payments: [] },
message: data.message,
};
};
/**
* Get payments for a booking
* GET /api/payments/booking/:bookingId
@@ -173,16 +205,94 @@ export const getPaymentsByBookingId = async (
const response = await apiClient.get(
`/payments/booking/${bookingId}`
);
return response.data;
// Map backend response format (status: "success") to frontend format (success: true)
const data = response.data;
return {
success: data.status === "success" || data.success === true,
data: data.data || { payments: [] },
message: data.message,
};
};
/**
* Create Stripe payment intent
* POST /api/payments/stripe/create-intent
*/
export const createStripePaymentIntent = async (
bookingId: number,
amount: number,
currency: string = 'usd'
): Promise<{
success: boolean;
data: {
client_secret: string;
payment_intent_id: string;
publishable_key: string;
};
message?: string;
}> => {
const response = await apiClient.post(
'/payments/stripe/create-intent',
{
booking_id: bookingId,
amount,
currency,
}
);
// Map backend response format (status: "success") to frontend format (success: true)
const data = response.data;
return {
success: data.status === "success" || data.success === true,
data: data.data || {},
message: data.message,
};
};
/**
* Confirm Stripe payment
* POST /api/payments/stripe/confirm
*/
export const confirmStripePayment = async (
paymentIntentId: string,
bookingId: number
): Promise<{
success: boolean;
data: {
payment: Payment;
booking: {
id: number;
booking_number: string;
status: string;
};
};
message?: string;
}> => {
const response = await apiClient.post(
'/payments/stripe/confirm',
{
payment_intent_id: paymentIntentId,
booking_id: bookingId,
}
);
// Map backend response format (status: "success") to frontend format (success: true)
const data = response.data;
return {
success: data.status === "success" || data.success === true,
data: data.data || {},
message: data.message,
};
};
export default {
createPayment,
getPayments,
getPaymentByBookingId,
confirmBankTransfer,
getBankTransferInfo,
confirmDepositPayment,
notifyPaymentCompletion,
getPaymentsByBookingId,
createStripePaymentIntent,
confirmStripePayment,
};

View File

@@ -11,6 +11,11 @@ export interface Room {
floor: number;
status: 'available' | 'occupied' | 'maintenance';
featured: boolean;
price?: number;
description?: string;
capacity?: number;
room_size?: string;
view?: string;
images?: string[];
amenities?: string[];
created_at: string;
@@ -86,12 +91,22 @@ export const getRooms = async (
};
/**
* Get room by ID
* Get room by ID (deprecated - use getRoomByNumber instead)
*/
export const getRoomById = async (
id: number
): Promise<{ success: boolean; data: { room: Room } }> => {
const response = await apiClient.get(`/rooms/${id}`);
const response = await apiClient.get(`/rooms/id/${id}`);
return response.data;
};
/**
* Get room by room number
*/
export const getRoomByNumber = async (
room_number: string
): Promise<{ success: boolean; data: { room: Room } }> => {
const response = await apiClient.get(`/rooms/${room_number}`);
return response.data;
};
@@ -137,6 +152,12 @@ export interface CreateRoomData {
room_type_id: number;
status: 'available' | 'occupied' | 'maintenance';
featured?: boolean;
price?: number;
description?: string;
capacity?: number;
room_size?: string;
view?: string;
amenities?: string[];
}
export const createRoom = async (
@@ -167,13 +188,25 @@ export const deleteRoom = async (
return response.data;
};
/**
* Bulk delete rooms
*/
export const bulkDeleteRooms = async (
ids: number[]
): Promise<{ success: boolean; message: string; data: { deleted_count: number; deleted_ids: number[] } }> => {
const response = await apiClient.post('/rooms/bulk-delete', { ids });
return response.data;
};
export default {
getFeaturedRooms,
getRooms,
getRoomById,
getRoomByNumber,
searchAvailableRooms,
getAmenities,
createRoom,
updateRoom,
deleteRoom,
bulkDeleteRooms,
};

View File

@@ -0,0 +1,88 @@
import apiClient from './apiClient';
export interface PlatformCurrencyResponse {
status: string;
data: {
currency: string;
updated_at: string | null;
updated_by: string | null;
};
}
export interface UpdateCurrencyRequest {
currency: string;
}
export interface StripeSettingsResponse {
status: string;
data: {
stripe_secret_key: string;
stripe_publishable_key: string;
stripe_webhook_secret: string;
stripe_secret_key_masked: string;
stripe_webhook_secret_masked: string;
has_secret_key: boolean;
has_publishable_key: boolean;
has_webhook_secret: boolean;
updated_at?: string | null;
updated_by?: string | null;
};
message?: string;
}
export interface UpdateStripeSettingsRequest {
stripe_secret_key?: string;
stripe_publishable_key?: string;
stripe_webhook_secret?: string;
}
const systemSettingsService = {
/**
* Get platform currency (public endpoint)
*/
getPlatformCurrency: async (): Promise<PlatformCurrencyResponse> => {
const response = await apiClient.get<PlatformCurrencyResponse>(
'/api/admin/system-settings/currency'
);
return response.data;
},
/**
* Update platform currency (Admin only)
*/
updatePlatformCurrency: async (
currency: string
): Promise<PlatformCurrencyResponse> => {
const response = await apiClient.put<PlatformCurrencyResponse>(
'/api/admin/system-settings/currency',
{ currency }
);
return response.data;
},
/**
* Get Stripe settings (Admin only)
*/
getStripeSettings: async (): Promise<StripeSettingsResponse> => {
const response = await apiClient.get<StripeSettingsResponse>(
'/api/admin/system-settings/stripe'
);
return response.data;
},
/**
* Update Stripe settings (Admin only)
*/
updateStripeSettings: async (
settings: UpdateStripeSettingsRequest
): Promise<StripeSettingsResponse> => {
const response = await apiClient.put<StripeSettingsResponse>(
'/api/admin/system-settings/stripe',
settings
);
return response.data;
},
};
export default systemSettingsService;

View File

@@ -1,6 +1,6 @@
/* React DatePicker Custom Styles */
/* React DatePicker Custom Styles - Luxury Theme */
/* Override default datepicker styles to match Tailwind theme */
/* Override default datepicker styles to match luxury hotel theme */
.react-datepicker-wrapper {
width: 100%;
}
@@ -11,55 +11,71 @@
.react-datepicker {
font-family: inherit;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
border: 2px solid rgba(212, 175, 55, 0.3);
border-radius: 0.75rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 10px 10px -5px rgba(212, 175, 55, 0.1);
background: linear-gradient(to bottom, #1a1a1a, #0f0f0f);
color: #ffffff;
}
.react-datepicker__header {
background-color: #4f46e5;
border-bottom: none;
border-radius: 0.5rem 0.5rem 0 0;
background: linear-gradient(135deg, rgba(212, 175, 55, 0.2), rgba(201, 162, 39, 0.15));
border-bottom: 2px solid rgba(212, 175, 55, 0.3);
border-radius: 0.75rem 0.75rem 0 0;
padding-top: 0.75rem;
}
.react-datepicker__current-month,
.react-datepicker__day-name {
color: #ffffff;
color: #d4af37;
font-weight: 600;
font-size: 0.9rem;
}
.react-datepicker__day {
color: #374151;
color: #e5e5e5;
border-radius: 0.375rem;
transition: all 0.2s;
font-weight: 400;
}
.react-datepicker__day:hover {
background-color: #e0e7ff;
color: #4f46e5;
background-color: rgba(212, 175, 55, 0.2);
color: #d4af37;
border: 1px solid rgba(212, 175, 55, 0.4);
}
.react-datepicker__day--selected,
.react-datepicker__day--keyboard-selected {
background-color: #4f46e5;
color: #ffffff;
background: linear-gradient(135deg, #d4af37, #c9a227);
color: #0f0f0f;
font-weight: 600;
border: 1px solid rgba(212, 175, 55, 0.5);
}
.react-datepicker__day--in-range,
.react-datepicker__day--in-selecting-range {
background-color: #e0e7ff;
color: #4f46e5;
background-color: rgba(212, 175, 55, 0.15);
color: #d4af37;
border: 1px solid rgba(212, 175, 55, 0.3);
}
.react-datepicker__day--range-start,
.react-datepicker__day--range-end {
background: linear-gradient(135deg, #d4af37, #c9a227);
color: #0f0f0f;
font-weight: 600;
}
.react-datepicker__day--disabled {
color: #d1d5db;
color: #525252;
cursor: not-allowed;
opacity: 0.4;
}
.react-datepicker__day--disabled:hover {
background-color: transparent;
border: none;
}
.react-datepicker__navigation {
@@ -67,28 +83,30 @@
}
.react-datepicker__navigation--previous {
border-right-color: #ffffff;
border-right-color: #d4af37;
}
.react-datepicker__navigation--next {
border-left-color: #ffffff;
border-left-color: #d4af37;
}
.react-datepicker__navigation:hover
*::before {
border-color: #e0e7ff;
.react-datepicker__navigation:hover *::before {
border-color: #f5d76e;
}
.react-datepicker__month {
margin: 0.75rem;
background: transparent;
}
.react-datepicker__day--today {
font-weight: 600;
background-color: #fef3c7;
color: #92400e;
background-color: rgba(212, 175, 55, 0.1);
color: #d4af37;
border: 1px solid rgba(212, 175, 55, 0.4);
}
.react-datepicker__day--today:hover {
background-color: #fde68a;
background-color: rgba(212, 175, 55, 0.25);
border-color: rgba(212, 175, 55, 0.6);
}

View File

@@ -66,6 +66,31 @@
background: linear-gradient(180deg, var(--luxury-gold-light) 0%, var(--luxury-gold) 100%);
}
/* Custom scrollbar for specific elements */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: #f59e0b #1e293b;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(30, 41, 59, 0.5);
border-radius: 8px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #f59e0b 0%, #d97706 100%);
border-radius: 8px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%);
}
/* Base styles - Luxury Hotel Typography */
body {
margin: 0;
@@ -228,6 +253,94 @@ code {
@apply border border-[#d4af37]/20;
@apply shadow-2xl;
}
/* Enterprise Card - Luxury & Professional */
.enterprise-card {
@apply bg-white rounded-lg shadow-xl border border-gray-200/60;
@apply relative overflow-hidden;
@apply transition-all duration-300 ease-out;
@apply hover:shadow-2xl hover:shadow-[#d4af37]/10;
@apply hover:-translate-y-0.5;
@apply backdrop-blur-sm;
}
.enterprise-card::before {
content: '';
@apply absolute top-0 left-0 right-0 h-0.5;
background: linear-gradient(90deg, transparent 0%, #d4af37 50%, transparent 100%);
opacity: 0;
transition: opacity 0.3s ease-out;
}
.enterprise-card:hover::before {
opacity: 1;
}
/* Enterprise Section Title */
.enterprise-section-title {
@apply text-3xl md:text-4xl font-serif font-bold;
@apply text-gray-900 tracking-tight;
@apply bg-gradient-to-r from-gray-900 via-gray-800 to-gray-900;
@apply bg-clip-text text-transparent;
}
/* Enterprise Section Subtitle */
.enterprise-section-subtitle {
@apply text-gray-600 text-base font-light;
@apply tracking-wide leading-relaxed;
}
/* Enterprise Button Primary */
.btn-enterprise-primary {
@apply px-8 py-3.5 rounded-lg font-semibold tracking-wide text-sm;
@apply bg-gradient-to-r from-[#d4af37] via-[#e8c547] to-[#d4af37];
@apply text-[#0f0f0f] shadow-lg shadow-[#d4af37]/30;
@apply transition-all duration-300 ease-out;
@apply hover:from-[#f5d76e] hover:via-[#d4af37] hover:to-[#f5d76e];
@apply hover:shadow-xl hover:shadow-[#d4af37]/40;
@apply hover:-translate-y-0.5 hover:scale-[1.02];
@apply active:translate-y-0 active:scale-100;
@apply disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0 disabled:hover:scale-100;
@apply relative overflow-hidden;
}
.btn-enterprise-primary::before {
content: '';
@apply absolute inset-0 bg-gradient-to-r from-white/0 via-white/30 to-white/0;
@apply translate-x-[-100%] transition-transform duration-700;
}
.btn-enterprise-primary:hover::before {
@apply translate-x-[100%];
}
/* Enterprise Button Secondary */
.btn-enterprise-secondary {
@apply px-5 py-2.5 rounded-lg font-medium tracking-wide text-xs;
@apply bg-white border-2 border-gray-300 text-gray-700;
@apply shadow-md shadow-gray-200/50;
@apply transition-all duration-300 ease-out;
@apply hover:bg-gray-50 hover:border-[#d4af37] hover:text-[#c9a227];
@apply hover:shadow-lg hover:shadow-[#d4af37]/20;
@apply hover:-translate-y-0.5;
@apply active:translate-y-0;
@apply disabled:opacity-50 disabled:cursor-not-allowed;
}
/* Enterprise Input */
.enterprise-input {
@apply w-full px-4 py-3 rounded-lg border-2 border-gray-200;
@apply focus:ring-2 focus:ring-[#d4af37]/30 focus:border-[#d4af37];
@apply transition-all duration-200;
@apply bg-white text-gray-900;
@apply placeholder:text-gray-400;
@apply font-light tracking-wide;
@apply shadow-sm;
}
.enterprise-input:focus {
@apply shadow-md shadow-[#d4af37]/10;
}
}
/* Custom utilities */

View File

@@ -25,6 +25,7 @@ export const STORAGE_KEYS = {
GUEST_FAVORITES: 'guestFavorites',
THEME: 'theme',
LANGUAGE: 'language',
CURRENCY: 'currency',
} as const;
export const ROUTES = {

View File

@@ -2,18 +2,86 @@
* Utility functions for formatting data
*/
// Cache for currency to avoid repeated localStorage reads
let cachedCurrency: string | null = null;
// Listen for currency changes
if (typeof window !== 'undefined') {
window.addEventListener('currencyChanged', ((e: CustomEvent) => {
cachedCurrency = e.detail?.currency || null;
}) as EventListener);
// Also listen to storage events (in case currency is changed in another tab)
window.addEventListener('storage', (e) => {
if (e.key === 'currency') {
cachedCurrency = e.newValue;
window.dispatchEvent(new CustomEvent('currencyChanged', {
detail: { currency: e.newValue }
}));
}
});
}
/**
* Get currency symbol dynamically
*/
export const getCurrencySymbol = (currencyCode: string): string => {
try {
// Use Intl.NumberFormat to get the currency symbol
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
// Format 0 to get the symbol
const parts = formatter.formatToParts(0);
const symbolPart = parts.find(part => part.type === 'currency');
return symbolPart?.value || currencyCode;
} catch {
return currencyCode;
}
};
/**
* Format currency
* If currency is not provided, it will try to use the currency from localStorage (set by CurrencyContext)
* For components that have access to CurrencyContext, use the useFormatCurrency hook instead
*/
export const formatCurrency = (
amount: number | string,
currency: string = 'VND'
currency?: string
): string => {
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount;
if (isNaN(numAmount)) return '0 ₫';
if (isNaN(numAmount)) {
// Return default based on currency
if (currency === 'VND') return '0 ₫';
return `0 ${currency || 'VND'}`;
}
if (currency === 'VND') {
// Use provided currency or try to get from localStorage (set by CurrencyContext)
let currencyToUse = currency;
if (!currencyToUse && typeof window !== 'undefined') {
try {
// Use cached currency if available, otherwise read from localStorage
if (cachedCurrency) {
currencyToUse = cachedCurrency;
} else {
const storedCurrency = localStorage.getItem('currency');
if (storedCurrency) {
cachedCurrency = storedCurrency;
currencyToUse = storedCurrency;
}
}
} catch (e) {
// localStorage not available, use default
}
}
currencyToUse = currencyToUse || 'VND';
if (currencyToUse === 'VND') {
return new Intl.NumberFormat('vi-VN', {
style: 'currency',
currency: 'VND',
@@ -22,9 +90,27 @@ export const formatCurrency = (
}).format(numAmount);
}
return new Intl.NumberFormat('en-US', {
// For other currencies, use appropriate locale
const localeMap: Record<string, string> = {
'USD': 'en-US',
'EUR': 'de-DE',
'GBP': 'en-GB',
'JPY': 'ja-JP',
'CNY': 'zh-CN',
'KRW': 'ko-KR',
'SGD': 'en-SG',
'THB': 'th-TH',
'AUD': 'en-AU',
'CAD': 'en-CA',
};
const locale = localeMap[currencyToUse] || 'en-US';
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency,
currency: currencyToUse,
minimumFractionDigits: currencyToUse === 'JPY' ? 0 : 2,
maximumFractionDigits: currencyToUse === 'JPY' ? 0 : 2,
}).format(numAmount);
};

View File

@@ -33,10 +33,10 @@ export const bookingValidationSchema = yup.object().shape({
.optional(),
paymentMethod: yup
.mixed<'cash' | 'bank_transfer'>()
.mixed<'cash' | 'stripe'>()
.required('Please select payment method')
.oneOf(
['cash', 'bank_transfer'],
['cash', 'stripe'],
'Invalid payment method'
),