This commit is contained in:
Iliyan Angelov
2025-11-20 02:18:52 +02:00
parent 34b4c969d4
commit 44e11520c5
55 changed files with 4741 additions and 876 deletions

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -216,7 +216,7 @@ function App() {
}
/>
<Route
path="deposit-payment/:bookingId"
path="payment/deposit/:bookingId"
element={
<ProtectedRoute>
<DepositPaymentPage />

View 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;

View File

@@ -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>
);
};

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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

View File

@@ -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 */}

View File

@@ -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)}

View File

@@ -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" />

View File

@@ -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)}

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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,
};

View File

@@ -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,
};