updates
This commit is contained in:
45
Frontend/package-lock.json
generated
45
Frontend/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"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",
|
||||
@@ -1103,6 +1104,38 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@paypal/paypal-js": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@paypal/paypal-js/-/paypal-js-9.0.1.tgz",
|
||||
"integrity": "sha512-6A5hkyYBSuloO7rPwnoPoy/0PPNH0pZZ1TBSTuEaSVqJ9h9t13PiP1ZarffirsO/e5t4hKjUmUORjgKV9sTMLw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"promise-polyfill": "^8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@paypal/react-paypal-js": {
|
||||
"version": "8.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@paypal/react-paypal-js/-/react-paypal-js-8.9.2.tgz",
|
||||
"integrity": "sha512-z1GoA7KAkhFCSmpIsRxe9aseXRvfOMgF6vCJ2Mym0VOSYJm8bZSC3Ui4SjONnglfV8S4P8djpe5QB7FtKsDXrQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@paypal/paypal-js": "^9.0.0",
|
||||
"@paypal/sdk-constants": "^1.0.122"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19",
|
||||
"react-dom": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@paypal/sdk-constants": {
|
||||
"version": "1.0.157",
|
||||
"resolved": "https://registry.npmjs.org/@paypal/sdk-constants/-/sdk-constants-1.0.157.tgz",
|
||||
"integrity": "sha512-BjxWT9rK6dM1AOffSpvHYY47/8BY775jgEYYiwH6eL4YaqU5Epcw7zOtwQ8L4UaEn4FCAjZ2EWxaS83dCN7SpA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"hi-base32": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@@ -3140,6 +3173,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hi-base32": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/hi-base32/-/hi-base32-0.5.1.tgz",
|
||||
"integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -4012,6 +4051,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/promise-polyfill": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz",
|
||||
"integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -45,6 +45,8 @@ const DepositPaymentPage = lazy(() => import('./pages/customer/DepositPaymentPag
|
||||
const FullPaymentPage = lazy(() => import('./pages/customer/FullPaymentPage'));
|
||||
const PaymentConfirmationPage = lazy(() => import('./pages/customer/PaymentConfirmationPage'));
|
||||
const PaymentResultPage = lazy(() => import('./pages/customer/PaymentResultPage'));
|
||||
const PayPalReturnPage = lazy(() => import('./pages/customer/PayPalReturnPage'));
|
||||
const PayPalCancelPage = lazy(() => import('./pages/customer/PayPalCancelPage'));
|
||||
const InvoicePage = lazy(() => import('./pages/customer/InvoicePage'));
|
||||
const ProfilePage = lazy(() => import('./pages/customer/ProfilePage'));
|
||||
const AboutPage = lazy(() => import('./pages/AboutPage'));
|
||||
@@ -163,6 +165,14 @@ function App() {
|
||||
path="payment-result"
|
||||
element={<PaymentResultPage />}
|
||||
/>
|
||||
<Route
|
||||
path="payment/paypal/return"
|
||||
element={<PayPalReturnPage />}
|
||||
/>
|
||||
<Route
|
||||
path="payment/paypal/cancel"
|
||||
element={<PayPalCancelPage />}
|
||||
/>
|
||||
<Route
|
||||
path="invoices/:id"
|
||||
element={
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useClickOutside } from '../../hooks/useClickOutside';
|
||||
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
|
||||
import { normalizeImageUrl } from '../../utils/imageUtils';
|
||||
|
||||
interface HeaderProps {
|
||||
isAuthenticated?: boolean;
|
||||
@@ -209,7 +210,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
>
|
||||
{userInfo?.avatar ? (
|
||||
<img
|
||||
src={userInfo.avatar}
|
||||
src={normalizeImageUrl(userInfo.avatar)}
|
||||
alt={userInfo.name}
|
||||
className="w-9 h-9 rounded-full
|
||||
object-cover ring-2 ring-[#d4af37]/50"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
ChevronRight,
|
||||
Star,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Menu,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
|
||||
interface SidebarAdminProps {
|
||||
isCollapsed?: boolean;
|
||||
@@ -32,6 +34,20 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { logout } = useAuthStore();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
if (isMobile) {
|
||||
setIsMobileOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if mobile on mount and resize
|
||||
useEffect(() => {
|
||||
@@ -246,6 +262,33 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Logout Button */}
|
||||
<div className="p-4 border-t border-slate-700/50">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={`
|
||||
w-full flex items-center
|
||||
space-x-3 px-4 py-3.5 rounded-xl
|
||||
transition-all duration-200 group relative
|
||||
text-slate-300 hover:bg-gradient-to-r hover:from-rose-600/20 hover:to-rose-700/20
|
||||
hover:text-rose-100 border border-transparent hover:border-rose-500/30
|
||||
${isCollapsed && !isMobile ? 'justify-center' : ''}
|
||||
`}
|
||||
title={isCollapsed && !isMobile ? 'Logout' : undefined}
|
||||
>
|
||||
<LogOut className={`
|
||||
flex-shrink-0 transition-transform duration-200
|
||||
text-slate-400 group-hover:text-rose-400 group-hover:rotate-12
|
||||
${isCollapsed && !isMobile ? 'w-6 h-6' : 'w-5 h-5'}
|
||||
`} />
|
||||
{(!isCollapsed || isMobile) && (
|
||||
<span className="font-semibold transition-all duration-200 group-hover:text-rose-100">
|
||||
Logout
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Luxury Sidebar Footer */}
|
||||
<div className="p-4 border-t border-slate-700/50 bg-gradient-to-r from-slate-800/50 to-slate-900/50 backdrop-blur-sm">
|
||||
{(!isCollapsed || isMobile) ? (
|
||||
|
||||
161
Frontend/src/components/payments/PayPalPaymentWrapper.tsx
Normal file
161
Frontend/src/components/payments/PayPalPaymentWrapper.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createPayPalOrder } from '../../services/api/paymentService';
|
||||
import { Loader2, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface PayPalPaymentWrapperProps {
|
||||
bookingId: number;
|
||||
amount: number;
|
||||
currency?: string;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
const PayPalPaymentWrapper: React.FC<PayPalPaymentWrapperProps> = ({
|
||||
bookingId,
|
||||
amount,
|
||||
currency = 'USD',
|
||||
onError,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [approvalUrl, setApprovalUrl] = useState<string | null>(null);
|
||||
|
||||
// Initialize PayPal order
|
||||
useEffect(() => {
|
||||
const initializePayPal = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Get current URL for return/cancel URLs
|
||||
const currentUrl = window.location.origin;
|
||||
const returnUrl = `${currentUrl}/payment/paypal/return?bookingId=${bookingId}`;
|
||||
const cancelUrl = `${currentUrl}/payment/paypal/cancel?bookingId=${bookingId}`;
|
||||
|
||||
const response = await createPayPalOrder(
|
||||
bookingId,
|
||||
amount,
|
||||
currency,
|
||||
returnUrl,
|
||||
cancelUrl
|
||||
);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const { approval_url } = response.data;
|
||||
|
||||
if (!approval_url) {
|
||||
throw new Error('Approval URL not received from server');
|
||||
}
|
||||
|
||||
setApprovalUrl(approval_url);
|
||||
} else {
|
||||
throw new Error(response.message || 'Failed to initialize PayPal payment');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error initializing PayPal:', err);
|
||||
const errorMessage = err.response?.data?.message || err.message || 'Failed to initialize PayPal payment';
|
||||
setError(errorMessage);
|
||||
if (onError) {
|
||||
onError(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initializePayPal();
|
||||
}, [bookingId, amount, currency, onError]);
|
||||
|
||||
const handlePayPalClick = () => {
|
||||
if (approvalUrl) {
|
||||
// Redirect to PayPal approval page
|
||||
window.location.href = approvalUrl;
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-red-900 mb-1">
|
||||
Payment Initialization Failed
|
||||
</h3>
|
||||
<p className="text-sm text-red-800">
|
||||
{error || 'Unable to initialize PayPal payment. Please try again.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PayPalPaymentWrapper;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Elements } from '@stripe/react-stripe-js';
|
||||
import StripePaymentForm from './StripePaymentForm';
|
||||
import { createStripePaymentIntent, confirmStripePayment } from '../../services/api/paymentService';
|
||||
import { Loader2, AlertCircle } from 'lucide-react';
|
||||
import Loading from '../common/Loading';
|
||||
|
||||
interface StripePaymentWrapperProps {
|
||||
bookingId: number;
|
||||
|
||||
@@ -199,6 +199,7 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
cash: { bg: 'bg-gradient-to-r from-emerald-50 to-green-50', text: 'text-emerald-800', label: 'Cash', border: 'border-emerald-200' },
|
||||
bank_transfer: { bg: 'bg-gradient-to-r from-blue-50 to-indigo-50', text: 'text-blue-800', label: 'Bank transfer', border: 'border-blue-200' },
|
||||
stripe: { bg: 'bg-gradient-to-r from-indigo-50 to-purple-50', text: 'text-indigo-800', label: 'Stripe', border: 'border-indigo-200' },
|
||||
paypal: { bg: 'bg-gradient-to-r from-blue-50 to-cyan-50', text: 'text-blue-800', label: 'PayPal', border: 'border-blue-200' },
|
||||
credit_card: { bg: 'bg-gradient-to-r from-purple-50 to-pink-50', text: 'text-purple-800', label: 'Credit card', border: 'border-purple-200' },
|
||||
};
|
||||
const badge = badges[method] || badges.cash;
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
TrendingUp,
|
||||
RefreshCw,
|
||||
TrendingDown,
|
||||
CreditCard
|
||||
CreditCard,
|
||||
LogOut
|
||||
} from 'lucide-react';
|
||||
import { reportService, ReportData, paymentService, Payment } from '../../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -17,16 +18,27 @@ import { formatDate } from '../../utils/format';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
import { useAsync } from '../../hooks/useAsync';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const navigate = useNavigate();
|
||||
const { logout } = useAuthStore();
|
||||
const [dateRange, setDateRange] = useState({
|
||||
from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
to: new Date().toISOString().split('T')[0],
|
||||
});
|
||||
const [recentPayments, setRecentPayments] = useState<Payment[]>([]);
|
||||
const [loadingPayments, setLoadingPayments] = useState(false);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
const response = await reportService.getReports({
|
||||
@@ -91,6 +103,8 @@ const DashboardPage: React.FC = () => {
|
||||
case 'stripe':
|
||||
case 'credit_card':
|
||||
return 'Card';
|
||||
case 'paypal':
|
||||
return 'PayPal';
|
||||
case 'bank_transfer':
|
||||
return 'Bank Transfer';
|
||||
case 'cash':
|
||||
@@ -133,7 +147,7 @@ const DashboardPage: React.FC = () => {
|
||||
<p className="text-slate-600 mt-3 text-lg font-light">Hotel operations overview and analytics</p>
|
||||
</div>
|
||||
|
||||
{/* Date Range Filter */}
|
||||
{/* Date Range Filter & Actions */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
|
||||
<div className="flex gap-3 items-center">
|
||||
<input
|
||||
@@ -150,14 +164,24 @@ const DashboardPage: React.FC = () => {
|
||||
className="px-4 py-2.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 text-sm"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
<div className="flex gap-3 items-center">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 text-sm"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-6 py-2.5 bg-gradient-to-r from-rose-500 to-rose-600 text-white rounded-xl font-semibold hover:from-rose-600 hover:to-rose-700 transition-all duration-200 shadow-lg hover:shadow-xl flex items-center gap-2 text-sm"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -70,6 +70,12 @@ const PaymentManagementPage: React.FC = () => {
|
||||
label: 'Stripe',
|
||||
border: 'border-indigo-200'
|
||||
},
|
||||
paypal: {
|
||||
bg: 'bg-gradient-to-r from-blue-50 to-cyan-50',
|
||||
text: 'text-blue-800',
|
||||
label: 'PayPal',
|
||||
border: 'border-blue-200'
|
||||
},
|
||||
credit_card: {
|
||||
bg: 'bg-gradient-to-r from-purple-50 to-pink-50',
|
||||
text: 'text-purple-800',
|
||||
|
||||
@@ -29,6 +29,8 @@ import adminPrivacyService, {
|
||||
import systemSettingsService, {
|
||||
StripeSettingsResponse,
|
||||
UpdateStripeSettingsRequest,
|
||||
PayPalSettingsResponse,
|
||||
UpdatePayPalSettingsRequest,
|
||||
SmtpSettingsResponse,
|
||||
UpdateSmtpSettingsRequest,
|
||||
CompanySettingsResponse,
|
||||
@@ -71,6 +73,15 @@ const SettingsPage: React.FC = () => {
|
||||
const [showSecretKey, setShowSecretKey] = useState(false);
|
||||
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
|
||||
|
||||
// PayPal Settings State
|
||||
const [paypalSettings, setPaypalSettings] = useState<PayPalSettingsResponse['data'] | null>(null);
|
||||
const [paypalFormData, setPaypalFormData] = useState<UpdatePayPalSettingsRequest>({
|
||||
paypal_client_id: '',
|
||||
paypal_client_secret: '',
|
||||
paypal_mode: 'sandbox',
|
||||
});
|
||||
const [showPayPalSecret, setShowPayPalSecret] = useState(false);
|
||||
|
||||
// SMTP Settings State
|
||||
const [smtpSettings, setSmtpSettings] = useState<SmtpSettingsResponse['data'] | null>(null);
|
||||
const [smtpFormData, setSmtpFormData] = useState<UpdateSmtpSettingsRequest>({
|
||||
@@ -144,11 +155,12 @@ const SettingsPage: React.FC = () => {
|
||||
const loadAllSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [policyRes, integrationRes, currencyRes, stripeRes] = await Promise.all([
|
||||
const [policyRes, integrationRes, currencyRes, stripeRes, paypalRes] = await Promise.all([
|
||||
adminPrivacyService.getCookiePolicy(),
|
||||
adminPrivacyService.getIntegrations(),
|
||||
systemSettingsService.getPlatformCurrency(),
|
||||
systemSettingsService.getStripeSettings(),
|
||||
systemSettingsService.getPayPalSettings(),
|
||||
]);
|
||||
|
||||
setPolicy(policyRes.data);
|
||||
@@ -166,6 +178,12 @@ const SettingsPage: React.FC = () => {
|
||||
stripe_publishable_key: stripeRes.data.stripe_publishable_key || '',
|
||||
stripe_webhook_secret: '',
|
||||
});
|
||||
setPaypalSettings(paypalRes.data);
|
||||
setPaypalFormData({
|
||||
paypal_client_id: '',
|
||||
paypal_client_secret: '',
|
||||
paypal_mode: paypalRes.data.paypal_mode || 'sandbox',
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to load settings');
|
||||
} finally {
|
||||
@@ -307,6 +325,45 @@ const SettingsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// PayPal Settings Handlers
|
||||
const handleSavePayPal = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const updateData: UpdatePayPalSettingsRequest = {};
|
||||
|
||||
if (paypalFormData.paypal_client_id && paypalFormData.paypal_client_id.trim()) {
|
||||
updateData.paypal_client_id = paypalFormData.paypal_client_id.trim();
|
||||
}
|
||||
|
||||
if (paypalFormData.paypal_client_secret && paypalFormData.paypal_client_secret.trim()) {
|
||||
updateData.paypal_client_secret = paypalFormData.paypal_client_secret.trim();
|
||||
}
|
||||
|
||||
if (paypalFormData.paypal_mode) {
|
||||
updateData.paypal_mode = paypalFormData.paypal_mode;
|
||||
}
|
||||
|
||||
await systemSettingsService.updatePayPalSettings(updateData);
|
||||
await loadAllSettings();
|
||||
|
||||
setPaypalFormData({
|
||||
...paypalFormData,
|
||||
paypal_client_id: '',
|
||||
paypal_client_secret: '',
|
||||
});
|
||||
|
||||
toast.success('PayPal settings updated successfully');
|
||||
} catch (error: any) {
|
||||
toast.error(
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
'Failed to update PayPal settings'
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// SMTP Settings Handlers
|
||||
const handleSaveSmtp = async () => {
|
||||
try {
|
||||
@@ -1305,6 +1362,180 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PayPal Payment Settings Section */}
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-6 mb-8">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-gradient-to-br from-blue-500/10 to-cyan-500/10 border border-blue-200/40">
|
||||
<CreditCard className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-extrabold text-gray-900">PayPal Payment Settings</h2>
|
||||
</div>
|
||||
<p className="text-gray-600 text-base max-w-2xl leading-relaxed">
|
||||
Configure your PayPal account credentials to enable PayPal payments. All PayPal payments will be processed through your PayPal account.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSavePayPal}
|
||||
disabled={saving}
|
||||
className="group relative px-8 py-4 bg-gradient-to-r from-amber-500 via-amber-500 to-amber-600 text-white font-semibold rounded-xl shadow-xl shadow-amber-500/30 hover:shadow-2xl hover:shadow-amber-500/40 transition-all duration-300 hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000"></div>
|
||||
<div className="relative flex items-center gap-3">
|
||||
<Save className={`w-5 h-5 ${saving ? 'animate-spin' : ''}`} />
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info Card */}
|
||||
<div className="relative bg-gradient-to-br from-blue-50/80 via-cyan-50/60 to-blue-50/80 backdrop-blur-xl rounded-2xl shadow-xl border border-blue-200/50 p-8 overflow-hidden mb-8">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-gradient-to-br from-blue-400/20 to-transparent rounded-bl-full"></div>
|
||||
<div className="relative flex gap-6">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="p-4 rounded-xl bg-gradient-to-br from-blue-500 to-cyan-600 shadow-lg border border-blue-400/50">
|
||||
<Info className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 flex-1">
|
||||
<p className="font-bold text-gray-900 text-lg">
|
||||
How PayPal payments work
|
||||
</p>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
PayPal handles all PayPal payments securely. You need to provide your PayPal API credentials from your PayPal Developer Dashboard. The client ID and client secret are used to process payments. You can use sandbox mode for testing or live mode for production.
|
||||
</p>
|
||||
<div className="pt-3 border-t border-blue-200/50">
|
||||
<p className="text-sm text-gray-600">
|
||||
<strong className="text-gray-900">Note:</strong> Leave fields empty to keep existing values. Only enter new values when you want to update them.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PayPal Settings Form */}
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-start gap-4 pb-6 border-b border-gray-200/60">
|
||||
<div className="p-3 rounded-xl bg-gradient-to-br from-blue-500/10 to-cyan-500/10 border border-blue-200/40">
|
||||
<Key className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="space-y-2 flex-1">
|
||||
<p className="font-bold text-gray-900 text-xl">PayPal API Credentials</p>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
Get these credentials from your{' '}
|
||||
<a
|
||||
href="https://developer.paypal.com/dashboard"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-700 underline font-medium"
|
||||
>
|
||||
PayPal Developer Dashboard
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Client ID */}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<Globe className="w-4 h-4 text-gray-600" />
|
||||
PayPal Client ID
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={paypalFormData.paypal_client_id}
|
||||
onChange={(e) =>
|
||||
setPaypalFormData({ ...paypalFormData, paypal_client_id: e.target.value })
|
||||
}
|
||||
placeholder={
|
||||
paypalSettings?.has_client_id
|
||||
? `Current: ${paypalSettings.paypal_client_id || '****'}`
|
||||
: 'Client ID from PayPal Dashboard'
|
||||
}
|
||||
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"
|
||||
/>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
{paypalSettings?.has_client_id && (
|
||||
<span className="text-emerald-600 font-medium flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-emerald-500 rounded-full"></span>
|
||||
Currently configured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Client Secret */}
|
||||
<div className="space-y-4">
|
||||
<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" />
|
||||
PayPal Client Secret
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPayPalSecret ? 'text' : 'password'}
|
||||
value={paypalFormData.paypal_client_secret}
|
||||
onChange={(e) =>
|
||||
setPaypalFormData({ ...paypalFormData, paypal_client_secret: e.target.value })
|
||||
}
|
||||
placeholder={
|
||||
paypalSettings?.has_client_secret
|
||||
? `Current: ${paypalSettings.paypal_client_secret_masked || '****'}`
|
||||
: 'Client Secret from PayPal Dashboard'
|
||||
}
|
||||
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 font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPayPalSecret(!showPayPalSecret)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700 transition-colors p-1"
|
||||
>
|
||||
{showPayPalSecret ? (
|
||||
<EyeOff className="w-5 h-5" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
{paypalSettings?.has_client_secret && (
|
||||
<span className="text-emerald-600 font-medium flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-emerald-500 rounded-full"></span>
|
||||
Currently configured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode */}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<Globe className="w-4 h-4 text-gray-600" />
|
||||
PayPal Mode
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={paypalFormData.paypal_mode}
|
||||
onChange={(e) =>
|
||||
setPaypalFormData({ ...paypalFormData, paypal_mode: e.target.value })
|
||||
}
|
||||
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"
|
||||
>
|
||||
<option value="sandbox">Sandbox (Testing)</option>
|
||||
<option value="live">Live (Production)</option>
|
||||
</select>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<p>Use sandbox mode for testing with test credentials, or live mode for production payments.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
Hotel,
|
||||
Home,
|
||||
} from 'lucide-react';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import {
|
||||
@@ -29,6 +30,12 @@ const ForgotPasswordPage: React.FC = () => {
|
||||
const supportEmail = settings.company_email || 'support@hotel.com';
|
||||
const supportPhone = settings.company_phone || '1900-xxxx';
|
||||
|
||||
// Update page title
|
||||
useEffect(() => {
|
||||
const companyName = settings.company_name || 'Luxury Hotel';
|
||||
document.title = `Forgot Password - ${companyName}`;
|
||||
}, [settings.company_name]);
|
||||
|
||||
// React Hook Form setup
|
||||
const {
|
||||
register,
|
||||
@@ -60,65 +67,82 @@ const ForgotPasswordPage: React.FC = () => {
|
||||
<div
|
||||
className="min-h-screen bg-gradient-to-br
|
||||
from-blue-50 to-indigo-100 flex items-center
|
||||
justify-center py-12 px-4 sm:px-6 lg:px-8"
|
||||
justify-center py-4 sm:py-8 lg:py-12 px-3 sm:px-4 lg:px-8"
|
||||
>
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="max-w-md w-full space-y-4 sm:space-y-6 lg:space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="p-3 bg-blue-600 rounded-full">
|
||||
<Hotel className="w-12 h-12 text-white" />
|
||||
</div>
|
||||
<div className="flex justify-center mb-3 sm:mb-4">
|
||||
{settings.company_logo_url ? (
|
||||
<img
|
||||
src={settings.company_logo_url.startsWith('http')
|
||||
? settings.company_logo_url
|
||||
: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`
|
||||
}
|
||||
alt={settings.company_name || 'Logo'}
|
||||
className="h-14 sm:h-16 lg:h-20 w-auto max-w-[150px] sm:max-w-[180px] lg:max-w-[200px] object-contain"
|
||||
style={{ filter: 'drop-shadow(0 4px 6px rgba(0,0,0,0.1))' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-2.5 sm:p-3 bg-blue-600 rounded-full">
|
||||
<Hotel className="w-8 h-8 sm:w-10 sm:h-10 lg:w-12 lg:h-12 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900">
|
||||
{settings.company_tagline && (
|
||||
<p className="text-[10px] sm:text-xs text-blue-600 uppercase tracking-[1.5px] sm:tracking-[2px] mb-1 sm:mb-2 font-light px-2">
|
||||
{settings.company_tagline}
|
||||
</p>
|
||||
)}
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-gray-900 px-2">
|
||||
Forgot Password?
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Enter your email to receive a password reset link
|
||||
<p className="mt-1 sm:mt-2 text-xs sm:text-sm text-gray-600 px-4">
|
||||
Enter your email to receive a password reset link for {settings.company_name || 'Luxury Hotel'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form Container */}
|
||||
<div className="bg-white rounded-lg shadow-xl p-8">
|
||||
<div className="bg-white rounded-lg shadow-xl p-4 sm:p-6 lg:p-8">
|
||||
{isSuccess ? (
|
||||
// Success State
|
||||
<div className="text-center space-y-6">
|
||||
<div className="text-center space-y-4 sm:space-y-5 lg:space-y-6">
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className="w-16 h-16 bg-green-100
|
||||
className="w-12 h-12 sm:w-14 sm:h-14 lg:w-16 lg:h-16 bg-green-100
|
||||
rounded-full flex items-center
|
||||
justify-center"
|
||||
>
|
||||
<CheckCircle
|
||||
className="w-10 h-10 text-green-600"
|
||||
className="w-6 h-6 sm:w-8 sm:h-8 lg:w-10 lg:h-10 text-green-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<h3
|
||||
className="text-xl font-semibold
|
||||
text-gray-900"
|
||||
className="text-lg sm:text-xl font-semibold
|
||||
text-gray-900 px-2"
|
||||
>
|
||||
Email Sent!
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="text-xs sm:text-sm text-gray-600 px-2">
|
||||
We have sent a password reset link to
|
||||
</p>
|
||||
<p className="text-sm font-medium text-blue-600">
|
||||
<p className="text-xs sm:text-sm font-medium text-blue-600 break-all px-4">
|
||||
{submittedEmail}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-blue-50 border border-blue-200
|
||||
rounded-lg p-4 text-left"
|
||||
rounded-lg p-3 sm:p-4 text-left"
|
||||
>
|
||||
<p className="text-sm text-gray-700">
|
||||
<p className="text-xs sm:text-sm text-gray-700">
|
||||
<strong>Note:</strong>
|
||||
</p>
|
||||
<ul
|
||||
className="mt-2 space-y-1 text-sm
|
||||
className="mt-2 space-y-1 text-xs sm:text-sm
|
||||
text-gray-600 list-disc list-inside"
|
||||
>
|
||||
<li>Link is valid for 1 hour</li>
|
||||
@@ -129,32 +153,32 @@ const ForgotPasswordPage: React.FC = () => {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2.5 sm:space-y-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsSuccess(false);
|
||||
clearError();
|
||||
}}
|
||||
className="w-full flex items-center
|
||||
justify-center py-3 px-4 border
|
||||
justify-center py-2.5 sm:py-3 px-4 border
|
||||
border-gray-300 rounded-lg
|
||||
text-sm font-medium text-gray-700
|
||||
text-xs sm:text-sm font-medium text-gray-700
|
||||
bg-white hover:bg-gray-50
|
||||
focus:outline-none focus:ring-2
|
||||
focus:ring-offset-2
|
||||
focus:ring-blue-500
|
||||
transition-colors"
|
||||
>
|
||||
<Mail className="-ml-1 mr-2 h-5 w-5" />
|
||||
<Mail className="-ml-1 mr-2 h-4 w-4 sm:h-5 sm:w-5" />
|
||||
Resend Email
|
||||
</button>
|
||||
|
||||
<Link
|
||||
to="/login"
|
||||
className="w-full flex items-center
|
||||
justify-center py-3 px-4 border
|
||||
justify-center py-2.5 sm:py-3 px-4 border
|
||||
border-transparent rounded-lg
|
||||
text-sm font-medium text-white
|
||||
text-xs sm:text-sm font-medium text-white
|
||||
bg-blue-600 hover:bg-blue-700
|
||||
focus:outline-none focus:ring-2
|
||||
focus:ring-offset-2
|
||||
@@ -162,24 +186,36 @@ const ForgotPasswordPage: React.FC = () => {
|
||||
transition-colors"
|
||||
>
|
||||
<ArrowLeft
|
||||
className="-ml-1 mr-2 h-5 w-5"
|
||||
className="-ml-1 mr-2 h-4 w-4 sm:h-5 sm:w-5"
|
||||
/>
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Back to Home Button */}
|
||||
<div className="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-gray-200">
|
||||
<Link
|
||||
to="/"
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-3 sm:px-4 py-2 sm:py-2.5 rounded-lg bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] font-semibold text-xs sm:text-sm shadow-lg shadow-[#d4af37]/30 hover:from-[#f5d76e] hover:to-[#d4af37] hover:shadow-xl hover:shadow-[#d4af37]/40 transition-all duration-300 ease-out hover:-translate-y-0.5 active:translate-y-0 relative overflow-hidden group"
|
||||
>
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-white/0 via-white/20 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700"></span>
|
||||
<Home className="w-3.5 h-3.5 sm:w-4 sm:h-4 relative z-10" />
|
||||
<span className="relative z-10 tracking-wide">Back to Homepage</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Form State
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-6"
|
||||
className="space-y-4 sm:space-y-5 lg:space-y-6"
|
||||
>
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div
|
||||
className="bg-red-50 border
|
||||
border-red-200 text-red-700
|
||||
px-4 py-3 rounded-lg text-sm"
|
||||
px-3 sm:px-4 py-2.5 sm:py-3 rounded-lg text-xs sm:text-sm"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
@@ -189,8 +225,8 @@ const ForgotPasswordPage: React.FC = () => {
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
className="block text-xs sm:text-sm font-medium
|
||||
text-gray-700 mb-1.5 sm:mb-2"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
@@ -201,7 +237,7 @@ const ForgotPasswordPage: React.FC = () => {
|
||||
pointer-events-none"
|
||||
>
|
||||
<Mail
|
||||
className="h-5 w-5 text-gray-400"
|
||||
className="h-4 w-4 sm:h-5 sm:w-5 text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
@@ -210,10 +246,10 @@ const ForgotPasswordPage: React.FC = () => {
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
className={`block w-full pl-10 pr-3
|
||||
py-3 border rounded-lg
|
||||
className={`block w-full pl-9 sm:pl-10 pr-3
|
||||
py-2.5 sm:py-3 border rounded-lg
|
||||
focus:outline-none focus:ring-2
|
||||
transition-colors
|
||||
transition-colors text-sm sm:text-base
|
||||
${
|
||||
errors.email
|
||||
? 'border-red-300 ' +
|
||||
@@ -225,7 +261,7 @@ const ForgotPasswordPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
<p className="mt-1 text-xs sm:text-sm text-red-600">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -236,9 +272,9 @@ const ForgotPasswordPage: React.FC = () => {
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center
|
||||
justify-center py-3 px-4 border
|
||||
justify-center py-2.5 sm:py-3 px-4 border
|
||||
border-transparent rounded-lg
|
||||
shadow-sm text-sm font-medium
|
||||
shadow-sm text-xs sm:text-sm font-medium
|
||||
text-white bg-blue-600
|
||||
hover:bg-blue-700 focus:outline-none
|
||||
focus:ring-2 focus:ring-offset-2
|
||||
@@ -251,13 +287,13 @@ const ForgotPasswordPage: React.FC = () => {
|
||||
<>
|
||||
<Loader2
|
||||
className="animate-spin -ml-1
|
||||
mr-2 h-5 w-5"
|
||||
mr-2 h-4 w-4 sm:h-5 sm:w-5"
|
||||
/>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="-ml-1 mr-2 h-5 w-5" />
|
||||
<Send className="-ml-1 mr-2 h-4 w-4 sm:h-5 sm:w-5" />
|
||||
Send Reset Link
|
||||
</>
|
||||
)}
|
||||
@@ -268,22 +304,34 @@ const ForgotPasswordPage: React.FC = () => {
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-flex items-center
|
||||
text-sm font-medium text-blue-600
|
||||
text-xs sm:text-sm font-medium text-blue-600
|
||||
hover:text-blue-500 transition-colors"
|
||||
>
|
||||
<ArrowLeft
|
||||
className="mr-1 h-4 w-4"
|
||||
className="mr-1 h-3.5 w-3.5 sm:h-4 sm:w-4"
|
||||
/>
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Back to Home Button */}
|
||||
<div className="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-gray-200">
|
||||
<Link
|
||||
to="/"
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-3 sm:px-4 py-2 sm:py-2.5 rounded-lg bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] font-semibold text-xs sm:text-sm shadow-lg shadow-[#d4af37]/30 hover:from-[#f5d76e] hover:to-[#d4af37] hover:shadow-xl hover:shadow-[#d4af37]/40 transition-all duration-300 ease-out hover:-translate-y-0.5 active:translate-y-0 relative overflow-hidden group"
|
||||
>
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-white/0 via-white/20 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700"></span>
|
||||
<Home className="w-3.5 h-3.5 sm:w-4 sm:h-4 relative z-10" />
|
||||
<span className="relative z-10 tracking-wide">Back to Homepage</span>
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
{!isSuccess && (
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<div className="text-center text-xs sm:text-sm text-gray-500 px-2">
|
||||
<p>
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
@@ -300,20 +348,20 @@ const ForgotPasswordPage: React.FC = () => {
|
||||
{/* Help Section */}
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-sm
|
||||
border border-gray-200 p-4"
|
||||
border border-gray-200 p-3 sm:p-4"
|
||||
>
|
||||
<h3
|
||||
className="text-sm font-semibold text-gray-900
|
||||
mb-2"
|
||||
className="text-xs sm:text-sm font-semibold text-gray-900
|
||||
mb-1.5 sm:mb-2"
|
||||
>
|
||||
Need Help?
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600">
|
||||
<p className="text-[10px] sm:text-xs text-gray-600 leading-relaxed">
|
||||
If you're having trouble resetting your password,
|
||||
please contact our support team via email{' '}
|
||||
<a
|
||||
href={`mailto:${supportEmail}`}
|
||||
className="text-blue-600 hover:underline"
|
||||
className="text-blue-600 hover:underline break-all"
|
||||
>
|
||||
{supportEmail}
|
||||
</a>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
@@ -9,21 +9,56 @@ import {
|
||||
Loader2,
|
||||
Mail,
|
||||
Lock,
|
||||
Hotel
|
||||
Hotel,
|
||||
Home,
|
||||
Shield,
|
||||
ArrowLeft
|
||||
} from 'lucide-react';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import {
|
||||
loginSchema,
|
||||
LoginFormData
|
||||
} from '../../utils/validationSchemas';
|
||||
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
|
||||
import * as yup from 'yup';
|
||||
|
||||
const mfaTokenSchema = yup.object().shape({
|
||||
mfaToken: yup
|
||||
.string()
|
||||
.required('MFA token is required')
|
||||
.min(6, 'MFA token must be 6 digits')
|
||||
.max(8, 'MFA token must be 6-8 characters')
|
||||
.matches(/^\d+$|^[A-Z0-9]{8}$/, 'Invalid token format'),
|
||||
});
|
||||
|
||||
type MFATokenFormData = yup.InferType<typeof mfaTokenSchema>;
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { login, isLoading, error, clearError } =
|
||||
const { login, verifyMFA, isLoading, error, clearError, requiresMFA, clearMFA } =
|
||||
useAuthStore();
|
||||
const { settings } = useCompanySettings();
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// MFA form setup
|
||||
const {
|
||||
register: registerMFA,
|
||||
handleSubmit: handleSubmitMFA,
|
||||
formState: { errors: mfaErrors },
|
||||
} = useForm<MFATokenFormData>({
|
||||
resolver: yupResolver(mfaTokenSchema),
|
||||
defaultValues: {
|
||||
mfaToken: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Update page title
|
||||
useEffect(() => {
|
||||
const companyName = settings.company_name || 'Luxury Hotel';
|
||||
document.title = requiresMFA ? `Verify Identity - ${companyName}` : `Login - ${companyName}`;
|
||||
}, [settings.company_name, requiresMFA]);
|
||||
|
||||
// React Hook Form setup
|
||||
const {
|
||||
@@ -49,231 +84,384 @@ const LoginPage: React.FC = () => {
|
||||
rememberMe: data.rememberMe,
|
||||
});
|
||||
|
||||
// Redirect to previous page or dashboard
|
||||
const from = location.state?.from?.pathname ||
|
||||
'/dashboard';
|
||||
navigate(from, { replace: true });
|
||||
// If MFA is required, don't redirect yet
|
||||
if (!requiresMFA) {
|
||||
// Redirect to previous page or dashboard
|
||||
const from = location.state?.from?.pathname ||
|
||||
'/dashboard';
|
||||
navigate(from, { replace: true });
|
||||
}
|
||||
} catch (error) {
|
||||
// Error has been handled in store
|
||||
console.error('Login error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle MFA verification
|
||||
const onSubmitMFA = async (data: MFATokenFormData) => {
|
||||
try {
|
||||
clearError();
|
||||
await verifyMFA(data.mfaToken);
|
||||
|
||||
// Redirect to previous page or dashboard
|
||||
const from = location.state?.from?.pathname ||
|
||||
'/dashboard';
|
||||
navigate(from, { replace: true });
|
||||
} catch (error) {
|
||||
// Error has been handled in store
|
||||
console.error('MFA verification error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle back to login
|
||||
const handleBackToLogin = () => {
|
||||
clearMFA();
|
||||
clearError();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br
|
||||
from-gray-50 via-gray-100 to-gray-50
|
||||
flex items-center justify-center py-12 px-4
|
||||
sm:px-6 lg:px-8 relative overflow-hidden"
|
||||
flex items-center justify-center py-4 sm:py-8 lg:py-12 px-3 sm:px-4 lg:px-8 relative overflow-hidden"
|
||||
>
|
||||
{/* Luxury background pattern */}
|
||||
<div className="absolute inset-0 opacity-5" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23000000' fill-opacity='1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`
|
||||
}}></div>
|
||||
|
||||
<div className="max-w-md w-full space-y-8 relative z-10">
|
||||
<div className="max-w-md w-full space-y-4 sm:space-y-6 lg:space-y-8 relative z-10">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="relative p-4 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full shadow-lg shadow-[#d4af37]/30">
|
||||
<Hotel className="w-12 h-12 text-[#0f0f0f] relative z-10" />
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#f5d76e] to-[#d4af37] rounded-full blur-xl opacity-50"></div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-3xl font-serif font-semibold text-gray-900 tracking-tight">
|
||||
Welcome Back
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 font-light tracking-wide">
|
||||
Sign in to Luxury Hotel
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<div className="luxury-glass rounded-sm p-8 border border-[#d4af37]/20 shadow-2xl">
|
||||
<form onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50/80 backdrop-blur-sm border border-red-200
|
||||
text-red-700 px-4 py-3 rounded-sm
|
||||
text-sm font-light"
|
||||
>
|
||||
{error}
|
||||
<div className="flex justify-center mb-3 sm:mb-4">
|
||||
{settings.company_logo_url ? (
|
||||
<img
|
||||
src={settings.company_logo_url.startsWith('http')
|
||||
? settings.company_logo_url
|
||||
: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`
|
||||
}
|
||||
alt={settings.company_name || 'Logo'}
|
||||
className="h-14 sm:h-16 lg:h-20 w-auto max-w-[150px] sm:max-w-[180px] lg:max-w-[200px] object-contain"
|
||||
style={{ filter: 'drop-shadow(0 4px 6px rgba(0,0,0,0.1))' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative p-3 sm:p-4 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full shadow-lg shadow-[#d4af37]/30">
|
||||
<Hotel className="w-8 h-8 sm:w-10 sm:h-10 lg:w-12 lg:h-12 text-[#0f0f0f] relative z-10" />
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#f5d76e] to-[#d4af37] rounded-full blur-xl opacity-50"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Field */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0
|
||||
pl-3 flex items-center pointer-events-none"
|
||||
>
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
{...register('email')}
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className={`luxury-input pl-10 ${
|
||||
errors.email
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: ''
|
||||
}`}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600 font-light">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0
|
||||
pl-3 flex items-center pointer-events-none"
|
||||
>
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
{...register('password')}
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
className={`luxury-input pl-10 pr-10 ${
|
||||
errors.password
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: ''
|
||||
}`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0
|
||||
pr-3 flex items-center transition-colors
|
||||
hover:text-[#d4af37]"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5
|
||||
text-gray-400"
|
||||
/>
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600 font-light">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remember Me & Forgot Password */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
{...register('rememberMe')}
|
||||
id="rememberMe"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-[#d4af37]
|
||||
focus:ring-[#d4af37]/50 border-gray-300
|
||||
rounded-sm cursor-pointer accent-[#d4af37]"
|
||||
/>
|
||||
<label
|
||||
htmlFor="rememberMe"
|
||||
className="ml-2 block text-sm
|
||||
text-gray-700 cursor-pointer font-light tracking-wide"
|
||||
>
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-sm font-medium
|
||||
text-[#d4af37] hover:text-[#c9a227]
|
||||
transition-colors tracking-wide"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="btn-luxury-primary w-full flex items-center
|
||||
justify-center py-3 px-4 text-sm relative"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin -ml-1
|
||||
mr-2 h-5 w-5 relative z-10"
|
||||
/>
|
||||
<span className="relative z-10">Processing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="-ml-1 mr-2 h-5 w-5 relative z-10" />
|
||||
<span className="relative z-10">Sign In</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Register Link */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600 font-light tracking-wide">
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="font-medium text-[#d4af37]
|
||||
hover:text-[#c9a227] transition-colors"
|
||||
>
|
||||
Register now
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="text-center text-sm text-gray-500 font-light tracking-wide">
|
||||
<p>
|
||||
By logging in, you agree to our{' '}
|
||||
<Link
|
||||
to="/terms"
|
||||
className="text-[#d4af37] hover:underline"
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="text-[#d4af37] hover:underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
{settings.company_tagline && (
|
||||
<p className="text-[10px] sm:text-xs text-[#d4af37] uppercase tracking-[1.5px] sm:tracking-[2px] mb-1 sm:mb-2 font-light px-2">
|
||||
{settings.company_tagline}
|
||||
</p>
|
||||
)}
|
||||
<h2 className="text-2xl sm:text-3xl font-serif font-semibold text-gray-900 tracking-tight px-2">
|
||||
{requiresMFA ? 'Verify Your Identity' : 'Welcome Back'}
|
||||
</h2>
|
||||
<p className="mt-1 sm:mt-2 text-xs sm:text-sm text-gray-600 font-light tracking-wide px-4">
|
||||
{requiresMFA
|
||||
? 'Enter the 6-digit code from your authenticator app'
|
||||
: `Sign in to ${settings.company_name || 'Luxury Hotel'}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{requiresMFA ? (
|
||||
/* MFA Verification Form */
|
||||
<div className="luxury-glass rounded-sm p-4 sm:p-6 lg:p-8 border border-[#d4af37]/20 shadow-2xl">
|
||||
<form onSubmit={handleSubmitMFA(onSubmitMFA)}
|
||||
className="space-y-4 sm:space-y-5 lg:space-y-6"
|
||||
>
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50/80 backdrop-blur-sm border border-red-200
|
||||
text-red-700 px-4 py-3 rounded-sm
|
||||
text-sm font-light"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MFA Token Field */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="mfaToken"
|
||||
className="block text-xs sm:text-sm font-medium
|
||||
text-gray-700 mb-1.5 sm:mb-2 tracking-wide"
|
||||
>
|
||||
Authentication Code
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0
|
||||
pl-3 flex items-center pointer-events-none"
|
||||
>
|
||||
<Shield className="h-4 w-4 sm:h-5 sm:w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
{...registerMFA('mfaToken')}
|
||||
id="mfaToken"
|
||||
type="text"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={8}
|
||||
className={`luxury-input pl-9 sm:pl-10 py-2.5 sm:py-3 text-sm sm:text-base text-center tracking-widest ${
|
||||
mfaErrors.mfaToken
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: ''
|
||||
}`}
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
{mfaErrors.mfaToken && (
|
||||
<p className="mt-1 text-xs sm:text-sm text-red-600 font-light">
|
||||
{mfaErrors.mfaToken.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1.5 text-xs text-gray-500 font-light">
|
||||
Enter the 6-digit code from your authenticator app or an 8-character backup code
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="btn-luxury-primary w-full flex items-center
|
||||
justify-center py-2.5 sm:py-3 px-4 text-xs sm:text-sm relative"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin -ml-1
|
||||
mr-2 h-4 w-4 sm:h-5 sm:w-5 relative z-10"
|
||||
/>
|
||||
<span className="relative z-10">Verifying...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Shield className="-ml-1 mr-2 h-4 w-4 sm:h-5 sm:w-5 relative z-10" />
|
||||
<span className="relative z-10">Verify</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Back to Login Link */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBackToLogin}
|
||||
className="inline-flex items-center
|
||||
text-xs sm:text-sm font-medium text-gray-600
|
||||
hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<ArrowLeft
|
||||
className="mr-1 h-3.5 w-3.5 sm:h-4 sm:w-4"
|
||||
/>
|
||||
Back to Login
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
/* Login Form */
|
||||
<>
|
||||
<div className="luxury-glass rounded-sm p-4 sm:p-6 lg:p-8 border border-[#d4af37]/20 shadow-2xl">
|
||||
<form onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-4 sm:space-y-5 lg:space-y-6"
|
||||
>
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50/80 backdrop-blur-sm border border-red-200
|
||||
text-red-700 px-4 py-3 rounded-sm
|
||||
text-sm font-light"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Field */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-xs sm:text-sm font-medium
|
||||
text-gray-700 mb-1.5 sm:mb-2 tracking-wide"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0
|
||||
pl-3 flex items-center pointer-events-none"
|
||||
>
|
||||
<Mail className="h-4 w-4 sm:h-5 sm:w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
{...register('email')}
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className={`luxury-input pl-9 sm:pl-10 py-2.5 sm:py-3 text-sm sm:text-base ${
|
||||
errors.email
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: ''
|
||||
}`}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600 font-light">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-xs sm:text-sm font-medium
|
||||
text-gray-700 mb-1.5 sm:mb-2 tracking-wide"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0
|
||||
pl-3 flex items-center pointer-events-none"
|
||||
>
|
||||
<Lock className="h-4 w-4 sm:h-5 sm:w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
{...register('password')}
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
className={`luxury-input pl-9 sm:pl-10 pr-9 sm:pr-10 py-2.5 sm:py-3 text-sm sm:text-base ${
|
||||
errors.password
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: ''
|
||||
}`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0
|
||||
pr-3 flex items-center transition-colors
|
||||
hover:text-[#d4af37]"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4 sm:h-5 sm:w-5
|
||||
text-gray-400"
|
||||
/>
|
||||
) : (
|
||||
<Eye className="h-4 w-4 sm:h-5 sm:w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600 font-light">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remember Me & Forgot Password */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 sm:gap-0">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
{...register('rememberMe')}
|
||||
id="rememberMe"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-[#d4af37]
|
||||
focus:ring-[#d4af37]/50 border-gray-300
|
||||
rounded-sm cursor-pointer accent-[#d4af37]"
|
||||
/>
|
||||
<label
|
||||
htmlFor="rememberMe"
|
||||
className="ml-2 block text-xs sm:text-sm
|
||||
text-gray-700 cursor-pointer font-light tracking-wide"
|
||||
>
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-xs sm:text-sm font-medium
|
||||
text-[#d4af37] hover:text-[#c9a227]
|
||||
transition-colors tracking-wide"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="btn-luxury-primary w-full flex items-center
|
||||
justify-center py-2.5 sm:py-3 px-4 text-xs sm:text-sm relative"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin -ml-1
|
||||
mr-2 h-4 w-4 sm:h-5 sm:w-5 relative z-10"
|
||||
/>
|
||||
<span className="relative z-10">Processing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="-ml-1 mr-2 h-4 w-4 sm:h-5 sm:w-5 relative z-10" />
|
||||
<span className="relative z-10">Sign In</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Back to Home Button */}
|
||||
<div className="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-gray-200">
|
||||
<Link
|
||||
to="/"
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-3 sm:px-4 py-2 sm:py-2.5 rounded-lg bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] font-semibold text-xs sm:text-sm shadow-lg shadow-[#d4af37]/30 hover:from-[#f5d76e] hover:to-[#d4af37] hover:shadow-xl hover:shadow-[#d4af37]/40 transition-all duration-300 ease-out hover:-translate-y-0.5 active:translate-y-0 relative overflow-hidden group"
|
||||
>
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-white/0 via-white/20 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700"></span>
|
||||
<Home className="w-3.5 h-3.5 sm:w-4 sm:h-4 relative z-10" />
|
||||
<span className="relative z-10 tracking-wide">Back to Homepage</span>
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Register Link */}
|
||||
<div className="mt-4 sm:mt-6 text-center">
|
||||
<p className="text-xs sm:text-sm text-gray-600 font-light tracking-wide">
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="font-medium text-[#d4af37]
|
||||
hover:text-[#c9a227] transition-colors"
|
||||
>
|
||||
Register now
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="text-center text-xs sm:text-sm text-gray-500 font-light tracking-wide px-2">
|
||||
<p>
|
||||
By logging in, you agree to our{' '}
|
||||
<Link
|
||||
to="/terms"
|
||||
className="text-[#d4af37] hover:underline"
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="text-[#d4af37] hover:underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
@@ -14,22 +14,31 @@ import {
|
||||
Hotel,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Home,
|
||||
} from 'lucide-react';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import {
|
||||
registerSchema,
|
||||
RegisterFormData,
|
||||
} from '../../utils/validationSchemas';
|
||||
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
|
||||
|
||||
const RegisterPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { register: registerUser, isLoading, error, clearError } =
|
||||
useAuthStore();
|
||||
const { settings } = useCompanySettings();
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] =
|
||||
useState(false);
|
||||
|
||||
// Update page title
|
||||
useEffect(() => {
|
||||
const companyName = settings.company_name || 'Luxury Hotel';
|
||||
document.title = `Register - ${companyName}`;
|
||||
}, [settings.company_name]);
|
||||
|
||||
// React Hook Form setup
|
||||
const {
|
||||
register,
|
||||
@@ -97,35 +106,52 @@ const RegisterPage: React.FC = () => {
|
||||
<div
|
||||
className="min-h-screen bg-gradient-to-br
|
||||
from-gray-50 via-gray-100 to-gray-50 flex items-center
|
||||
justify-center py-12 px-4 sm:px-6 lg:px-8 relative overflow-hidden"
|
||||
justify-center py-4 sm:py-8 lg:py-12 px-3 sm:px-4 lg:px-8 relative overflow-hidden"
|
||||
>
|
||||
{/* Luxury background pattern */}
|
||||
<div className="absolute inset-0 opacity-5" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23000000' fill-opacity='1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`
|
||||
}}></div>
|
||||
|
||||
<div className="max-w-md w-full space-y-8 relative z-10">
|
||||
<div className="max-w-md w-full space-y-4 sm:space-y-6 lg:space-y-8 relative z-10">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="relative p-4 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full shadow-lg shadow-[#d4af37]/30">
|
||||
<Hotel className="w-12 h-12 text-[#0f0f0f] relative z-10" />
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#f5d76e] to-[#d4af37] rounded-full blur-xl opacity-50"></div>
|
||||
</div>
|
||||
<div className="flex justify-center mb-3 sm:mb-4">
|
||||
{settings.company_logo_url ? (
|
||||
<img
|
||||
src={settings.company_logo_url.startsWith('http')
|
||||
? settings.company_logo_url
|
||||
: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`
|
||||
}
|
||||
alt={settings.company_name || 'Logo'}
|
||||
className="h-14 sm:h-16 lg:h-20 w-auto max-w-[150px] sm:max-w-[180px] lg:max-w-[200px] object-contain"
|
||||
style={{ filter: 'drop-shadow(0 4px 6px rgba(0,0,0,0.1))' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative p-3 sm:p-4 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full shadow-lg shadow-[#d4af37]/30">
|
||||
<Hotel className="w-8 h-8 sm:w-10 sm:h-10 lg:w-12 lg:h-12 text-[#0f0f0f] relative z-10" />
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#f5d76e] to-[#d4af37] rounded-full blur-xl opacity-50"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-3xl font-serif font-semibold text-gray-900 tracking-tight">
|
||||
{settings.company_tagline && (
|
||||
<p className="text-[10px] sm:text-xs text-[#d4af37] uppercase tracking-[1.5px] sm:tracking-[2px] mb-1 sm:mb-2 font-light px-2">
|
||||
{settings.company_tagline}
|
||||
</p>
|
||||
)}
|
||||
<h2 className="text-2xl sm:text-3xl font-serif font-semibold text-gray-900 tracking-tight px-2">
|
||||
Create Account
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 font-light tracking-wide">
|
||||
Join Luxury Hotel for exclusive benefits
|
||||
<p className="mt-1 sm:mt-2 text-xs sm:text-sm text-gray-600 font-light tracking-wide px-4">
|
||||
Join {settings.company_name || 'Luxury Hotel'} for exclusive benefits
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Register Form */}
|
||||
<div className="luxury-glass rounded-sm p-8 border border-[#d4af37]/20 shadow-2xl">
|
||||
<div className="luxury-glass rounded-sm p-4 sm:p-6 lg:p-8 border border-[#d4af37]/20 shadow-2xl">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-5"
|
||||
className="space-y-4 sm:space-y-5"
|
||||
>
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
@@ -142,8 +168,8 @@ const RegisterPage: React.FC = () => {
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
className="block text-xs sm:text-sm font-medium
|
||||
text-gray-700 mb-1.5 sm:mb-2 tracking-wide"
|
||||
>
|
||||
Full Name
|
||||
</label>
|
||||
@@ -153,14 +179,14 @@ const RegisterPage: React.FC = () => {
|
||||
pl-3 flex items-center
|
||||
pointer-events-none"
|
||||
>
|
||||
<User className="h-5 w-5 text-gray-400" />
|
||||
<User className="h-4 w-4 sm:h-5 sm:w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
{...register('name')}
|
||||
id="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
className={`luxury-input pl-10 ${
|
||||
className={`luxury-input pl-9 sm:pl-10 py-2.5 sm:py-3 text-sm sm:text-base ${
|
||||
errors.name
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: ''
|
||||
@@ -169,7 +195,7 @@ const RegisterPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
{errors.name && (
|
||||
<p className="mt-1 text-sm text-red-600 font-light">
|
||||
<p className="mt-1 text-xs sm:text-sm text-red-600 font-light">
|
||||
{errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -179,8 +205,8 @@ const RegisterPage: React.FC = () => {
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
className="block text-xs sm:text-sm font-medium
|
||||
text-gray-700 mb-1.5 sm:mb-2 tracking-wide"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
@@ -190,14 +216,14 @@ const RegisterPage: React.FC = () => {
|
||||
pl-3 flex items-center
|
||||
pointer-events-none"
|
||||
>
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
<Mail className="h-4 w-4 sm:h-5 sm:w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
{...register('email')}
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className={`luxury-input pl-10 ${
|
||||
className={`luxury-input pl-9 sm:pl-10 py-2.5 sm:py-3 text-sm sm:text-base ${
|
||||
errors.email
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: ''
|
||||
@@ -206,7 +232,7 @@ const RegisterPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600 font-light">
|
||||
<p className="mt-1 text-xs sm:text-sm text-red-600 font-light">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -216,8 +242,8 @@ const RegisterPage: React.FC = () => {
|
||||
<div>
|
||||
<label
|
||||
htmlFor="phone"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
className="block text-xs sm:text-sm font-medium
|
||||
text-gray-700 mb-1.5 sm:mb-2 tracking-wide"
|
||||
>
|
||||
Phone Number (Optional)
|
||||
</label>
|
||||
@@ -227,14 +253,14 @@ const RegisterPage: React.FC = () => {
|
||||
pl-3 flex items-center
|
||||
pointer-events-none"
|
||||
>
|
||||
<Phone className="h-5 w-5 text-gray-400" />
|
||||
<Phone className="h-4 w-4 sm:h-5 sm:w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
{...register('phone')}
|
||||
id="phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
className={`luxury-input pl-10 ${
|
||||
className={`luxury-input pl-9 sm:pl-10 py-2.5 sm:py-3 text-sm sm:text-base ${
|
||||
errors.phone
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: ''
|
||||
@@ -243,7 +269,7 @@ const RegisterPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
{errors.phone && (
|
||||
<p className="mt-1 text-sm text-red-600 font-light">
|
||||
<p className="mt-1 text-xs sm:text-sm text-red-600 font-light">
|
||||
{errors.phone.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -253,8 +279,8 @@ const RegisterPage: React.FC = () => {
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
className="block text-xs sm:text-sm font-medium
|
||||
text-gray-700 mb-1.5 sm:mb-2 tracking-wide"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
@@ -264,14 +290,14 @@ const RegisterPage: React.FC = () => {
|
||||
pl-3 flex items-center
|
||||
pointer-events-none"
|
||||
>
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
<Lock className="h-4 w-4 sm:h-5 sm:w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
{...register('password')}
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
className={`luxury-input pl-10 pr-10 ${
|
||||
className={`luxury-input pl-9 sm:pl-10 pr-9 sm:pr-10 py-2.5 sm:py-3 text-sm sm:text-base ${
|
||||
errors.password
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: ''
|
||||
@@ -287,17 +313,17 @@ const RegisterPage: React.FC = () => {
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff
|
||||
className="h-5 w-5 text-gray-400"
|
||||
className="h-4 w-4 sm:h-5 sm:w-5 text-gray-400"
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="h-5 w-5 text-gray-400"
|
||||
className="h-4 w-4 sm:h-5 sm:w-5 text-gray-400"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600 font-light">
|
||||
<p className="mt-1 text-xs sm:text-sm text-red-600 font-light">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -325,7 +351,7 @@ const RegisterPage: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-medium
|
||||
<span className="text-[10px] sm:text-xs font-medium
|
||||
text-gray-600 tracking-wide"
|
||||
>
|
||||
{passwordStrength.label}
|
||||
@@ -363,8 +389,8 @@ const RegisterPage: React.FC = () => {
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2 tracking-wide"
|
||||
className="block text-xs sm:text-sm font-medium
|
||||
text-gray-700 mb-1.5 sm:mb-2 tracking-wide"
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
@@ -374,7 +400,7 @@ const RegisterPage: React.FC = () => {
|
||||
pl-3 flex items-center
|
||||
pointer-events-none"
|
||||
>
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
<Lock className="h-4 w-4 sm:h-5 sm:w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
{...register('confirmPassword')}
|
||||
@@ -383,7 +409,7 @@ const RegisterPage: React.FC = () => {
|
||||
showConfirmPassword ? 'text' : 'password'
|
||||
}
|
||||
autoComplete="new-password"
|
||||
className={`luxury-input pl-10 pr-10 ${
|
||||
className={`luxury-input pl-9 sm:pl-10 pr-9 sm:pr-10 py-2.5 sm:py-3 text-sm sm:text-base ${
|
||||
errors.confirmPassword
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: ''
|
||||
@@ -401,17 +427,17 @@ const RegisterPage: React.FC = () => {
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff
|
||||
className="h-5 w-5 text-gray-400"
|
||||
className="h-4 w-4 sm:h-5 sm:w-5 text-gray-400"
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="h-5 w-5 text-gray-400"
|
||||
className="h-4 w-4 sm:h-5 sm:w-5 text-gray-400"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-600 font-light">
|
||||
<p className="mt-1 text-xs sm:text-sm text-red-600 font-light">
|
||||
{errors.confirmPassword.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -422,18 +448,18 @@ const RegisterPage: React.FC = () => {
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="btn-luxury-primary w-full flex items-center
|
||||
justify-center py-3 px-4 text-sm relative"
|
||||
justify-center py-2.5 sm:py-3 px-4 text-xs sm:text-sm relative"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin -ml-1
|
||||
mr-2 h-5 w-5 relative z-10"
|
||||
mr-2 h-4 w-4 sm:h-5 sm:w-5 relative z-10"
|
||||
/>
|
||||
<span className="relative z-10">Processing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="-ml-1 mr-2 h-5 w-5 relative z-10" />
|
||||
<UserPlus className="-ml-1 mr-2 h-4 w-4 sm:h-5 sm:w-5 relative z-10" />
|
||||
<span className="relative z-10">Register</span>
|
||||
</>
|
||||
)}
|
||||
@@ -441,8 +467,8 @@ const RegisterPage: React.FC = () => {
|
||||
</form>
|
||||
|
||||
{/* Login Link */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600 font-light tracking-wide">
|
||||
<div className="mt-4 sm:mt-6 text-center">
|
||||
<p className="text-xs sm:text-sm text-gray-600 font-light tracking-wide">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
@@ -453,10 +479,22 @@ const RegisterPage: React.FC = () => {
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Back to Home Button */}
|
||||
<div className="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-gray-200">
|
||||
<Link
|
||||
to="/"
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-3 sm:px-4 py-2 sm:py-2.5 rounded-lg bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] font-semibold text-xs sm:text-sm shadow-lg shadow-[#d4af37]/30 hover:from-[#f5d76e] hover:to-[#d4af37] hover:shadow-xl hover:shadow-[#d4af37]/40 transition-all duration-300 ease-out hover:-translate-y-0.5 active:translate-y-0 relative overflow-hidden group"
|
||||
>
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-white/0 via-white/20 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700"></span>
|
||||
<Home className="w-3.5 h-3.5 sm:w-4 sm:h-4 relative z-10" />
|
||||
<span className="relative z-10 tracking-wide">Back to Homepage</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="text-center text-sm text-gray-500 font-light tracking-wide">
|
||||
<div className="text-center text-xs sm:text-sm text-gray-500 font-light tracking-wide px-2">
|
||||
<p>
|
||||
By registering, you agree to our{' '}
|
||||
<Link
|
||||
@@ -484,11 +522,11 @@ const PasswordRequirement: React.FC<{
|
||||
met: boolean;
|
||||
text: string;
|
||||
}> = ({ met, text }) => (
|
||||
<div className="flex items-center gap-2 text-xs font-light">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs font-light">
|
||||
{met ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-[#d4af37]" />
|
||||
<CheckCircle2 className="h-3.5 w-3.5 sm:h-4 sm:w-4 text-[#d4af37] flex-shrink-0" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-gray-300" />
|
||||
<XCircle className="h-3.5 w-3.5 sm:h-4 sm:w-4 text-gray-300 flex-shrink-0" />
|
||||
)}
|
||||
<span className={met ? 'text-[#c9a227] font-medium' : 'text-gray-500'}>
|
||||
{text}
|
||||
|
||||
@@ -12,24 +12,33 @@ import {
|
||||
AlertCircle,
|
||||
KeyRound,
|
||||
Hotel,
|
||||
Home,
|
||||
} from 'lucide-react';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import {
|
||||
resetPasswordSchema,
|
||||
ResetPasswordFormData,
|
||||
} from '../../utils/validationSchemas';
|
||||
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
|
||||
|
||||
const ResetPasswordPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const { resetPassword, isLoading, error, clearError } =
|
||||
useAuthStore();
|
||||
const { settings } = useCompanySettings();
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] =
|
||||
useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
|
||||
// Update page title
|
||||
useEffect(() => {
|
||||
const companyName = settings.company_name || 'Luxury Hotel';
|
||||
document.title = `Reset Password - ${companyName}`;
|
||||
}, [settings.company_name]);
|
||||
|
||||
// React Hook Form setup
|
||||
const {
|
||||
register,
|
||||
@@ -118,68 +127,85 @@ const ResetPasswordPage: React.FC = () => {
|
||||
<div
|
||||
className="min-h-screen bg-gradient-to-br
|
||||
from-indigo-50 to-purple-100 flex items-center
|
||||
justify-center py-12 px-4 sm:px-6 lg:px-8"
|
||||
justify-center py-4 sm:py-8 lg:py-12 px-3 sm:px-4 lg:px-8"
|
||||
>
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="max-w-md w-full space-y-4 sm:space-y-6 lg:space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="p-3 bg-indigo-600 rounded-full">
|
||||
<Hotel className="w-12 h-12 text-white" />
|
||||
</div>
|
||||
<div className="flex justify-center mb-3 sm:mb-4">
|
||||
{settings.company_logo_url ? (
|
||||
<img
|
||||
src={settings.company_logo_url.startsWith('http')
|
||||
? settings.company_logo_url
|
||||
: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`
|
||||
}
|
||||
alt={settings.company_name || 'Logo'}
|
||||
className="h-14 sm:h-16 lg:h-20 w-auto max-w-[150px] sm:max-w-[180px] lg:max-w-[200px] object-contain"
|
||||
style={{ filter: 'drop-shadow(0 4px 6px rgba(0,0,0,0.1))' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-2.5 sm:p-3 bg-indigo-600 rounded-full">
|
||||
<Hotel className="w-8 h-8 sm:w-10 sm:h-10 lg:w-12 lg:h-12 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900">
|
||||
{settings.company_tagline && (
|
||||
<p className="text-[10px] sm:text-xs text-indigo-600 uppercase tracking-[1.5px] sm:tracking-[2px] mb-1 sm:mb-2 font-light px-2">
|
||||
{settings.company_tagline}
|
||||
</p>
|
||||
)}
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-gray-900 px-2">
|
||||
{isSuccess ? 'Complete!' : 'Reset Password'}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
<p className="mt-1 sm:mt-2 text-xs sm:text-sm text-gray-600 px-4">
|
||||
{isSuccess
|
||||
? 'Password has been reset successfully'
|
||||
: 'Enter a new password for your account'}
|
||||
: `Enter a new password for your ${settings.company_name || 'Luxury Hotel'} account`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form Container */}
|
||||
<div className="bg-white rounded-lg shadow-xl p-8">
|
||||
<div className="bg-white rounded-lg shadow-xl p-4 sm:p-6 lg:p-8">
|
||||
{isSuccess ? (
|
||||
// Success State
|
||||
<div className="text-center space-y-6">
|
||||
<div className="text-center space-y-4 sm:space-y-5 lg:space-y-6">
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className="w-16 h-16 bg-green-100
|
||||
className="w-12 h-12 sm:w-14 sm:h-14 lg:w-16 lg:h-16 bg-green-100
|
||||
rounded-full flex items-center
|
||||
justify-center"
|
||||
>
|
||||
<CheckCircle2
|
||||
className="w-10 h-10 text-green-600"
|
||||
className="w-6 h-6 sm:w-8 sm:h-8 lg:w-10 lg:h-10 text-green-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<h3
|
||||
className="text-xl font-semibold
|
||||
text-gray-900"
|
||||
className="text-lg sm:text-xl font-semibold
|
||||
text-gray-900 px-2"
|
||||
>
|
||||
Password reset successful!
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="text-xs sm:text-sm text-gray-600 px-2">
|
||||
Your password has been updated.
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="text-xs sm:text-sm text-gray-600 px-2">
|
||||
You can now login with your new password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-blue-50 border border-blue-200
|
||||
rounded-lg p-4"
|
||||
rounded-lg p-3 sm:p-4"
|
||||
>
|
||||
<p className="text-sm text-gray-700">
|
||||
<p className="text-xs sm:text-sm text-gray-700">
|
||||
Redirecting to login page...
|
||||
</p>
|
||||
<div className="mt-2 flex justify-center">
|
||||
<Loader2
|
||||
className="animate-spin h-5 w-5
|
||||
className="animate-spin h-4 w-4 sm:h-5 sm:w-5
|
||||
text-blue-600"
|
||||
/>
|
||||
</div>
|
||||
@@ -188,30 +214,42 @@ const ResetPasswordPage: React.FC = () => {
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-flex items-center
|
||||
justify-center w-full py-3 px-4
|
||||
justify-center w-full py-2.5 sm:py-3 px-4
|
||||
border border-transparent rounded-lg
|
||||
text-sm font-medium text-white
|
||||
text-xs sm:text-sm font-medium text-white
|
||||
bg-indigo-600 hover:bg-indigo-700
|
||||
focus:outline-none focus:ring-2
|
||||
focus:ring-offset-2
|
||||
focus:ring-indigo-500
|
||||
transition-colors"
|
||||
>
|
||||
<KeyRound className="-ml-1 mr-2 h-5 w-5" />
|
||||
<KeyRound className="-ml-1 mr-2 h-4 w-4 sm:h-5 sm:w-5" />
|
||||
Login Now
|
||||
</Link>
|
||||
|
||||
{/* Back to Home Button */}
|
||||
<div className="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-gray-200">
|
||||
<Link
|
||||
to="/"
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-3 sm:px-4 py-2 sm:py-2.5 rounded-lg bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] font-semibold text-xs sm:text-sm shadow-lg shadow-[#d4af37]/30 hover:from-[#f5d76e] hover:to-[#d4af37] hover:shadow-xl hover:shadow-[#d4af37]/40 transition-all duration-300 ease-out hover:-translate-y-0.5 active:translate-y-0 relative overflow-hidden group"
|
||||
>
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-white/0 via-white/20 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700"></span>
|
||||
<Home className="w-3.5 h-3.5 sm:w-4 sm:h-4 relative z-10" />
|
||||
<span className="relative z-10 tracking-wide">Back to Homepage</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Form State
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-5"
|
||||
className="space-y-4 sm:space-y-5"
|
||||
>
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div
|
||||
className={`border px-4 py-3 rounded-lg
|
||||
text-sm flex items-start gap-2
|
||||
className={`border px-3 sm:px-4 py-2.5 sm:py-3 rounded-lg
|
||||
text-xs sm:text-sm flex items-start gap-2
|
||||
${
|
||||
isTokenError
|
||||
? 'bg-yellow-50 border-yellow-200 ' +
|
||||
@@ -220,7 +258,7 @@ const ResetPasswordPage: React.FC = () => {
|
||||
'text-red-700'
|
||||
}`}
|
||||
>
|
||||
<AlertCircle className="h-5 w-5 flex-shrink-0 mt-0.5" />
|
||||
<AlertCircle className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">
|
||||
{isReuseError
|
||||
@@ -230,7 +268,7 @@ const ResetPasswordPage: React.FC = () => {
|
||||
{isTokenError && (
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="mt-2 inline-block text-sm
|
||||
className="mt-2 inline-block text-xs sm:text-sm
|
||||
font-medium underline
|
||||
hover:text-yellow-900"
|
||||
>
|
||||
@@ -245,8 +283,8 @@ const ResetPasswordPage: React.FC = () => {
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
className="block text-xs sm:text-sm font-medium
|
||||
text-gray-700 mb-1.5 sm:mb-2"
|
||||
>
|
||||
New Password
|
||||
</label>
|
||||
@@ -257,7 +295,7 @@ const ResetPasswordPage: React.FC = () => {
|
||||
pointer-events-none"
|
||||
>
|
||||
<Lock
|
||||
className="h-5 w-5 text-gray-400"
|
||||
className="h-4 w-4 sm:h-5 sm:w-5 text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
@@ -266,10 +304,10 @@ const ResetPasswordPage: React.FC = () => {
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
autoFocus
|
||||
className={`block w-full pl-10 pr-10
|
||||
py-3 border rounded-lg
|
||||
className={`block w-full pl-9 sm:pl-10 pr-9 sm:pr-10
|
||||
py-2.5 sm:py-3 border rounded-lg
|
||||
focus:outline-none focus:ring-2
|
||||
transition-colors
|
||||
transition-colors text-sm sm:text-base
|
||||
${
|
||||
errors.password
|
||||
? 'border-red-300 ' +
|
||||
@@ -289,19 +327,19 @@ const ResetPasswordPage: React.FC = () => {
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff
|
||||
className="h-5 w-5 text-gray-400
|
||||
className="h-4 w-4 sm:h-5 sm:w-5 text-gray-400
|
||||
hover:text-gray-600"
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="h-5 w-5 text-gray-400
|
||||
className="h-4 w-4 sm:h-5 sm:w-5 text-gray-400
|
||||
hover:text-gray-600"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
<p className="mt-1 text-xs sm:text-sm text-red-600">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -327,7 +365,7 @@ const ResetPasswordPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className="text-xs font-medium
|
||||
className="text-[10px] sm:text-xs font-medium
|
||||
text-gray-600"
|
||||
>
|
||||
{passwordStrength.label}
|
||||
@@ -365,8 +403,8 @@ const ResetPasswordPage: React.FC = () => {
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium
|
||||
text-gray-700 mb-2"
|
||||
className="block text-xs sm:text-sm font-medium
|
||||
text-gray-700 mb-1.5 sm:mb-2"
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
@@ -377,7 +415,7 @@ const ResetPasswordPage: React.FC = () => {
|
||||
pointer-events-none"
|
||||
>
|
||||
<Lock
|
||||
className="h-5 w-5 text-gray-400"
|
||||
className="h-4 w-4 sm:h-5 sm:w-5 text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
@@ -387,10 +425,10 @@ const ResetPasswordPage: React.FC = () => {
|
||||
showConfirmPassword ? 'text' : 'password'
|
||||
}
|
||||
autoComplete="new-password"
|
||||
className={`block w-full pl-10 pr-10
|
||||
py-3 border rounded-lg
|
||||
className={`block w-full pl-9 sm:pl-10 pr-9 sm:pr-10
|
||||
py-2.5 sm:py-3 border rounded-lg
|
||||
focus:outline-none focus:ring-2
|
||||
transition-colors
|
||||
transition-colors text-sm sm:text-base
|
||||
${
|
||||
errors.confirmPassword
|
||||
? 'border-red-300 ' +
|
||||
@@ -412,19 +450,19 @@ const ResetPasswordPage: React.FC = () => {
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff
|
||||
className="h-5 w-5 text-gray-400
|
||||
className="h-4 w-4 sm:h-5 sm:w-5 text-gray-400
|
||||
hover:text-gray-600"
|
||||
/>
|
||||
) : (
|
||||
<Eye
|
||||
className="h-5 w-5 text-gray-400
|
||||
className="h-4 w-4 sm:h-5 sm:w-5 text-gray-400
|
||||
hover:text-gray-600"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
<p className="mt-1 text-xs sm:text-sm text-red-600">
|
||||
{errors.confirmPassword.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -435,9 +473,9 @@ const ResetPasswordPage: React.FC = () => {
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center
|
||||
justify-center py-3 px-4 border
|
||||
justify-center py-2.5 sm:py-3 px-4 border
|
||||
border-transparent rounded-lg
|
||||
shadow-sm text-sm font-medium
|
||||
shadow-sm text-xs sm:text-sm font-medium
|
||||
text-white bg-indigo-600
|
||||
hover:bg-indigo-700
|
||||
focus:outline-none focus:ring-2
|
||||
@@ -451,14 +489,14 @@ const ResetPasswordPage: React.FC = () => {
|
||||
<>
|
||||
<Loader2
|
||||
className="animate-spin -ml-1 mr-2
|
||||
h-5 w-5"
|
||||
h-4 w-4 sm:h-5 sm:w-5"
|
||||
/>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<KeyRound
|
||||
className="-ml-1 mr-2 h-5 w-5"
|
||||
className="-ml-1 mr-2 h-4 w-4 sm:h-5 sm:w-5"
|
||||
/>
|
||||
Reset Password
|
||||
</>
|
||||
@@ -469,13 +507,25 @@ const ResetPasswordPage: React.FC = () => {
|
||||
<div className="text-center">
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-sm font-medium
|
||||
className="text-xs sm:text-sm font-medium
|
||||
text-indigo-600 hover:text-indigo-500
|
||||
transition-colors"
|
||||
>
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Back to Home Button */}
|
||||
<div className="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-gray-200">
|
||||
<Link
|
||||
to="/"
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-3 sm:px-4 py-2 sm:py-2.5 rounded-lg bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] font-semibold text-xs sm:text-sm shadow-lg shadow-[#d4af37]/30 hover:from-[#f5d76e] hover:to-[#d4af37] hover:shadow-xl hover:shadow-[#d4af37]/40 transition-all duration-300 ease-out hover:-translate-y-0.5 active:translate-y-0 relative overflow-hidden group"
|
||||
>
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-white/0 via-white/20 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700"></span>
|
||||
<Home className="w-3.5 h-3.5 sm:w-4 sm:h-4 relative z-10" />
|
||||
<span className="relative z-10 tracking-wide">Back to Homepage</span>
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
@@ -484,18 +534,18 @@ const ResetPasswordPage: React.FC = () => {
|
||||
{!isSuccess && (
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-sm
|
||||
border border-gray-200 p-4"
|
||||
border border-gray-200 p-3 sm:p-4"
|
||||
>
|
||||
<h3
|
||||
className="text-sm font-semibold
|
||||
text-gray-900 mb-2 flex items-center
|
||||
className="text-xs sm:text-sm font-semibold
|
||||
text-gray-900 mb-1.5 sm:mb-2 flex items-center
|
||||
gap-2"
|
||||
>
|
||||
<Lock className="h-4 w-4" />
|
||||
<Lock className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
Security
|
||||
</h3>
|
||||
<ul
|
||||
className="text-xs text-gray-600 space-y-1
|
||||
className="text-[10px] sm:text-xs text-gray-600 space-y-1
|
||||
list-disc list-inside"
|
||||
>
|
||||
<li>Reset link is valid for 1 hour only</li>
|
||||
@@ -516,11 +566,11 @@ const PasswordRequirement: React.FC<{
|
||||
met: boolean;
|
||||
text: string;
|
||||
}> = ({ met, text }) => (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs">
|
||||
{met ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<CheckCircle2 className="h-3.5 w-3.5 sm:h-4 sm:w-4 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-gray-300" />
|
||||
<XCircle className="h-3.5 w-3.5 sm:h-4 sm:w-4 text-gray-300 flex-shrink-0" />
|
||||
)}
|
||||
<span className={met ? 'text-green-600' : 'text-gray-500'}>
|
||||
{text}
|
||||
|
||||
@@ -514,6 +514,8 @@ const BookingDetailPage: React.FC = () => {
|
||||
? '💵 Pay at hotel'
|
||||
: booking.payment_method === 'stripe'
|
||||
? '💳 Pay with Card (Stripe)'
|
||||
: booking.payment_method === 'paypal'
|
||||
? '💳 PayPal'
|
||||
: booking.payment_method || 'N/A'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
type BookingData,
|
||||
} from '../../services/api/bookingService';
|
||||
import { serviceService, Service } from '../../services/api';
|
||||
import { createPayPalOrder } from '../../services/api/paymentService';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import {
|
||||
bookingValidationSchema,
|
||||
@@ -306,6 +307,43 @@ const BookingPage: React.FC = () => {
|
||||
) {
|
||||
const bookingId = response.data.booking.id;
|
||||
|
||||
// If PayPal payment, redirect to PayPal payment flow
|
||||
if (paymentMethod === 'paypal') {
|
||||
try {
|
||||
// Get current URL for return/cancel URLs
|
||||
const currentUrl = window.location.origin;
|
||||
const returnUrl = `${currentUrl}/payment/paypal/return?bookingId=${bookingId}`;
|
||||
const cancelUrl = `${currentUrl}/payment/paypal/cancel?bookingId=${bookingId}`;
|
||||
|
||||
const paypalResponse = await createPayPalOrder(
|
||||
bookingId,
|
||||
totalPrice,
|
||||
'USD',
|
||||
returnUrl,
|
||||
cancelUrl
|
||||
);
|
||||
|
||||
if (paypalResponse.success && paypalResponse.data?.approval_url) {
|
||||
// Redirect to PayPal for payment
|
||||
window.location.href = paypalResponse.data.approval_url;
|
||||
return; // Don't navigate to success page
|
||||
} else {
|
||||
throw new Error(paypalResponse.message || 'Failed to initialize PayPal payment');
|
||||
}
|
||||
} catch (paypalError: any) {
|
||||
console.error('Error initializing PayPal payment:', paypalError);
|
||||
toast.error(
|
||||
paypalError.response?.data?.message ||
|
||||
paypalError.message ||
|
||||
'Failed to initialize PayPal payment. Please try again or contact support.'
|
||||
);
|
||||
// Still navigate to booking success page so user can see their booking
|
||||
navigate(`/booking-success/${bookingId}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// For other payment methods, navigate to success page
|
||||
toast.success(
|
||||
'🎉 Booking successful!',
|
||||
{ icon: <CheckCircle className="text-green-500" /> }
|
||||
@@ -1077,6 +1115,49 @@ const BookingPage: React.FC = () => {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* PayPal Payment */}
|
||||
<label
|
||||
className="flex items-start p-5
|
||||
bg-gradient-to-br from-[#0a0a0a] to-[#1a1a1a]
|
||||
border-2 border-[#d4af37]/20
|
||||
rounded-lg cursor-pointer
|
||||
hover:border-[#d4af37]/40 hover:shadow-lg hover:shadow-[#d4af37]/10
|
||||
transition-all duration-300"
|
||||
>
|
||||
<input
|
||||
{...register('paymentMethod')}
|
||||
type="radio"
|
||||
value="paypal"
|
||||
className="mt-1 mr-4 w-5 h-5 text-[#d4af37]
|
||||
border-[#d4af37]/30 focus:ring-[#d4af37]/50"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center
|
||||
gap-3 mb-2"
|
||||
>
|
||||
<CreditCard
|
||||
className="w-5 h-5
|
||||
text-[#d4af37]"
|
||||
/>
|
||||
<span className="font-medium
|
||||
text-white tracking-wide"
|
||||
>
|
||||
Pay with PayPal
|
||||
</span>
|
||||
<span className="text-xs bg-gradient-to-r
|
||||
from-[#d4af37]/20 to-[#c9a227]/20
|
||||
text-[#d4af37] border border-[#d4af37]/40
|
||||
px-3 py-1 rounded font-medium tracking-wide"
|
||||
>
|
||||
Instant
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 font-light tracking-wide">
|
||||
Secure payment with PayPal account
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Stripe Payment Info */}
|
||||
{paymentMethod === 'stripe' && (
|
||||
<div
|
||||
@@ -1098,6 +1179,27 @@ const BookingPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PayPal Payment Info */}
|
||||
{paymentMethod === 'paypal' && (
|
||||
<div
|
||||
className="bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/5
|
||||
border border-[#d4af37]/30 rounded-lg
|
||||
p-5 mt-3 backdrop-blur-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Shield className="w-5 h-5 text-[#d4af37]" />
|
||||
<p className="text-sm text-[#d4af37]
|
||||
font-medium tracking-wide"
|
||||
>
|
||||
Secure PayPal Payment
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300 font-light tracking-wide leading-relaxed">
|
||||
You will be redirected to PayPal to securely complete your payment.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -80,6 +80,8 @@ const DashboardPage: React.FC = () => {
|
||||
case 'stripe':
|
||||
case 'credit_card':
|
||||
return 'Card';
|
||||
case 'paypal':
|
||||
return 'PayPal';
|
||||
case 'bank_transfer':
|
||||
return 'Bank Transfer';
|
||||
case 'cash':
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import Loading from '../../components/common/Loading';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
import StripePaymentWrapper from '../../components/payments/StripePaymentWrapper';
|
||||
import PayPalPaymentWrapper from '../../components/payments/PayPalPaymentWrapper';
|
||||
|
||||
const DepositPaymentPage: React.FC = () => {
|
||||
const { bookingId } = useParams<{ bookingId: string }>();
|
||||
@@ -294,7 +295,22 @@ const DepositPaymentPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VNPay removed */}
|
||||
{/* 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>
|
||||
|
||||
43
Frontend/src/pages/customer/PayPalCancelPage.tsx
Normal file
43
Frontend/src/pages/customer/PayPalCancelPage.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { XCircle, ArrowLeft } from 'lucide-react';
|
||||
|
||||
const PayPalCancelPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const bookingId = searchParams.get('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">
|
||||
{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"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
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"
|
||||
>
|
||||
My Bookings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PayPalCancelPage;
|
||||
|
||||
116
Frontend/src/pages/customer/PayPalReturnPage.tsx
Normal file
116
Frontend/src/pages/customer/PayPalReturnPage.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { capturePayPalPayment } from '../../services/api/paymentService';
|
||||
import { toast } from 'react-toastify';
|
||||
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import Loading from '../../components/common/Loading';
|
||||
|
||||
const PayPalReturnPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const orderId = searchParams.get('token');
|
||||
const bookingId = searchParams.get('bookingId');
|
||||
|
||||
useEffect(() => {
|
||||
const capturePayment = async () => {
|
||||
if (!orderId || !bookingId) {
|
||||
setError('Missing payment information');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await capturePayPalPayment(orderId, Number(bookingId));
|
||||
|
||||
if (response.success) {
|
||||
setSuccess(true);
|
||||
toast.success('Payment confirmed successfully!');
|
||||
// Redirect to booking details after a short delay
|
||||
setTimeout(() => {
|
||||
navigate(`/bookings/${bookingId}`);
|
||||
}, 2000);
|
||||
} else {
|
||||
setError(response.message || 'Payment capture failed');
|
||||
toast.error(response.message || 'Payment capture failed');
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.message || err.message || 'Failed to capture payment';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
capturePayment();
|
||||
}, [orderId, bookingId, navigate]);
|
||||
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
Payment Successful!
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
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"
|
||||
>
|
||||
View Booking
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
Payment Failed
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{error || 'Unable to process your payment. Please try again.'}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<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"
|
||||
>
|
||||
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"
|
||||
>
|
||||
My Bookings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PayPalReturnPage;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ export interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
rememberMe?: boolean;
|
||||
mfaToken?: string;
|
||||
}
|
||||
|
||||
export interface RegisterData {
|
||||
@@ -19,6 +20,8 @@ export interface AuthResponse {
|
||||
status?: string;
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
requires_mfa?: boolean;
|
||||
user_id?: number;
|
||||
data?: {
|
||||
token?: string;
|
||||
refreshToken?: string;
|
||||
@@ -32,6 +35,11 @@ export interface AuthResponse {
|
||||
role: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
secret?: string;
|
||||
qr_code?: string;
|
||||
backup_codes?: string[];
|
||||
mfa_enabled?: boolean;
|
||||
backup_codes_count?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -153,6 +161,68 @@ const authService = {
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* MFA - Initialize MFA setup
|
||||
*/
|
||||
initMFA: async (): Promise<AuthResponse> => {
|
||||
const response = await apiClient.get<AuthResponse>('/api/auth/mfa/init');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* MFA - Enable MFA
|
||||
*/
|
||||
enableMFA: async (secret: string, verificationToken: string): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post<AuthResponse>('/api/auth/mfa/enable', {
|
||||
secret,
|
||||
verification_token: verificationToken
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* MFA - Disable MFA
|
||||
*/
|
||||
disableMFA: async (): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post<AuthResponse>('/api/auth/mfa/disable');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* MFA - Get MFA status
|
||||
*/
|
||||
getMFAStatus: async (): Promise<{ mfa_enabled: boolean; backup_codes_count: number }> => {
|
||||
const response = await apiClient.get<{ mfa_enabled: boolean; backup_codes_count: number }>('/api/auth/mfa/status');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* MFA - Regenerate backup codes
|
||||
*/
|
||||
regenerateBackupCodes: async (): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post<AuthResponse>('/api/auth/mfa/regenerate-backup-codes');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload avatar
|
||||
*/
|
||||
uploadAvatar: async (file: File): Promise<AuthResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
const response = await apiClient.post<AuthResponse>(
|
||||
'/api/auth/avatar/upload',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default authService;
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface BookingData {
|
||||
check_out_date: string; // YYYY-MM-DD
|
||||
guest_count: number;
|
||||
notes?: string;
|
||||
payment_method: 'cash' | 'stripe';
|
||||
payment_method: 'cash' | 'stripe' | 'paypal';
|
||||
total_price: number;
|
||||
guest_info: {
|
||||
full_name: string;
|
||||
@@ -35,7 +35,7 @@ export interface Booking {
|
||||
| 'cancelled'
|
||||
| 'checked_in'
|
||||
| 'checked_out';
|
||||
payment_method: 'cash' | 'stripe';
|
||||
payment_method: 'cash' | 'stripe' | 'paypal';
|
||||
payment_status:
|
||||
| 'unpaid'
|
||||
| 'paid'
|
||||
@@ -233,16 +233,27 @@ export const checkRoomAvailability = async (
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Handle new response format when roomId is provided
|
||||
if (response.data?.data?.available !== undefined) {
|
||||
return {
|
||||
available: response.data.data.available,
|
||||
message: response.data.data.message,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for old format
|
||||
return {
|
||||
available: true,
|
||||
message: response.data.message,
|
||||
message: response.data?.message || 'Room is available',
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 409) {
|
||||
if (error.response?.status === 409 || error.response?.status === 404) {
|
||||
return {
|
||||
available: false,
|
||||
message:
|
||||
error.response.data.message ||
|
||||
error.response.data?.message ||
|
||||
error.response.data?.detail ||
|
||||
'Room already booked during this time',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import apiClient from './apiClient';
|
||||
export interface PaymentData {
|
||||
booking_id: number;
|
||||
amount: number;
|
||||
payment_method: 'cash' | 'bank_transfer' | 'stripe';
|
||||
payment_method: 'cash' | 'bank_transfer' | 'stripe' | 'paypal';
|
||||
transaction_id?: string;
|
||||
notes?: string;
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export interface Payment {
|
||||
id: number;
|
||||
booking_id: number;
|
||||
amount: number;
|
||||
payment_method: 'cash' | 'bank_transfer' | 'credit_card' | 'debit_card' | 'e_wallet' | 'stripe';
|
||||
payment_method: 'cash' | 'bank_transfer' | 'credit_card' | 'debit_card' | 'e_wallet' | 'stripe' | 'paypal';
|
||||
payment_type: 'full' | 'deposit' | 'remaining';
|
||||
deposit_percentage?: number;
|
||||
payment_status: 'pending' | 'completed' | 'failed' | 'refunded';
|
||||
@@ -22,6 +22,14 @@ export interface Payment {
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
booking?: {
|
||||
id: number;
|
||||
booking_number: string;
|
||||
user?: {
|
||||
name: string;
|
||||
email?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface BankInfo {
|
||||
@@ -284,6 +292,79 @@ export const confirmStripePayment = async (
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create PayPal order
|
||||
* POST /api/payments/paypal/create-order
|
||||
*/
|
||||
export const createPayPalOrder = async (
|
||||
bookingId: number,
|
||||
amount: number,
|
||||
currency: string = 'USD',
|
||||
returnUrl?: string,
|
||||
cancelUrl?: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data: {
|
||||
order_id: string;
|
||||
approval_url: string;
|
||||
status: string;
|
||||
};
|
||||
message?: string;
|
||||
}> => {
|
||||
const response = await apiClient.post(
|
||||
'/payments/paypal/create-order',
|
||||
{
|
||||
booking_id: bookingId,
|
||||
amount,
|
||||
currency,
|
||||
return_url: returnUrl,
|
||||
cancel_url: cancelUrl,
|
||||
}
|
||||
);
|
||||
// Map backend response format (status: "success") to frontend format (success: true)
|
||||
const data = response.data;
|
||||
return {
|
||||
success: data.status === "success" || data.success === true,
|
||||
data: data.data || {},
|
||||
message: data.message,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Capture PayPal payment
|
||||
* POST /api/payments/paypal/capture
|
||||
*/
|
||||
export const capturePayPalPayment = async (
|
||||
orderId: string,
|
||||
bookingId: number
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data: {
|
||||
payment: Payment;
|
||||
booking: {
|
||||
id: number;
|
||||
booking_number: string;
|
||||
status: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
}> => {
|
||||
const response = await apiClient.post(
|
||||
'/payments/paypal/capture',
|
||||
{
|
||||
order_id: orderId,
|
||||
booking_id: bookingId,
|
||||
}
|
||||
);
|
||||
// Map backend response format (status: "success") to frontend format (success: true)
|
||||
const data = response.data;
|
||||
return {
|
||||
success: data.status === "success" || data.success === true,
|
||||
data: data.data || {},
|
||||
message: data.message,
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
createPayment,
|
||||
getPayments,
|
||||
@@ -295,4 +376,6 @@ export default {
|
||||
getPaymentsByBookingId,
|
||||
createStripePaymentIntent,
|
||||
confirmStripePayment,
|
||||
createPayPalOrder,
|
||||
capturePayPalPayment,
|
||||
};
|
||||
|
||||
@@ -36,6 +36,24 @@ export interface UpdateStripeSettingsRequest {
|
||||
stripe_webhook_secret?: string;
|
||||
}
|
||||
|
||||
export interface PayPalSettingsResponse {
|
||||
status: string;
|
||||
data: {
|
||||
paypal_client_id: string;
|
||||
paypal_client_secret: string;
|
||||
paypal_mode: string;
|
||||
paypal_client_secret_masked: string;
|
||||
has_client_id: boolean;
|
||||
has_client_secret: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdatePayPalSettingsRequest {
|
||||
paypal_client_id?: string;
|
||||
paypal_client_secret?: string;
|
||||
paypal_mode?: string;
|
||||
}
|
||||
|
||||
export interface SmtpSettingsResponse {
|
||||
status: string;
|
||||
data: {
|
||||
@@ -168,6 +186,29 @@ const systemSettingsService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get PayPal settings (Admin only)
|
||||
*/
|
||||
getPayPalSettings: async (): Promise<PayPalSettingsResponse> => {
|
||||
const response = await apiClient.get<PayPalSettingsResponse>(
|
||||
'/api/admin/system-settings/paypal'
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update PayPal settings (Admin only)
|
||||
*/
|
||||
updatePayPalSettings: async (
|
||||
settings: UpdatePayPalSettingsRequest
|
||||
): Promise<PayPalSettingsResponse> => {
|
||||
const response = await apiClient.put<PayPalSettingsResponse>(
|
||||
'/api/admin/system-settings/paypal',
|
||||
settings
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get SMTP settings (Admin only)
|
||||
*/
|
||||
@@ -277,6 +318,8 @@ export type {
|
||||
UpdateCurrencyRequest,
|
||||
StripeSettingsResponse,
|
||||
UpdateStripeSettingsRequest,
|
||||
PayPalSettingsResponse,
|
||||
UpdatePayPalSettingsRequest,
|
||||
SmtpSettingsResponse,
|
||||
UpdateSmtpSettingsRequest,
|
||||
TestSmtpEmailRequest,
|
||||
|
||||
@@ -25,9 +25,13 @@ interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
requiresMFA: boolean;
|
||||
mfaUserId: number | null;
|
||||
pendingCredentials: LoginCredentials | null;
|
||||
|
||||
// Actions
|
||||
login: (credentials: LoginCredentials) => Promise<void>;
|
||||
verifyMFA: (mfaToken: string) => Promise<void>;
|
||||
register: (data: RegisterData) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
setUser: (user: UserInfo) => void;
|
||||
@@ -40,6 +44,7 @@ interface AuthState {
|
||||
) => Promise<void>;
|
||||
initializeAuth: () => void;
|
||||
clearError: () => void;
|
||||
clearMFA: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,6 +60,9 @@ const useAuthStore = create<AuthState>((set, get) => ({
|
||||
isAuthenticated: !!localStorage.getItem('token'),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
requiresMFA: false,
|
||||
mfaUserId: null,
|
||||
pendingCredentials: null,
|
||||
|
||||
/**
|
||||
* Login - User login
|
||||
@@ -64,6 +72,18 @@ const useAuthStore = create<AuthState>((set, get) => ({
|
||||
try {
|
||||
const response = await authService.login(credentials);
|
||||
|
||||
// Check if MFA is required
|
||||
if (response.requires_mfa) {
|
||||
set({
|
||||
isLoading: false,
|
||||
requiresMFA: true,
|
||||
mfaUserId: response.user_id || null,
|
||||
pendingCredentials: credentials,
|
||||
error: null,
|
||||
});
|
||||
return; // Stop here, waiting for MFA verification
|
||||
}
|
||||
|
||||
// Accept either boolean `success` (client) or `status: 'success'` (server)
|
||||
if (response.success || response.status === 'success') {
|
||||
const token = response.data?.token;
|
||||
@@ -85,6 +105,9 @@ const useAuthStore = create<AuthState>((set, get) => ({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
requiresMFA: false,
|
||||
mfaUserId: null,
|
||||
pendingCredentials: null,
|
||||
});
|
||||
|
||||
toast.success('Login successful!');
|
||||
@@ -97,7 +120,10 @@ const useAuthStore = create<AuthState>((set, get) => ({
|
||||
set({
|
||||
isLoading: false,
|
||||
error: errorMessage,
|
||||
isAuthenticated: false
|
||||
isAuthenticated: false,
|
||||
requiresMFA: false,
|
||||
mfaUserId: null,
|
||||
pendingCredentials: null,
|
||||
});
|
||||
|
||||
toast.error(errorMessage);
|
||||
@@ -105,6 +131,76 @@ const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Verify MFA - Complete login with MFA token
|
||||
*/
|
||||
verifyMFA: async (mfaToken: string) => {
|
||||
const state = get();
|
||||
if (!state.pendingCredentials) {
|
||||
throw new Error('No pending login credentials');
|
||||
}
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const credentials = {
|
||||
...state.pendingCredentials,
|
||||
mfaToken,
|
||||
};
|
||||
|
||||
const response = await authService.login(credentials);
|
||||
|
||||
if (response.success || response.status === 'success') {
|
||||
const token = response.data?.token;
|
||||
const user = response.data?.user ?? null;
|
||||
|
||||
if (!token || !user) {
|
||||
throw new Error(response.message || 'MFA verification failed.');
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('userInfo', JSON.stringify(user));
|
||||
|
||||
// Update state
|
||||
set({
|
||||
token,
|
||||
userInfo: user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
requiresMFA: false,
|
||||
mfaUserId: null,
|
||||
pendingCredentials: null,
|
||||
});
|
||||
|
||||
toast.success('Login successful!');
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
'MFA verification failed. Please try again.';
|
||||
|
||||
set({
|
||||
isLoading: false,
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
toast.error(errorMessage);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear MFA - Clear MFA state
|
||||
*/
|
||||
clearMFA: () => {
|
||||
set({
|
||||
requiresMFA: false,
|
||||
mfaUserId: null,
|
||||
pendingCredentials: null,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Register - Register new account
|
||||
*/
|
||||
|
||||
27
Frontend/src/utils/imageUtils.ts
Normal file
27
Frontend/src/utils/imageUtils.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Utility functions for image URL normalization
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalize image URL to absolute URL
|
||||
* @param imageUrl - The image URL (can be relative or absolute)
|
||||
* @returns Normalized absolute URL
|
||||
*/
|
||||
export const normalizeImageUrl = (imageUrl: string | null | undefined): string => {
|
||||
if (!imageUrl) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// If already a full URL, return as-is
|
||||
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
// Get API base URL from environment or default
|
||||
const apiBaseUrl = import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000';
|
||||
|
||||
// Normalize relative paths
|
||||
const cleanPath = imageUrl.startsWith('/') ? imageUrl : `/${imageUrl}`;
|
||||
return `${apiBaseUrl}${cleanPath}`;
|
||||
};
|
||||
|
||||
@@ -33,10 +33,10 @@ export const bookingValidationSchema = yup.object().shape({
|
||||
.optional(),
|
||||
|
||||
paymentMethod: yup
|
||||
.mixed<'cash' | 'stripe'>()
|
||||
.mixed<'cash' | 'stripe' | 'paypal'>()
|
||||
.required('Please select payment method')
|
||||
.oneOf(
|
||||
['cash', 'stripe'],
|
||||
['cash', 'stripe', 'paypal'],
|
||||
'Invalid payment method'
|
||||
),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user