updates
This commit is contained in:
46
Frontend/package-lock.json
generated
46
Frontend/package-lock.json
generated
@@ -13,12 +13,14 @@
|
||||
"@stripe/react-stripe-js": "^2.9.0",
|
||||
"@stripe/stripe-js": "^2.4.0",
|
||||
"@types/react-datepicker": "^6.2.0",
|
||||
"@types/react-google-recaptcha": "^2.1.9",
|
||||
"axios": "^1.6.2",
|
||||
"date-fns": "^2.30.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"react": "^18.3.1",
|
||||
"react-datepicker": "^8.9.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-google-recaptcha": "^3.1.0",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"react-toastify": "^9.1.3",
|
||||
@@ -1610,6 +1612,15 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-google-recaptcha": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.9.tgz",
|
||||
"integrity": "sha512-nT31LrBDuoSZJN4QuwtQSF3O89FVHC4jLhM+NtKEmVF5R1e8OY0Jo4//x2Yapn2aNHguwgX5doAq8Zo+Ehd0ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/semver": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
|
||||
@@ -3179,6 +3190,15 @@
|
||||
"integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hoist-non-react-statics": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"react-is": "^16.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -4124,6 +4144,19 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-async-script": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz",
|
||||
"integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hoist-non-react-statics": "^3.3.0",
|
||||
"prop-types": "^15.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-datepicker": {
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-8.9.0.tgz",
|
||||
@@ -4187,6 +4220,19 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-google-recaptcha": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz",
|
||||
"integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.5.0",
|
||||
"react-async-script": "^1.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.65.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz",
|
||||
|
||||
@@ -11,19 +11,21 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.3.2",
|
||||
"@paypal/react-paypal-js": "^8.1.3",
|
||||
"@stripe/react-stripe-js": "^2.9.0",
|
||||
"@stripe/stripe-js": "^2.4.0",
|
||||
"@types/react-datepicker": "^6.2.0",
|
||||
"@types/react-google-recaptcha": "^2.1.9",
|
||||
"axios": "^1.6.2",
|
||||
"date-fns": "^2.30.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"react": "^18.3.1",
|
||||
"react-datepicker": "^8.9.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-google-recaptcha": "^3.1.0",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"react-toastify": "^9.1.3",
|
||||
"@paypal/react-paypal-js": "^8.1.3",
|
||||
"yup": "^1.3.3",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
|
||||
@@ -216,7 +216,7 @@ function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="deposit-payment/:bookingId"
|
||||
path="payment/deposit/:bookingId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DepositPaymentPage />
|
||||
|
||||
91
Frontend/src/components/common/Recaptcha.tsx
Normal file
91
Frontend/src/components/common/Recaptcha.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import ReCAPTCHA from 'react-google-recaptcha';
|
||||
import { recaptchaService } from '../../services/api/systemSettingsService';
|
||||
|
||||
interface RecaptchaProps {
|
||||
onChange?: (token: string | null) => void;
|
||||
onError?: (error: string) => void;
|
||||
theme?: 'light' | 'dark';
|
||||
size?: 'normal' | 'compact';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Recaptcha: React.FC<RecaptchaProps> = ({
|
||||
onChange,
|
||||
onError,
|
||||
theme = 'dark',
|
||||
size = 'normal',
|
||||
className = '',
|
||||
}) => {
|
||||
const recaptchaRef = useRef<ReCAPTCHA>(null);
|
||||
const [siteKey, setSiteKey] = useState<string>('');
|
||||
const [enabled, setEnabled] = useState<boolean>(false);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const response = await recaptchaService.getRecaptchaSettings();
|
||||
if (response.status === 'success' && response.data) {
|
||||
setSiteKey(response.data.recaptcha_site_key || '');
|
||||
setEnabled(response.data.recaptcha_enabled || false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching reCAPTCHA settings:', error);
|
||||
if (onError) {
|
||||
onError('Failed to load reCAPTCHA settings');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSettings();
|
||||
}, [onError]);
|
||||
|
||||
const handleChange = (token: string | null) => {
|
||||
if (onChange) {
|
||||
onChange(token);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExpired = () => {
|
||||
if (onChange) {
|
||||
onChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
if (onError) {
|
||||
onError('reCAPTCHA error occurred');
|
||||
}
|
||||
if (onChange) {
|
||||
onChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!enabled || !siteKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ReCAPTCHA
|
||||
ref={recaptchaRef}
|
||||
sitekey={siteKey}
|
||||
onChange={handleChange}
|
||||
onExpired={handleExpired}
|
||||
onError={handleError}
|
||||
theme={theme}
|
||||
size={size}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Recaptcha;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createPayPalOrder } from '../../services/api/paymentService';
|
||||
import { Loader2, AlertCircle } from 'lucide-react';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
|
||||
interface PayPalPaymentWrapperProps {
|
||||
bookingId: number;
|
||||
@@ -12,9 +13,12 @@ interface PayPalPaymentWrapperProps {
|
||||
const PayPalPaymentWrapper: React.FC<PayPalPaymentWrapperProps> = ({
|
||||
bookingId,
|
||||
amount,
|
||||
currency = 'USD',
|
||||
currency: propCurrency,
|
||||
onError,
|
||||
}) => {
|
||||
// Get currency from context if not provided as prop
|
||||
const { currency: contextCurrency } = useFormatCurrency();
|
||||
const currency = propCurrency || contextCurrency || 'USD';
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [approvalUrl, setApprovalUrl] = useState<string | null>(null);
|
||||
@@ -75,22 +79,29 @@ const PayPalPaymentWrapper: React.FC<PayPalPaymentWrapperProps> = ({
|
||||
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 PayPal payment...</span>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
|
||||
rounded-full flex items-center justify-center
|
||||
border border-[#d4af37]/30 shadow-lg shadow-[#d4af37]/20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[#d4af37]" />
|
||||
</div>
|
||||
<span className="ml-4 text-gray-300 font-light tracking-wide">
|
||||
Initializing PayPal 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 className="bg-gradient-to-br from-red-900/20 to-red-800/10
|
||||
border border-red-500/30 rounded-xl p-6 backdrop-blur-sm">
|
||||
<div className="flex items-start gap-4">
|
||||
<AlertCircle className="w-6 h-6 text-red-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-red-900 mb-1">
|
||||
<h3 className="text-lg font-serif font-semibold text-red-300 mb-2 tracking-wide">
|
||||
Payment Initialization Failed
|
||||
</h3>
|
||||
<p className="text-sm text-red-800">
|
||||
<p className="text-sm text-red-200/80 font-light tracking-wide">
|
||||
{error || 'Unable to initialize PayPal payment. Please try again.'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -102,57 +113,65 @@ const PayPalPaymentWrapper: React.FC<PayPalPaymentWrapperProps> = ({
|
||||
if (!approvalUrl) {
|
||||
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 PayPal...</span>
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
|
||||
rounded-full flex items-center justify-center
|
||||
border border-[#d4af37]/30 shadow-lg shadow-[#d4af37]/20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[#d4af37]" />
|
||||
</div>
|
||||
<span className="ml-4 text-gray-300 font-light tracking-wide">
|
||||
Loading PayPal...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="text-center">
|
||||
<div className="mb-4">
|
||||
<svg
|
||||
className="mx-auto h-12 w-auto"
|
||||
viewBox="0 0 283 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.7-9.2 12.2-9.2z"
|
||||
fill="#003087"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Complete Payment with PayPal
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
You will be redirected to PayPal to securely complete your payment of{' '}
|
||||
<span className="font-semibold">
|
||||
{new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
}).format(amount)}
|
||||
</span>
|
||||
</p>
|
||||
<button
|
||||
onClick={handlePayPalClick}
|
||||
className="w-full bg-[#0070ba] hover:bg-[#005ea6] text-white font-semibold py-3 px-6 rounded-lg transition-colors duration-200 flex items-center justify-center gap-2"
|
||||
<div className="text-center">
|
||||
<div className="mb-6">
|
||||
<svg
|
||||
className="mx-auto h-14 w-auto"
|
||||
viewBox="0 0 283 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M7.076 21.337H2.47a.641.641 0 0 1-.633-.74L4.944.901C5.026.382 5.474 0 5.998 0h7.46c2.57 0 4.578.543 5.69 1.81 1.01 1.15 1.304 2.42 1.012 4.287-.023.143-.047.288-.077.437-.983 5.05-4.349 6.797-8.647 6.797h-2.19c-.524 0-.968.382-1.05.9l-1.12 7.203zm14.146-14.42a.477.477 0 0 0-.414-.24h-3.84c-.48 0-.856.355-.932.826-.075.47-.232 1.21-.232 1.21s-.156-.74-.232-1.21a.957.957 0 0 0-.932-.826H5.342a.957.957 0 0 0-.932.826c-.076.47-.232 1.21-.232 1.21s-.156-.74-.232-1.21a.957.957 0 0 0-.932-.826H.477a.477.477 0 0 0-.414.24c-.11.19-.14.426-.08.643.06.217.2.4.388.51l.04.02c.19.11.426.14.643.08.217-.06.4-.2.51-.388l.01-.02c.11-.19.14-.426.08-.643a.955.955 0 0 0-.388-.51l-.01-.01a.955.955 0 0 0-.51-.388.955.955 0 0 0-.643.08l-.01.01a.955.955 0 0 0-.388.51c-.06.217-.03.453.08.643l.01.02c.11.188.293.328.51.388.217.06.453.03.643-.08l.01-.02c.188-.11.328-.293.388-.51.06-.217.03-.453-.08-.643l-.01-.01z"/>
|
||||
</svg>
|
||||
Pay with PayPal
|
||||
</button>
|
||||
<p className="text-xs text-gray-500 mt-4">
|
||||
Secure payment powered by PayPal
|
||||
</p>
|
||||
<path
|
||||
d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.7-9.2 12.2-9.2z"
|
||||
fill="#003087"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-serif font-semibold text-[#d4af37] mb-3 tracking-wide">
|
||||
Complete Payment with PayPal
|
||||
</h3>
|
||||
<p className="text-gray-300/80 font-light text-lg mb-8 tracking-wide">
|
||||
You will be redirected to PayPal to securely complete your payment of{' '}
|
||||
<span className="font-semibold text-[#d4af37]">
|
||||
{new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
}).format(amount)}
|
||||
</span>
|
||||
</p>
|
||||
<button
|
||||
onClick={handlePayPalClick}
|
||||
className="w-full bg-gradient-to-r from-[#0070ba] to-[#005ea6]
|
||||
hover:from-[#0080cc] hover:to-[#0070ba] text-white
|
||||
font-semibold py-4 px-8 rounded-sm transition-all duration-300
|
||||
flex items-center justify-center gap-3 shadow-lg shadow-blue-500/30
|
||||
hover:shadow-xl hover:shadow-blue-500/40 tracking-wide"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M7.076 21.337H2.47a.641.641 0 0 1-.633-.74L4.944.901C5.026.382 5.474 0 5.998 0h7.46c2.57 0 4.578.543 5.69 1.81 1.01 1.15 1.304 2.42 1.012 4.287-.023.143-.047.288-.077.437-.983 5.05-4.349 6.797-8.647 6.797h-2.19c-.524 0-.968.382-1.05.9l-1.12 7.203zm14.146-14.42a.477.477 0 0 0-.414-.24h-3.84c-.48 0-.856.355-.932.826-.075.47-.232 1.21-.232 1.21s-.156-.74-.232-1.21a.957.957 0 0 0-.932-.826H5.342a.957.957 0 0 0-.932.826c-.076.47-.232 1.21-.232 1.21s-.156-.74-.232-1.21a.957.957 0 0 0-.932-.826H.477a.477.477 0 0 0-.414.24c-.11.19-.14.426-.08.643.06.217.2.4.388.51l.04.02c.19.11.426.14.643.08.217-.06.4-.2.51-.388l.01-.02c.11-.19.14-.426.08-.643a.955.955 0 0 0-.388-.51l-.01-.01a.955.955 0 0 0-.51-.388.955.955 0 0 0-.643.08l-.01.01a.955.955 0 0 0-.388.51c-.06.217-.03.453.08.643l.01.02c.11.188.293.328.51.388.217.06.453.03.643-.08l.01-.02c.188-.11.328-.293.388-.51.06-.217.03-.453-.08-.643l-.01-.01z"/>
|
||||
</svg>
|
||||
Pay with PayPal
|
||||
</button>
|
||||
<p className="text-xs text-gray-400/70 mt-6 font-light tracking-wide">
|
||||
Secure payment powered by PayPal
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -71,15 +71,15 @@ const RatingStars: React.FC<RatingStarsProps> = ({
|
||||
<Star
|
||||
className={`${sizeClasses[size]} ${
|
||||
isFilled
|
||||
? 'text-yellow-500 fill-yellow-500'
|
||||
: 'text-gray-300'
|
||||
? 'text-[#d4af37] fill-[#d4af37]'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{showNumber && (
|
||||
<span className="ml-2 text-sm font-semibold text-gray-700">
|
||||
<span className="ml-2 text-xs sm:text-sm font-semibold text-white">
|
||||
{rating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
type Review,
|
||||
} from '../../services/api/reviewService';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import Recaptcha from '../common/Recaptcha';
|
||||
import { recaptchaService } from '../../services/api/systemSettingsService';
|
||||
|
||||
interface ReviewSectionProps {
|
||||
roomId: number;
|
||||
@@ -42,6 +44,7 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [averageRating, setAverageRating] = useState<number>(0);
|
||||
const [totalReviews, setTotalReviews] = useState<number>(0);
|
||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -87,6 +90,22 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify reCAPTCHA if enabled
|
||||
if (recaptchaToken) {
|
||||
try {
|
||||
const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
|
||||
if (verifyResponse.status === 'error' || !verifyResponse.data.verified) {
|
||||
toast.error('reCAPTCHA verification failed. Please try again.');
|
||||
setRecaptchaToken(null);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('reCAPTCHA verification failed. Please try again.');
|
||||
setRecaptchaToken(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const response = await createReview({
|
||||
@@ -101,12 +120,14 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
||||
);
|
||||
reset();
|
||||
fetchReviews();
|
||||
setRecaptchaToken(null);
|
||||
}
|
||||
} catch (error: any) {
|
||||
const message =
|
||||
error.response?.data?.message ||
|
||||
'Unable to submit review';
|
||||
toast.error(message);
|
||||
setRecaptchaToken(null);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -121,24 +142,26 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
{/* Rating Summary */}
|
||||
<div className="bg-gray-50 rounded-lg p-6">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
<div className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg border border-[#d4af37]/20 p-3 sm:p-4 backdrop-blur-xl shadow-lg shadow-[#d4af37]/5">
|
||||
<h3 className="text-sm sm:text-base font-serif font-semibold text-white mb-3 tracking-wide">
|
||||
Customer Reviews
|
||||
</h3>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-5xl font-bold text-gray-900">
|
||||
<div className="text-2xl sm:text-3xl font-serif font-bold bg-gradient-to-r from-[#d4af37] to-[#f5d76e] bg-clip-text text-transparent">
|
||||
{averageRating > 0
|
||||
? averageRating.toFixed(1)
|
||||
: 'N/A'}
|
||||
</div>
|
||||
<RatingStars
|
||||
rating={averageRating}
|
||||
size="md"
|
||||
/>
|
||||
<div className="text-sm text-gray-600 mt-2">
|
||||
<div className="mt-1">
|
||||
<RatingStars
|
||||
rating={averageRating}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] sm:text-xs text-gray-400 mt-1.5 font-light">
|
||||
{totalReviews} review{totalReviews !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,29 +170,29 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
||||
|
||||
{/* Review Form */}
|
||||
{isAuthenticated ? (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h4 className="text-xl font-semibold mb-4">
|
||||
<div className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg border border-[#d4af37]/20 p-3 sm:p-4 backdrop-blur-xl shadow-lg shadow-[#d4af37]/5">
|
||||
<h4 className="text-xs sm:text-sm font-serif font-semibold text-white mb-3 tracking-wide">
|
||||
Write Your Review
|
||||
</h4>
|
||||
<form onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
className="space-y-3"
|
||||
>
|
||||
<div>
|
||||
<label className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
<label className="block text-[10px] sm:text-xs font-light
|
||||
text-gray-300 mb-1.5 tracking-wide"
|
||||
>
|
||||
Your Rating
|
||||
</label>
|
||||
<RatingStars
|
||||
rating={rating}
|
||||
size="lg"
|
||||
size="sm"
|
||||
interactive
|
||||
onRatingChange={(value) =>
|
||||
setValue('rating', value)
|
||||
}
|
||||
/>
|
||||
{errors.rating && (
|
||||
<p className="text-red-600 text-sm mt-1">
|
||||
<p className="text-red-400 text-[10px] sm:text-xs mt-1 font-light">
|
||||
{errors.rating.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -178,51 +201,66 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
||||
<div>
|
||||
<label
|
||||
htmlFor="comment"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
className="block text-[10px] sm:text-xs font-light
|
||||
text-gray-300 mb-1.5 tracking-wide"
|
||||
>
|
||||
Comment
|
||||
</label>
|
||||
<textarea
|
||||
{...register('comment')}
|
||||
id="comment"
|
||||
rows={4}
|
||||
className="w-full px-4 py-2 border
|
||||
border-gray-300 rounded-lg
|
||||
focus:ring-2 focus:ring-blue-500
|
||||
focus:border-transparent"
|
||||
rows={3}
|
||||
className="w-full px-2.5 py-1.5 bg-[#0a0a0a] border
|
||||
border-[#d4af37]/20 rounded-lg text-white placeholder-gray-500 text-xs sm:text-sm
|
||||
focus:ring-2 focus:ring-[#d4af37]/50
|
||||
focus:border-[#d4af37] transition-all duration-300
|
||||
font-light tracking-wide resize-none"
|
||||
placeholder="Share your experience..."
|
||||
/>
|
||||
{errors.comment && (
|
||||
<p className="text-red-600 text-sm mt-1">
|
||||
<p className="text-red-400 text-[10px] sm:text-xs mt-1 font-light">
|
||||
{errors.comment.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* reCAPTCHA */}
|
||||
<div className="flex justify-center">
|
||||
<Recaptcha
|
||||
onChange={(token) => setRecaptchaToken(token)}
|
||||
onError={(error) => {
|
||||
console.error('reCAPTCHA error:', error);
|
||||
setRecaptchaToken(null);
|
||||
}}
|
||||
theme="dark"
|
||||
size="normal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-6 py-3 bg-blue-600 text-white
|
||||
rounded-lg hover:bg-blue-700
|
||||
disabled:bg-gray-400
|
||||
className="px-4 py-2 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||
text-[#0f0f0f] rounded-sm hover:from-[#f5d76e] hover:to-[#d4af37]
|
||||
disabled:bg-gray-800 disabled:text-gray-500
|
||||
disabled:cursor-not-allowed
|
||||
transition-colors font-medium"
|
||||
transition-all duration-300 font-medium text-xs sm:text-sm
|
||||
shadow-sm shadow-[#d4af37]/30 tracking-wide"
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit Review'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-blue-50 border border-blue-200
|
||||
rounded-lg p-6 text-center"
|
||||
<div className="bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/5 border border-[#d4af37]/30
|
||||
rounded-lg p-3 sm:p-4 text-center backdrop-blur-sm"
|
||||
>
|
||||
<p className="text-blue-800">
|
||||
<p className="text-[#d4af37] text-xs sm:text-sm font-light">
|
||||
Please{' '}
|
||||
<a
|
||||
href="/login"
|
||||
className="font-semibold underline
|
||||
hover:text-blue-900"
|
||||
hover:text-[#f5d76e] transition-colors"
|
||||
>
|
||||
login
|
||||
</a>{' '}
|
||||
@@ -233,71 +271,70 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
||||
|
||||
{/* Reviews List */}
|
||||
<div>
|
||||
<h4 className="text-xl font-semibold mb-6">
|
||||
<h4 className="text-xs sm:text-sm font-serif font-semibold text-white mb-3 tracking-wide">
|
||||
All Reviews ({totalReviews})
|
||||
</h4>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2.5">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-gray-100 rounded-lg p-6
|
||||
className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg border border-[#d4af37]/20 p-3
|
||||
animate-pulse"
|
||||
>
|
||||
<div className="h-4 bg-gray-300
|
||||
<div className="h-3 bg-gray-700
|
||||
rounded w-1/4 mb-2"
|
||||
/>
|
||||
<div className="h-4 bg-gray-300
|
||||
<div className="h-3 bg-gray-700
|
||||
rounded w-3/4"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : reviews.length === 0 ? (
|
||||
<div className="text-center py-12 bg-gray-50
|
||||
rounded-lg"
|
||||
<div className="text-center py-6 sm:py-8 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg border border-[#d4af37]/20 p-4"
|
||||
>
|
||||
<p className="text-gray-600 text-lg">
|
||||
<p className="text-gray-300 text-sm sm:text-base font-light">
|
||||
No reviews yet
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm mt-2">
|
||||
<p className="text-gray-400 text-xs sm:text-sm mt-1.5 font-light">
|
||||
Be the first to review this room!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2.5 sm:space-y-3">
|
||||
{reviews.map((review) => (
|
||||
<div
|
||||
key={review.id}
|
||||
className="bg-white rounded-lg shadow-md
|
||||
p-6"
|
||||
className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg border border-[#d4af37]/20
|
||||
p-3 sm:p-4 backdrop-blur-xl shadow-sm shadow-[#d4af37]/5"
|
||||
>
|
||||
<div className="flex items-start
|
||||
justify-between mb-3"
|
||||
justify-between mb-2"
|
||||
>
|
||||
<div>
|
||||
<h5 className="font-semibold
|
||||
text-gray-900"
|
||||
text-white text-xs sm:text-sm"
|
||||
>
|
||||
{review.user?.full_name || 'Guest'}
|
||||
</h5>
|
||||
<div className="flex items-center
|
||||
gap-2 mt-1"
|
||||
gap-1.5 mt-1"
|
||||
>
|
||||
<RatingStars
|
||||
rating={review.rating}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-sm
|
||||
text-gray-500"
|
||||
<span className="text-[10px] sm:text-xs
|
||||
text-gray-400 font-light"
|
||||
>
|
||||
{formatDate(review.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
<p className="text-gray-300 leading-relaxed text-xs sm:text-sm font-light">
|
||||
{review.comment}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { pageContentService } from '../services/api';
|
||||
import type { PageContent } from '../services/api/pageContentService';
|
||||
import { useCompanySettings } from '../contexts/CompanySettingsContext';
|
||||
import { toast } from 'react-toastify';
|
||||
import Recaptcha from '../components/common/Recaptcha';
|
||||
import { recaptchaService } from '../services/api/systemSettingsService';
|
||||
|
||||
const ContactPage: React.FC = () => {
|
||||
const { settings } = useCompanySettings();
|
||||
@@ -18,6 +20,7 @@ const ContactPage: React.FC = () => {
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
@@ -53,6 +56,22 @@ const ContactPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify reCAPTCHA if enabled
|
||||
if (recaptchaToken) {
|
||||
try {
|
||||
const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
|
||||
if (verifyResponse.status === 'error' || !verifyResponse.data.verified) {
|
||||
toast.error('reCAPTCHA verification failed. Please try again.');
|
||||
setRecaptchaToken(null);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('reCAPTCHA verification failed. Please try again.');
|
||||
setRecaptchaToken(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await submitContactForm(formData);
|
||||
@@ -67,9 +86,11 @@ const ContactPage: React.FC = () => {
|
||||
message: '',
|
||||
});
|
||||
setErrors({});
|
||||
setRecaptchaToken(null);
|
||||
} catch (error: any) {
|
||||
const errorMessage = error?.response?.data?.detail || error?.message || 'Failed to send message. Please try again.';
|
||||
toast.error(errorMessage);
|
||||
setRecaptchaToken(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -400,6 +421,20 @@ const ContactPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* reCAPTCHA */}
|
||||
<div className="pt-2 sm:pt-3">
|
||||
<Recaptcha
|
||||
onChange={(token) => setRecaptchaToken(token)}
|
||||
onError={(error) => {
|
||||
console.error('reCAPTCHA error:', error);
|
||||
setRecaptchaToken(null);
|
||||
}}
|
||||
theme="dark"
|
||||
size="normal"
|
||||
className="flex justify-center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-2 sm:pt-3 md:pt-4">
|
||||
<button
|
||||
|
||||
@@ -214,9 +214,37 @@ const BookingManagementPage: React.FC = () => {
|
||||
</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>
|
||||
{(() => {
|
||||
const completedPayments = booking.payments?.filter(
|
||||
(p) => p.payment_status === 'completed'
|
||||
) || [];
|
||||
const amountPaid = completedPayments.reduce(
|
||||
(sum, p) => sum + (p.amount || 0),
|
||||
0
|
||||
);
|
||||
const remainingDue = booking.total_price - amountPaid;
|
||||
const hasPayments = completedPayments.length > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
{hasPayments && (
|
||||
<div className="text-xs mt-1">
|
||||
<div className="text-green-600 font-medium">
|
||||
Paid: {formatCurrency(amountPaid)}
|
||||
</div>
|
||||
{remainingDue > 0 && (
|
||||
<div className="text-amber-600 font-medium">
|
||||
Due: {formatCurrency(remainingDue)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
{getStatusBadge(booking.status)}
|
||||
@@ -369,12 +397,219 @@ const BookingManagementPage: React.FC = () => {
|
||||
<p className="text-lg font-semibold text-slate-900">{selectedBooking.guest_count} guest{selectedBooking.guest_count !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{/* Payment Method & Status */}
|
||||
<div className="bg-gradient-to-br from-indigo-50/50 to-purple-50/50 p-6 rounded-xl border border-indigo-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-indigo-400 to-indigo-600 rounded-full"></div>
|
||||
Payment Information
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Payment Method</p>
|
||||
<p className="text-base font-semibold text-slate-900">
|
||||
{selectedBooking.payment_method === 'cash'
|
||||
? '💵 Pay at Hotel'
|
||||
: selectedBooking.payment_method === 'stripe'
|
||||
? '💳 Stripe (Card)'
|
||||
: selectedBooking.payment_method === 'paypal'
|
||||
? '💳 PayPal'
|
||||
: selectedBooking.payment_method || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Payment Status</p>
|
||||
<p className={`text-base font-semibold ${
|
||||
selectedBooking.payment_status === 'paid' || selectedBooking.payment_status === 'completed'
|
||||
? 'text-green-600'
|
||||
: selectedBooking.payment_status === 'pending'
|
||||
? 'text-yellow-600'
|
||||
: 'text-red-600'
|
||||
}`}>
|
||||
{selectedBooking.payment_status === 'paid' || selectedBooking.payment_status === 'completed'
|
||||
? '✅ Paid'
|
||||
: selectedBooking.payment_status === 'pending'
|
||||
? '⏳ Pending'
|
||||
: selectedBooking.payment_status === 'failed'
|
||||
? '❌ Failed'
|
||||
: selectedBooking.payment_status || 'Unpaid'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Usages */}
|
||||
{selectedBooking.service_usages && selectedBooking.service_usages.length > 0 && (
|
||||
<div className="bg-gradient-to-br from-purple-50/50 to-pink-50/50 p-6 rounded-xl border border-purple-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-purple-400 to-purple-600 rounded-full"></div>
|
||||
Additional Services
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{selectedBooking.service_usages.map((service: any, idx: number) => (
|
||||
<div key={service.id || idx} className="flex justify-between items-center py-2 border-b border-purple-100 last:border-0">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">{service.service_name || service.name || 'Service'}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{formatCurrency(service.unit_price || service.price || 0)} × {service.quantity || 1}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{formatCurrency(service.total_price || (service.unit_price || service.price || 0) * (service.quantity || 1))}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Breakdown */}
|
||||
{(() => {
|
||||
const completedPayments = selectedBooking.payments?.filter(
|
||||
(p) => p.payment_status === 'completed'
|
||||
) || [];
|
||||
const allPayments = selectedBooking.payments || [];
|
||||
const amountPaid = completedPayments.reduce(
|
||||
(sum, p) => sum + (p.amount || 0),
|
||||
0
|
||||
);
|
||||
const remainingDue = selectedBooking.total_price - amountPaid;
|
||||
const hasPayments = allPayments.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasPayments && (
|
||||
<div className="bg-gradient-to-br from-teal-50/50 to-cyan-50/50 p-6 rounded-xl border border-teal-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-teal-400 to-teal-600 rounded-full"></div>
|
||||
Payment History
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
{allPayments.map((payment: any, idx: number) => (
|
||||
<div key={payment.id || idx} className="p-3 bg-white rounded-lg border border-teal-100">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{formatCurrency(payment.amount || 0)}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
{payment.payment_type === 'deposit' ? 'Deposit (20%)' : payment.payment_type === 'remaining' ? 'Remaining Payment' : 'Full Payment'}
|
||||
{' • '}
|
||||
{payment.payment_method === 'stripe' ? 'Stripe' : payment.payment_method === 'paypal' ? 'PayPal' : payment.payment_method || 'Cash'}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
|
||||
payment.payment_status === 'completed' || payment.payment_status === 'paid'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: payment.payment_status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{payment.payment_status === 'completed' || payment.payment_status === 'paid' ? 'Paid' : payment.payment_status || 'Pending'}
|
||||
</span>
|
||||
</div>
|
||||
{payment.transaction_id && (
|
||||
<p className="text-xs text-slate-400 font-mono">ID: {payment.transaction_id}</p>
|
||||
)}
|
||||
{payment.payment_date && (
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
{new Date(payment.payment_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Payment Summary - Always show, even if no payments */}
|
||||
<div className="bg-gradient-to-br from-green-50 via-emerald-50 to-green-50 p-6 rounded-xl border-2 border-green-200 shadow-lg mb-4">
|
||||
<label className="text-xs font-semibold text-green-700 uppercase tracking-wider mb-2 block">Amount Paid</label>
|
||||
<p className="text-3xl font-bold bg-gradient-to-r from-green-600 via-emerald-700 to-green-600 bg-clip-text text-transparent">
|
||||
{formatCurrency(amountPaid)}
|
||||
</p>
|
||||
{hasPayments && completedPayments.length > 0 && (
|
||||
<p className="text-xs text-green-600 mt-2">
|
||||
{completedPayments.length} payment{completedPayments.length !== 1 ? 's' : ''} completed
|
||||
{amountPaid > 0 && selectedBooking.total_price > 0 && (
|
||||
<span className="ml-2">
|
||||
({((amountPaid / selectedBooking.total_price) * 100).toFixed(0)}% of total)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{amountPaid === 0 && !hasPayments && (
|
||||
<p className="text-sm text-gray-500 mt-2">No payments made yet</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remaining Due - Show prominently if there's remaining balance */}
|
||||
{remainingDue > 0 && (
|
||||
<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 mb-4">
|
||||
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wider mb-2 block">Remaining Due (To be paid)</label>
|
||||
<p className="text-3xl font-bold text-amber-600">
|
||||
{formatCurrency(remainingDue)}
|
||||
</p>
|
||||
{selectedBooking.total_price > 0 && (
|
||||
<p className="text-xs text-amber-600 mt-2">
|
||||
({((remainingDue / selectedBooking.total_price) * 100).toFixed(0)}% of total)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Total Booking Price - Show as reference */}
|
||||
<div className="bg-gradient-to-br from-slate-50 to-gray-50 p-6 rounded-xl border-2 border-slate-200 shadow-lg">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2 block">Total Booking Price</label>
|
||||
<p className="text-2xl font-bold text-slate-700">
|
||||
{formatCurrency(selectedBooking.total_price)}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
This is the total amount for the booking
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Booking Metadata */}
|
||||
<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-4 block flex items-center gap-2">
|
||||
<div className="w-1 h-4 bg-gradient-to-b from-slate-400 to-slate-600 rounded-full"></div>
|
||||
Booking Metadata
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{selectedBooking.createdAt && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Created At</p>
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{new Date(selectedBooking.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedBooking.updatedAt && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Last Updated</p>
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{new Date(selectedBooking.updatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedBooking.requires_deposit !== undefined && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Deposit Required</p>
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{selectedBooking.requires_deposit ? 'Yes (20%)' : 'No'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedBooking.deposit_paid !== undefined && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Deposit Paid</p>
|
||||
<p className={`text-sm font-medium ${selectedBooking.deposit_paid ? 'text-green-600' : 'text-amber-600'}`}>
|
||||
{selectedBooking.deposit_paid ? '✅ Yes' : '❌ No'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
|
||||
@@ -742,6 +742,7 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Booking Number</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Customer</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Method</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Type</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Amount</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Payment Date</th>
|
||||
</tr>
|
||||
@@ -756,11 +757,28 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
<div className="text-sm font-semibold text-emerald-600">{payment.booking?.booking_number}</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{payment.booking?.user?.name}</div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{payment.booking?.user?.name || payment.booking?.user?.full_name || 'N/A'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
{getPaymentMethodBadge(payment.payment_method)}
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
{payment.payment_type === 'deposit' ? (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 border border-amber-200">
|
||||
Deposit (20%)
|
||||
</span>
|
||||
) : payment.payment_type === 'remaining' ? (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-800 border border-blue-200">
|
||||
Remaining
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800 border border-green-200">
|
||||
Full Payment
|
||||
</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(payment.amount)}
|
||||
|
||||
@@ -35,7 +35,17 @@ const CheckInPage: React.FC = () => {
|
||||
const response = await bookingService.checkBookingByNumber(bookingNumber);
|
||||
setBooking(response.data.booking);
|
||||
setActualRoomNumber(response.data.booking.room?.room_number || '');
|
||||
toast.success('Booking found');
|
||||
|
||||
// Show warning if there's remaining balance
|
||||
if ((response as any).warning) {
|
||||
const warning = (response as any).warning;
|
||||
toast.warning(
|
||||
`⚠️ Payment Reminder: Guest has remaining balance of ${formatCurrency(warning.remaining_balance)} (${warning.payment_percentage.toFixed(1)}% paid)`,
|
||||
{ autoClose: 8000 }
|
||||
);
|
||||
} else {
|
||||
toast.success('Booking found');
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Booking not found');
|
||||
setBooking(null);
|
||||
@@ -89,12 +99,21 @@ const CheckInPage: React.FC = () => {
|
||||
// Calculate additional fee
|
||||
calculateAdditionalFee();
|
||||
|
||||
await bookingService.updateBooking(booking.id, {
|
||||
const response = await bookingService.updateBooking(booking.id, {
|
||||
status: 'checked_in',
|
||||
// Can send additional data about guests, room_number, additional_fee
|
||||
} as any);
|
||||
|
||||
toast.success('Check-in successful');
|
||||
// Show warning if there's remaining balance
|
||||
if ((response as any).warning) {
|
||||
const warning = (response as any).warning;
|
||||
toast.warning(
|
||||
`⚠️ Check-in successful, but guest has remaining balance: ${formatCurrency(warning.remaining_balance)} (${warning.payment_percentage.toFixed(1)}% paid)`,
|
||||
{ autoClose: 10000 }
|
||||
);
|
||||
} else {
|
||||
toast.success('Check-in successful');
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setBooking(null);
|
||||
@@ -201,6 +220,150 @@ const CheckInPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Warning Alert */}
|
||||
{booking.payment_balance && booking.payment_balance.remaining_balance > 0.01 && (
|
||||
<div className="mt-6 p-4 bg-gradient-to-r from-amber-50 to-orange-50 rounded-lg border-2 border-amber-400">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-6 h-6 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-bold text-amber-900 mb-2">
|
||||
⚠️ Payment Reminder
|
||||
</h3>
|
||||
<p className="text-amber-800 mb-3">
|
||||
This guest has <strong>not fully paid</strong> for their booking. Please collect the remaining balance during check-in.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4 bg-white/50 p-3 rounded-lg">
|
||||
<div>
|
||||
<span className="text-sm text-amber-700">Total Price:</span>
|
||||
<p className="text-lg font-bold text-gray-900">{formatCurrency(booking.payment_balance.total_price)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-amber-700">Amount Paid:</span>
|
||||
<p className="text-lg font-bold text-green-600">{formatCurrency(booking.payment_balance.total_paid)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-amber-700">Payment Progress:</span>
|
||||
<p className="text-lg font-bold text-blue-600">{booking.payment_balance.payment_percentage.toFixed(1)}%</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-amber-700">Remaining Balance:</span>
|
||||
<p className="text-xl font-bold text-red-600">{formatCurrency(booking.payment_balance.remaining_balance)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Information */}
|
||||
<div className="mt-6 p-4 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-200">
|
||||
<h3 className="text-md font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
Payment Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Payment Method:</span>
|
||||
<span className="font-semibold">
|
||||
{booking.payment_method === 'cash'
|
||||
? '💵 Pay at Hotel'
|
||||
: booking.payment_method === 'stripe'
|
||||
? '💳 Stripe (Card)'
|
||||
: booking.payment_method === 'paypal'
|
||||
? '💳 PayPal'
|
||||
: booking.payment_method || 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Payment Status:</span>
|
||||
<span className={`font-semibold ${
|
||||
booking.payment_status === 'paid' || booking.payment_status === 'completed'
|
||||
? 'text-green-600'
|
||||
: booking.payment_status === 'pending'
|
||||
? 'text-yellow-600'
|
||||
: 'text-red-600'
|
||||
}`}>
|
||||
{booking.payment_status === 'paid' || booking.payment_status === 'completed'
|
||||
? '✅ Paid'
|
||||
: booking.payment_status === 'pending'
|
||||
? '⏳ Pending'
|
||||
: booking.payment_status === 'failed'
|
||||
? '❌ Failed'
|
||||
: booking.payment_status || 'Unpaid'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
{(() => {
|
||||
// Use payment_balance from API if available, otherwise calculate from payments
|
||||
const paymentBalance = booking.payment_balance || (() => {
|
||||
const completedPayments = booking.payments?.filter(
|
||||
(p) => p.payment_status === 'completed'
|
||||
) || [];
|
||||
const amountPaid = completedPayments.reduce(
|
||||
(sum, p) => sum + (p.amount || 0),
|
||||
0
|
||||
);
|
||||
const remainingDue = booking.total_price - amountPaid;
|
||||
return {
|
||||
total_paid: amountPaid,
|
||||
total_price: booking.total_price,
|
||||
remaining_balance: remainingDue,
|
||||
is_fully_paid: remainingDue <= 0.01,
|
||||
payment_percentage: booking.total_price > 0 ? (amountPaid / booking.total_price * 100) : 0
|
||||
};
|
||||
})();
|
||||
|
||||
const completedPayments = booking.payments?.filter(
|
||||
(p) => p.payment_status === 'completed'
|
||||
) || [];
|
||||
const hasPayments = completedPayments.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Total Price:</span>
|
||||
<span className="font-semibold text-gray-900">{formatCurrency(paymentBalance.total_price)}</span>
|
||||
</div>
|
||||
{hasPayments && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Amount Paid:</span>
|
||||
<span className="font-semibold text-green-600">{formatCurrency(paymentBalance.total_paid)}</span>
|
||||
</div>
|
||||
{paymentBalance.remaining_balance > 0.01 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Remaining Due:</span>
|
||||
<span className="font-semibold text-red-600">{formatCurrency(paymentBalance.remaining_balance)}</span>
|
||||
</div>
|
||||
)}
|
||||
{completedPayments.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-green-200">
|
||||
<p className="text-xs text-gray-500 mb-1">Payment Details:</p>
|
||||
{completedPayments.map((payment, idx) => (
|
||||
<div key={payment.id || idx} className="text-xs text-gray-600">
|
||||
• {formatCurrency(payment.amount)} via {payment.payment_method === 'stripe' ? 'Stripe' : payment.payment_method === 'paypal' ? 'PayPal' : payment.payment_method || 'Cash'}
|
||||
{payment.payment_type === 'deposit' && ' (Deposit 20%)'}
|
||||
{payment.transaction_id && ` - ${payment.transaction_id}`}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{booking.status !== 'confirmed' && (
|
||||
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg flex items-start gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-600 mt-0.5" />
|
||||
|
||||
@@ -158,6 +158,7 @@ const PaymentManagementPage: React.FC = () => {
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">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">Type</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>
|
||||
@@ -181,6 +182,21 @@ const PaymentManagementPage: React.FC = () => {
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
{getMethodBadge(payment.payment_method)}
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
{payment.payment_type === 'deposit' ? (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 border border-amber-200">
|
||||
Deposit (20%)
|
||||
</span>
|
||||
) : payment.payment_type === 'remaining' ? (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-800 border border-blue-200">
|
||||
Remaining
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800 border border-green-200">
|
||||
Full Payment
|
||||
</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(payment.amount)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
LogIn,
|
||||
LogOut,
|
||||
@@ -189,6 +189,14 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
return total;
|
||||
};
|
||||
|
||||
// Calculate additional fee when extraPersons or children change
|
||||
useEffect(() => {
|
||||
const extraPersonFee = extraPersons * 200000;
|
||||
const childrenFee = children * 100000;
|
||||
const total = extraPersonFee + childrenFee;
|
||||
setAdditionalFee(total);
|
||||
}, [extraPersons, children]);
|
||||
|
||||
const handleCheckIn = async () => {
|
||||
if (!checkInBooking) return;
|
||||
|
||||
@@ -272,8 +280,11 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
const calculateDeposit = () => {
|
||||
return checkOutBooking?.total_price ? checkOutBooking.total_price * 0.3 : 0;
|
||||
const calculateTotalPaid = () => {
|
||||
if (!checkOutBooking?.payments) return 0;
|
||||
return checkOutBooking.payments
|
||||
.filter(payment => payment.payment_status === 'completed')
|
||||
.reduce((sum, payment) => sum + (payment.amount || 0), 0);
|
||||
};
|
||||
|
||||
const calculateSubtotal = () => {
|
||||
@@ -285,7 +296,9 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const calculateRemaining = () => {
|
||||
return calculateTotal() - calculateDeposit();
|
||||
const total = calculateTotal();
|
||||
const totalPaid = calculateTotalPaid();
|
||||
return total - totalPaid;
|
||||
};
|
||||
|
||||
const handleCheckOut = async () => {
|
||||
@@ -326,17 +339,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
};
|
||||
|
||||
// Bookings Management Functions
|
||||
useEffect(() => {
|
||||
setBookingCurrentPage(1);
|
||||
}, [bookingFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'bookings') {
|
||||
fetchBookings();
|
||||
}
|
||||
}, [bookingFilters, bookingCurrentPage, activeTab]);
|
||||
|
||||
const fetchBookings = async () => {
|
||||
const fetchBookings = useCallback(async () => {
|
||||
try {
|
||||
setBookingsLoading(true);
|
||||
const response = await bookingService.getAllBookings({
|
||||
@@ -354,7 +357,17 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
} finally {
|
||||
setBookingsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [bookingFilters.search, bookingFilters.status, bookingCurrentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setBookingCurrentPage(1);
|
||||
}, [bookingFilters.search, bookingFilters.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'bookings') {
|
||||
fetchBookings();
|
||||
}
|
||||
}, [activeTab, fetchBookings]);
|
||||
|
||||
const handleUpdateBookingStatus = async (id: number, status: string) => {
|
||||
try {
|
||||
@@ -426,19 +439,63 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
};
|
||||
|
||||
// Rooms Management Functions
|
||||
const fetchAvailableAmenities = useCallback(async () => {
|
||||
try {
|
||||
const response = await roomService.getAmenities();
|
||||
if (response.data?.amenities) {
|
||||
setAvailableAmenities(response.data.amenities);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch amenities:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchRooms = useCallback(async () => {
|
||||
try {
|
||||
setRoomsLoading(true);
|
||||
const response = await roomService.getRooms({
|
||||
...roomFilters,
|
||||
page: roomCurrentPage,
|
||||
limit: roomItemsPerPage,
|
||||
});
|
||||
setRooms(response.data.rooms);
|
||||
if (response.data.pagination) {
|
||||
setRoomTotalPages(response.data.pagination.totalPages);
|
||||
setRoomTotalItems(response.data.pagination.total);
|
||||
}
|
||||
|
||||
const uniqueRoomTypes = new Map<number, { id: number; name: string }>();
|
||||
response.data.rooms.forEach((room: Room) => {
|
||||
if (room.room_type && !uniqueRoomTypes.has(room.room_type.id)) {
|
||||
uniqueRoomTypes.set(room.room_type.id, {
|
||||
id: room.room_type.id,
|
||||
name: room.room_type.name,
|
||||
});
|
||||
}
|
||||
});
|
||||
setRoomTypes(Array.from(uniqueRoomTypes.values()));
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load rooms list');
|
||||
} finally {
|
||||
setRoomsLoading(false);
|
||||
}
|
||||
}, [roomFilters.search, roomFilters.status, roomFilters.type, roomCurrentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setRoomCurrentPage(1);
|
||||
setSelectedRooms([]);
|
||||
}, [roomFilters]);
|
||||
}, [roomFilters.search, roomFilters.status, roomFilters.type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'rooms') {
|
||||
fetchRooms();
|
||||
fetchAvailableAmenities();
|
||||
}
|
||||
}, [roomFilters, roomCurrentPage, activeTab]);
|
||||
}, [activeTab, fetchRooms, fetchAvailableAmenities]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'rooms') return;
|
||||
|
||||
const fetchAllRoomTypes = async () => {
|
||||
try {
|
||||
const response = await roomService.getRooms({ limit: 100, page: 1 });
|
||||
@@ -474,60 +531,21 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
if (allUniqueRoomTypes.size > 0) {
|
||||
const roomTypesList = Array.from(allUniqueRoomTypes.values());
|
||||
setRoomTypes(roomTypesList);
|
||||
if (!editingRoom && roomFormData.room_type_id === 1 && roomTypesList.length > 0) {
|
||||
setRoomFormData(prev => ({ ...prev, room_type_id: roomTypesList[0].id }));
|
||||
}
|
||||
setRoomFormData(prev => {
|
||||
if (!editingRoom && prev.room_type_id === 1 && roomTypesList.length > 0) {
|
||||
return { ...prev, room_type_id: roomTypesList[0].id };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch room types:', err);
|
||||
}
|
||||
};
|
||||
if (activeTab === 'rooms') {
|
||||
fetchAllRoomTypes();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
fetchAllRoomTypes();
|
||||
}, [activeTab, editingRoom]);
|
||||
|
||||
const fetchAvailableAmenities = async () => {
|
||||
try {
|
||||
const response = await roomService.getAmenities();
|
||||
if (response.data?.amenities) {
|
||||
setAvailableAmenities(response.data.amenities);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch amenities:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRooms = async () => {
|
||||
try {
|
||||
setRoomsLoading(true);
|
||||
const response = await roomService.getRooms({
|
||||
...roomFilters,
|
||||
page: roomCurrentPage,
|
||||
limit: roomItemsPerPage,
|
||||
});
|
||||
setRooms(response.data.rooms);
|
||||
if (response.data.pagination) {
|
||||
setRoomTotalPages(response.data.pagination.totalPages);
|
||||
setRoomTotalItems(response.data.pagination.total);
|
||||
}
|
||||
|
||||
const uniqueRoomTypes = new Map<number, { id: number; name: string }>();
|
||||
response.data.rooms.forEach((room: Room) => {
|
||||
if (room.room_type && !uniqueRoomTypes.has(room.room_type.id)) {
|
||||
uniqueRoomTypes.set(room.room_type.id, {
|
||||
id: room.room_type.id,
|
||||
name: room.room_type.name,
|
||||
});
|
||||
}
|
||||
});
|
||||
setRoomTypes(Array.from(uniqueRoomTypes.values()));
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load rooms list');
|
||||
} finally {
|
||||
setRoomsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoomSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -866,17 +884,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
};
|
||||
|
||||
// Services Management Functions
|
||||
useEffect(() => {
|
||||
setServiceCurrentPage(1);
|
||||
}, [serviceFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'services') {
|
||||
fetchServices();
|
||||
}
|
||||
}, [serviceFilters, serviceCurrentPage, activeTab]);
|
||||
|
||||
const fetchServices = async () => {
|
||||
const fetchServices = useCallback(async () => {
|
||||
try {
|
||||
setServicesLoading(true);
|
||||
const response = await serviceService.getServices({
|
||||
@@ -894,7 +902,17 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
} finally {
|
||||
setServicesLoading(false);
|
||||
}
|
||||
};
|
||||
}, [serviceFilters.search, serviceFilters.status, serviceCurrentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setServiceCurrentPage(1);
|
||||
}, [serviceFilters.search, serviceFilters.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'services') {
|
||||
fetchServices();
|
||||
}
|
||||
}, [activeTab, fetchServices]);
|
||||
|
||||
const handleServiceSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -1311,6 +1329,100 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Information */}
|
||||
<div className="mt-6 p-6 bg-gradient-to-r from-green-50 to-emerald-50 rounded-xl border border-green-200">
|
||||
<h3 className="text-md font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
Payment Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center py-2 border-b border-green-100">
|
||||
<span className="text-gray-600 font-medium">Payment Method:</span>
|
||||
<span className="font-semibold text-gray-900">
|
||||
{checkInBooking.payment_method === 'cash'
|
||||
? '💵 Pay at Hotel'
|
||||
: checkInBooking.payment_method === 'stripe'
|
||||
? '💳 Stripe (Card)'
|
||||
: checkInBooking.payment_method === 'paypal'
|
||||
? '💳 PayPal'
|
||||
: checkInBooking.payment_method || 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-gray-600 font-medium">Payment Status:</span>
|
||||
<span className={`font-semibold ${
|
||||
checkInBooking.payment_status === 'paid' || checkInBooking.payment_status === 'completed'
|
||||
? 'text-green-600'
|
||||
: checkInBooking.payment_status === 'pending'
|
||||
? 'text-yellow-600'
|
||||
: 'text-red-600'
|
||||
}`}>
|
||||
{checkInBooking.payment_status === 'paid' || checkInBooking.payment_status === 'completed'
|
||||
? '✅ Paid'
|
||||
: checkInBooking.payment_status === 'pending'
|
||||
? '⏳ Pending'
|
||||
: checkInBooking.payment_status === 'failed'
|
||||
? '❌ Failed'
|
||||
: checkInBooking.payment_status || 'Unpaid'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
{(() => {
|
||||
const completedPayments = checkInBooking.payments?.filter(
|
||||
(p) => p.payment_status === 'completed'
|
||||
) || [];
|
||||
const amountPaid = completedPayments.reduce(
|
||||
(sum, p) => sum + (p.amount || 0),
|
||||
0
|
||||
);
|
||||
const remainingDue = checkInBooking.total_price - amountPaid;
|
||||
const hasPayments = completedPayments.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between items-center py-2 border-b border-green-100">
|
||||
<span className="text-gray-600 font-medium">Total Price:</span>
|
||||
<span className="font-semibold text-gray-900">{formatCurrency(checkInBooking.total_price)}</span>
|
||||
</div>
|
||||
{hasPayments && (
|
||||
<>
|
||||
<div className="flex justify-between items-center py-2 border-b border-green-100">
|
||||
<span className="text-gray-600 font-medium">Amount Paid:</span>
|
||||
<span className="font-semibold text-green-600">{formatCurrency(amountPaid)}</span>
|
||||
</div>
|
||||
{remainingDue > 0 && (
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-gray-600 font-medium">Remaining Due:</span>
|
||||
<span className="font-semibold text-amber-600">{formatCurrency(remainingDue)}</span>
|
||||
</div>
|
||||
)}
|
||||
{completedPayments.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-green-200">
|
||||
<p className="text-xs text-gray-500 mb-2 font-medium">Payment Details:</p>
|
||||
{completedPayments.map((payment, idx) => (
|
||||
<div key={payment.id || idx} className="text-xs text-gray-600 mb-1">
|
||||
• {formatCurrency(payment.amount)} via {payment.payment_method === 'stripe' ? 'Stripe' : payment.payment_method === 'paypal' ? 'PayPal' : payment.payment_method || 'Cash'}
|
||||
{payment.payment_type === 'deposit' && ' (Deposit 20%)'}
|
||||
{payment.transaction_id && ` - ${payment.transaction_id}`}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{checkInBooking.status !== 'confirmed' && (
|
||||
<div className="mt-6 p-4 bg-gradient-to-br from-amber-50 to-yellow-50 border border-amber-200 rounded-xl flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||
@@ -1458,7 +1570,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-semibold text-gray-900">Total Additional Fee</label>
|
||||
<div className="px-4 py-3.5 bg-gradient-to-br from-emerald-50 to-green-50 border-2 border-emerald-200 rounded-xl text-lg font-bold text-emerald-600">
|
||||
{formatCurrency(calculateCheckInAdditionalFee())}
|
||||
{formatCurrency(additionalFee)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1693,10 +1805,12 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
<span>Total:</span>
|
||||
<span>{formatCurrency(calculateTotal())}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-lg text-gray-600">
|
||||
<span>Deposit paid:</span>
|
||||
<span className="font-semibold">-{formatCurrency(calculateDeposit())}</span>
|
||||
</div>
|
||||
{calculateTotalPaid() > 0 && (
|
||||
<div className="flex justify-between items-center text-lg text-gray-600">
|
||||
<span>Total paid:</span>
|
||||
<span className="font-semibold">-{formatCurrency(calculateTotalPaid())}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center text-3xl font-extrabold text-emerald-600 pt-4 border-t-2 border-gray-300">
|
||||
<span>Remaining payment:</span>
|
||||
<span>{formatCurrency(calculateRemaining())}</span>
|
||||
@@ -2075,13 +2189,178 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
<p className="text-lg font-semibold text-slate-900">{selectedBooking.guest_count} guest{selectedBooking.guest_count !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{/* Payment Method & Status */}
|
||||
<div className="bg-gradient-to-br from-indigo-50/50 to-purple-50/50 p-6 rounded-xl border border-indigo-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-indigo-400 to-indigo-600 rounded-full"></div>
|
||||
Payment Information
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Payment Method</p>
|
||||
<p className="text-base font-semibold text-slate-900">
|
||||
{selectedBooking.payment_method === 'cash'
|
||||
? '💵 Pay at Hotel'
|
||||
: selectedBooking.payment_method === 'stripe'
|
||||
? '💳 Stripe (Card)'
|
||||
: selectedBooking.payment_method === 'paypal'
|
||||
? '💳 PayPal'
|
||||
: selectedBooking.payment_method || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Payment Status</p>
|
||||
<p className={`text-base font-semibold ${
|
||||
selectedBooking.payment_status === 'paid' || selectedBooking.payment_status === 'completed'
|
||||
? 'text-green-600'
|
||||
: selectedBooking.payment_status === 'pending'
|
||||
? 'text-yellow-600'
|
||||
: 'text-red-600'
|
||||
}`}>
|
||||
{selectedBooking.payment_status === 'paid' || selectedBooking.payment_status === 'completed'
|
||||
? '✅ Paid'
|
||||
: selectedBooking.payment_status === 'pending'
|
||||
? '⏳ Pending'
|
||||
: selectedBooking.payment_status === 'failed'
|
||||
? '❌ Failed'
|
||||
: selectedBooking.payment_status || 'Unpaid'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment History */}
|
||||
{selectedBooking.payments && selectedBooking.payments.length > 0 && (
|
||||
<div className="bg-gradient-to-br from-teal-50/50 to-cyan-50/50 p-6 rounded-xl border border-teal-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-teal-400 to-teal-600 rounded-full"></div>
|
||||
Payment History
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
{selectedBooking.payments.map((payment: any, idx: number) => (
|
||||
<div key={payment.id || idx} className="p-3 bg-white rounded-lg border border-teal-100">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{formatCurrency(payment.amount || 0)}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
{payment.payment_type === 'deposit' ? 'Deposit (20%)' : payment.payment_type === 'remaining' ? 'Remaining Payment' : 'Full Payment'}
|
||||
{' • '}
|
||||
{payment.payment_method === 'stripe' ? 'Stripe' : payment.payment_method === 'paypal' ? 'PayPal' : payment.payment_method || 'Cash'}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
|
||||
payment.payment_status === 'completed' || payment.payment_status === 'paid'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: payment.payment_status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{payment.payment_status === 'completed' || payment.payment_status === 'paid' ? 'Paid' : payment.payment_status || 'Pending'}
|
||||
</span>
|
||||
</div>
|
||||
{payment.transaction_id && (
|
||||
<p className="text-xs text-slate-400 font-mono">ID: {payment.transaction_id}</p>
|
||||
)}
|
||||
{payment.payment_date && (
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
{new Date(payment.payment_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Breakdown */}
|
||||
{(() => {
|
||||
const completedPayments = selectedBooking.payments?.filter(
|
||||
(p) => p.payment_status === 'completed'
|
||||
) || [];
|
||||
const amountPaid = completedPayments.reduce(
|
||||
(sum, p) => sum + (p.amount || 0),
|
||||
0
|
||||
);
|
||||
const remainingDue = selectedBooking.total_price - amountPaid;
|
||||
const hasPayments = selectedBooking.payments && selectedBooking.payments.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Payment Summary */}
|
||||
<div className="bg-gradient-to-br from-green-50 via-emerald-50 to-green-50 p-6 rounded-xl border-2 border-green-200 shadow-lg mb-4">
|
||||
<label className="text-xs font-semibold text-green-700 uppercase tracking-wider mb-2 block">Amount Paid</label>
|
||||
<p className="text-3xl font-bold bg-gradient-to-r from-green-600 via-emerald-700 to-green-600 bg-clip-text text-transparent">
|
||||
{formatCurrency(amountPaid)}
|
||||
</p>
|
||||
{hasPayments && completedPayments.length > 0 && (
|
||||
<p className="text-xs text-green-600 mt-2">
|
||||
{completedPayments.length} payment{completedPayments.length !== 1 ? 's' : ''} completed
|
||||
{amountPaid > 0 && selectedBooking.total_price > 0 && (
|
||||
<span className="ml-2">
|
||||
({((amountPaid / selectedBooking.total_price) * 100).toFixed(0)}% of total)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{amountPaid === 0 && !hasPayments && (
|
||||
<p className="text-sm text-gray-500 mt-2">No payments made yet</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remaining Due */}
|
||||
{remainingDue > 0 && (
|
||||
<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 mb-4">
|
||||
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wider mb-2 block">Remaining Due (To be paid)</label>
|
||||
<p className="text-3xl font-bold text-amber-600">
|
||||
{formatCurrency(remainingDue)}
|
||||
</p>
|
||||
{selectedBooking.total_price > 0 && (
|
||||
<p className="text-xs text-amber-600 mt-2">
|
||||
({((remainingDue / selectedBooking.total_price) * 100).toFixed(0)}% of total)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Total Booking Price */}
|
||||
<div className="bg-gradient-to-br from-slate-50 to-gray-50 p-6 rounded-xl border-2 border-slate-200 shadow-lg">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2 block">Total Booking Price</label>
|
||||
{selectedBooking.original_price && selectedBooking.discount_amount && selectedBooking.discount_amount > 0 ? (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-sm text-slate-600">Subtotal:</span>
|
||||
<span className="text-lg font-semibold text-slate-700">{formatCurrency(selectedBooking.original_price)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm text-green-600">
|
||||
Discount{selectedBooking.promotion_code ? ` (${selectedBooking.promotion_code})` : ''}:
|
||||
</span>
|
||||
<span className="text-lg font-semibold text-green-600">-{formatCurrency(selectedBooking.discount_amount)}</span>
|
||||
</div>
|
||||
<div className="border-t border-slate-300 pt-2 mt-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-semibold text-slate-700">Total:</span>
|
||||
<span className="text-2xl font-bold text-slate-700">{formatCurrency(selectedBooking.total_price)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-2xl font-bold text-slate-700">
|
||||
{formatCurrency(selectedBooking.total_price)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
This is the total amount for the booking
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{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>
|
||||
|
||||
@@ -36,11 +36,12 @@ import systemSettingsService, {
|
||||
CompanySettingsResponse,
|
||||
UpdateCompanySettingsRequest,
|
||||
} from '../../services/api/systemSettingsService';
|
||||
import { recaptchaService, RecaptchaSettingsAdminResponse, UpdateRecaptchaSettingsRequest } from '../../services/api/systemSettingsService';
|
||||
import { useCurrency } from '../../contexts/CurrencyContext';
|
||||
import { Loading } from '../../components/common';
|
||||
import { getCurrencySymbol } from '../../utils/format';
|
||||
|
||||
type SettingsTab = 'general' | 'cookie' | 'currency' | 'payment' | 'smtp' | 'company';
|
||||
type SettingsTab = 'general' | 'cookie' | 'currency' | 'payment' | 'smtp' | 'company' | 'recaptcha';
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { currency, supportedCurrencies, refreshCurrency } = useCurrency();
|
||||
@@ -105,12 +106,22 @@ const SettingsPage: React.FC = () => {
|
||||
company_phone: '',
|
||||
company_email: '',
|
||||
company_address: '',
|
||||
tax_rate: 0,
|
||||
});
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
const [faviconPreview, setFaviconPreview] = useState<string | null>(null);
|
||||
const [uploadingLogo, setUploadingLogo] = useState(false);
|
||||
const [uploadingFavicon, setUploadingFavicon] = useState(false);
|
||||
|
||||
// reCAPTCHA Settings State
|
||||
const [recaptchaSettings, setRecaptchaSettings] = useState<RecaptchaSettingsAdminResponse['data'] | null>(null);
|
||||
const [recaptchaFormData, setRecaptchaFormData] = useState<UpdateRecaptchaSettingsRequest>({
|
||||
recaptcha_site_key: '',
|
||||
recaptcha_secret_key: '',
|
||||
recaptcha_enabled: false,
|
||||
});
|
||||
const [showRecaptchaSecret, setShowRecaptchaSecret] = useState(false);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -146,6 +157,9 @@ const SettingsPage: React.FC = () => {
|
||||
if (activeTab === 'company') {
|
||||
loadCompanySettings();
|
||||
}
|
||||
if (activeTab === 'recaptcha') {
|
||||
loadRecaptchaSettings();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -219,6 +233,7 @@ const SettingsPage: React.FC = () => {
|
||||
company_phone: companyRes.data.company_phone || '',
|
||||
company_email: companyRes.data.company_email || '',
|
||||
company_address: companyRes.data.company_address || '',
|
||||
tax_rate: companyRes.data.tax_rate || 0,
|
||||
});
|
||||
|
||||
// Set previews if URLs exist
|
||||
@@ -579,6 +594,41 @@ const SettingsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadRecaptchaSettings = async () => {
|
||||
try {
|
||||
const recaptchaRes = await recaptchaService.getRecaptchaSettingsAdmin();
|
||||
setRecaptchaSettings(recaptchaRes.data);
|
||||
setRecaptchaFormData({
|
||||
recaptcha_site_key: recaptchaRes.data.recaptcha_site_key || '',
|
||||
recaptcha_secret_key: '',
|
||||
recaptcha_enabled: recaptchaRes.data.recaptcha_enabled || false,
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast.error(
|
||||
error.response?.data?.detail ||
|
||||
error.response?.data?.message ||
|
||||
'Failed to load reCAPTCHA settings'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveRecaptcha = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
await recaptchaService.updateRecaptchaSettings(recaptchaFormData);
|
||||
toast.success('reCAPTCHA settings saved successfully');
|
||||
await loadRecaptchaSettings();
|
||||
} catch (error: any) {
|
||||
toast.error(
|
||||
error.response?.data?.detail ||
|
||||
error.response?.data?.message ||
|
||||
'Failed to save reCAPTCHA settings'
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading fullScreen={false} text="Loading settings..." />;
|
||||
}
|
||||
@@ -590,6 +640,7 @@ const SettingsPage: React.FC = () => {
|
||||
{ id: 'payment' as SettingsTab, label: 'Payment', icon: CreditCard },
|
||||
{ id: 'smtp' as SettingsTab, label: 'Email Server', icon: Mail },
|
||||
{ id: 'company' as SettingsTab, label: 'Company Info', icon: Building2 },
|
||||
{ id: 'recaptcha' as SettingsTab, label: 'reCAPTCHA', icon: Shield },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -2154,6 +2205,29 @@ const SettingsPage: React.FC = () => {
|
||||
Physical address of your company or hotel
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tax Rate */}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<DollarSign className="w-4 h-4 text-gray-600" />
|
||||
Tax Rate (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
value={companyFormData.tax_rate || 0}
|
||||
onChange={(e) =>
|
||||
setCompanyFormData({ ...companyFormData, tax_rate: parseFloat(e.target.value) || 0 })
|
||||
}
|
||||
placeholder="0.00"
|
||||
className="w-full px-4 py-3.5 bg-white border border-gray-300 rounded-xl shadow-sm focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-all duration-200 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Default tax rate percentage to be applied to all invoices (e.g., 10 for 10%). This will be used for all bookings unless overridden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2178,6 +2252,152 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'recaptcha' && (
|
||||
<div className="space-y-8">
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<Shield className="w-6 h-6 text-amber-600" />
|
||||
Google reCAPTCHA Settings
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Configure Google reCAPTCHA to protect your forms from spam and abuse
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* reCAPTCHA Settings Form */}
|
||||
<div className="bg-white rounded-xl shadow-lg border border-gray-200 p-8">
|
||||
<div className="space-y-6">
|
||||
{/* Enable/Disable Toggle */}
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-gray-900">
|
||||
Enable reCAPTCHA
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Enable or disable reCAPTCHA verification across all forms
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={recaptchaFormData.recaptcha_enabled || false}
|
||||
onChange={(e) =>
|
||||
setRecaptchaFormData({
|
||||
...recaptchaFormData,
|
||||
recaptcha_enabled: e.target.checked,
|
||||
})
|
||||
}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-amber-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-amber-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Site Key */}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<Key className="w-4 h-4 text-gray-600" />
|
||||
reCAPTCHA Site Key
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={recaptchaFormData.recaptcha_site_key || ''}
|
||||
onChange={(e) =>
|
||||
setRecaptchaFormData({
|
||||
...recaptchaFormData,
|
||||
recaptcha_site_key: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI"
|
||||
className="w-full px-4 py-3.5 bg-white border border-gray-300 rounded-xl shadow-sm focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-all duration-200 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Your reCAPTCHA site key from Google. Get it from{' '}
|
||||
<a
|
||||
href="https://www.google.com/recaptcha/admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-amber-600 hover:underline"
|
||||
>
|
||||
Google reCAPTCHA Admin
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Secret Key */}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<Lock className="w-4 h-4 text-gray-600" />
|
||||
reCAPTCHA Secret Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showRecaptchaSecret ? 'text' : 'password'}
|
||||
value={recaptchaFormData.recaptcha_secret_key || ''}
|
||||
onChange={(e) =>
|
||||
setRecaptchaFormData({
|
||||
...recaptchaFormData,
|
||||
recaptcha_secret_key: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={recaptchaSettings?.recaptcha_secret_key_masked || 'Enter secret key'}
|
||||
className="w-full px-4 py-3.5 pr-12 bg-white border border-gray-300 rounded-xl shadow-sm focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-all duration-200 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRecaptchaSecret(!showRecaptchaSecret)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{showRecaptchaSecret ? (
|
||||
<EyeOff className="w-5 h-5" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Your reCAPTCHA secret key (keep this secure). Leave empty to keep existing value.
|
||||
</p>
|
||||
{recaptchaSettings?.recaptcha_secret_key_masked && (
|
||||
<p className="text-xs text-amber-600">
|
||||
Current: {recaptchaSettings.recaptcha_secret_key_masked}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-semibold mb-1">About reCAPTCHA</p>
|
||||
<p className="text-xs">
|
||||
reCAPTCHA protects your forms from spam and abuse. You can use reCAPTCHA v2 (checkbox) or v3 (invisible).
|
||||
Make sure to use the same version for both site key and secret key.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={handleSaveRecaptcha}
|
||||
disabled={saving}
|
||||
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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save className="w-5 h-5" />
|
||||
{saving ? 'Saving...' : 'Save reCAPTCHA Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,9 @@ import {
|
||||
} from '../../utils/validationSchemas';
|
||||
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
|
||||
import * as yup from 'yup';
|
||||
import { toast } from 'react-toastify';
|
||||
import Recaptcha from '../../components/common/Recaptcha';
|
||||
import { recaptchaService } from '../../services/api/systemSettingsService';
|
||||
|
||||
const mfaTokenSchema = yup.object().shape({
|
||||
mfaToken: yup
|
||||
@@ -41,6 +44,7 @@ const LoginPage: React.FC = () => {
|
||||
const { settings } = useCompanySettings();
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||
|
||||
// MFA form setup
|
||||
const {
|
||||
@@ -78,6 +82,23 @@ const LoginPage: React.FC = () => {
|
||||
const onSubmit = async (data: LoginFormData) => {
|
||||
try {
|
||||
clearError();
|
||||
|
||||
// Verify reCAPTCHA if enabled
|
||||
if (recaptchaToken) {
|
||||
try {
|
||||
const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
|
||||
if (verifyResponse.status === 'error' || !verifyResponse.data.verified) {
|
||||
toast.error('reCAPTCHA verification failed. Please try again.');
|
||||
setRecaptchaToken(null);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('reCAPTCHA verification failed. Please try again.');
|
||||
setRecaptchaToken(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await login({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
@@ -91,9 +112,11 @@ const LoginPage: React.FC = () => {
|
||||
'/dashboard';
|
||||
navigate(from, { replace: true });
|
||||
}
|
||||
setRecaptchaToken(null);
|
||||
} catch (error) {
|
||||
// Error has been handled in store
|
||||
console.error('Login error:', error);
|
||||
setRecaptchaToken(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -391,6 +414,19 @@ const LoginPage: React.FC = () => {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* reCAPTCHA */}
|
||||
<div className="flex justify-center">
|
||||
<Recaptcha
|
||||
onChange={(token) => setRecaptchaToken(token)}
|
||||
onError={(error) => {
|
||||
console.error('reCAPTCHA error:', error);
|
||||
setRecaptchaToken(null);
|
||||
}}
|
||||
theme="light"
|
||||
size="normal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -22,6 +22,9 @@ import {
|
||||
RegisterFormData,
|
||||
} from '../../utils/validationSchemas';
|
||||
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
|
||||
import { toast } from 'react-toastify';
|
||||
import Recaptcha from '../../components/common/Recaptcha';
|
||||
import { recaptchaService } from '../../services/api/systemSettingsService';
|
||||
|
||||
const RegisterPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -32,6 +35,7 @@ const RegisterPage: React.FC = () => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] =
|
||||
useState(false);
|
||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||
|
||||
// Update page title
|
||||
useEffect(() => {
|
||||
@@ -87,6 +91,23 @@ const RegisterPage: React.FC = () => {
|
||||
const onSubmit = async (data: RegisterFormData) => {
|
||||
try {
|
||||
clearError();
|
||||
|
||||
// Verify reCAPTCHA if enabled
|
||||
if (recaptchaToken) {
|
||||
try {
|
||||
const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
|
||||
if (verifyResponse.status === 'error' || !verifyResponse.data.verified) {
|
||||
toast.error('reCAPTCHA verification failed. Please try again.');
|
||||
setRecaptchaToken(null);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('reCAPTCHA verification failed. Please try again.');
|
||||
setRecaptchaToken(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await registerUser({
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
@@ -96,9 +117,11 @@ const RegisterPage: React.FC = () => {
|
||||
|
||||
// Redirect to login page
|
||||
navigate('/login', { replace: true });
|
||||
setRecaptchaToken(null);
|
||||
} catch (error) {
|
||||
// Error has been handled in store
|
||||
console.error('Register error:', error);
|
||||
setRecaptchaToken(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -443,6 +466,19 @@ const RegisterPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* reCAPTCHA */}
|
||||
<div className="flex justify-center">
|
||||
<Recaptcha
|
||||
onChange={(token) => setRecaptchaToken(token)}
|
||||
onError={(error) => {
|
||||
console.error('reCAPTCHA error:', error);
|
||||
setRecaptchaToken(null);
|
||||
}}
|
||||
theme="light"
|
||||
size="normal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -503,7 +503,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Method */}
|
||||
{/* Payment Method & Status */}
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
<CreditCard className="w-4 h-4 inline mr-1" />
|
||||
@@ -529,6 +529,70 @@ const BookingDetailPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment History */}
|
||||
{booking.payments && booking.payments.length > 0 && (
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Payment History
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{booking.payments.map((payment: any, index: number) => (
|
||||
<div key={payment.id || index} className="p-4 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-200">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg font-bold text-green-700">
|
||||
{formatPrice(payment.amount || 0)}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
|
||||
payment.payment_status === 'completed' || payment.payment_status === 'paid'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: payment.payment_status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{payment.payment_status === 'completed' || payment.payment_status === 'paid' ? 'Paid' : payment.payment_status || 'Pending'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-gray-700">
|
||||
<span className="font-medium">Payment Method:</span>{' '}
|
||||
{payment.payment_method === 'stripe' ? '💳 Stripe (Card)' :
|
||||
payment.payment_method === 'paypal' ? '💳 PayPal' :
|
||||
payment.payment_method === 'cash' ? '💵 Cash' :
|
||||
payment.payment_method || 'N/A'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-700">
|
||||
<span className="font-medium">Payment Type:</span>{' '}
|
||||
{payment.payment_type === 'deposit' ? 'Deposit (20%)' :
|
||||
payment.payment_type === 'remaining' ? 'Remaining Payment' :
|
||||
'Full Payment'}
|
||||
</p>
|
||||
{payment.transaction_id && (
|
||||
<p className="text-xs text-gray-500 font-mono">
|
||||
Transaction ID: {payment.transaction_id}
|
||||
</p>
|
||||
)}
|
||||
{payment.payment_date && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Paid on: {new Date(payment.payment_date).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Price Breakdown */}
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
@@ -579,17 +643,59 @@ const BookingDetailPage: React.FC = () => {
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* Total */}
|
||||
<div className="border-t pt-3 mt-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-lg font-semibold text-gray-900">
|
||||
Total Payment
|
||||
</span>
|
||||
<span className="text-2xl font-bold text-indigo-600">
|
||||
{formatPrice(booking.total_price)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Payment Breakdown */}
|
||||
{(() => {
|
||||
// Calculate amount paid from completed payments
|
||||
const completedPayments = booking.payments?.filter(
|
||||
(p) => p.payment_status === 'completed'
|
||||
) || [];
|
||||
const amountPaid = completedPayments.reduce(
|
||||
(sum, p) => sum + (p.amount || 0),
|
||||
0
|
||||
);
|
||||
const remainingDue = booking.total_price - amountPaid;
|
||||
const hasPayments = completedPayments.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasPayments && (
|
||||
<>
|
||||
<div className="border-t pt-3 mt-3">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Amount Paid:
|
||||
</span>
|
||||
<span className="text-base font-semibold text-green-600">
|
||||
{formatPrice(amountPaid)}
|
||||
</span>
|
||||
</div>
|
||||
{remainingDue > 0 && (
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Remaining Due:
|
||||
</span>
|
||||
<span className="text-base font-semibold text-amber-600">
|
||||
{formatPrice(remainingDue)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Total */}
|
||||
<div className="border-t pt-3 mt-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-lg font-semibold text-gray-900">
|
||||
Total Booking Price
|
||||
</span>
|
||||
<span className="text-2xl font-bold text-indigo-600">
|
||||
{formatPrice(booking.total_price)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -484,20 +484,33 @@ const BookingSuccessPage: React.FC = () => {
|
||||
|
||||
{/* Total Price */}
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex justify-between
|
||||
items-center"
|
||||
>
|
||||
<span className="text-lg font-semibold
|
||||
text-gray-900"
|
||||
>
|
||||
Total Payment
|
||||
</span>
|
||||
<span className="text-2xl font-bold
|
||||
text-indigo-600"
|
||||
>
|
||||
{formatPrice(booking.total_price)}
|
||||
</span>
|
||||
</div>
|
||||
{booking.original_price && booking.discount_amount && booking.discount_amount > 0 ? (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-sm text-gray-600">Subtotal:</span>
|
||||
<span className="text-base font-semibold text-gray-900">{formatPrice(booking.original_price)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm text-green-600">
|
||||
Discount{booking.promotion_code ? ` (${booking.promotion_code})` : ''}:
|
||||
</span>
|
||||
<span className="text-base font-semibold text-green-600">-{formatPrice(booking.discount_amount)}</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-300 pt-2 mt-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-lg font-semibold text-gray-900">Total Payment</span>
|
||||
<span className="text-2xl font-bold text-indigo-600">{formatPrice(booking.total_price)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-lg font-semibold text-gray-900">Total Payment</span>
|
||||
<span className="text-2xl font-bold text-indigo-600">{formatPrice(booking.total_price)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
AlertCircle,
|
||||
CreditCard,
|
||||
ArrowLeft,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { getBookingById, type Booking } from
|
||||
import { getBookingById, cancelBooking, type Booking } from
|
||||
'../../services/api/bookingService';
|
||||
import {
|
||||
getPaymentsByBookingId,
|
||||
@@ -21,13 +22,15 @@ import PayPalPaymentWrapper from '../../components/payments/PayPalPaymentWrapper
|
||||
const DepositPaymentPage: React.FC = () => {
|
||||
const { bookingId } = useParams<{ bookingId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const { formatCurrency, currency } = useFormatCurrency();
|
||||
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [depositPayment, setDepositPayment] = useState<Payment | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [paymentSuccess, setPaymentSuccess] = useState(false);
|
||||
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<'stripe' | 'paypal' | null>(null);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (bookingId) {
|
||||
@@ -86,30 +89,82 @@ const DepositPaymentPage: React.FC = () => {
|
||||
|
||||
const formatPrice = (price: number) => formatCurrency(price);
|
||||
|
||||
const handleCancelBooking = async () => {
|
||||
if (!booking) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`Are you sure you want to cancel this booking?\n\n` +
|
||||
`Booking Number: ${booking.booking_number}\n\n` +
|
||||
`⚠️ Note: This will cancel your booking and free up the room.`
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
setCancelling(true);
|
||||
const response = await cancelBooking(booking.id);
|
||||
|
||||
if (response.success) {
|
||||
toast.success(
|
||||
`✅ Booking ${booking.booking_number} has been cancelled successfully!`
|
||||
);
|
||||
// Navigate to bookings list after cancellation
|
||||
setTimeout(() => {
|
||||
navigate('/bookings');
|
||||
}, 1500);
|
||||
} else {
|
||||
throw new Error(response.message || 'Unable to cancel booking');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error cancelling booking:', err);
|
||||
const message =
|
||||
err.response?.data?.message ||
|
||||
'Unable to cancel booking. Please try again.';
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
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">
|
||||
<Loading fullScreen text="Loading payment information..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !booking || !depositPayment) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div
|
||||
className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] py-8 sm:py-12 w-screen relative -mt-6 -mb-6"
|
||||
style={{
|
||||
marginLeft: 'calc(50% - 50vw)',
|
||||
marginRight: 'calc(50% - 50vw)',
|
||||
width: '100vw',
|
||||
zIndex: 1
|
||||
}}
|
||||
>
|
||||
<div className="w-full px-2 sm:px-4 md:px-6 lg:px-8 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-6 sm: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" />
|
||||
<p className="text-red-700 font-medium mb-4">
|
||||
<AlertCircle className="w-10 h-10 sm:w-12 sm:h-12 text-red-400 mx-auto mb-4" />
|
||||
<p className="text-red-300 font-light text-base sm:text-lg mb-6 tracking-wide px-2">
|
||||
{error || 'Payment information not found'}
|
||||
</p>
|
||||
<Link
|
||||
to="/bookings"
|
||||
className="inline-flex items-center gap-2 px-6 py-2
|
||||
bg-red-600 text-white rounded-lg hover:bg-red-700
|
||||
transition-colors"
|
||||
className="inline-flex items-center gap-2 bg-gradient-to-r
|
||||
from-[#d4af37] to-[#c9a227] text-[#0f0f0f]
|
||||
px-4 py-2 sm:px-6 sm:py-3 rounded-sm hover:from-[#f5d76e]
|
||||
hover:to-[#d4af37] transition-all duration-300
|
||||
font-medium tracking-wide shadow-lg shadow-[#d4af37]/30 text-sm sm:text-base"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<ArrowLeft className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
Back to booking list
|
||||
</Link>
|
||||
</div>
|
||||
@@ -123,36 +178,71 @@ const DepositPaymentPage: React.FC = () => {
|
||||
const isDepositPaid = depositPayment.payment_status === 'completed';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 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-gray-600
|
||||
hover:text-gray-900 mb-6 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span>Back to booking details</span>
|
||||
</Link>
|
||||
<div
|
||||
className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] py-8 sm:py-12 w-screen relative -mt-6 -mb-6"
|
||||
style={{
|
||||
marginLeft: 'calc(50% - 50vw)',
|
||||
marginRight: 'calc(50% - 50vw)',
|
||||
width: '100vw',
|
||||
zIndex: 1
|
||||
}}
|
||||
>
|
||||
<div className="w-full px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4">
|
||||
{/* Back Button and Cancel Button */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 mb-3 sm:mb-4">
|
||||
<Link
|
||||
to={`/bookings/${bookingId}`}
|
||||
className="inline-flex items-center gap-1
|
||||
text-[#d4af37]/80 hover:text-[#d4af37]
|
||||
transition-colors font-light tracking-wide text-xs sm:text-sm"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Back to booking details</span>
|
||||
</Link>
|
||||
|
||||
{/* Cancel Booking Button - Only show if deposit not paid */}
|
||||
{!isDepositPaid && booking && (
|
||||
<button
|
||||
onClick={handleCancelBooking}
|
||||
disabled={cancelling}
|
||||
className="inline-flex items-center gap-1
|
||||
bg-gradient-to-br from-red-900/20 to-red-800/10
|
||||
border border-red-500/30 text-red-300
|
||||
px-2.5 py-1 rounded-sm
|
||||
hover:border-red-400/50 hover:bg-gradient-to-br
|
||||
hover:from-red-800/30 hover:to-red-700/20
|
||||
transition-all duration-300 font-light tracking-wide text-xs sm:text-sm
|
||||
backdrop-blur-sm shadow-sm shadow-red-500/10
|
||||
hover:shadow-md hover:shadow-red-500/20
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
w-full sm:w-auto"
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
{cancelling ? 'Cancelling...' : 'Cancel Booking'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Success Header (if paid) */}
|
||||
{isDepositPaid && (
|
||||
<div
|
||||
className="bg-green-50 border-2 border-green-200
|
||||
rounded-lg p-6 mb-6"
|
||||
className="bg-gradient-to-br from-green-900/20 to-green-800/10
|
||||
border border-green-500/30 rounded-lg p-3 sm:p-4 mb-3 sm:mb-4
|
||||
backdrop-blur-xl shadow-lg shadow-green-500/10"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-2.5 sm:gap-3">
|
||||
<div
|
||||
className="w-16 h-16 bg-green-100 rounded-full
|
||||
flex items-center justify-center"
|
||||
className="w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-green-500/20 to-green-600/20
|
||||
rounded-full flex items-center justify-center flex-shrink-0
|
||||
border border-green-500/30 shadow-sm shadow-green-500/20"
|
||||
>
|
||||
<CheckCircle className="w-10 h-10 text-green-600" />
|
||||
<CheckCircle className="w-6 h-6 sm:w-7 sm:h-7 text-green-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-green-900 mb-1">
|
||||
Deposit payment successful!
|
||||
<div className="flex-1 text-center sm:text-left">
|
||||
<h1 className="text-base sm:text-lg font-serif font-semibold text-green-300 mb-1 tracking-wide">
|
||||
Deposit Payment Successful!
|
||||
</h1>
|
||||
<p className="text-green-700">
|
||||
<p className="text-green-200/80 font-light text-xs sm:text-sm tracking-wide">
|
||||
Your booking has been confirmed.
|
||||
Remaining amount to be paid at check-in.
|
||||
</p>
|
||||
@@ -164,22 +254,24 @@ const DepositPaymentPage: React.FC = () => {
|
||||
{/* Pending Header */}
|
||||
{!isDepositPaid && (
|
||||
<div
|
||||
className="bg-orange-50 border-2 border-orange-200
|
||||
rounded-lg p-6 mb-6"
|
||||
className="bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/5
|
||||
border border-[#d4af37]/30 rounded-lg p-3 sm:p-4 mb-3 sm:mb-4
|
||||
backdrop-blur-xl shadow-lg shadow-[#d4af37]/10"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-2.5 sm:gap-3">
|
||||
<div
|
||||
className="w-16 h-16 bg-orange-100 rounded-full
|
||||
flex items-center justify-center"
|
||||
className="w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
|
||||
rounded-full flex items-center justify-center flex-shrink-0
|
||||
border border-[#d4af37]/30 shadow-sm shadow-[#d4af37]/20"
|
||||
>
|
||||
<CreditCard className="w-10 h-10 text-orange-600" />
|
||||
<CreditCard className="w-6 h-6 sm:w-7 sm:h-7 text-[#d4af37]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-orange-900 mb-1">
|
||||
Deposit Payment
|
||||
<div className="flex-1 text-center sm:text-left">
|
||||
<h1 className="text-base sm:text-lg font-serif font-semibold text-[#d4af37] mb-1 tracking-wide">
|
||||
Deposit Payment Required
|
||||
</h1>
|
||||
<p className="text-orange-700">
|
||||
Please pay <strong>20% deposit</strong> to
|
||||
<p className="text-gray-300/80 font-light text-xs sm:text-sm tracking-wide">
|
||||
Please pay <strong className="text-[#d4af37] font-medium">20% deposit</strong> to
|
||||
confirm your booking
|
||||
</p>
|
||||
</div>
|
||||
@@ -187,132 +279,225 @@ const DepositPaymentPage: React.FC = () => {
|
||||
</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-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||
Payment Information
|
||||
</h2>
|
||||
<div className="w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 sm:gap-4">
|
||||
{/* Payment Info */}
|
||||
<div className="lg:col-span-2 space-y-3">
|
||||
{/* Payment Summary */}
|
||||
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||
border border-gray-700/50 rounded-lg p-3 sm:p-4
|
||||
backdrop-blur-xl shadow-lg shadow-black/20">
|
||||
<h2 className="text-sm sm:text-base font-serif font-semibold text-[#d4af37] mb-2.5 sm:mb-3 tracking-wide">
|
||||
Payment Information
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Total Room Price</span>
|
||||
<span className="font-medium">
|
||||
{formatPrice(booking.total_price)}
|
||||
</span>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-1 sm:gap-0 py-1.5 border-b border-gray-700/30">
|
||||
<span className="text-gray-300 font-light tracking-wide text-xs sm:text-sm">Total Room Price</span>
|
||||
<span className="font-medium text-gray-100 text-xs sm:text-sm">
|
||||
{formatPrice(booking.total_price)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-1 sm:gap-0 py-2
|
||||
border-t-2 border-[#d4af37]/30 pt-2"
|
||||
>
|
||||
<span className="font-medium text-[#d4af37] text-xs sm:text-sm tracking-wide">
|
||||
Deposit Amount to Pay (20%)
|
||||
</span>
|
||||
<span className="text-base sm:text-lg font-bold text-[#d4af37]">
|
||||
{formatPrice(depositAmount)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between gap-0.5 text-[10px] sm:text-xs text-gray-400/80 font-light pt-1">
|
||||
<span>Remaining amount to be paid at check-in</span>
|
||||
<span className="text-gray-300">{formatPrice(remainingAmount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-between border-t pt-3
|
||||
text-orange-600"
|
||||
>
|
||||
<span className="font-medium">
|
||||
Deposit Amount to Pay (20%)
|
||||
</span>
|
||||
<span className="text-xl font-bold">
|
||||
{formatPrice(depositAmount)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-sm text-gray-500">
|
||||
<span>Remaining amount to be paid at check-in</span>
|
||||
<span>{formatPrice(remainingAmount)}</span>
|
||||
</div>
|
||||
{isDepositPaid && (
|
||||
<div className="mt-4 sm:mt-6 bg-gradient-to-br from-green-900/20 to-green-800/10
|
||||
border border-green-500/30 rounded-lg p-3 sm:p-4 backdrop-blur-sm">
|
||||
<p className="text-xs sm:text-sm text-green-300 font-light break-words">
|
||||
✓ Deposit paid on:{' '}
|
||||
{depositPayment.payment_date
|
||||
? new Date(depositPayment.payment_date).toLocaleString('en-US')
|
||||
: 'N/A'}
|
||||
</p>
|
||||
{depositPayment.transaction_id && (
|
||||
<p className="text-xs text-green-400/70 mt-2 font-mono break-all">
|
||||
Transaction ID: {depositPayment.transaction_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isDepositPaid && (
|
||||
<div className="mt-4 bg-green-50 border border-green-200 rounded p-3">
|
||||
<p className="text-sm text-green-800">
|
||||
✓ Deposit paid on:{' '}
|
||||
{depositPayment.payment_date
|
||||
? new Date(depositPayment.payment_date).toLocaleString('en-US')
|
||||
: 'N/A'}
|
||||
{/* Payment Method Selection */}
|
||||
{!isDepositPaid && !selectedPaymentMethod && (
|
||||
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||
border border-gray-700/50 rounded-lg p-3 sm:p-4
|
||||
backdrop-blur-xl shadow-lg shadow-black/20">
|
||||
<h2 className="text-sm sm:text-base font-serif font-semibold text-[#d4af37] mb-1.5 tracking-wide">
|
||||
Choose Payment Method
|
||||
</h2>
|
||||
<p className="text-gray-300/80 font-light mb-3 sm:mb-4 tracking-wide text-xs sm:text-sm">
|
||||
Please select how you would like to pay the deposit:
|
||||
</p>
|
||||
{depositPayment.transaction_id && (
|
||||
<p className="text-xs text-green-700 mt-1">
|
||||
Transaction ID: {depositPayment.transaction_id}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 sm:gap-3">
|
||||
{/* Stripe Option */}
|
||||
<button
|
||||
onClick={() => setSelectedPaymentMethod('stripe')}
|
||||
className="bg-gradient-to-br from-gray-800/40 to-gray-700/20
|
||||
border-2 border-gray-600/30 rounded-lg p-3
|
||||
hover:border-[#d4af37]/50 hover:bg-gradient-to-br
|
||||
hover:from-[#d4af37]/10 hover:to-[#c9a227]/5
|
||||
transition-all duration-300 text-left group
|
||||
backdrop-blur-sm shadow-sm shadow-black/10
|
||||
hover:shadow-md hover:shadow-[#d4af37]/20"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-br from-indigo-500/20 to-indigo-600/20
|
||||
rounded-lg flex items-center justify-center
|
||||
border border-indigo-500/30 group-hover:border-[#d4af37]/50
|
||||
transition-colors">
|
||||
<CreditCard className="w-4 h-4 sm:w-5 sm:h-5 text-indigo-400 group-hover:text-[#d4af37] transition-colors" />
|
||||
</div>
|
||||
<span className="text-[10px] sm:text-xs font-semibold text-indigo-400 group-hover:text-[#d4af37]
|
||||
transition-colors tracking-wide">Card Payment</span>
|
||||
</div>
|
||||
<p className="text-[10px] sm:text-xs text-gray-300/70 font-light group-hover:text-gray-200 transition-colors">
|
||||
Pay with credit or debit card via Stripe
|
||||
</p>
|
||||
</button>
|
||||
|
||||
{/* PayPal Option */}
|
||||
<button
|
||||
onClick={() => setSelectedPaymentMethod('paypal')}
|
||||
className="bg-gradient-to-br from-gray-800/40 to-gray-700/20
|
||||
border-2 border-gray-600/30 rounded-lg p-3
|
||||
hover:border-[#d4af37]/50 hover:bg-gradient-to-br
|
||||
hover:from-[#d4af37]/10 hover:to-[#c9a227]/5
|
||||
transition-all duration-300 text-left group
|
||||
backdrop-blur-sm shadow-sm shadow-black/10
|
||||
hover:shadow-md hover:shadow-[#d4af37]/20"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-br from-blue-500/20 to-blue-600/20
|
||||
rounded-lg flex items-center justify-center
|
||||
border border-blue-500/30 group-hover:border-[#d4af37]/50
|
||||
transition-colors">
|
||||
<svg
|
||||
className="w-4 h-4 sm:w-5 sm:h-5 text-blue-400 group-hover:text-[#d4af37] transition-colors"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M7.076 21.337H2.47a.641.641 0 0 1-.633-.74L4.944.901C5.026.382 5.474 0 5.998 0h7.46c2.57 0 4.578.543 5.69 1.81 1.01 1.15 1.304 2.42 1.012 4.287-.023.143-.047.288-.077.437-.983 5.05-4.349 6.797-8.647 6.797h-2.19c-.524 0-.968.382-1.05.9l-1.12 7.203zm14.146-14.42a.477.477 0 0 0-.414-.24h-3.84c-.48 0-.856.355-.932.826-.075.47-.232 1.21-.232 1.21s-.156-.74-.232-1.21a.957.957 0 0 0-.932-.826H5.342a.957.957 0 0 0-.932.826c-.076.47-.232 1.21-.232 1.21s-.156-.74-.232-1.21a.957.957 0 0 0-.932-.826H.477a.477.477 0 0 0-.414.24c-.11.19-.14.426-.08.643.06.217.2.4.388.51l.04.02c.19.11.426.14.643.08.217-.06.4-.2.51-.388l.01-.02c.11-.19.14-.426.08-.643a.955.955 0 0 0-.388-.51l-.01-.01a.955.955 0 0 0-.51-.388.955.955 0 0 0-.643.08l-.01.01a.955.955 0 0 0-.388.51c-.06.217-.03.453.08.643l.01.02c.11.188.293.328.51.388.217.06.453.03.643-.08l.01-.02c.188-.11.328-.293.388-.51.06-.217.03-.453-.08-.643l-.01-.01z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-[10px] sm:text-xs font-semibold text-blue-400 group-hover:text-[#d4af37]
|
||||
transition-colors tracking-wide">PayPal</span>
|
||||
</div>
|
||||
<p className="text-[10px] sm:text-xs text-gray-300/70 font-light group-hover:text-gray-200 transition-colors">
|
||||
Pay securely with your PayPal account
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Method Selection Header (when method is selected) */}
|
||||
{!isDepositPaid && selectedPaymentMethod && (
|
||||
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||
border border-gray-700/50 rounded-lg p-3 sm:p-4 mb-3 sm:mb-4
|
||||
backdrop-blur-xl shadow-lg shadow-black/10">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-0">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-serif font-semibold text-[#d4af37] mb-0.5 tracking-wide">
|
||||
{selectedPaymentMethod === 'stripe' ? 'Card Payment' : 'PayPal Payment'}
|
||||
</h3>
|
||||
<p className="text-xs sm:text-sm text-gray-300/80 font-light tracking-wide">
|
||||
Pay deposit of <span className="text-[#d4af37] font-medium">{formatPrice(depositAmount)}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedPaymentMethod(null)}
|
||||
className="text-[10px] sm:text-xs text-gray-400 hover:text-[#d4af37]
|
||||
underline transition-colors font-light tracking-wide self-start sm:self-auto"
|
||||
>
|
||||
Change method
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stripe Payment Panel */}
|
||||
{!isDepositPaid && booking && depositPayment && selectedPaymentMethod === 'stripe' && (
|
||||
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||
border border-gray-700/50 rounded-lg p-3 sm:p-4
|
||||
backdrop-blur-xl shadow-lg shadow-black/20">
|
||||
{paymentSuccess ? (
|
||||
<div className="bg-gradient-to-br from-green-900/20 to-green-800/10
|
||||
border border-green-500/30 rounded-lg p-4 sm:p-5 text-center
|
||||
backdrop-blur-sm">
|
||||
<CheckCircle className="w-10 h-10 sm:w-12 sm:h-12 text-green-400 mx-auto mb-3" />
|
||||
<h3 className="text-base sm:text-lg font-serif font-semibold text-green-300 mb-2 tracking-wide">
|
||||
Payment Successful!
|
||||
</h3>
|
||||
<p className="text-green-200/80 mb-4 font-light tracking-wide text-xs sm:text-sm">
|
||||
Your deposit payment has been confirmed.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate(`/bookings/${booking.id}`)}
|
||||
className="bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||
text-[#0f0f0f] px-4 py-1.5 sm:px-5 sm:py-2 rounded-sm
|
||||
hover:from-[#f5d76e] hover:to-[#d4af37]
|
||||
transition-all duration-300 font-medium tracking-wide
|
||||
shadow-sm shadow-[#d4af37]/30 text-xs sm:text-sm"
|
||||
>
|
||||
View Booking
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment Method Selection */}
|
||||
{!isDepositPaid && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6">
|
||||
Payment Method
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Pay with your credit or debit card
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<CreditCard className="w-5 h-5 inline mr-2" />
|
||||
Card Payment
|
||||
</h2>
|
||||
|
||||
{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>
|
||||
) : (
|
||||
<StripePaymentWrapper
|
||||
{/* PayPal Payment Panel */}
|
||||
{!isDepositPaid && booking && depositPayment && selectedPaymentMethod === 'paypal' && (
|
||||
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||
border border-gray-700/50 rounded-lg p-3 sm:p-4
|
||||
backdrop-blur-xl shadow-lg shadow-black/20">
|
||||
<PayPalPaymentWrapper
|
||||
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);
|
||||
}}
|
||||
currency={currency || 'USD'}
|
||||
onError={(error) => {
|
||||
toast.error(error || 'Payment failed');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PayPal Payment Panel */}
|
||||
{!paymentSuccess && booking && depositPayment && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||
<CreditCard className="w-5 h-5 inline mr-2" />
|
||||
PayPal Payment
|
||||
</h2>
|
||||
<PayPalPaymentWrapper
|
||||
bookingId={booking.id}
|
||||
amount={depositAmount}
|
||||
onError={(error) => {
|
||||
toast.error(error || 'Payment failed');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -564,6 +564,33 @@ const MyBookingsPage: React.FC = () => {
|
||||
>
|
||||
{formatPrice(booking.total_price)}
|
||||
</p>
|
||||
{(() => {
|
||||
const completedPayments = booking.payments?.filter(
|
||||
(p) => p.payment_status === 'completed'
|
||||
) || [];
|
||||
const amountPaid = completedPayments.reduce(
|
||||
(sum, p) => sum + (p.amount || 0),
|
||||
0
|
||||
);
|
||||
const remainingDue = booking.total_price - amountPaid;
|
||||
const hasPayments = completedPayments.length > 0;
|
||||
|
||||
if (hasPayments) {
|
||||
return (
|
||||
<div className="text-xs mt-1">
|
||||
<div className="text-green-600 font-medium">
|
||||
Paid: {formatPrice(amountPaid)}
|
||||
</div>
|
||||
{remainingDue > 0 && (
|
||||
<div className="text-amber-600 font-medium">
|
||||
Due: {formatPrice(remainingDue)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,35 +1,95 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { XCircle, ArrowLeft } from 'lucide-react';
|
||||
import { XCircle, ArrowLeft, Loader2 } from 'lucide-react';
|
||||
import { cancelPayPalPayment } from '../../services/api/paymentService';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
const PayPalCancelPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const bookingId = searchParams.get('bookingId');
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCancel = async () => {
|
||||
if (!bookingId) return;
|
||||
|
||||
try {
|
||||
setCancelling(true);
|
||||
const response = await cancelPayPalPayment(Number(bookingId));
|
||||
|
||||
if (response.success) {
|
||||
toast.info('Payment canceled. Your booking has been automatically cancelled.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error canceling payment:', err);
|
||||
// Don't show error toast - user already canceled, just log it
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
handleCancel();
|
||||
}, [bookingId]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center">
|
||||
<XCircle className="w-16 h-16 text-orange-500 mx-auto mb-4" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Payment Cancelled
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
You cancelled the PayPal payment. No charges were made.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]
|
||||
flex items-center justify-center py-8 sm:py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||
border border-gray-700/50 rounded-xl p-6 sm:p-10 w-full max-w-2xl mx-auto text-center
|
||||
backdrop-blur-xl shadow-2xl shadow-black/20">
|
||||
{cancelling ? (
|
||||
<>
|
||||
<div className="w-16 h-16 sm:w-20 sm:h-20 bg-gradient-to-br from-orange-500/20 to-orange-600/20
|
||||
rounded-full flex items-center justify-center mx-auto mb-4 sm:mb-6
|
||||
border border-orange-500/30 shadow-lg shadow-orange-500/20">
|
||||
<Loader2 className="w-10 h-10 sm:w-12 sm:h-12 text-orange-400 animate-spin" />
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-3xl font-serif font-semibold text-orange-300 mb-3 tracking-wide">
|
||||
Processing Cancellation
|
||||
</h1>
|
||||
<p className="text-gray-300/80 font-light text-base sm:text-lg mb-6 sm:mb-8 tracking-wide px-2">
|
||||
Canceling your payment and booking...
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-16 h-16 sm:w-20 sm:h-20 bg-gradient-to-br from-orange-500/20 to-orange-600/20
|
||||
rounded-full flex items-center justify-center mx-auto mb-4 sm:mb-6
|
||||
border border-orange-500/30 shadow-lg shadow-orange-500/20">
|
||||
<XCircle className="w-10 h-10 sm:w-12 sm:h-12 text-orange-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-3xl font-serif font-semibold text-orange-300 mb-3 tracking-wide">
|
||||
Payment Cancelled
|
||||
</h1>
|
||||
<p className="text-gray-300/80 font-light text-base sm:text-lg mb-6 sm:mb-8 tracking-wide leading-relaxed px-2">
|
||||
You cancelled the PayPal payment. No charges were made. Your booking has been automatically cancelled.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
|
||||
{bookingId && (
|
||||
<button
|
||||
onClick={() => navigate(`/deposit-payment/${bookingId}`)}
|
||||
className="flex-1 bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition-colors flex items-center justify-center gap-2"
|
||||
onClick={() => navigate(`/payment/deposit/${bookingId}`)}
|
||||
className="flex-1 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||
text-[#0f0f0f] px-4 py-2 sm:px-6 sm:py-3 rounded-sm
|
||||
hover:from-[#f5d76e] hover:to-[#d4af37]
|
||||
transition-all duration-300 font-medium tracking-wide
|
||||
shadow-lg shadow-[#d4af37]/30 flex items-center justify-center gap-2
|
||||
disabled:opacity-50 disabled:cursor-not-allowed text-sm sm:text-base"
|
||||
disabled={cancelling}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<ArrowLeft className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
Try Again
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate('/bookings')}
|
||||
className="flex-1 bg-gray-200 text-gray-700 px-6 py-2 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
className="flex-1 bg-gradient-to-br from-gray-800/40 to-gray-700/20
|
||||
border border-gray-600/30 text-gray-300 px-4 py-2 sm:px-6 sm:py-3 rounded-sm
|
||||
hover:border-[#d4af37]/50 hover:text-[#d4af37]
|
||||
transition-all duration-300 font-light tracking-wide
|
||||
backdrop-blur-sm text-sm sm:text-base"
|
||||
>
|
||||
My Bookings
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { capturePayPalPayment } from '../../services/api/paymentService';
|
||||
import { capturePayPalPayment, cancelPayPalPayment } from '../../services/api/paymentService';
|
||||
import { toast } from 'react-toastify';
|
||||
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import Loading from '../../components/common/Loading';
|
||||
@@ -37,6 +37,13 @@ const PayPalReturnPage: React.FC = () => {
|
||||
} else {
|
||||
setError(response.message || 'Payment capture failed');
|
||||
toast.error(response.message || 'Payment capture failed');
|
||||
|
||||
// If payment capture fails, cancel the payment and booking
|
||||
try {
|
||||
await cancelPayPalPayment(Number(bookingId));
|
||||
} catch (cancelErr) {
|
||||
console.error('Error canceling payment after capture failure:', cancelErr);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.message || err.message || 'Failed to capture payment';
|
||||
@@ -52,10 +59,17 @@ const PayPalReturnPage: React.FC = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-indigo-600 mx-auto mb-4" />
|
||||
<p className="text-gray-600">Processing your payment...</p>
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]
|
||||
flex items-center justify-center py-8 sm:py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center w-full max-w-2xl mx-auto">
|
||||
<div className="w-16 h-16 sm:w-20 sm:h-20 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
|
||||
rounded-full flex items-center justify-center mx-auto mb-4 sm:mb-6
|
||||
border border-[#d4af37]/30 shadow-lg shadow-[#d4af37]/20">
|
||||
<Loader2 className="w-10 h-10 sm:w-12 sm:h-12 text-[#d4af37] animate-spin" />
|
||||
</div>
|
||||
<p className="text-gray-300/80 font-light text-base sm:text-lg tracking-wide">
|
||||
Processing your payment...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -63,18 +77,29 @@ const PayPalReturnPage: React.FC = () => {
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center">
|
||||
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]
|
||||
flex items-center justify-center py-8 sm:py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||
border border-gray-700/50 rounded-xl p-6 sm:p-10 w-full max-w-2xl mx-auto text-center
|
||||
backdrop-blur-xl shadow-2xl shadow-black/20">
|
||||
<div className="w-16 h-16 sm:w-20 sm:h-20 bg-gradient-to-br from-green-500/20 to-green-600/20
|
||||
rounded-full flex items-center justify-center mx-auto mb-4 sm:mb-6
|
||||
border border-green-500/30 shadow-lg shadow-green-500/20">
|
||||
<CheckCircle className="w-10 h-10 sm:w-12 sm:h-12 text-green-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-3xl font-serif font-semibold text-green-300 mb-3 tracking-wide">
|
||||
Payment Successful!
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
<p className="text-gray-300/80 font-light text-base sm:text-lg mb-6 sm:mb-8 tracking-wide px-2">
|
||||
Your payment has been confirmed. Redirecting to booking details...
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate(`/bookings/${bookingId}`)}
|
||||
className="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
className="bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||
text-[#0f0f0f] px-6 py-2 sm:px-8 sm:py-3 rounded-sm
|
||||
hover:from-[#f5d76e] hover:to-[#d4af37]
|
||||
transition-all duration-300 font-medium tracking-wide
|
||||
shadow-lg shadow-[#d4af37]/30 text-sm sm:text-base w-full sm:w-auto"
|
||||
>
|
||||
View Booking
|
||||
</button>
|
||||
@@ -84,25 +109,40 @@ const PayPalReturnPage: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center">
|
||||
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f]
|
||||
flex items-center justify-center py-8 sm:py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||
border border-gray-700/50 rounded-xl p-6 sm:p-10 w-full max-w-2xl text-center
|
||||
backdrop-blur-xl shadow-2xl shadow-black/20">
|
||||
<div className="w-16 h-16 sm:w-20 sm:h-20 bg-gradient-to-br from-red-500/20 to-red-600/20
|
||||
rounded-full flex items-center justify-center mx-auto mb-4 sm:mb-6
|
||||
border border-red-500/30 shadow-lg shadow-red-500/20">
|
||||
<XCircle className="w-10 h-10 sm:w-12 sm:h-12 text-red-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-3xl font-serif font-semibold text-red-300 mb-3 tracking-wide">
|
||||
Payment Failed
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
<p className="text-gray-300/80 font-light text-base sm:text-lg mb-6 sm:mb-8 tracking-wide leading-relaxed px-2">
|
||||
{error || 'Unable to process your payment. Please try again.'}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
|
||||
<button
|
||||
onClick={() => navigate(`/deposit-payment/${bookingId}`)}
|
||||
className="flex-1 bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
onClick={() => navigate(`/payment/deposit/${bookingId}`)}
|
||||
className="flex-1 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
|
||||
text-[#0f0f0f] px-4 py-2 sm:px-6 sm:py-3 rounded-sm
|
||||
hover:from-[#f5d76e] hover:to-[#d4af37]
|
||||
transition-all duration-300 font-medium tracking-wide
|
||||
shadow-lg shadow-[#d4af37]/30 text-sm sm:text-base"
|
||||
>
|
||||
Retry Payment
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/bookings')}
|
||||
className="flex-1 bg-gray-200 text-gray-700 px-6 py-2 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
className="flex-1 bg-gradient-to-br from-gray-800/40 to-gray-700/20
|
||||
border border-gray-600/30 text-gray-300 px-4 py-2 sm:px-6 sm:py-3 rounded-sm
|
||||
hover:border-[#d4af37]/50 hover:text-[#d4af37]
|
||||
transition-all duration-300 font-light tracking-wide
|
||||
backdrop-blur-sm text-sm sm:text-base"
|
||||
>
|
||||
My Bookings
|
||||
</button>
|
||||
|
||||
@@ -71,8 +71,18 @@ const RoomDetailPage: React.FC = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<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="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-screen relative -mt-6 -mb-6"
|
||||
style={{
|
||||
marginLeft: 'calc(50% - 50vw)',
|
||||
marginRight: 'calc(50% - 50vw)',
|
||||
width: '100vw',
|
||||
paddingTop: '1.5rem',
|
||||
paddingBottom: '1.5rem',
|
||||
zIndex: 1
|
||||
}}
|
||||
>
|
||||
<div className="w-full px-2 sm:px-4 md:px-6 lg:px-8 py-8 sm:py-12">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<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" />
|
||||
@@ -85,8 +95,18 @@ const RoomDetailPage: React.FC = () => {
|
||||
|
||||
if (error || !room) {
|
||||
return (
|
||||
<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="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-screen relative -mt-6 -mb-6"
|
||||
style={{
|
||||
marginLeft: 'calc(50% - 50vw)',
|
||||
marginRight: 'calc(50% - 50vw)',
|
||||
width: '100vw',
|
||||
paddingTop: '1.5rem',
|
||||
paddingBottom: '1.5rem',
|
||||
zIndex: 1
|
||||
}}
|
||||
>
|
||||
<div className="w-full px-2 sm:px-4 md:px-6 lg:px-8 py-8 sm:py-12">
|
||||
<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"
|
||||
@@ -115,22 +135,32 @@ const RoomDetailPage: React.FC = () => {
|
||||
const formattedPrice = formatCurrency(room?.price || roomType?.base_price || 0);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div
|
||||
className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-screen relative -mt-6 -mb-6"
|
||||
style={{
|
||||
marginLeft: 'calc(50% - 50vw)',
|
||||
marginRight: 'calc(50% - 50vw)',
|
||||
width: '100vw',
|
||||
paddingTop: '1.5rem',
|
||||
paddingBottom: '1.5rem',
|
||||
zIndex: 1
|
||||
}}
|
||||
>
|
||||
<div className="w-full px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4">
|
||||
{/* Back Button */}
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="inline-flex items-center gap-2
|
||||
className="inline-flex items-center gap-1
|
||||
text-[#d4af37]/80 hover:text-[#d4af37]
|
||||
mb-8 transition-all duration-300
|
||||
group font-light tracking-wide"
|
||||
mb-3 transition-all duration-300
|
||||
group font-light tracking-wide text-xs sm:text-sm"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
|
||||
<ArrowLeft className="w-3.5 h-3.5 group-hover:-translate-x-1 transition-transform" />
|
||||
<span>Back to room list</span>
|
||||
</Link>
|
||||
|
||||
{/* Image Gallery */}
|
||||
<div className="mb-12">
|
||||
<div className="mb-4">
|
||||
<RoomGallery
|
||||
images={(room.images && room.images.length > 0)
|
||||
? room.images
|
||||
@@ -140,30 +170,30 @@ const RoomDetailPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Room Information */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 mb-16">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-3 sm:gap-4 lg:gap-6 mb-4 sm:mb-5">
|
||||
{/* Main Info */}
|
||||
<div className="lg:col-span-8 space-y-10">
|
||||
<div className="lg:col-span-8 space-y-3 sm:space-y-4">
|
||||
{/* Title & Basic Info */}
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
{/* 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">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
{room.featured && (
|
||||
<div className="flex items-center gap-2
|
||||
<div className="flex items-center gap-1
|
||||
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"
|
||||
text-[#0f0f0f] px-2 py-0.5 rounded-sm
|
||||
text-[10px] sm:text-xs font-medium tracking-wide
|
||||
shadow-sm shadow-[#d4af37]/30"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<Sparkles className="w-2.5 h-2.5" />
|
||||
Featured
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`px-4 py-1.5 rounded-sm
|
||||
text-xs font-medium tracking-wide
|
||||
backdrop-blur-sm shadow-lg
|
||||
className={`px-2 py-0.5 rounded-sm
|
||||
text-[10px] sm:text-xs font-medium tracking-wide
|
||||
backdrop-blur-sm shadow-sm
|
||||
${
|
||||
room.status === 'available'
|
||||
? 'bg-green-500/90 text-white border border-green-400/50'
|
||||
@@ -180,8 +210,8 @@ const RoomDetailPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl font-serif font-semibold
|
||||
text-white mb-6 tracking-tight leading-tight
|
||||
<h1 className="text-xl sm:text-2xl lg:text-3xl font-serif font-semibold
|
||||
text-white mb-2 tracking-tight leading-tight
|
||||
bg-gradient-to-r from-white via-[#d4af37] to-white
|
||||
bg-clip-text text-transparent"
|
||||
>
|
||||
@@ -191,63 +221,63 @@ const RoomDetailPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* 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]
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 sm:gap-3 mb-3">
|
||||
<div className="flex items-center gap-2
|
||||
p-2 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
|
||||
<div className="p-1 bg-[#d4af37]/10 rounded-lg
|
||||
border border-[#d4af37]/30">
|
||||
<MapPin className="w-5 h-5 text-[#d4af37]" />
|
||||
<MapPin className="w-3.5 h-3.5 text-[#d4af37]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 font-light tracking-wide mb-0.5">
|
||||
<p className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide mb-0.5">
|
||||
Location
|
||||
</p>
|
||||
<p className="text-white font-light tracking-wide">
|
||||
<p className="text-xs sm:text-sm text-white font-light tracking-wide">
|
||||
Room {room.room_number} - Floor {room.floor}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3
|
||||
p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
<div className="flex items-center gap-2
|
||||
p-2 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
rounded-lg border border-[#d4af37]/20"
|
||||
>
|
||||
<div className="p-2 bg-[#d4af37]/10 rounded-lg
|
||||
<div className="p-1 bg-[#d4af37]/10 rounded-lg
|
||||
border border-[#d4af37]/30">
|
||||
<Users className="w-5 h-5 text-[#d4af37]" />
|
||||
<Users className="w-3.5 h-3.5 text-[#d4af37]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 font-light tracking-wide mb-0.5">
|
||||
<p className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide mb-0.5">
|
||||
Capacity
|
||||
</p>
|
||||
<p className="text-white font-light tracking-wide">
|
||||
<p className="text-xs sm:text-sm 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]
|
||||
<div className="flex items-center gap-2
|
||||
p-2 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
|
||||
<div className="p-1 bg-[#d4af37]/10 rounded-lg
|
||||
border border-[#d4af37]/30">
|
||||
<Star className="w-5 h-5 text-[#d4af37] fill-[#d4af37]" />
|
||||
<Star className="w-3.5 h-3.5 text-[#d4af37] fill-[#d4af37]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 font-light tracking-wide mb-0.5">
|
||||
<p className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide mb-0.5">
|
||||
Rating
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-white font-semibold">
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-xs sm:text-sm text-white font-semibold">
|
||||
{Number(room.average_rating).toFixed(1)}
|
||||
</p>
|
||||
<span className="text-xs text-gray-500 font-light">
|
||||
<span className="text-[10px] sm:text-xs text-gray-500 font-light">
|
||||
({room.total_reviews || 0})
|
||||
</span>
|
||||
</div>
|
||||
@@ -259,23 +289,23 @@ const RoomDetailPage: React.FC = () => {
|
||||
|
||||
{/* 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="p-3 sm:p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
rounded-lg border border-[#d4af37]/20
|
||||
backdrop-blur-xl shadow-lg shadow-[#d4af37]/5"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-[#d4af37]/10 rounded-lg
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="p-1 bg-[#d4af37]/10 rounded-lg
|
||||
border border-[#d4af37]/30">
|
||||
<Award className="w-5 h-5 text-[#d4af37]" />
|
||||
<Award className="w-3.5 h-3.5 text-[#d4af37]" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-serif font-semibold
|
||||
<h2 className="text-sm sm:text-base font-serif font-semibold
|
||||
text-white tracking-wide"
|
||||
>
|
||||
{room?.description ? 'Room Description' : 'Room Type Description'}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-gray-300 leading-relaxed
|
||||
font-light tracking-wide text-lg"
|
||||
font-light tracking-wide text-xs sm:text-sm"
|
||||
>
|
||||
{room?.description || roomType?.description}
|
||||
</p>
|
||||
@@ -283,16 +313,16 @@ const RoomDetailPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Amenities */}
|
||||
<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="p-3 sm:p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
rounded-lg border border-[#d4af37]/20
|
||||
backdrop-blur-xl shadow-lg shadow-[#d4af37]/5"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-[#d4af37]/10 rounded-lg
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="p-1 bg-[#d4af37]/10 rounded-lg
|
||||
border border-[#d4af37]/30">
|
||||
<Sparkles className="w-5 h-5 text-[#d4af37]" />
|
||||
<Sparkles className="w-3.5 h-3.5 text-[#d4af37]" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-serif font-semibold
|
||||
<h2 className="text-sm sm:text-base font-serif font-semibold
|
||||
text-white tracking-wide"
|
||||
>
|
||||
Amenities & Features
|
||||
@@ -311,25 +341,25 @@ const RoomDetailPage: React.FC = () => {
|
||||
{/* Booking Card */}
|
||||
<aside className="lg:col-span-4">
|
||||
<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"
|
||||
rounded-lg border border-[#d4af37]/30
|
||||
backdrop-blur-xl shadow-lg shadow-[#d4af37]/20
|
||||
p-3 sm:p-4 sticky top-4"
|
||||
>
|
||||
{/* 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">
|
||||
<div className="mb-4 pb-4 border-b border-[#d4af37]/20">
|
||||
<p className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide mb-1">
|
||||
Starting from
|
||||
</p>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<CurrencyIcon className="text-[#d4af37]" size={24} />
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<CurrencyIcon className="text-[#d4af37]" size={16} />
|
||||
<div>
|
||||
<div className="text-4xl font-serif font-semibold
|
||||
<div className="text-2xl sm:text-3xl 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">
|
||||
<div className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide mt-0.5">
|
||||
/ night
|
||||
</div>
|
||||
</div>
|
||||
@@ -337,23 +367,23 @@ const RoomDetailPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Booking Button */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-3">
|
||||
<Link
|
||||
to={`/booking/${room.id}`}
|
||||
className={`block w-full py-4 text-center
|
||||
className={`block w-full py-2 text-center
|
||||
font-medium rounded-sm transition-all duration-300
|
||||
tracking-wide relative overflow-hidden group
|
||||
tracking-wide relative overflow-hidden group text-xs sm:text-sm
|
||||
${
|
||||
room.status === 'available'
|
||||
? '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-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] hover:from-[#f5d76e] hover:to-[#d4af37] shadow-sm 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" />
|
||||
<span className="relative z-10 flex items-center justify-center gap-1.5">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
{room.status === 'available' ? 'Book Now' : 'Not Available'}
|
||||
</span>
|
||||
{room.status === 'available' && (
|
||||
@@ -363,42 +393,42 @@ const RoomDetailPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{room.status === 'available' && (
|
||||
<div className="flex items-start gap-3 p-4 bg-[#d4af37]/5
|
||||
rounded-lg border border-[#d4af37]/20 mb-6"
|
||||
<div className="flex items-start gap-2 p-2 bg-[#d4af37]/5
|
||||
rounded-lg border border-[#d4af37]/20 mb-3"
|
||||
>
|
||||
<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">
|
||||
<Shield className="w-3.5 h-3.5 text-[#d4af37] mt-0.5 flex-shrink-0" />
|
||||
<p className="text-[10px] sm:text-xs text-gray-300 font-light tracking-wide leading-relaxed">
|
||||
No immediate charge — secure your booking now and pay at the hotel
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Room Details */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between
|
||||
py-3 border-b border-[#d4af37]/10"
|
||||
py-1.5 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>
|
||||
<span className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide">Room Type</span>
|
||||
<strong className="text-xs sm:text-sm text-white font-light">{roomType?.name}</strong>
|
||||
</div>
|
||||
<div className="flex items-center justify-between
|
||||
py-3 border-b border-[#d4af37]/10"
|
||||
py-1.5 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>
|
||||
<span className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide">Max Guests</span>
|
||||
<span className="text-xs sm:text-sm 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"
|
||||
py-1.5 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>
|
||||
<span className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide">Room Size</span>
|
||||
<span className="text-xs sm:text-sm 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 className={`flex items-center justify-between ${room?.room_size ? 'py-1.5 border-b border-[#d4af37]/10' : 'py-1.5'}`}>
|
||||
<span className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide">View</span>
|
||||
<span className="text-xs sm:text-sm text-white font-light">{room.view}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -407,9 +437,9 @@ const RoomDetailPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Reviews Section */}
|
||||
<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"
|
||||
<div className="mb-4 p-3 sm:p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
rounded-lg border border-[#d4af37]/20
|
||||
backdrop-blur-xl shadow-lg shadow-[#d4af37]/5"
|
||||
>
|
||||
<ReviewSection roomId={room.id} />
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,9 @@ export interface Booking {
|
||||
check_out_date: string;
|
||||
guest_count: number;
|
||||
total_price: number;
|
||||
original_price?: number;
|
||||
discount_amount?: number;
|
||||
promotion_code?: string;
|
||||
status:
|
||||
| 'pending'
|
||||
| 'confirmed'
|
||||
@@ -70,6 +73,13 @@ export interface Booking {
|
||||
phone_number?: string;
|
||||
};
|
||||
payments?: Payment[];
|
||||
payment_balance?: {
|
||||
total_paid: number;
|
||||
total_price: number;
|
||||
remaining_balance: number;
|
||||
is_fully_paid: boolean;
|
||||
payment_percentage: number;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -365,6 +365,30 @@ export const capturePayPalPayment = async (
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancel PayPal payment (when user cancels on PayPal page)
|
||||
* POST /api/payments/paypal/cancel
|
||||
*/
|
||||
export const cancelPayPalPayment = async (
|
||||
bookingId: number
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}> => {
|
||||
const response = await apiClient.post(
|
||||
'/payments/paypal/cancel',
|
||||
{
|
||||
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,
|
||||
message: data.message,
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
createPayment,
|
||||
getPayments,
|
||||
@@ -378,4 +402,5 @@ export default {
|
||||
confirmStripePayment,
|
||||
createPayPalOrder,
|
||||
capturePayPalPayment,
|
||||
cancelPayPalPayment,
|
||||
};
|
||||
|
||||
@@ -107,6 +107,7 @@ export interface CompanySettingsResponse {
|
||||
company_phone: string;
|
||||
company_email: string;
|
||||
company_address: string;
|
||||
tax_rate: number;
|
||||
updated_at?: string | null;
|
||||
updated_by?: string | null;
|
||||
};
|
||||
@@ -119,6 +120,7 @@ export interface UpdateCompanySettingsRequest {
|
||||
company_phone?: string;
|
||||
company_email?: string;
|
||||
company_address?: string;
|
||||
tax_rate?: number;
|
||||
}
|
||||
|
||||
export interface UploadLogoResponse {
|
||||
@@ -139,6 +141,49 @@ export interface UploadFaviconResponse {
|
||||
};
|
||||
}
|
||||
|
||||
export interface RecaptchaSettingsResponse {
|
||||
status: string;
|
||||
data: {
|
||||
recaptcha_site_key: string;
|
||||
recaptcha_enabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RecaptchaSettingsAdminResponse {
|
||||
status: string;
|
||||
data: {
|
||||
recaptcha_site_key: string;
|
||||
recaptcha_secret_key: string;
|
||||
recaptcha_secret_key_masked: string;
|
||||
recaptcha_enabled: boolean;
|
||||
has_site_key: boolean;
|
||||
has_secret_key: boolean;
|
||||
updated_at?: string | null;
|
||||
updated_by?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateRecaptchaSettingsRequest {
|
||||
recaptcha_site_key?: string;
|
||||
recaptcha_secret_key?: string;
|
||||
recaptcha_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface VerifyRecaptchaRequest {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface VerifyRecaptchaResponse {
|
||||
status: string;
|
||||
data: {
|
||||
verified: boolean;
|
||||
score?: number;
|
||||
action?: string;
|
||||
error_codes?: string[];
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const systemSettingsService = {
|
||||
/**
|
||||
* Get platform currency (public endpoint)
|
||||
@@ -311,7 +356,56 @@ const systemSettingsService = {
|
||||
},
|
||||
};
|
||||
|
||||
const recaptchaService = {
|
||||
/**
|
||||
* Get reCAPTCHA settings (public endpoint)
|
||||
*/
|
||||
getRecaptchaSettings: async (): Promise<RecaptchaSettingsResponse> => {
|
||||
const response = await apiClient.get<RecaptchaSettingsResponse>(
|
||||
'/api/admin/system-settings/recaptcha'
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get reCAPTCHA settings (admin only)
|
||||
*/
|
||||
getRecaptchaSettingsAdmin: async (): Promise<RecaptchaSettingsAdminResponse> => {
|
||||
const response = await apiClient.get<RecaptchaSettingsAdminResponse>(
|
||||
'/api/admin/system-settings/recaptcha/admin'
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update reCAPTCHA settings (admin only)
|
||||
*/
|
||||
updateRecaptchaSettings: async (
|
||||
settings: UpdateRecaptchaSettingsRequest
|
||||
): Promise<RecaptchaSettingsAdminResponse> => {
|
||||
const response = await apiClient.put<RecaptchaSettingsAdminResponse>(
|
||||
'/api/admin/system-settings/recaptcha',
|
||||
settings
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Verify reCAPTCHA token
|
||||
*/
|
||||
verifyRecaptcha: async (
|
||||
token: string
|
||||
): Promise<VerifyRecaptchaResponse> => {
|
||||
const response = await apiClient.post<VerifyRecaptchaResponse>(
|
||||
'/api/admin/system-settings/recaptcha/verify',
|
||||
{ token }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default systemSettingsService;
|
||||
export { recaptchaService };
|
||||
|
||||
export type {
|
||||
PlatformCurrencyResponse,
|
||||
@@ -328,5 +422,10 @@ export type {
|
||||
UpdateCompanySettingsRequest,
|
||||
UploadLogoResponse,
|
||||
UploadFaviconResponse,
|
||||
RecaptchaSettingsResponse,
|
||||
RecaptchaSettingsAdminResponse,
|
||||
UpdateRecaptchaSettingsRequest,
|
||||
VerifyRecaptchaRequest,
|
||||
VerifyRecaptchaResponse,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user