This commit is contained in:
Iliyan Angelov
2025-11-19 12:27:01 +02:00
parent 2043ac897c
commit 34b4c969d4
469 changed files with 26870 additions and 8329 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) ? (

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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'
),