updates
This commit is contained in:
41
Frontend/package-lock.json
generated
41
Frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
41
Frontend/src/components/common/CurrencyIcon.tsx
Normal file
41
Frontend/src/components/common/CurrencyIcon.tsx
Normal 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;
|
||||
|
||||
126
Frontend/src/components/common/CurrencySelector.tsx
Normal file
126
Frontend/src/components/common/CurrencySelector.tsx
Normal 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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
174
Frontend/src/components/payments/StripePaymentForm.tsx
Normal file
174
Frontend/src/components/payments/StripePaymentForm.tsx
Normal 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;
|
||||
|
||||
197
Frontend/src/components/payments/StripePaymentWrapper.tsx
Normal file
197
Frontend/src/components/payments/StripePaymentWrapper.tsx
Normal 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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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: 'Wi‑Fi',
|
||||
'wi-fi': 'Wi‑Fi',
|
||||
'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> = {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
102
Frontend/src/contexts/CurrencyContext.tsx
Normal file
102
Frontend/src/contexts/CurrencyContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
20
Frontend/src/hooks/useFormatCurrency.ts
Normal file
20
Frontend/src/hooks/useFormatCurrency.ts
Normal 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 };
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
187
Frontend/src/pages/admin/CurrencySettingsPage.tsx
Normal file
187
Frontend/src/pages/admin/CurrencySettingsPage.tsx
Normal 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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
306
Frontend/src/pages/admin/InvoiceManagementPage.tsx
Normal file
306
Frontend/src/pages/admin/InvoiceManagementPage.tsx
Normal 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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
366
Frontend/src/pages/admin/StripeSettingsPage.tsx
Normal file
366
Frontend/src/pages/admin/StripeSettingsPage.tsx
Normal 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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
437
Frontend/src/pages/customer/FullPaymentPage.tsx
Normal file
437
Frontend/src/pages/customer/FullPaymentPage.tsx
Normal 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;
|
||||
|
||||
330
Frontend/src/pages/customer/InvoicePage.tsx
Normal file
330
Frontend/src/pages/customer/InvoicePage.tsx
Normal 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;
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
175
Frontend/src/services/api/invoiceService.ts
Normal file
175
Frontend/src/services/api/invoiceService.ts
Normal 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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
88
Frontend/src/services/api/systemSettingsService.ts
Normal file
88
Frontend/src/services/api/systemSettingsService.ts
Normal 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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -25,6 +25,7 @@ export const STORAGE_KEYS = {
|
||||
GUEST_FAVORITES: 'guestFavorites',
|
||||
THEME: 'theme',
|
||||
LANGUAGE: 'language',
|
||||
CURRENCY: 'currency',
|
||||
} as const;
|
||||
|
||||
export const ROUTES = {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user