updates
This commit is contained in:
@@ -4,10 +4,33 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- Content Security Policy - Additional layer of XSS protection -->
|
||||
<!-- Allows HTTP localhost connections for development, HTTPS for production -->
|
||||
<!-- Note: Backend CSP headers (production only) will override/merge with this meta tag -->
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https: http: blob:; connect-src 'self' https: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* wss: https://js.stripe.com https://hooks.stripe.com; frame-src 'self' https://js.stripe.com https://hooks.stripe.com; object-src 'none'; base-uri 'self'; form-action 'self';" />
|
||||
<!-- SECURITY: Security headers for payment pages and general security -->
|
||||
<meta http-equiv="X-Content-Type-Options" content="nosniff" />
|
||||
<meta http-equiv="X-Frame-Options" content="SAMEORIGIN" />
|
||||
<meta http-equiv="X-XSS-Protection" content="1; mode=block" />
|
||||
<!-- SECURITY: Prevent caching of sensitive pages (payment pages should set no-cache) -->
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<!-- SECURITY: Content Security Policy - XSS protection -->
|
||||
<!--
|
||||
IMPORTANT: This CSP is permissive for development compatibility.
|
||||
For production, the backend MUST set strict CSP headers that override this.
|
||||
|
||||
Production CSP should:
|
||||
- Remove 'unsafe-inline' and 'unsafe-eval' from script-src
|
||||
- Use nonces or hashes for inline scripts
|
||||
- Remove 'unsafe-inline' from style-src (use nonces)
|
||||
- Set report-uri for violation reporting
|
||||
|
||||
Current CSP allows:
|
||||
- unsafe-inline/unsafe-eval: Required for Vite HMR in development
|
||||
- ws://localhost: Required for Vite HMR websocket
|
||||
|
||||
Backend should set headers like:
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{NONCE}' https://js.stripe.com ...; ...
|
||||
-->
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https: http: blob:; connect-src 'self' https: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* wss: https://js.stripe.com https://hooks.stripe.com https://www.google.com; frame-src 'self' https://js.stripe.com https://hooks.stripe.com https://www.google.com; object-src 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests;" />
|
||||
<!-- Preconnect to external resources for faster loading -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
12
Frontend/package-lock.json
generated
12
Frontend/package-lock.json
generated
@@ -4454,9 +4454,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6134,9 +6134,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sucrase/node_modules/glob": {
|
||||
"version": "10.4.5",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { CurrencyProvider } from './features/payments/contexts/CurrencyContext';
|
||||
import { CompanySettingsProvider } from './shared/contexts/CompanySettingsContext';
|
||||
import { AuthModalProvider } from './features/auth/contexts/AuthModalContext';
|
||||
import { AntibotProvider } from './features/auth/contexts/AntibotContext';
|
||||
import { logDebug } from './shared/utils/errorReporter';
|
||||
import OfflineIndicator from './shared/components/OfflineIndicator';
|
||||
import CookieConsentBanner from './shared/components/CookieConsentBanner';
|
||||
import CookiePreferencesModal from './shared/components/CookiePreferencesModal';
|
||||
@@ -61,6 +62,7 @@ const LoyaltyPage = lazy(() => import('./pages/customer/LoyaltyPage'));
|
||||
const GroupBookingPage = lazy(() => import('./pages/customer/GroupBookingPage'));
|
||||
const ComplaintPage = lazy(() => import('./pages/customer/ComplaintPage'));
|
||||
const GDPRPage = lazy(() => import('./pages/customer/GDPRPage'));
|
||||
const GDPRDeletionConfirmPage = lazy(() => import('./pages/customer/GDPRDeletionConfirmPage'));
|
||||
const AboutPage = lazy(() => import('./features/content/pages/AboutPage'));
|
||||
const ContactPage = lazy(() => import('./features/content/pages/ContactPage'));
|
||||
const PrivacyPolicyPage = lazy(() => import('./features/content/pages/PrivacyPolicyPage'));
|
||||
@@ -168,9 +170,10 @@ function App() {
|
||||
useEffect(() => {
|
||||
// Initialize auth asynchronously to validate cookies
|
||||
initializeAuth().catch((error) => {
|
||||
// Silently handle auth initialization errors
|
||||
// SECURITY: Silently handle auth initialization errors
|
||||
// User will be prompted to login if needed
|
||||
console.debug('Auth initialization failed:', error);
|
||||
// Only log in development to prevent information disclosure
|
||||
logDebug('Auth initialization failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
}, [initializeAuth]);
|
||||
|
||||
@@ -282,9 +285,11 @@ function App() {
|
||||
<Route
|
||||
path="invoices/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<InvoicePage />
|
||||
</ProtectedRoute>
|
||||
<ErrorBoundaryRoute>
|
||||
<CustomerRoute>
|
||||
<InvoicePage />
|
||||
</CustomerRoute>
|
||||
</ErrorBoundaryRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
@@ -332,9 +337,11 @@ function App() {
|
||||
<Route
|
||||
path="dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DashboardPage />
|
||||
</ProtectedRoute>
|
||||
<ErrorBoundaryRoute>
|
||||
<CustomerRoute>
|
||||
<DashboardPage />
|
||||
</CustomerRoute>
|
||||
</ErrorBoundaryRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
@@ -390,41 +397,59 @@ function App() {
|
||||
<Route
|
||||
path="profile"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProfilePage />
|
||||
</ProtectedRoute>
|
||||
<ErrorBoundaryRoute>
|
||||
<CustomerRoute>
|
||||
<ProfilePage />
|
||||
</CustomerRoute>
|
||||
</ErrorBoundaryRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="loyalty"
|
||||
element={
|
||||
<CustomerRoute>
|
||||
<LoyaltyPage />
|
||||
</CustomerRoute>
|
||||
<ErrorBoundaryRoute>
|
||||
<CustomerRoute>
|
||||
<LoyaltyPage />
|
||||
</CustomerRoute>
|
||||
</ErrorBoundaryRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="group-bookings"
|
||||
element={
|
||||
<CustomerRoute>
|
||||
<GroupBookingPage />
|
||||
</CustomerRoute>
|
||||
<ErrorBoundaryRoute>
|
||||
<CustomerRoute>
|
||||
<GroupBookingPage />
|
||||
</CustomerRoute>
|
||||
</ErrorBoundaryRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="complaints"
|
||||
element={
|
||||
<CustomerRoute>
|
||||
<ComplaintPage />
|
||||
</CustomerRoute>
|
||||
<ErrorBoundaryRoute>
|
||||
<CustomerRoute>
|
||||
<ComplaintPage />
|
||||
</CustomerRoute>
|
||||
</ErrorBoundaryRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="gdpr"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<GDPRPage />
|
||||
</ProtectedRoute>
|
||||
<ErrorBoundaryRoute>
|
||||
<CustomerRoute>
|
||||
<GDPRPage />
|
||||
</CustomerRoute>
|
||||
</ErrorBoundaryRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="gdpr/delete/confirm"
|
||||
element={
|
||||
<ErrorBoundaryRoute>
|
||||
<GDPRDeletionConfirmPage />
|
||||
</ErrorBoundaryRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
@@ -7,13 +7,21 @@ interface AdminRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* SECURITY NOTE: This component performs CLIENT-SIDE authorization checks only.
|
||||
* These checks are for UX purposes (showing/hiding UI elements).
|
||||
*
|
||||
* ALL authorization must be enforced server-side. Client-side checks can be bypassed
|
||||
* by modifying localStorage or browser DevTools. The backend API must validate
|
||||
* user roles and permissions for every request.
|
||||
*/
|
||||
const AdminRoute: React.FC<AdminRouteProps> = ({
|
||||
children
|
||||
}) => {
|
||||
const { isAuthenticated, userInfo, isLoading } = useAuthStore();
|
||||
const { openModal } = useAuthModal();
|
||||
|
||||
|
||||
// SECURITY: Client-side role check - backend must also validate
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
openModal('login');
|
||||
@@ -45,6 +53,8 @@ const AdminRoute: React.FC<AdminRouteProps> = ({
|
||||
}
|
||||
|
||||
|
||||
// SECURITY: Client-side role check - MUST be validated server-side
|
||||
// This check can be bypassed by modifying localStorage
|
||||
const isAdmin = userInfo?.role === 'admin';
|
||||
if (!isAdmin) {
|
||||
// Redirect to appropriate dashboard based on role
|
||||
|
||||
@@ -179,7 +179,8 @@ const authService = {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
|
||||
// FormData handling: apiClient interceptor will remove Content-Type header
|
||||
// to let browser set it automatically with boundary
|
||||
const response = await apiClient.post<AuthResponse>(
|
||||
'/api/auth/avatar/upload',
|
||||
formData
|
||||
|
||||
@@ -5,7 +5,7 @@ import pageContentService from '../services/pageContentService';
|
||||
import type { PageContent } from '../services/pageContentService';
|
||||
import { useCompanySettings } from '../../../shared/contexts/CompanySettingsContext';
|
||||
import Loading from '../../../shared/components/Loading';
|
||||
import { createSanitizedHtml } from '../../../shared/utils/htmlSanitizer';
|
||||
import { createSanitizedHtml, sanitizeHtml } from '../../../shared/utils/htmlSanitizer';
|
||||
|
||||
const AccessibilityPage: React.FC = () => {
|
||||
const { settings } = useCompanySettings();
|
||||
@@ -21,8 +21,10 @@ const AccessibilityPage: React.FC = () => {
|
||||
const content = response.data.page_content;
|
||||
|
||||
if (content.content) {
|
||||
// SECURITY: Sanitize HTML before assigning to innerHTML to prevent XSS
|
||||
const sanitizedContent = sanitizeHtml(content.content);
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = content.content;
|
||||
tempDiv.innerHTML = sanitizedContent;
|
||||
|
||||
const allElements = tempDiv.querySelectorAll('*');
|
||||
allElements.forEach((el) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Link } from 'react-router-dom';
|
||||
import pageContentService, { PageContent } from '../services/pageContentService';
|
||||
import { useCompanySettings } from '../../../shared/contexts/CompanySettingsContext';
|
||||
import Loading from '../../../shared/components/Loading';
|
||||
import { createSanitizedHtml } from '../../../shared/utils/htmlSanitizer';
|
||||
import { createSanitizedHtml, sanitizeHtml } from '../../../shared/utils/htmlSanitizer';
|
||||
|
||||
const CancellationPolicyPage: React.FC = () => {
|
||||
const { settings } = useCompanySettings();
|
||||
@@ -21,8 +21,10 @@ const CancellationPolicyPage: React.FC = () => {
|
||||
|
||||
// Process HTML content to ensure text is visible
|
||||
if (content.content) {
|
||||
// SECURITY: Sanitize HTML before assigning to innerHTML to prevent XSS
|
||||
const sanitizedContent = sanitizeHtml(content.content);
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = content.content;
|
||||
tempDiv.innerHTML = sanitizedContent;
|
||||
|
||||
const allElements = tempDiv.querySelectorAll('*');
|
||||
allElements.forEach((el) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import pageContentService from '../services/pageContentService';
|
||||
import type { PageContent } from '../services/pageContentService';
|
||||
import { useCompanySettings } from '../../../shared/contexts/CompanySettingsContext';
|
||||
import Loading from '../../../shared/components/Loading';
|
||||
import { createSanitizedHtml } from '../../../shared/utils/htmlSanitizer';
|
||||
import { createSanitizedHtml, sanitizeHtml } from '../../../shared/utils/htmlSanitizer';
|
||||
|
||||
const FAQPage: React.FC = () => {
|
||||
const { settings } = useCompanySettings();
|
||||
@@ -21,8 +21,10 @@ const FAQPage: React.FC = () => {
|
||||
const content = response.data.page_content;
|
||||
|
||||
if (content.content) {
|
||||
// SECURITY: Sanitize HTML before assigning to innerHTML to prevent XSS
|
||||
const sanitizedContent = sanitizeHtml(content.content);
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = content.content;
|
||||
tempDiv.innerHTML = sanitizedContent;
|
||||
|
||||
const allElements = tempDiv.querySelectorAll('*');
|
||||
allElements.forEach((el) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import pageContentService from '../services/pageContentService';
|
||||
import type { PageContent } from '../services/pageContentService';
|
||||
import { useCompanySettings } from '../../../shared/contexts/CompanySettingsContext';
|
||||
import Loading from '../../../shared/components/Loading';
|
||||
import { createSanitizedHtml } from '../../../shared/utils/htmlSanitizer';
|
||||
import { createSanitizedHtml, sanitizeHtml } from '../../../shared/utils/htmlSanitizer';
|
||||
|
||||
const PrivacyPolicyPage: React.FC = () => {
|
||||
const { settings } = useCompanySettings();
|
||||
@@ -22,9 +22,11 @@ const PrivacyPolicyPage: React.FC = () => {
|
||||
|
||||
// Process HTML content to ensure text is visible
|
||||
if (content.content) {
|
||||
// SECURITY: Sanitize HTML before assigning to innerHTML to prevent XSS
|
||||
const sanitizedContent = sanitizeHtml(content.content);
|
||||
// Create a temporary div to parse HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = content.content;
|
||||
tempDiv.innerHTML = sanitizedContent;
|
||||
|
||||
// Add color styles to elements that don't have them
|
||||
const allElements = tempDiv.querySelectorAll('*');
|
||||
@@ -169,16 +171,27 @@ const PrivacyPolicyPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Footer Note */}
|
||||
{settings.company_email && (
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-sm text-gray-400 font-light">
|
||||
For questions about this policy, contact us at{' '}
|
||||
<a href={`mailto:${settings.company_email}`} className="text-[#d4af37] hover:underline">
|
||||
{settings.company_email}
|
||||
</a>
|
||||
</p>
|
||||
<div className="mt-8 space-y-4">
|
||||
{settings.company_email && (
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-400 font-light">
|
||||
For questions about this policy, contact us at{' '}
|
||||
<a href={`mailto:${settings.company_email}`} className="text-[#d4af37] hover:underline">
|
||||
{settings.company_email}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center">
|
||||
<Link
|
||||
to="/gdpr"
|
||||
className="inline-flex items-center gap-2 text-sm text-[#d4af37] hover:text-[#f5d76e] transition-colors font-light"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
<span>Manage Your Data Privacy (GDPR)</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import pageContentService from '../services/pageContentService';
|
||||
import type { PageContent } from '../services/pageContentService';
|
||||
import { useCompanySettings } from '../../../shared/contexts/CompanySettingsContext';
|
||||
import Loading from '../../../shared/components/Loading';
|
||||
import { createSanitizedHtml } from '../../../shared/utils/htmlSanitizer';
|
||||
import { createSanitizedHtml, sanitizeHtml } from '../../../shared/utils/htmlSanitizer';
|
||||
|
||||
const RefundsPolicyPage: React.FC = () => {
|
||||
const { settings } = useCompanySettings();
|
||||
@@ -22,9 +22,11 @@ const RefundsPolicyPage: React.FC = () => {
|
||||
|
||||
// Process HTML content to ensure text is visible
|
||||
if (content.content) {
|
||||
// SECURITY: Sanitize HTML before assigning to innerHTML to prevent XSS
|
||||
const sanitizedContent = sanitizeHtml(content.content);
|
||||
// Create a temporary div to parse HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = content.content;
|
||||
tempDiv.innerHTML = sanitizedContent;
|
||||
|
||||
// Add color styles to elements that don't have them
|
||||
const allElements = tempDiv.querySelectorAll('*');
|
||||
|
||||
@@ -5,7 +5,7 @@ import pageContentService from '../services/pageContentService';
|
||||
import type { PageContent } from '../services/pageContentService';
|
||||
import { useCompanySettings } from '../../../shared/contexts/CompanySettingsContext';
|
||||
import Loading from '../../../shared/components/Loading';
|
||||
import { createSanitizedHtml } from '../../../shared/utils/htmlSanitizer';
|
||||
import { createSanitizedHtml, sanitizeHtml } from '../../../shared/utils/htmlSanitizer';
|
||||
|
||||
const TermsPage: React.FC = () => {
|
||||
const { settings } = useCompanySettings();
|
||||
@@ -22,9 +22,11 @@ const TermsPage: React.FC = () => {
|
||||
|
||||
// Process HTML content to ensure text is visible
|
||||
if (content.content) {
|
||||
// SECURITY: Sanitize HTML before assigning to innerHTML to prevent XSS
|
||||
const sanitizedContent = sanitizeHtml(content.content);
|
||||
// Create a temporary div to parse HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = content.content;
|
||||
tempDiv.innerHTML = sanitizedContent;
|
||||
|
||||
// Add color styles to elements that don't have them
|
||||
const allElements = tempDiv.querySelectorAll('*');
|
||||
|
||||
@@ -59,8 +59,12 @@ const PayPalPaymentModal: React.FC<PayPalPaymentModalProps> = ({
|
||||
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';
|
||||
// SECURITY: Don't log payment errors with sensitive data in production
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('Error initializing PayPal:', err);
|
||||
}
|
||||
// SECURITY: Sanitize error message to prevent information disclosure
|
||||
const errorMessage = 'Failed to initialize PayPal payment. Please try again.';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
|
||||
@@ -55,8 +55,12 @@ const PayPalPaymentWrapper: React.FC<PayPalPaymentWrapperProps> = ({
|
||||
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';
|
||||
// SECURITY: Don't log payment errors with sensitive data in production
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('Error initializing PayPal:', err);
|
||||
}
|
||||
// SECURITY: Sanitize error message to prevent information disclosure
|
||||
const errorMessage = 'Failed to initialize PayPal payment. Please try again.';
|
||||
setError(errorMessage);
|
||||
if (onError) {
|
||||
onError(errorMessage);
|
||||
@@ -169,9 +173,21 @@ const PayPalPaymentWrapper: React.FC<PayPalPaymentWrapperProps> = ({
|
||||
</svg>
|
||||
Pay with PayPal
|
||||
</button>
|
||||
<p className="text-xs text-gray-400/70 mt-6 font-light tracking-wide">
|
||||
Secure payment powered by PayPal
|
||||
</p>
|
||||
<div className="mt-6 space-y-2">
|
||||
<p className="text-xs text-gray-400/70 font-light tracking-wide text-center">
|
||||
Secure payment powered by PayPal
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-3 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
PCI DSS Compliant
|
||||
</span>
|
||||
<span className="text-gray-600">•</span>
|
||||
<span>SSL Encrypted</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -82,7 +82,12 @@ const StripePaymentForm: React.FC<StripePaymentFormProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4"
|
||||
autoComplete="off"
|
||||
// SECURITY: Prevent form caching and autocomplete for payment forms
|
||||
>
|
||||
<div className="mb-4 p-4 bg-[#d4af37]/10 border border-[#d4af37]/20 rounded-lg">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-gray-300">
|
||||
@@ -154,9 +159,21 @@ const StripePaymentForm: React.FC<StripePaymentFormProps> = ({
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-gray-500 text-center">
|
||||
Your payment is secure and encrypted
|
||||
</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-xs text-gray-500 text-center">
|
||||
Your payment is secure and encrypted
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-3 text-xs text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
PCI DSS Compliant
|
||||
</span>
|
||||
<span className="text-gray-600">•</span>
|
||||
<span>Powered by Stripe</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -49,7 +49,13 @@ const StripePaymentWrapper: React.FC<StripePaymentWrapperProps> = ({
|
||||
if (response.success && response.data) {
|
||||
const { publishable_key, client_secret } = response.data;
|
||||
|
||||
console.log('Payment intent response:', { publishable_key: publishable_key ? 'present' : 'missing', client_secret: client_secret ? 'present' : 'missing' });
|
||||
// SECURITY: Don't log payment data - only log presence in development
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('Payment intent response:', {
|
||||
publishable_key: publishable_key ? 'present' : 'missing',
|
||||
client_secret: client_secret ? 'present' : 'missing'
|
||||
});
|
||||
}
|
||||
|
||||
if (!client_secret) {
|
||||
throw new Error('Client secret not received from server');
|
||||
@@ -76,8 +82,12 @@ const StripePaymentWrapper: React.FC<StripePaymentWrapperProps> = ({
|
||||
throw new Error(response.message || 'Failed to initialize payment');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error initializing Stripe:', err);
|
||||
const errorMessage = err.response?.data?.message || err.message || 'Failed to initialize payment';
|
||||
// SECURITY: Don't log payment errors with sensitive data in production
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('Error initializing Stripe:', err);
|
||||
}
|
||||
// SECURITY: Sanitize error message to prevent information disclosure
|
||||
const errorMessage = 'Failed to initialize payment. Please try again.';
|
||||
setError(errorMessage);
|
||||
if (onError) {
|
||||
onError(errorMessage);
|
||||
@@ -92,12 +102,15 @@ const StripePaymentWrapper: React.FC<StripePaymentWrapperProps> = ({
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (clientSecret && stripePromise) {
|
||||
console.log('Stripe initialized successfully', { hasClientSecret: !!clientSecret, hasStripePromise: !!stripePromise });
|
||||
} else {
|
||||
console.log('Stripe not ready', { hasClientSecret: !!clientSecret, hasStripePromise: !!stripePromise, error });
|
||||
// SECURITY: Only log in development, never log client_secret or sensitive data
|
||||
if (import.meta.env.DEV) {
|
||||
if (clientSecret && stripePromise) {
|
||||
console.log('Stripe initialized successfully', { hasClientSecret: !!clientSecret, hasStripePromise: !!stripePromise });
|
||||
} else {
|
||||
console.log('Stripe not ready', { hasClientSecret: !!clientSecret, hasStripePromise: !!stripePromise });
|
||||
}
|
||||
}
|
||||
}, [clientSecret, stripePromise, error]);
|
||||
}, [clientSecret, stripePromise]);
|
||||
|
||||
const handlePaymentSuccess = async (paymentIntentId: string) => {
|
||||
try {
|
||||
@@ -114,10 +127,14 @@ const StripePaymentWrapper: React.FC<StripePaymentWrapperProps> = ({
|
||||
throw new Error(response.message || 'Payment confirmation failed');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error confirming payment:', err);
|
||||
// SECURITY: Don't log payment errors with sensitive data in production
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('Error confirming payment:', err);
|
||||
}
|
||||
|
||||
setPaymentCompleted(false);
|
||||
const errorMessage = err.response?.data?.message || err.message || 'Payment confirmation failed';
|
||||
// SECURITY: Sanitize error message to prevent information disclosure
|
||||
const errorMessage = 'Payment confirmation failed. Please contact support if the issue persists.';
|
||||
setError(errorMessage);
|
||||
if (onError) {
|
||||
onError(errorMessage);
|
||||
|
||||
@@ -40,6 +40,7 @@ import reviewService, { Review } from '../../features/reviews/services/reviewSer
|
||||
import { auditService, AuditLog, AuditLogFilters } from '../../features/analytics/services/auditService';
|
||||
import { formatDate } from '../../shared/utils/format';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
import analyticsService, {
|
||||
ComprehensiveAnalyticsData,
|
||||
RevPARData,
|
||||
@@ -252,7 +253,7 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
setTotalItems(response.data.pagination.total);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching audit logs:', error);
|
||||
logger.error('Error fetching audit logs', error);
|
||||
toast.error(error.response?.data?.message || 'Unable to load audit logs');
|
||||
} finally {
|
||||
setAuditLoading(false);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
CreditCard,
|
||||
Receipt,
|
||||
@@ -20,6 +20,8 @@ import { formatDate } from '../../shared/utils/format';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
import { useAsync } from '../../shared/hooks/useAsync';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
import { getPaymentStatusColor } from '../../shared/utils/paymentUtils';
|
||||
|
||||
const AccountantDashboardPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
@@ -32,6 +34,8 @@ const AccountantDashboardPage: React.FC = () => {
|
||||
const [recentInvoices, setRecentInvoices] = useState<Invoice[]>([]);
|
||||
const [loadingPayments, setLoadingPayments] = useState(false);
|
||||
const [loadingInvoices, setLoadingInvoices] = useState(false);
|
||||
const paymentsAbortRef = useRef<AbortController | null>(null);
|
||||
const invoicesAbortRef = useRef<AbortController | null>(null);
|
||||
const [financialSummary, setFinancialSummary] = useState({
|
||||
totalRevenue: 0,
|
||||
totalPayments: 0,
|
||||
@@ -64,6 +68,14 @@ const AccountantDashboardPage: React.FC = () => {
|
||||
}, [dateRange]);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (paymentsAbortRef.current) {
|
||||
paymentsAbortRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
paymentsAbortRef.current = new AbortController();
|
||||
|
||||
const fetchPayments = async () => {
|
||||
try {
|
||||
setLoadingPayments(true);
|
||||
@@ -71,7 +83,7 @@ const AccountantDashboardPage: React.FC = () => {
|
||||
if (response.success && response.data?.payments) {
|
||||
setRecentPayments(response.data.payments);
|
||||
// Calculate financial summary
|
||||
const completedPayments = response.data.payments.filter((p: Payment) => p.payment_status === 'completed');
|
||||
const completedPayments = response.data.payments.filter((p: Payment) => p.payment_status === 'completed' || p.payment_status === 'paid');
|
||||
const pendingPayments = response.data.payments.filter((p: Payment) => p.payment_status === 'pending');
|
||||
const totalRevenue = completedPayments.reduce((sum: number, p: Payment) => sum + (p.amount || 0), 0);
|
||||
|
||||
@@ -83,15 +95,34 @@ const AccountantDashboardPage: React.FC = () => {
|
||||
}));
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching payments:', err);
|
||||
// Handle AbortError silently
|
||||
if (err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
logger.error('Error fetching payments', err);
|
||||
} finally {
|
||||
setLoadingPayments(false);
|
||||
}
|
||||
};
|
||||
fetchPayments();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (paymentsAbortRef.current) {
|
||||
paymentsAbortRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (invoicesAbortRef.current) {
|
||||
invoicesAbortRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
invoicesAbortRef.current = new AbortController();
|
||||
|
||||
const fetchInvoices = async () => {
|
||||
try {
|
||||
setLoadingInvoices(true);
|
||||
@@ -110,32 +141,29 @@ const AccountantDashboardPage: React.FC = () => {
|
||||
}));
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching invoices:', err);
|
||||
// Handle AbortError silently
|
||||
if (err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
logger.error('Error fetching invoices', err);
|
||||
} finally {
|
||||
setLoadingInvoices(false);
|
||||
}
|
||||
};
|
||||
fetchInvoices();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (invoicesAbortRef.current) {
|
||||
invoicesAbortRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRefresh = () => {
|
||||
execute();
|
||||
};
|
||||
|
||||
const getPaymentStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-gradient-to-r from-emerald-50 to-green-50 text-emerald-800 border-emerald-200';
|
||||
case 'pending':
|
||||
return 'bg-gradient-to-r from-amber-50 to-yellow-50 text-amber-800 border-amber-200';
|
||||
case 'failed':
|
||||
return 'bg-gradient-to-r from-rose-50 to-red-50 text-rose-800 border-rose-200';
|
||||
case 'refunded':
|
||||
return 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200';
|
||||
default:
|
||||
return 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getInvoiceStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import ExportButton from '../../shared/components/ExportButton';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { formatDate } from '../../shared/utils/format';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
|
||||
const InvoiceManagementPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
@@ -22,13 +23,29 @@ const InvoiceManagementPage: React.FC = () => {
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const itemsPerPage = 10;
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchInvoices();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [filters, currentPage]);
|
||||
|
||||
const fetchInvoices = async () => {
|
||||
@@ -58,6 +75,11 @@ const InvoiceManagementPage: React.FC = () => {
|
||||
setTotalItems(response.data.total || 0);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Handle AbortError silently
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
logger.error('Error fetching invoices', error);
|
||||
toast.error(error.response?.data?.message || 'Unable to load invoices');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import paymentService from '../../features/payments/services/paymentService';
|
||||
import type { Payment } from '../../features/payments/services/paymentService';
|
||||
@@ -8,6 +8,8 @@ import Pagination from '../../shared/components/Pagination';
|
||||
import ExportButton from '../../shared/components/ExportButton';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
import { formatDate } from '../../shared/utils/format';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
import { getPaymentStatusColor, getPaymentMethodLabel } from '../../shared/utils/paymentUtils';
|
||||
|
||||
const PaymentManagementPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
@@ -23,13 +25,29 @@ const PaymentManagementPage: React.FC = () => {
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const itemsPerPage = 5;
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchPayments();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [filters, currentPage]);
|
||||
|
||||
const fetchPayments = async () => {
|
||||
@@ -53,77 +71,55 @@ const PaymentManagementPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const getMethodBadge = (method: string) => {
|
||||
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
|
||||
const label = getPaymentMethodLabel(method);
|
||||
const badges: Record<string, { bg: string; text: string; border: string }> = {
|
||||
cash: {
|
||||
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
|
||||
text: 'text-emerald-800',
|
||||
label: 'Cash',
|
||||
border: 'border-emerald-200'
|
||||
},
|
||||
bank_transfer: {
|
||||
bg: 'bg-gradient-to-r from-blue-50 to-indigo-50',
|
||||
text: 'text-blue-800',
|
||||
label: 'Bank transfer',
|
||||
border: 'border-blue-200'
|
||||
},
|
||||
stripe: {
|
||||
bg: 'bg-gradient-to-r from-indigo-50 to-purple-50',
|
||||
text: 'text-indigo-800',
|
||||
label: 'Stripe',
|
||||
border: 'border-indigo-200'
|
||||
},
|
||||
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;
|
||||
return (
|
||||
<span className={`px-4 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${badge.bg} ${badge.text} ${badge.border}`}>
|
||||
{badge.label}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getPaymentStatusBadge = (status: string) => {
|
||||
const statusConfig: Record<string, { bg: string; text: string; label: string; border: string }> = {
|
||||
completed: {
|
||||
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
|
||||
text: 'text-emerald-800',
|
||||
label: '✅ Paid',
|
||||
border: 'border-emerald-200'
|
||||
},
|
||||
pending: {
|
||||
bg: 'bg-gradient-to-r from-amber-50 to-yellow-50',
|
||||
text: 'text-amber-800',
|
||||
label: '⏳ Pending',
|
||||
border: 'border-amber-200'
|
||||
},
|
||||
failed: {
|
||||
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
|
||||
text: 'text-rose-800',
|
||||
label: '❌ Failed',
|
||||
border: 'border-rose-200'
|
||||
},
|
||||
refunded: {
|
||||
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
|
||||
text: 'text-slate-700',
|
||||
label: '💰 Refunded',
|
||||
border: 'border-slate-200'
|
||||
},
|
||||
const colorClasses = getPaymentStatusColor(status);
|
||||
const labels: Record<string, string> = {
|
||||
completed: '✅ Paid',
|
||||
paid: '✅ Paid',
|
||||
pending: '⏳ Pending',
|
||||
failed: '❌ Failed',
|
||||
refunded: '💰 Refunded',
|
||||
};
|
||||
const config = statusConfig[status] || statusConfig.pending;
|
||||
const label = labels[status] || status.charAt(0).toUpperCase() + status.slice(1);
|
||||
return (
|
||||
<span className={`px-3 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${config.bg} ${config.text} ${config.border}`}>
|
||||
{config.label}
|
||||
<span className={`px-3 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${colorClasses}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ const APIKeyManagementPage: React.FC = () => {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingKey, setEditingKey] = useState<APIKey | null>(null);
|
||||
const [newKey, setNewKey] = useState<string | null>(null);
|
||||
const [showFullKey, setShowFullKey] = useState<{ [key: number]: boolean }>({});
|
||||
const [formData, setFormData] = useState<CreateAPIKeyData>({
|
||||
name: '',
|
||||
scopes: [],
|
||||
@@ -58,7 +59,11 @@ const APIKeyManagementPage: React.FC = () => {
|
||||
} else {
|
||||
const response = await apiKeyService.createAPIKey(formData);
|
||||
toast.success('API key created successfully');
|
||||
// SECURITY: Store key temporarily, clear after 5 minutes or on close
|
||||
setNewKey(response.data.key);
|
||||
setTimeout(() => {
|
||||
setNewKey(null);
|
||||
}, 5 * 60 * 1000); // Clear after 5 minutes
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
fetchAPIKeys();
|
||||
@@ -143,19 +148,29 @@ const APIKeyManagementPage: React.FC = () => {
|
||||
|
||||
{newKey && (
|
||||
<div className="bg-gradient-to-r from-amber-50 to-yellow-50 border-2 border-amber-200 rounded-xl sm:rounded-2xl p-3 sm:p-4 mb-4 sm:mb-6 animate-fade-in shadow-lg">
|
||||
<p className="font-semibold mb-2 text-sm sm:text-base text-amber-900">API Key Created (save this - it won't be shown again):</p>
|
||||
<p className="font-semibold mb-2 text-sm sm:text-base text-amber-900">
|
||||
⚠️ SECURITY WARNING: API Key Created (save this now - it will be hidden automatically)
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:gap-3">
|
||||
<code className="flex-1 bg-white p-2 sm:p-3 rounded-lg sm:rounded-xl border-2 border-amber-200 text-xs sm:text-sm font-mono break-all">{newKey}</code>
|
||||
<code className="flex-1 bg-white p-2 sm:p-3 rounded-lg sm:rounded-xl border-2 border-amber-200 text-xs sm:text-sm font-mono break-all">
|
||||
{newKey}
|
||||
</code>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => copyToClipboard(newKey)}
|
||||
onClick={() => {
|
||||
copyToClipboard(newKey);
|
||||
// SECURITY: Clear key from state after copying
|
||||
setTimeout(() => setNewKey(null), 30000); // Clear after 30 seconds
|
||||
}}
|
||||
className="p-2 sm:p-2.5 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg sm:rounded-xl hover:from-blue-700 hover:to-blue-800 shadow-md hover:shadow-lg transition-all duration-200"
|
||||
title="Copy and hide key"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setNewKey(null)}
|
||||
className="p-2 sm:p-2.5 bg-slate-200 hover:bg-slate-300 rounded-lg sm:rounded-xl transition-colors"
|
||||
title="Hide key immediately"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
@@ -16,6 +16,7 @@ import EmptyState from '../../shared/components/EmptyState';
|
||||
import Pagination from '../../shared/components/Pagination';
|
||||
import { auditService, AuditLog, AuditLogFilters } from '../../features/analytics/services/auditService';
|
||||
import { formatDate } from '../../shared/utils/format';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
|
||||
const AuditLogsPage: React.FC = () => {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
@@ -26,6 +27,7 @@ const AuditLogsPage: React.FC = () => {
|
||||
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const itemsPerPage = 20;
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const [filters, setFilters] = useState<AuditLogFilters>({
|
||||
page: 1,
|
||||
@@ -33,7 +35,22 @@ const AuditLogsPage: React.FC = () => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchLogs();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [filters, currentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -54,7 +71,11 @@ const AuditLogsPage: React.FC = () => {
|
||||
setTotalItems(response.data.pagination.total);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching audit logs:', error);
|
||||
// Handle AbortError silently
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
logger.error('Error fetching audit logs', error);
|
||||
toast.error(error.response?.data?.message || 'Unable to load audit logs');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { Download, Trash2, Plus, HardDrive, AlertTriangle } from 'lucide-react';
|
||||
import backupService, { Backup } from '../../features/system/services/backupService';
|
||||
import { toast } from 'react-toastify';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import { formatDate } from '../../shared/utils/format';
|
||||
|
||||
@@ -24,7 +25,7 @@ const BackupManagementPage: React.FC = () => {
|
||||
toast.warning(response.data.message || 'Backup service is not available', { autoClose: 8000 });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error checking backup status:', error);
|
||||
logger.error('Error checking backup status', error);
|
||||
setBackupAvailable(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Search, Eye, XCircle, CheckCircle, Loader2, FileText, Plus, Mail } from 'lucide-react';
|
||||
import bookingService, { Booking } from '../../features/bookings/services/bookingService';
|
||||
import invoiceService from '../../features/payments/services/invoiceService';
|
||||
@@ -35,13 +35,29 @@ const BookingManagementPage: React.FC = () => {
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const itemsPerPage = 5;
|
||||
const showOnlyWithoutInvoices = searchParams.get('createInvoice') === 'true';
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchBookings();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [filters, currentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -68,6 +84,11 @@ const BookingManagementPage: React.FC = () => {
|
||||
setTotalItems(response.data.pagination.total);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Handle AbortError silently
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
logger.error('Error fetching bookings', error);
|
||||
toast.error(error.response?.data?.message || 'Unable to load bookings list');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -170,7 +191,6 @@ const BookingManagementPage: React.FC = () => {
|
||||
const response = await invoiceService.createInvoice(invoiceData);
|
||||
|
||||
// Log the full response for debugging
|
||||
console.log('Invoice creation response:', JSON.stringify(response, null, 2));
|
||||
logger.info('Invoice creation response', { response });
|
||||
|
||||
// Check response structure - handle different possible formats
|
||||
@@ -178,11 +198,10 @@ const BookingManagementPage: React.FC = () => {
|
||||
if (response.status === 'success' && response.data) {
|
||||
// Try different possible response structures
|
||||
invoice = response.data.invoice || response.data.data?.invoice || response.data;
|
||||
console.log('Extracted invoice:', invoice);
|
||||
logger.debug('Extracted invoice', { invoice });
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
console.error('Failed to create invoice - no invoice in response', response);
|
||||
logger.error('Failed to create invoice - no invoice in response', { response });
|
||||
toast.error(response.message || 'Failed to create invoice - no invoice data received');
|
||||
return;
|
||||
@@ -192,7 +211,6 @@ const BookingManagementPage: React.FC = () => {
|
||||
let invoiceId = invoice.id;
|
||||
|
||||
// Log the invoice ID for debugging
|
||||
console.log('Extracted invoice ID:', { invoiceId, type: typeof invoiceId, invoice });
|
||||
logger.info('Extracted invoice ID', { invoiceId, type: typeof invoiceId, invoice });
|
||||
|
||||
// Convert to number if it's a string
|
||||
@@ -202,7 +220,7 @@ const BookingManagementPage: React.FC = () => {
|
||||
|
||||
// Validate invoice ID before navigation
|
||||
if (!invoiceId || isNaN(invoiceId) || invoiceId <= 0 || !isFinite(invoiceId)) {
|
||||
console.error('Invalid invoice ID received from server', {
|
||||
logger.error('Invalid invoice ID received from server', {
|
||||
originalInvoiceId: invoice.id,
|
||||
convertedInvoiceId: invoiceId,
|
||||
type: typeof invoiceId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
BarChart3,
|
||||
Users,
|
||||
@@ -7,11 +7,15 @@ import {
|
||||
TrendingUp,
|
||||
RefreshCw,
|
||||
CreditCard,
|
||||
LogOut
|
||||
LogOut,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Tablet,
|
||||
} from 'lucide-react';
|
||||
import reportService, { ReportData } from '../../features/analytics/services/reportService';
|
||||
import paymentService from '../../features/payments/services/paymentService';
|
||||
import type { Payment } from '../../features/payments/services/paymentService';
|
||||
import sessionService, { UserSession } from '../../features/auth/services/sessionService';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import EmptyState from '../../shared/components/EmptyState';
|
||||
@@ -22,6 +26,7 @@ import { useAsync } from '../../shared/hooks/useAsync';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
import { getPaymentStatusColor, getPaymentMethodLabel } from '../../shared/utils/paymentUtils';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
@@ -33,6 +38,10 @@ const DashboardPage: React.FC = () => {
|
||||
});
|
||||
const [recentPayments, setRecentPayments] = useState<Payment[]>([]);
|
||||
const [loadingPayments, setLoadingPayments] = useState(false);
|
||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
const paymentsAbortRef = useRef<AbortController | null>(null);
|
||||
const sessionsAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
@@ -66,6 +75,14 @@ const DashboardPage: React.FC = () => {
|
||||
}, [dateRange]);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (paymentsAbortRef.current) {
|
||||
paymentsAbortRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
paymentsAbortRef.current = new AbortController();
|
||||
|
||||
const fetchPayments = async () => {
|
||||
try {
|
||||
setLoadingPayments(true);
|
||||
@@ -74,47 +91,81 @@ const DashboardPage: React.FC = () => {
|
||||
setRecentPayments(response.data.payments);
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Handle AbortError silently
|
||||
if (err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
logger.error('Error fetching payments', err);
|
||||
} finally {
|
||||
setLoadingPayments(false);
|
||||
}
|
||||
};
|
||||
fetchPayments();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (paymentsAbortRef.current) {
|
||||
paymentsAbortRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (sessionsAbortRef.current) {
|
||||
sessionsAbortRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
sessionsAbortRef.current = new AbortController();
|
||||
|
||||
const fetchSessions = async () => {
|
||||
try {
|
||||
setLoadingSessions(true);
|
||||
const response = await sessionService.getMySessions();
|
||||
if (response.success && response.data?.sessions) {
|
||||
setSessions(response.data.sessions || []);
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Handle AbortError silently
|
||||
if (err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
logger.error('Error fetching sessions', err);
|
||||
} finally {
|
||||
setLoadingSessions(false);
|
||||
}
|
||||
};
|
||||
fetchSessions();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (sessionsAbortRef.current) {
|
||||
sessionsAbortRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRefresh = () => {
|
||||
execute();
|
||||
};
|
||||
|
||||
const getPaymentStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-gradient-to-r from-emerald-50 to-green-50 text-emerald-800 border-emerald-200';
|
||||
case 'pending':
|
||||
return 'bg-gradient-to-r from-amber-50 to-yellow-50 text-amber-800 border-amber-200';
|
||||
case 'failed':
|
||||
return 'bg-gradient-to-r from-rose-50 to-red-50 text-rose-800 border-rose-200';
|
||||
case 'refunded':
|
||||
return 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200';
|
||||
default:
|
||||
return 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200';
|
||||
}
|
||||
const getDeviceIcon = (userAgent?: string) => {
|
||||
if (!userAgent) return <Monitor className="w-4 h-4 text-slate-600" />;
|
||||
if (userAgent.includes('Mobile')) return <Smartphone className="w-4 h-4 text-blue-600" />;
|
||||
if (userAgent.includes('Tablet')) return <Tablet className="w-4 h-4 text-purple-600" />;
|
||||
return <Monitor className="w-4 h-4 text-slate-600" />;
|
||||
};
|
||||
|
||||
const getPaymentMethodLabel = (method: string) => {
|
||||
switch (method) {
|
||||
case 'stripe':
|
||||
case 'credit_card':
|
||||
return 'Card';
|
||||
case 'paypal':
|
||||
return 'PayPal';
|
||||
case 'bank_transfer':
|
||||
return 'Bank Transfer';
|
||||
case 'cash':
|
||||
return 'Cash';
|
||||
default:
|
||||
return method;
|
||||
}
|
||||
const getDeviceName = (userAgent?: string) => {
|
||||
if (!userAgent) return 'Unknown Device';
|
||||
if (userAgent.includes('Chrome')) return 'Chrome';
|
||||
if (userAgent.includes('Firefox')) return 'Firefox';
|
||||
if (userAgent.includes('Safari')) return 'Safari';
|
||||
if (userAgent.includes('Edge')) return 'Edge';
|
||||
if (userAgent.includes('Mobile')) return 'Mobile Device';
|
||||
if (userAgent.includes('Tablet')) return 'Tablet';
|
||||
return 'Web Browser';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -486,6 +537,63 @@ const DashboardPage: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in" style={{ animationDelay: '0.4s' }}>
|
||||
<div className="flex items-center justify-between mb-4 sm:mb-5 md:mb-6 pb-3 sm:pb-4 border-b border-slate-200">
|
||||
<h2 className="text-sm sm:text-base md:text-base font-bold text-slate-900">Active Sessions</h2>
|
||||
<div className="p-2 bg-gradient-to-br from-indigo-100 to-indigo-200 rounded-lg sm:rounded-xl flex-shrink-0">
|
||||
<Monitor className="w-4 h-4 sm:w-5 sm:h-5 text-indigo-600" />
|
||||
</div>
|
||||
</div>
|
||||
{loadingSessions ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loading text="Loading sessions..." />
|
||||
</div>
|
||||
) : sessions && sessions.length > 0 ? (
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{sessions.slice(0, 3).map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex items-center justify-between p-3 sm:p-4 bg-gradient-to-r from-slate-50 to-white rounded-lg sm:rounded-xl hover:from-indigo-50 hover:to-purple-50 border border-slate-200 hover:border-indigo-300 hover:shadow-lg transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-center space-x-2 sm:space-x-3 md:space-x-4 flex-1 min-w-0">
|
||||
<div className="p-2 sm:p-3 bg-gradient-to-br from-indigo-100 to-indigo-200 rounded-lg sm:rounded-xl shadow-md flex-shrink-0">
|
||||
{getDeviceIcon(session.user_agent)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-slate-900 text-xs sm:text-sm md:text-base truncate">
|
||||
{getDeviceName(session.user_agent)}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 sm:gap-2 mt-1 flex-wrap">
|
||||
<p className="text-xs sm:text-sm text-slate-600">
|
||||
{session.ip_address || 'Unknown IP'}
|
||||
</p>
|
||||
{session.last_activity && (
|
||||
<span className="text-xs text-slate-400">
|
||||
• {formatDate(session.last_activity, 'short')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{sessions.length > 3 && (
|
||||
<div className="text-center pt-2">
|
||||
<span className="text-xs sm:text-sm text-amber-600 font-semibold">
|
||||
+{sessions.length - 3} more session{sessions.length - 3 !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No Active Sessions"
|
||||
description="Your active sessions will appear here"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { toast } from 'react-toastify';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import Pagination from '../../shared/components/Pagination';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
|
||||
type TabType = 'list' | 'profile';
|
||||
|
||||
@@ -88,7 +89,7 @@ const GuestProfilePage: React.FC = () => {
|
||||
const response = await guestProfileService.getAllTags();
|
||||
setAllTags(response.data.tags);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load tags:', error);
|
||||
logger.error('Failed to load tags', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -97,7 +98,7 @@ const GuestProfilePage: React.FC = () => {
|
||||
const response = await guestProfileService.getAllSegments();
|
||||
setAllSegments(response.data.segments);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load segments:', error);
|
||||
logger.error('Failed to load segments', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { ArrowLeft, Save } from 'lucide-react';
|
||||
import invoiceService, { Invoice, UpdateInvoiceData } from '../../features/payments/services/invoiceService';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import { validateInvoiceId } from '../../shared/utils/routeValidation';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
|
||||
const InvoiceEditPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -14,6 +16,7 @@ const InvoiceEditPage: React.FC = () => {
|
||||
const [invoice, setInvoice] = useState<Invoice | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const [formData, setFormData] = useState<UpdateInvoiceData>({
|
||||
company_name: '',
|
||||
company_address: '',
|
||||
@@ -29,18 +32,44 @@ const InvoiceEditPage: React.FC = () => {
|
||||
discount_amount: 0,
|
||||
});
|
||||
|
||||
// Validate route parameter
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
const invoiceId = Number(id);
|
||||
if (!isNaN(invoiceId) && invoiceId > 0) {
|
||||
fetchInvoice(invoiceId);
|
||||
} else {
|
||||
toast.error('Invalid invoice ID');
|
||||
navigate(`${basePath}/invoices`);
|
||||
}
|
||||
const validatedId = validateInvoiceId(id);
|
||||
if (!validatedId) {
|
||||
toast.error('Invalid invoice ID');
|
||||
navigate(`${basePath}/invoices`);
|
||||
return;
|
||||
}
|
||||
}, [id, navigate]);
|
||||
}, [id, navigate, basePath]);
|
||||
|
||||
// Fetch invoice with request cancellation
|
||||
useEffect(() => {
|
||||
const validatedId = validateInvoiceId(id);
|
||||
if (!validatedId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchInvoice(validatedId);
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [id, basePath]);
|
||||
|
||||
/**
|
||||
* Fetches invoice details with proper error handling and request cancellation
|
||||
*/
|
||||
const fetchInvoice = async (invoiceId: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -68,6 +97,11 @@ const InvoiceEditPage: React.FC = () => {
|
||||
navigate(`${basePath}/invoices`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Handle AbortError silently
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
logger.error('Error fetching invoice', error);
|
||||
toast.error(error.response?.data?.message || 'Unable to load invoice');
|
||||
navigate(`${basePath}/invoices`);
|
||||
} finally {
|
||||
@@ -79,9 +113,14 @@ const InvoiceEditPage: React.FC = () => {
|
||||
e.preventDefault();
|
||||
if (!id || !invoice) return;
|
||||
|
||||
const invoiceId = validateInvoiceId(id);
|
||||
if (!invoiceId) {
|
||||
toast.error('Invalid invoice ID');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const invoiceId = Number(id);
|
||||
// Remove company_logo_url from formData to prevent it from being updated
|
||||
// It should always use the admin settings
|
||||
const { company_logo_url, ...updateData } = formData as any;
|
||||
|
||||
@@ -19,6 +19,7 @@ import EmptyState from '../../shared/components/EmptyState';
|
||||
import ConfirmationDialog from '../../shared/components/ConfirmationDialog';
|
||||
import loyaltyService, { LoyaltyTier, LoyaltyReward } from '../../features/loyalty/services/loyaltyService';
|
||||
import Pagination from '../../shared/components/Pagination';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
|
||||
type Tab = 'users' | 'tiers' | 'rewards';
|
||||
|
||||
@@ -116,7 +117,7 @@ const LoyaltyManagementPage: React.FC = () => {
|
||||
const response = await loyaltyService.getProgramStatus();
|
||||
setProgramEnabled(response.data.enabled);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load program status:', error);
|
||||
logger.error('Failed to load program status', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { toast } from 'react-toastify';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import Pagination from '../../shared/components/Pagination';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
|
||||
const PackageManagementPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
@@ -85,9 +86,9 @@ const PackageManagementPage: React.FC = () => {
|
||||
if (allUniqueRoomTypes.size > 0) {
|
||||
setRoomTypes(Array.from(allUniqueRoomTypes.values()));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch room types:', err);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch room types', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchServices = async () => {
|
||||
@@ -96,9 +97,9 @@ const PackageManagementPage: React.FC = () => {
|
||||
if (response.data && response.data.services) {
|
||||
setServices(response.data.services);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch services:', err);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch services', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPackages = async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import paymentService from '../../features/payments/services/paymentService';
|
||||
import type { Payment } from '../../features/payments/services/paymentService';
|
||||
@@ -8,6 +8,9 @@ import Pagination from '../../shared/components/Pagination';
|
||||
import ExportButton from '../../shared/components/ExportButton';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
import { formatDate } from '../../shared/utils/format';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
import { getPaymentStatusColor, getPaymentMethodLabel } from '../../shared/utils/paymentUtils';
|
||||
import { PAYMENT_METHOD, PAYMENT_STATUS } from '../../shared/constants/bookingConstants';
|
||||
|
||||
const PaymentManagementPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
@@ -23,13 +26,29 @@ const PaymentManagementPage: React.FC = () => {
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const itemsPerPage = 5;
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchPayments();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [filters, currentPage]);
|
||||
|
||||
const fetchPayments = async () => {
|
||||
@@ -68,6 +87,11 @@ const PaymentManagementPage: React.FC = () => {
|
||||
paymentsList = paymentsList.filter((p) => p.payment_method === filters.method);
|
||||
}
|
||||
|
||||
// Filter by payment status if needed
|
||||
if (filters.status) {
|
||||
paymentsList = paymentsList.filter((p) => p.payment_status === filters.status);
|
||||
}
|
||||
|
||||
if (filters.from) {
|
||||
const fromDate = new Date(filters.from);
|
||||
paymentsList = paymentsList.filter((p) => {
|
||||
@@ -98,6 +122,11 @@ const PaymentManagementPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Handle AbortError silently
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
logger.error('Error fetching payments', error);
|
||||
toast.error(error.response?.data?.message || 'Unable to load payments list');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -147,7 +176,7 @@ const PaymentManagementPage: React.FC = () => {
|
||||
|
||||
const getPaymentStatusBadge = (status: string) => {
|
||||
const statusConfig: Record<string, { bg: string; text: string; label: string; border: string }> = {
|
||||
completed: {
|
||||
[PAYMENT_STATUS.PAID]: {
|
||||
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
|
||||
text: 'text-emerald-800',
|
||||
label: '✅ Paid',
|
||||
@@ -165,12 +194,18 @@ const PaymentManagementPage: React.FC = () => {
|
||||
label: '❌ Failed',
|
||||
border: 'border-rose-200'
|
||||
},
|
||||
refunded: {
|
||||
[PAYMENT_STATUS.REFUNDED]: {
|
||||
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
|
||||
text: 'text-slate-700',
|
||||
label: '💰 Refunded',
|
||||
border: 'border-slate-200'
|
||||
},
|
||||
completed: { // Legacy support
|
||||
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
|
||||
text: 'text-emerald-800',
|
||||
label: '✅ Paid',
|
||||
border: 'border-emerald-200'
|
||||
},
|
||||
};
|
||||
const config = statusConfig[status] || statusConfig.pending;
|
||||
return (
|
||||
@@ -351,15 +386,15 @@ const PaymentManagementPage: React.FC = () => {
|
||||
<h3 className="text-lg font-semibold mb-2 text-amber-100">Total Revenue</h3>
|
||||
<p className="text-4xl font-bold">
|
||||
{formatCurrency(payments
|
||||
.filter(p => p.payment_status === 'completed')
|
||||
.filter(p => p.payment_status === PAYMENT_STATUS.PAID || p.payment_status === 'completed')
|
||||
.reduce((sum, p) => sum + p.amount, 0))}
|
||||
</p>
|
||||
<p className="text-sm mt-3 text-amber-100/90">
|
||||
Total {payments.filter(p => p.payment_status === 'completed').length} paid transaction{payments.filter(p => p.payment_status === 'completed').length !== 1 ? 's' : ''}
|
||||
Total {payments.filter(p => p.payment_status === PAYMENT_STATUS.PAID || p.payment_status === 'completed').length} paid transaction{payments.filter(p => p.payment_status === PAYMENT_STATUS.PAID || p.payment_status === 'completed').length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white/20 backdrop-blur-sm p-6 rounded-2xl">
|
||||
<div className="text-5xl font-bold text-white/80">{payments.filter(p => p.payment_status === 'completed').length}</div>
|
||||
<div className="text-5xl font-bold text-white/80">{payments.filter(p => p.payment_status === PAYMENT_STATUS.PAID || p.payment_status === 'completed').length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { toast } from 'react-toastify';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import Pagination from '../../shared/components/Pagination';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
|
||||
const RatePlanManagementPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
@@ -81,9 +82,9 @@ const RatePlanManagementPage: React.FC = () => {
|
||||
if (allUniqueRoomTypes.size > 0) {
|
||||
setRoomTypes(Array.from(allUniqueRoomTypes.values()));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch room types:', err);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch room types', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRatePlans = async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Plus, Search, Edit, Trash2, X, Upload, Image as ImageIcon, Check } from 'lucide-react';
|
||||
import roomService, { Room } from '../../features/rooms/services/roomService';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -6,6 +6,7 @@ import Loading from '../../shared/components/Loading';
|
||||
import Pagination from '../../shared/components/Pagination';
|
||||
import apiClient from '../../shared/services/apiClient';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
|
||||
const RoomManagementPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
@@ -42,6 +43,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
const [roomTypes, setRoomTypes] = useState<Array<{ id: number; name: string }>>([]);
|
||||
const [uploadingImages, setUploadingImages] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
@@ -49,8 +51,23 @@ const RoomManagementPage: React.FC = () => {
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchRooms();
|
||||
fetchAvailableAmenities();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [filters, currentPage]);
|
||||
|
||||
|
||||
@@ -84,8 +101,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
console.error(`Failed to fetch page ${page}:`, err);
|
||||
logger.error(`Failed to fetch page ${page}`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,7 +115,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch room types:', err);
|
||||
logger.error('Failed to fetch room types', err);
|
||||
}
|
||||
};
|
||||
fetchAllRoomTypes();
|
||||
@@ -112,7 +128,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
setAvailableAmenities(response.data.amenities);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch amenities:', error);
|
||||
logger.error('Failed to fetch amenities', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -185,6 +201,11 @@ const RoomManagementPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Handle AbortError silently
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
logger.error('Error fetching rooms', error);
|
||||
toast.error(error.response?.data?.message || 'Unable to load rooms list');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -216,7 +237,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
const updatedRoom = await roomService.getRoomByNumber(editingRoom.room_number);
|
||||
setEditingRoom(updatedRoom.data.room);
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh room data:', err);
|
||||
logger.error('Failed to refresh room data', err);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -376,7 +397,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
|
||||
setEditingRoom(roomData);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch full room details:', error);
|
||||
logger.error('Failed to fetch full room details', error);
|
||||
|
||||
}
|
||||
};
|
||||
@@ -525,7 +546,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
const response = await roomService.getRoomByNumber(editingRoom.room_number);
|
||||
setEditingRoom(response.data.room);
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting image:', error);
|
||||
logger.error('Error deleting image', error);
|
||||
toast.error(error.response?.data?.message || error.response?.data?.detail || 'Unable to delete image');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ import { toast } from 'react-toastify';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import Pagination from '../../shared/components/Pagination';
|
||||
import { formatDate } from '../../shared/utils/format';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
|
||||
type SecurityTab = 'events' | 'stats' | 'ip-whitelist' | 'ip-blacklist' | 'oauth' | 'gdpr' | 'scan';
|
||||
|
||||
@@ -1420,7 +1421,7 @@ const SecurityScanTab: React.FC = () => {
|
||||
setScanResults(results);
|
||||
toast.success(`Security scan completed: ${results.total_issues || 0} issues found`);
|
||||
} catch (error: any) {
|
||||
console.error('Security scan error:', error);
|
||||
logger.error('Security scan error', error);
|
||||
const errorMessage = error.response?.data?.detail || error.response?.data?.message || error.message || 'Failed to run security scan';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
|
||||
@@ -46,6 +46,7 @@ const UserManagementPage: React.FC = () => {
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const itemsPerPage = 5;
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
full_name: '',
|
||||
@@ -61,7 +62,22 @@ const UserManagementPage: React.FC = () => {
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchUsers();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [filters, currentPage]);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
useParams,
|
||||
useNavigate,
|
||||
@@ -15,12 +15,7 @@ import {
|
||||
Phone,
|
||||
FileText,
|
||||
Building2,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
XCircle,
|
||||
DoorOpen,
|
||||
DoorClosed,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
@@ -34,71 +29,99 @@ import PaymentStatusBadge from '../../shared/components/PaymentStatusBadge';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
import { parseDateLocal } from '../../shared/utils/format';
|
||||
import CancelBookingModal from '../../features/bookings/components/CancelBookingModal';
|
||||
import { validateBookingId } from '../../shared/utils/routeValidation';
|
||||
import { validateAndHandleBookingOwnership } from '../../shared/utils/ownershipValidation';
|
||||
import { getBookingStatusConfig, canCancelBooking, calculateNights } from '../../shared/utils/bookingUtils';
|
||||
|
||||
const BookingDetailPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
const { isAuthenticated, userInfo } = useAuthStore();
|
||||
const { openModal } = useAuthModal();
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const [booking, setBooking] = useState<Booking | null>(
|
||||
null
|
||||
);
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCancelModal, setShowCancelModal] = useState(false);
|
||||
|
||||
|
||||
// Validate route parameter
|
||||
useEffect(() => {
|
||||
const bookingId = validateBookingId(id);
|
||||
if (!bookingId) {
|
||||
setError('Invalid booking ID');
|
||||
setLoading(false);
|
||||
toast.error('Invalid booking ID');
|
||||
navigate('/bookings');
|
||||
return;
|
||||
}
|
||||
}, [id, navigate]);
|
||||
|
||||
// Fetch booking details with ownership validation
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
toast.error(
|
||||
'Please login to view booking details'
|
||||
);
|
||||
openModal('login');
|
||||
return;
|
||||
}
|
||||
}, [isAuthenticated, openModal, id]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (id && isAuthenticated) {
|
||||
fetchBookingDetails(Number(id));
|
||||
const bookingId = validateBookingId(id);
|
||||
if (!bookingId) {
|
||||
return;
|
||||
}
|
||||
}, [id, isAuthenticated]);
|
||||
|
||||
const fetchBookingDetails = async (bookingId: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
const response = await getBookingById(bookingId);
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
if (
|
||||
response.success &&
|
||||
response.data?.booking
|
||||
) {
|
||||
const bookingData = response.data.booking;
|
||||
|
||||
console.log('Booking data:', bookingData);
|
||||
console.log('Service usages:', (bookingData as any).service_usages);
|
||||
console.log('Total price:', bookingData.total_price);
|
||||
setBooking(bookingData);
|
||||
} else {
|
||||
throw new Error(
|
||||
'Unable to load booking information'
|
||||
);
|
||||
const fetchBookingDetails = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await getBookingById(bookingId);
|
||||
|
||||
if (response.success && response.data?.booking) {
|
||||
const bookingData = response.data.booking;
|
||||
|
||||
// Validate ownership
|
||||
if (!validateAndHandleBookingOwnership(bookingData, userInfo?.id, navigate)) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setBooking(bookingData);
|
||||
} else {
|
||||
throw new Error('Unable to load booking information');
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Don't show error if request was aborted
|
||||
if (err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
const message =
|
||||
err.response?.data?.message ||
|
||||
'Unable to load booking information';
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching booking:', err);
|
||||
const message =
|
||||
err.response?.data?.message ||
|
||||
'Unable to load booking information';
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
fetchBookingDetails();
|
||||
|
||||
// Cleanup: abort request on unmount or dependency change
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [id, isAuthenticated, userInfo?.id, navigate]);
|
||||
|
||||
const handleCancelBookingClick = () => {
|
||||
if (!booking) {
|
||||
@@ -116,7 +139,6 @@ const BookingDetailPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
|
||||
const date = parseDateLocal(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
@@ -128,17 +150,14 @@ const BookingDetailPage: React.FC = () => {
|
||||
|
||||
const formatPrice = (price: number) => formatCurrency(price);
|
||||
|
||||
|
||||
const calculateNights = () => {
|
||||
// Memoize nights calculation
|
||||
const nights = useMemo(() => {
|
||||
if (!booking) return 1;
|
||||
const checkIn = parseDateLocal(booking.check_in_date);
|
||||
const checkOut = parseDateLocal(booking.check_out_date);
|
||||
const nights = Math.ceil((checkOut.getTime() - checkIn.getTime()) / (1000 * 60 * 60 * 24));
|
||||
return nights > 0 ? nights : 1;
|
||||
};
|
||||
return calculateNights(booking.check_in_date, booking.check_out_date);
|
||||
}, [booking?.check_in_date, booking?.check_out_date]);
|
||||
|
||||
|
||||
const calculateServicesTotal = () => {
|
||||
// Memoize services total calculation
|
||||
const servicesTotal = useMemo(() => {
|
||||
if (!booking) return 0;
|
||||
|
||||
const serviceUsages = (booking as any).service_usages || (booking as any).services || [];
|
||||
@@ -148,96 +167,28 @@ const BookingDetailPage: React.FC = () => {
|
||||
}, 0);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
}, [booking]);
|
||||
|
||||
|
||||
const getServiceUsages = () => {
|
||||
// Memoize service usages
|
||||
const serviceUsages = useMemo(() => {
|
||||
if (!booking) return [];
|
||||
|
||||
const serviceUsages = (booking as any).service_usages || (booking as any).services || [];
|
||||
return Array.isArray(serviceUsages) ? serviceUsages : [];
|
||||
};
|
||||
const usages = (booking as any).service_usages || (booking as any).services || [];
|
||||
return Array.isArray(usages) ? usages : [];
|
||||
}, [booking]);
|
||||
|
||||
|
||||
|
||||
const calculateRoomPricePerNight = () => {
|
||||
// Memoize room price per night
|
||||
const roomPricePerNight = useMemo(() => {
|
||||
if (!booking) return 0;
|
||||
|
||||
const nights = calculateNights();
|
||||
const servicesTotal = calculateServicesTotal();
|
||||
|
||||
|
||||
console.log('Calculating room price:', {
|
||||
total_price: booking.total_price,
|
||||
servicesTotal,
|
||||
nights,
|
||||
roomTotal: booking.total_price - servicesTotal
|
||||
});
|
||||
|
||||
|
||||
const roomTotal = booking.total_price - servicesTotal;
|
||||
|
||||
|
||||
return nights > 0 ? roomTotal / nights : roomTotal;
|
||||
};
|
||||
}, [booking?.total_price, servicesTotal, nights]);
|
||||
|
||||
|
||||
const calculateRoomTotal = () => {
|
||||
return calculateRoomPricePerNight() * calculateNights();
|
||||
};
|
||||
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return {
|
||||
icon: Clock,
|
||||
color: 'bg-yellow-100 text-yellow-800',
|
||||
text: 'Pending confirmation',
|
||||
};
|
||||
case 'confirmed':
|
||||
return {
|
||||
icon: CheckCircle,
|
||||
color: 'bg-green-100 text-green-800',
|
||||
text: 'Confirmed',
|
||||
};
|
||||
case 'cancelled':
|
||||
return {
|
||||
icon: XCircle,
|
||||
color: 'bg-red-100 text-red-800',
|
||||
text: 'Cancelled',
|
||||
};
|
||||
case 'checked_in':
|
||||
return {
|
||||
icon: DoorOpen,
|
||||
color: 'bg-blue-100 text-blue-800',
|
||||
text: 'Checked in',
|
||||
};
|
||||
case 'checked_out':
|
||||
return {
|
||||
icon: DoorClosed,
|
||||
color: 'bg-gray-100 text-gray-800',
|
||||
text: 'Checked out',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
color: 'bg-gray-100 text-gray-800',
|
||||
text: status,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const canCancelBooking = (booking: Booking) => {
|
||||
|
||||
const canCancel = booking.status === 'pending';
|
||||
console.log('Can cancel booking?', {
|
||||
status: booking.status,
|
||||
canCancel,
|
||||
bookingId: booking.id,
|
||||
bookingNumber: booking.booking_number
|
||||
});
|
||||
return canCancel;
|
||||
};
|
||||
// Memoize room total
|
||||
const roomTotal = useMemo(() => {
|
||||
return roomPricePerNight * nights;
|
||||
}, [roomPricePerNight, nights]);
|
||||
|
||||
if (loading) {
|
||||
return <Loading fullScreen text="Loading..." />;
|
||||
@@ -274,8 +225,9 @@ const BookingDetailPage: React.FC = () => {
|
||||
|
||||
const room = booking.room;
|
||||
const roomType = room?.room_type;
|
||||
const statusConfig = getStatusConfig(booking.status);
|
||||
const statusConfig = getBookingStatusConfig(booking.status);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
const bookingCanCancel = canCancelBooking(booking);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
@@ -383,7 +335,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
Room Price
|
||||
</p>
|
||||
<p className="font-medium text-indigo-600">
|
||||
{formatPrice(calculateRoomPricePerNight())}/night
|
||||
{formatPrice(roomPricePerNight)}/night
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -552,25 +504,21 @@ const BookingDetailPage: React.FC = () => {
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
Room ({calculateNights()} night{calculateNights() !== 1 ? 's' : ''})
|
||||
Room ({nights} night{nights !== 1 ? 's' : ''})
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatPrice(calculateRoomPricePerNight())} per night
|
||||
{formatPrice(roomPricePerNight)} per night
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-base font-semibold text-gray-900">
|
||||
{formatPrice(calculateRoomTotal())}
|
||||
{formatPrice(roomTotal)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{}
|
||||
{(() => {
|
||||
const services = getServiceUsages();
|
||||
console.log('Services to display:', services);
|
||||
if (services.length > 0) {
|
||||
return (
|
||||
<>
|
||||
{services.map((serviceUsage: any, index: number) => (
|
||||
{serviceUsages.length > 0 && (
|
||||
<>
|
||||
{serviceUsages.map((serviceUsage: any, index: number) => (
|
||||
<div key={serviceUsage.id || index} className="flex justify-between items-center">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
@@ -584,12 +532,9 @@ const BookingDetailPage: React.FC = () => {
|
||||
{formatPrice(serviceUsage.total_price || (serviceUsage.unit_price || serviceUsage.price || 0) * (serviceUsage.quantity || 1))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{}
|
||||
{(() => {
|
||||
@@ -745,7 +690,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
Check-in time: 14:00 /
|
||||
Check-out time: 12:00
|
||||
</li>
|
||||
{canCancelBooking(booking) && (
|
||||
{bookingCanCancel && (
|
||||
<li>
|
||||
If you cancel the booking, 20% of
|
||||
the total order value will be charged
|
||||
@@ -772,7 +717,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{canCancelBooking(booking) ? (
|
||||
{bookingCanCancel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
useParams,
|
||||
useNavigate,
|
||||
@@ -33,11 +33,19 @@ import Loading from '../../shared/components/Loading';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
import { parseDateLocal } from '../../shared/utils/format';
|
||||
import DepositPaymentModal from '../../features/payments/components/DepositPaymentModal';
|
||||
import { validateBookingId } from '../../shared/utils/routeValidation';
|
||||
import { validateAndHandleBookingOwnership } from '../../shared/utils/ownershipValidation';
|
||||
import { getBookingStatusConfig } from '../../shared/utils/bookingUtils';
|
||||
import { getPaymentMethodLabel } from '../../shared/utils/paymentUtils';
|
||||
import { BOOKING_STATUS, PAYMENT_METHOD, PAYMENT_STATUS, PAYMENT_TYPE } from '../../shared/constants/bookingConstants';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
|
||||
const BookingSuccessPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const { userInfo } = useAuthStore();
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const [booking, setBooking] = useState<Booking | null>(
|
||||
null
|
||||
@@ -52,15 +60,50 @@ const BookingSuccessPage: React.FC = () => {
|
||||
useState(false);
|
||||
const [selectedFile, setSelectedFile] =
|
||||
useState<File | null>(null);
|
||||
useState<string | null>(null);
|
||||
const [showDepositModal, setShowDepositModal] = useState(false);
|
||||
|
||||
// Validate route parameter
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchBookingDetails(Number(id));
|
||||
const validatedId = validateBookingId(id);
|
||||
if (!validatedId) {
|
||||
setError('Invalid booking ID');
|
||||
setLoading(false);
|
||||
toast.error('Invalid booking ID');
|
||||
navigate('/bookings');
|
||||
return;
|
||||
}
|
||||
}, [id, navigate]);
|
||||
|
||||
// Fetch data with ownership validation and request cancellation
|
||||
useEffect(() => {
|
||||
const validatedId = validateBookingId(id);
|
||||
if (!validatedId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchBookingDetails(validatedId);
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
/**
|
||||
* Fetches booking details with ownership validation
|
||||
* Handles automatic redirects for pending Stripe payments
|
||||
* Auto-opens deposit modal for bookings requiring deposit
|
||||
*/
|
||||
const fetchBookingDetails = async (bookingId: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -73,29 +116,37 @@ const BookingSuccessPage: React.FC = () => {
|
||||
response.data?.booking
|
||||
) {
|
||||
const bookingData = response.data.booking;
|
||||
|
||||
// Validate that the booking belongs to the current user
|
||||
if (!validateAndHandleBookingOwnership(bookingData, userInfo?.id, navigate)) {
|
||||
setLoading(false);
|
||||
return; // Ownership validation handles redirect and error message
|
||||
}
|
||||
|
||||
setBooking(bookingData);
|
||||
|
||||
|
||||
if (bookingData.payment_method === 'stripe' && bookingData.payments) {
|
||||
// If Stripe payment is pending, redirect to payment completion page
|
||||
if (bookingData.payment_method === PAYMENT_METHOD.STRIPE && bookingData.payments) {
|
||||
// Find any pending Stripe payment that needs completion
|
||||
const pendingStripePayment = bookingData.payments.find(
|
||||
(p: any) =>
|
||||
p.payment_method === 'stripe' &&
|
||||
p.payment_status === 'pending'
|
||||
p.payment_method === PAYMENT_METHOD.STRIPE &&
|
||||
p.payment_status === 'pending' // Payment record status (not booking payment_status)
|
||||
);
|
||||
|
||||
if (pendingStripePayment) {
|
||||
|
||||
// Redirect to payment page to complete Stripe payment
|
||||
navigate(`/payment/${bookingId}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Only auto-open deposit modal if booking is not cancelled
|
||||
// Auto-open deposit payment modal for bookings requiring deposit
|
||||
// Only show if booking is not cancelled and deposit hasn't been paid
|
||||
if (
|
||||
bookingData.requires_deposit &&
|
||||
!bookingData.deposit_paid &&
|
||||
bookingData.status !== 'cancelled'
|
||||
bookingData.status !== BOOKING_STATUS.CANCELLED
|
||||
) {
|
||||
setShowDepositModal(true);
|
||||
}
|
||||
@@ -105,7 +156,11 @@ const BookingSuccessPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching booking:', err);
|
||||
// Handle AbortError silently (request was cancelled)
|
||||
if (err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
const message =
|
||||
err.response?.data?.message ||
|
||||
'Unable to load booking information';
|
||||
@@ -129,40 +184,6 @@ const BookingSuccessPage: React.FC = () => {
|
||||
|
||||
const formatPrice = (price: number) => formatCurrency(price);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'confirmed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'cancelled':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'checked_in':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'checked_out':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'confirmed':
|
||||
return 'Confirmed';
|
||||
case 'pending':
|
||||
return 'Pending confirmation';
|
||||
case 'cancelled':
|
||||
return 'Cancelled';
|
||||
case 'checked_in':
|
||||
return 'Checked in';
|
||||
case 'checked_out':
|
||||
return 'Checked out';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
const copyBookingNumber = async () => {
|
||||
if (!booking?.booking_number) return;
|
||||
|
||||
@@ -178,13 +199,17 @@ const BookingSuccessPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles receipt upload for bank transfer payment confirmation
|
||||
* Updates local state optimistically while waiting for backend verification
|
||||
*/
|
||||
const handleUploadReceipt = async () => {
|
||||
if (!selectedFile || !booking) return;
|
||||
|
||||
try {
|
||||
setUploadingReceipt(true);
|
||||
|
||||
|
||||
// Generate unique transaction ID for tracking
|
||||
const transactionId =
|
||||
`TXN-${booking.booking_number}-${Date.now()}`;
|
||||
|
||||
@@ -201,14 +226,16 @@ const BookingSuccessPage: React.FC = () => {
|
||||
);
|
||||
setReceiptUploaded(true);
|
||||
|
||||
|
||||
// Optimistically update booking state
|
||||
// Backend will verify and update actual status
|
||||
setBooking((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
payment_status: 'paid',
|
||||
status: prev.status === 'pending'
|
||||
? 'confirmed'
|
||||
payment_status: PAYMENT_STATUS.PAID,
|
||||
// Auto-confirm pending bookings after payment confirmation
|
||||
status: prev.status === BOOKING_STATUS.PENDING
|
||||
? BOOKING_STATUS.CONFIRMED
|
||||
: prev.status
|
||||
}
|
||||
: null
|
||||
@@ -220,7 +247,6 @@ const BookingSuccessPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error uploading receipt:', err);
|
||||
const message =
|
||||
err.response?.data?.message ||
|
||||
'Unable to send payment confirmation. ' +
|
||||
@@ -274,42 +300,49 @@ const BookingSuccessPage: React.FC = () => {
|
||||
const room = booking.room;
|
||||
const roomType = room?.room_type;
|
||||
|
||||
// Check if payment is completed
|
||||
/**
|
||||
* Determines if payment is completed by checking multiple payment indicators
|
||||
* Handles both full payment and deposit-only bookings
|
||||
*/
|
||||
const isPaymentCompleted = (() => {
|
||||
// Check if booking is cancelled
|
||||
if (booking.status === 'cancelled') {
|
||||
// Cancelled bookings are never considered paid
|
||||
if (booking.status === BOOKING_STATUS.CANCELLED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check payment_status
|
||||
if (booking.payment_status === 'paid') {
|
||||
// Check booking-level payment status
|
||||
if (booking.payment_status === PAYMENT_STATUS.PAID) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check payment_balance
|
||||
// Check payment balance indicator
|
||||
if (booking.payment_balance?.is_fully_paid === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For deposit bookings, check if deposit is paid
|
||||
// For deposit bookings, check if deposit flag is set
|
||||
if (booking.requires_deposit && booking.deposit_paid === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check payments array
|
||||
// Check individual payment records for more granular status
|
||||
if (booking.payments && Array.isArray(booking.payments)) {
|
||||
// Calculate total from completed payment records
|
||||
const totalPaid = booking.payments
|
||||
.filter((p: any) => p.payment_status === 'completed')
|
||||
.filter((p: any) => p.payment_status === 'completed') // Payment record status
|
||||
.reduce((sum: number, p: any) => sum + parseFloat(p.amount?.toString() || '0'), 0);
|
||||
|
||||
// For deposit bookings, check if deposit is paid
|
||||
// For deposit bookings, check if deposit payment is completed
|
||||
if (booking.requires_deposit) {
|
||||
const depositPayment = booking.payments.find((p: any) => p.payment_type === 'deposit' && p.payment_status === 'completed');
|
||||
const depositPayment = booking.payments.find(
|
||||
(p: any) => p.payment_type === PAYMENT_TYPE.DEPOSIT && p.payment_status === 'completed'
|
||||
);
|
||||
if (depositPayment) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// For full payment bookings, check if fully paid
|
||||
// For full payment bookings, check if total paid meets or exceeds booking price
|
||||
// Allow small floating point differences (0.01) for currency calculations
|
||||
return totalPaid >= booking.total_price - 0.01;
|
||||
}
|
||||
}
|
||||
@@ -317,7 +350,8 @@ const BookingSuccessPage: React.FC = () => {
|
||||
return false;
|
||||
})();
|
||||
|
||||
const isCancelled = booking.status === 'cancelled';
|
||||
const isCancelled = booking.status === BOOKING_STATUS.CANCELLED;
|
||||
const statusConfig = getBookingStatusConfig(booking.status);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
@@ -424,11 +458,12 @@ const BookingSuccessPage: React.FC = () => {
|
||||
{}
|
||||
<div className="mt-4">
|
||||
<span
|
||||
className={`inline-block px-4 py-2
|
||||
className={`inline-flex items-center gap-2 px-4 py-2
|
||||
rounded-full text-sm font-medium
|
||||
${getStatusColor(booking.status)}`}
|
||||
${statusConfig.color}`}
|
||||
>
|
||||
{getStatusText(booking.status)}
|
||||
<statusConfig.icon className="w-4 h-4" />
|
||||
{statusConfig.text}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -540,9 +575,9 @@ const BookingSuccessPage: React.FC = () => {
|
||||
Payment Method
|
||||
</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{booking.payment_method === 'cash'
|
||||
{booking.payment_method === PAYMENT_METHOD.CASH
|
||||
? '💵 Pay at hotel'
|
||||
: '🏦 Bank transfer'}
|
||||
: `🏦 ${getPaymentMethodLabel(booking.payment_method)}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -622,7 +657,7 @@ const BookingSuccessPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{}
|
||||
{(booking.payment_method === 'cash' || (booking as any).payment_method === 'bank_transfer') && (
|
||||
{(booking.payment_method === PAYMENT_METHOD.CASH || booking.payment_method === PAYMENT_METHOD.BANK_TRANSFER) && (
|
||||
<div
|
||||
className="bg-blue-50 border border-blue-200
|
||||
rounded-lg p-6 mb-6"
|
||||
@@ -835,7 +870,7 @@ const BookingSuccessPage: React.FC = () => {
|
||||
If you cancel the booking, 20% of
|
||||
the total order value will be charged
|
||||
</li>
|
||||
{(booking.payment_method === 'cash' || (booking as any).payment_method === 'bank_transfer') && (
|
||||
{(booking.payment_method === PAYMENT_METHOD.CASH || booking.payment_method === PAYMENT_METHOD.BANK_TRANSFER) && (
|
||||
<li>
|
||||
Please transfer within 24 hours
|
||||
to secure your room
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { confirmBoricaPayment } from '../../features/payments/services/paymentService';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -10,10 +10,19 @@ const BoricaReturnPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const bookingId = searchParams.get('bookingId');
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
const confirmPayment = async () => {
|
||||
if (!bookingId) {
|
||||
setError('Missing booking information');
|
||||
@@ -54,6 +63,10 @@ const BoricaReturnPage: React.FC = () => {
|
||||
toast.error(response.message || 'Payment confirmation failed');
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Don't show error if request was aborted
|
||||
if (err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
const errorMessage = err.response?.data?.message || err.message || 'Failed to confirm payment';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
@@ -63,6 +76,13 @@ const BoricaReturnPage: React.FC = () => {
|
||||
};
|
||||
|
||||
confirmPayment();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [bookingId, searchParams, navigate]);
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { AlertCircle, Plus, Eye, CheckCircle, Clock, XCircle } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
@@ -7,6 +7,7 @@ import Pagination from '../../shared/components/Pagination';
|
||||
import complaintService, { Complaint } from '../../features/guest_management/services/complaintService';
|
||||
import bookingService from '../../features/bookings/services/bookingService';
|
||||
import { formatDate } from '../../shared/utils/format';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
|
||||
const ComplaintPage: React.FC = () => {
|
||||
const [complaints, setComplaints] = useState<Complaint[]>([]);
|
||||
@@ -19,10 +20,26 @@ const ComplaintPage: React.FC = () => {
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const itemsPerPage = 10;
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchComplaints();
|
||||
fetchBookings();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [currentPage]);
|
||||
|
||||
const fetchComplaints = async () => {
|
||||
@@ -38,6 +55,10 @@ const ComplaintPage: React.FC = () => {
|
||||
setTotalItems(response.data.pagination.total || 0);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Don't show error if request was aborted
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
toast.error(error.response?.data?.message || 'Unable to load complaints');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -49,7 +70,12 @@ const ComplaintPage: React.FC = () => {
|
||||
const response = await bookingService.getMyBookings();
|
||||
setBookings(response.data.bookings || []);
|
||||
} catch (error: any) {
|
||||
// Silently fail - bookings are optional
|
||||
// Don't show error if request was aborted
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
// Silently fail - bookings are optional, but log for debugging
|
||||
logger.debug('Failed to fetch bookings for complaint form', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
TrendingUp,
|
||||
Hotel,
|
||||
@@ -6,11 +6,15 @@ import {
|
||||
Activity,
|
||||
TrendingDown,
|
||||
CreditCard,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Tablet,
|
||||
} from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dashboardService, { CustomerDashboardStats } from '../../features/analytics/services/dashboardService';
|
||||
import paymentService from '../../features/payments/services/paymentService';
|
||||
import type { Payment } from '../../features/payments/services/paymentService';
|
||||
import sessionService, { UserSession } from '../../features/auth/services/sessionService';
|
||||
import { toast } from 'react-toastify';
|
||||
import { formatDate, formatRelativeTime } from '../../shared/utils/format';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
@@ -18,12 +22,16 @@ import Loading from '../../shared/components/Loading';
|
||||
import EmptyState from '../../shared/components/EmptyState';
|
||||
import CurrencyIcon from '../../shared/components/CurrencyIcon';
|
||||
import { useAsync } from '../../shared/hooks/useAsync';
|
||||
import { getPaymentStatusColor, getPaymentMethodLabel } from '../../shared/utils/paymentUtils';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const [recentPayments, setRecentPayments] = useState<Payment[]>([]);
|
||||
const [loadingPayments, setLoadingPayments] = useState(false);
|
||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
|
||||
const fetchDashboardData = async (): Promise<CustomerDashboardStats> => {
|
||||
const response = await dashboardService.getCustomerDashboardStats();
|
||||
@@ -40,7 +48,17 @@ const DashboardPage: React.FC = () => {
|
||||
}
|
||||
);
|
||||
|
||||
const paymentsAbortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (paymentsAbortControllerRef.current) {
|
||||
paymentsAbortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
paymentsAbortControllerRef.current = new AbortController();
|
||||
|
||||
const fetchPayments = async () => {
|
||||
try {
|
||||
setLoadingPayments(true);
|
||||
@@ -49,47 +67,80 @@ const DashboardPage: React.FC = () => {
|
||||
setRecentPayments(response.data.payments);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching payments:', err);
|
||||
if (err.name !== 'AbortError') {
|
||||
logger.error('Error fetching payments', err);
|
||||
}
|
||||
} finally {
|
||||
setLoadingPayments(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPayments();
|
||||
|
||||
return () => {
|
||||
if (paymentsAbortControllerRef.current) {
|
||||
paymentsAbortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sessionsAbortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (sessionsAbortControllerRef.current) {
|
||||
sessionsAbortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
sessionsAbortControllerRef.current = new AbortController();
|
||||
|
||||
const fetchSessions = async () => {
|
||||
try {
|
||||
setLoadingSessions(true);
|
||||
const response = await sessionService.getMySessions();
|
||||
if (response.success && response.data?.sessions) {
|
||||
setSessions(response.data.sessions || []);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name !== 'AbortError') {
|
||||
logger.error('Error fetching sessions', err);
|
||||
}
|
||||
} finally {
|
||||
setLoadingSessions(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSessions();
|
||||
|
||||
return () => {
|
||||
if (sessionsAbortControllerRef.current) {
|
||||
sessionsAbortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRefresh = () => {
|
||||
execute();
|
||||
};
|
||||
|
||||
const getPaymentStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-gradient-to-r from-emerald-50 to-green-50 text-emerald-800 border-emerald-200';
|
||||
case 'pending':
|
||||
return 'bg-gradient-to-r from-amber-50 to-yellow-50 text-amber-800 border-amber-200';
|
||||
case 'failed':
|
||||
return 'bg-gradient-to-r from-rose-50 to-red-50 text-rose-800 border-rose-200';
|
||||
case 'refunded':
|
||||
return 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200';
|
||||
default:
|
||||
return 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200';
|
||||
}
|
||||
const getDeviceIcon = (userAgent?: string) => {
|
||||
if (!userAgent) return <Monitor className="w-4 h-4 text-slate-600" />;
|
||||
if (userAgent.includes('Mobile')) return <Smartphone className="w-4 h-4 text-blue-600" />;
|
||||
if (userAgent.includes('Tablet')) return <Tablet className="w-4 h-4 text-purple-600" />;
|
||||
return <Monitor className="w-4 h-4 text-slate-600" />;
|
||||
};
|
||||
|
||||
const getPaymentMethodLabel = (method: string) => {
|
||||
switch (method) {
|
||||
case 'stripe':
|
||||
case 'credit_card':
|
||||
return 'Card';
|
||||
case 'paypal':
|
||||
return 'PayPal';
|
||||
case 'bank_transfer':
|
||||
return 'Bank Transfer';
|
||||
case 'cash':
|
||||
return 'Cash';
|
||||
default:
|
||||
return method;
|
||||
}
|
||||
const getDeviceName = (userAgent?: string) => {
|
||||
if (!userAgent) return 'Unknown Device';
|
||||
// Try to extract browser/device info
|
||||
if (userAgent.includes('Chrome')) return 'Chrome';
|
||||
if (userAgent.includes('Firefox')) return 'Firefox';
|
||||
if (userAgent.includes('Safari')) return 'Safari';
|
||||
if (userAgent.includes('Edge')) return 'Edge';
|
||||
if (userAgent.includes('Mobile')) return 'Mobile Device';
|
||||
if (userAgent.includes('Tablet')) return 'Tablet';
|
||||
return 'Web Browser';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -222,7 +273,7 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-5 md:gap-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-4 sm:gap-5 md:gap-6">
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in hover:shadow-2xl transition-all duration-300">
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-slate-900 mb-4 sm:mb-5 md:mb-6 pb-3 sm:pb-4 border-b border-slate-200 flex items-center gap-2">
|
||||
@@ -378,6 +429,74 @@ const DashboardPage: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.4s' }}>
|
||||
<div className="flex items-center justify-between mb-4 sm:mb-5 md:mb-6 pb-3 sm:pb-4 border-b border-slate-200">
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-slate-900 flex items-center gap-2">
|
||||
<Monitor className="w-5 h-5 text-indigo-600" />
|
||||
Active Sessions
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => navigate('/sessions')}
|
||||
className="text-xs sm:text-sm text-blue-600 hover:text-blue-700 font-semibold hover:underline transition-colors"
|
||||
>
|
||||
Manage →
|
||||
</button>
|
||||
</div>
|
||||
{loadingSessions ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loading text="Loading sessions..." />
|
||||
</div>
|
||||
) : sessions && sessions.length > 0 ? (
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{sessions.slice(0, 3).map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex items-center justify-between p-3 sm:p-4 bg-gradient-to-r from-slate-50 to-white rounded-lg sm:rounded-xl border border-slate-200 hover:from-indigo-50 hover:to-purple-50 hover:border-indigo-300 hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-center space-x-3 sm:space-x-4 flex-1 min-w-0">
|
||||
<div className="p-2 sm:p-3 bg-gradient-to-br from-indigo-100 to-indigo-200 rounded-lg sm:rounded-xl shadow-md flex-shrink-0">
|
||||
{getDeviceIcon(session.user_agent)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-slate-900 text-sm sm:text-base truncate">
|
||||
{getDeviceName(session.user_agent)}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 sm:gap-2 mt-1 flex-wrap">
|
||||
<p className="text-xs sm:text-sm text-slate-600">
|
||||
{session.ip_address || 'Unknown IP'}
|
||||
</p>
|
||||
{session.last_activity && (
|
||||
<span className="text-xs text-slate-400">
|
||||
• {formatRelativeTime(new Date(session.last_activity))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{sessions.length > 3 && (
|
||||
<button
|
||||
onClick={() => navigate('/sessions')}
|
||||
className="w-full text-xs sm:text-sm text-blue-600 hover:text-blue-700 font-semibold hover:underline transition-colors pt-2"
|
||||
>
|
||||
View all {sessions.length} sessions →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No Active Sessions"
|
||||
description="Your active sessions will appear here"
|
||||
action={{
|
||||
label: 'View Session Management',
|
||||
onClick: () => navigate('/sessions')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import {
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
CreditCard,
|
||||
ArrowLeft,
|
||||
Shield,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { getBookingById, type Booking } from '../../features/bookings/services/bookingService';
|
||||
@@ -16,11 +18,18 @@ import Loading from '../../shared/components/Loading';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
import { parseDateLocal } from '../../shared/utils/format';
|
||||
import StripePaymentWrapper from '../../features/payments/components/StripePaymentWrapper';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import { validateBookingId } from '../../shared/utils/routeValidation';
|
||||
import { validateAndHandleBookingOwnership } from '../../shared/utils/ownershipValidation';
|
||||
import { BOOKING_STATUS, PAYMENT_METHOD } from '../../shared/constants/bookingConstants';
|
||||
import { logSecurityWarning } from '../../shared/utils/errorReporter';
|
||||
|
||||
const FullPaymentPage: React.FC = () => {
|
||||
const { bookingId } = useParams<{ bookingId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const { userInfo } = useAuthStore();
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [stripePayment, setStripePayment] = useState<Payment | null>(null);
|
||||
@@ -28,10 +37,69 @@ const FullPaymentPage: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [paymentSuccess, setPaymentSuccess] = useState(false);
|
||||
|
||||
// SECURITY: Payment page security - prevent caching and verify HTTPS
|
||||
useEffect(() => {
|
||||
if (bookingId) {
|
||||
fetchData(Number(bookingId));
|
||||
// Set no-cache headers via meta tags (server should also set HTTP headers)
|
||||
const metaTags = [
|
||||
{ httpEquiv: 'Cache-Control', content: 'no-cache, no-store, must-revalidate' },
|
||||
{ httpEquiv: 'Pragma', content: 'no-cache' },
|
||||
{ httpEquiv: 'Expires', content: '0' },
|
||||
];
|
||||
|
||||
metaTags.forEach(tag => {
|
||||
let meta = document.querySelector(`meta[http-equiv="${tag.httpEquiv}"]`);
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta');
|
||||
meta.setAttribute('http-equiv', tag.httpEquiv);
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
meta.setAttribute('content', tag.content);
|
||||
});
|
||||
|
||||
// Verify HTTPS in production
|
||||
if (import.meta.env.PROD && window.location.protocol !== 'https:') {
|
||||
logSecurityWarning('Payment page should use HTTPS in production!', {
|
||||
protocol: window.location.protocol,
|
||||
url: window.location.href,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Validate route parameter
|
||||
useEffect(() => {
|
||||
const validatedId = validateBookingId(bookingId);
|
||||
if (!validatedId) {
|
||||
setError('Invalid booking ID');
|
||||
setLoading(false);
|
||||
toast.error('Invalid booking ID');
|
||||
navigate('/bookings');
|
||||
return;
|
||||
}
|
||||
}, [bookingId, navigate]);
|
||||
|
||||
// Fetch data with ownership validation
|
||||
useEffect(() => {
|
||||
const validatedId = validateBookingId(bookingId);
|
||||
if (!validatedId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchData(validatedId);
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [bookingId]);
|
||||
|
||||
const fetchData = async (id: number) => {
|
||||
@@ -39,61 +107,65 @@ const FullPaymentPage: React.FC = () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
|
||||
const bookingResponse = await getBookingById(id);
|
||||
if (!bookingResponse.success || !bookingResponse.data?.booking) {
|
||||
throw new Error('Booking not found');
|
||||
}
|
||||
|
||||
const bookingData = bookingResponse.data.booking;
|
||||
|
||||
// Validate ownership
|
||||
if (!validateAndHandleBookingOwnership(bookingData, userInfo?.id, navigate)) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setBooking(bookingData);
|
||||
|
||||
|
||||
if (bookingData.status === 'confirmed' || bookingData.status === 'checked_in') {
|
||||
// Check if already confirmed
|
||||
if (bookingData.status === BOOKING_STATUS.CONFIRMED || bookingData.status === BOOKING_STATUS.CHECKED_IN) {
|
||||
toast.success('Booking is already confirmed!');
|
||||
navigate(`/bookings/${id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (bookingData.payment_method !== 'stripe') {
|
||||
// Check payment method
|
||||
if (bookingData.payment_method !== PAYMENT_METHOD.STRIPE) {
|
||||
toast.info('This booking does not use Stripe payment');
|
||||
navigate(`/bookings/${id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Fetch payments
|
||||
const paymentsResponse = await getPaymentsByBookingId(id);
|
||||
console.log('Payments response:', paymentsResponse);
|
||||
|
||||
if (paymentsResponse.success && paymentsResponse.data?.payments) {
|
||||
const payments = paymentsResponse.data.payments;
|
||||
console.log('Payments found:', payments);
|
||||
|
||||
|
||||
// Find pending Stripe payment
|
||||
const stripePaymentFound = payments.find(
|
||||
(p: Payment) =>
|
||||
(p.payment_method === 'stripe' || p.payment_method === 'credit_card') &&
|
||||
(p.payment_method === PAYMENT_METHOD.STRIPE || p.payment_method === PAYMENT_METHOD.CREDIT_CARD) &&
|
||||
p.payment_status === 'pending'
|
||||
);
|
||||
|
||||
if (stripePaymentFound) {
|
||||
console.log('Found pending Stripe payment:', stripePaymentFound);
|
||||
setStripePayment(stripePaymentFound);
|
||||
} else {
|
||||
|
||||
// Check for completed payment
|
||||
const completedPayment = payments.find(
|
||||
(p: Payment) =>
|
||||
(p.payment_method === 'stripe' || p.payment_method === 'credit_card') &&
|
||||
(p.payment_method === PAYMENT_METHOD.STRIPE || p.payment_method === PAYMENT_METHOD.CREDIT_CARD) &&
|
||||
p.payment_status === 'completed'
|
||||
);
|
||||
|
||||
if (completedPayment) {
|
||||
console.log('Found completed Stripe payment:', completedPayment);
|
||||
setStripePayment(completedPayment);
|
||||
setPaymentSuccess(true);
|
||||
|
||||
if ((bookingData.status as string) === 'confirmed' || (bookingData.status as string) === 'checked_in') {
|
||||
// Check if booking is already confirmed or checked in
|
||||
const bookingStatus = bookingData.status as string;
|
||||
if (bookingStatus === BOOKING_STATUS.CONFIRMED || bookingStatus === BOOKING_STATUS.CHECKED_IN) {
|
||||
toast.info('Payment already completed. Booking is confirmed.');
|
||||
setTimeout(() => {
|
||||
navigate(`/bookings/${id}`);
|
||||
@@ -101,23 +173,15 @@ const FullPaymentPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
|
||||
console.warn('No Stripe payment found in payments array:', payments);
|
||||
console.warn('Booking payment method:', bookingData.payment_method);
|
||||
|
||||
|
||||
throw new Error('No Stripe payment record found for this booking. The payment may not have been created properly.');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
console.warn('Payments response not successful or no payments data:', paymentsResponse);
|
||||
|
||||
// Fallback to payments from booking data
|
||||
if (bookingData.payments && bookingData.payments.length > 0) {
|
||||
console.log('Using payments from booking data:', bookingData.payments);
|
||||
const stripePaymentFromBooking = bookingData.payments.find(
|
||||
(p: any) =>
|
||||
(p.payment_method === 'stripe' || p.payment_method === 'credit_card') &&
|
||||
(p.payment_method === PAYMENT_METHOD.STRIPE || p.payment_method === PAYMENT_METHOD.CREDIT_CARD) &&
|
||||
p.payment_status === 'pending'
|
||||
);
|
||||
|
||||
@@ -127,13 +191,15 @@ const FullPaymentPage: React.FC = () => {
|
||||
throw new Error('No pending Stripe payment found for this booking');
|
||||
}
|
||||
} else {
|
||||
|
||||
console.error('No payments found for booking. This might be a timing issue.');
|
||||
throw new Error('Payment information not found. Please wait a moment and refresh, or contact support if the issue persists.');
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching data:', err);
|
||||
// Don't show error if request was aborted
|
||||
if (err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
const message =
|
||||
err.response?.data?.message || err.message || 'Unable to load payment information';
|
||||
setError(message);
|
||||
@@ -180,27 +246,19 @@ const FullPaymentPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
let paymentAmount = parseFloat(stripePayment.amount.toString());
|
||||
const isPaymentCompleted = stripePayment.payment_status === 'completed';
|
||||
|
||||
|
||||
console.log('Payment amount from payment record:', paymentAmount);
|
||||
console.log('Booking total price:', booking?.total_price);
|
||||
|
||||
|
||||
// Validate and correct payment amount if needed
|
||||
if (paymentAmount > 999999.99 || (booking && Math.abs(paymentAmount - booking.total_price) > 0.01)) {
|
||||
console.warn('Payment amount seems incorrect, using booking total price instead');
|
||||
if (booking) {
|
||||
paymentAmount = parseFloat(booking.total_price.toString());
|
||||
console.log('Using booking total price:', paymentAmount);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check Stripe maximum
|
||||
if (paymentAmount > 999999.99) {
|
||||
const errorMsg = `Payment amount $${paymentAmount.toLocaleString()} exceeds Stripe's maximum. Please contact support.`;
|
||||
console.error(errorMsg);
|
||||
const errorMsg = `Payment amount exceeds Stripe's maximum. Please contact support.`;
|
||||
setError(errorMsg);
|
||||
return null;
|
||||
}
|
||||
@@ -425,6 +483,38 @@ const FullPaymentPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PCI DSS Compliance Trust Badge */}
|
||||
{!isPaymentCompleted && (
|
||||
<div className="mt-6 pt-6 border-t border-[#d4af37]/20">
|
||||
<div className="bg-gradient-to-r from-blue-900/20 to-indigo-900/20 border border-blue-500/30 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500/20 to-indigo-500/20 rounded-full flex items-center justify-center border border-blue-500/30">
|
||||
<Shield className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-semibold text-blue-300 mb-1 flex items-center gap-2">
|
||||
<Lock className="w-4 h-4" />
|
||||
Secure Payment Processing
|
||||
</h4>
|
||||
<p className="text-xs text-blue-400/80 font-light leading-relaxed mb-2">
|
||||
Your payment is processed securely through Stripe, a PCI DSS Level 1 compliant payment processor. Your card details are encrypted and never stored on our servers.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||
<span className="text-xs px-2 py-1 bg-blue-500/20 border border-blue-500/30 rounded text-blue-300 font-light">
|
||||
PCI DSS Compliant
|
||||
</span>
|
||||
<span className="text-xs px-2 py-1 bg-green-500/20 border border-green-500/30 rounded text-green-300 font-light">
|
||||
SSL Encrypted
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
169
Frontend/src/pages/customer/GDPRDeletionConfirmPage.tsx
Normal file
169
Frontend/src/pages/customer/GDPRDeletionConfirmPage.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { CheckCircle, XCircle, AlertCircle, Loader2, Home, Shield } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import gdprService from '../../features/compliance/services/gdprService';
|
||||
|
||||
const GDPRDeletionConfirmPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
const [message, setMessage] = useState<string>('');
|
||||
|
||||
const requestId = searchParams.get('requestId');
|
||||
const verificationToken = searchParams.get('token');
|
||||
|
||||
useEffect(() => {
|
||||
const confirmDeletion = async () => {
|
||||
// Validate required parameters
|
||||
if (!requestId || !verificationToken) {
|
||||
setStatus('error');
|
||||
setMessage('Invalid confirmation link. Please check your email and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const requestIdNum = parseInt(requestId, 10);
|
||||
if (isNaN(requestIdNum)) {
|
||||
throw new Error('Invalid request ID');
|
||||
}
|
||||
|
||||
// SECURITY/COMPLIANCE: Confirm deletion with verification token
|
||||
// This ensures only the user who requested deletion can confirm it
|
||||
await gdprService.confirmDataDeletion(requestIdNum, verificationToken);
|
||||
|
||||
setStatus('success');
|
||||
setMessage('Your data deletion request has been confirmed. Your account and all associated data will be permanently deleted. You will be logged out shortly.');
|
||||
|
||||
// Logout user after successful deletion confirmation
|
||||
setTimeout(() => {
|
||||
// Clear local storage
|
||||
localStorage.clear();
|
||||
// Redirect to home page
|
||||
navigate('/', { replace: true });
|
||||
// Reload page to clear all state
|
||||
window.location.reload();
|
||||
}, 5000);
|
||||
} catch (error: any) {
|
||||
setStatus('error');
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
'Unable to confirm deletion request. The link may have expired or already been used.';
|
||||
setMessage(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
confirmDeletion();
|
||||
}, [requestId, verificationToken, navigate]);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 flex items-center justify-center px-4">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-16 h-16 animate-spin text-blue-500 mx-auto mb-4" />
|
||||
<p className="text-gray-600">Confirming deletion request...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-6 sm:p-8 md:p-10">
|
||||
{/* Icon */}
|
||||
<div className="flex justify-center mb-6">
|
||||
{status === 'success' ? (
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-green-100 to-emerald-100 rounded-full flex items-center justify-center border-4 border-green-200">
|
||||
<CheckCircle className="w-12 h-12 text-green-600" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-red-100 to-rose-100 rounded-full flex items-center justify-center border-4 border-red-200">
|
||||
<XCircle className="w-12 h-12 text-red-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-center mb-4 text-slate-900">
|
||||
{status === 'success' ? 'Deletion Confirmed' : 'Confirmation Failed'}
|
||||
</h1>
|
||||
|
||||
{/* Message */}
|
||||
<div className={`rounded-lg p-4 sm:p-6 mb-6 ${
|
||||
status === 'success'
|
||||
? 'bg-gradient-to-r from-green-50 to-emerald-50 border-2 border-green-200'
|
||||
: 'bg-gradient-to-r from-red-50 to-rose-50 border-2 border-red-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{status === 'error' && (
|
||||
<AlertCircle className="w-6 h-6 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<p className={`text-sm sm:text-base ${
|
||||
status === 'success' ? 'text-green-800' : 'text-red-800'
|
||||
}`}>
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Information */}
|
||||
{status === 'success' && (
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg p-4 sm:p-6 mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Shield className="w-6 h-6 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-blue-900 mb-2">What happens next?</h3>
|
||||
<ul className="text-sm text-blue-800 space-y-1 list-disc list-inside">
|
||||
<li>Your account and all personal data will be permanently deleted</li>
|
||||
<li>Financial records will be retained as required by law (7 years)</li>
|
||||
<li>You will be automatically logged out</li>
|
||||
<li>You will receive a confirmation email once deletion is complete</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<Link
|
||||
to="/gdpr"
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl font-semibold hover:from-blue-700 hover:to-blue-800 shadow-lg hover:shadow-xl transition-all duration-200 text-center"
|
||||
>
|
||||
Go to GDPR Page
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
className="px-6 py-3 bg-gradient-to-r from-slate-600 to-slate-700 text-white rounded-xl font-semibold hover:from-slate-700 hover:to-slate-800 shadow-lg hover:shadow-xl transition-all duration-200 text-center flex items-center justify-center gap-2"
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
<span>Go Home</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{status === 'success' && (
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
You will be redirected to the home page in a few seconds...
|
||||
</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-slate-600 to-slate-700 text-white rounded-xl font-semibold hover:from-slate-700 hover:to-slate-800 shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
<span>Go Home Now</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GDPRDeletionConfirmPage;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Download, Trash2, FileText, AlertCircle } from 'lucide-react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Download, Trash2, AlertCircle, Info } from 'lucide-react';
|
||||
import gdprService, { GDPRRequest } from '../../features/compliance/services/gdprService';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
@@ -9,9 +9,25 @@ const GDPRPage: React.FC = () => {
|
||||
const [requests, setRequests] = useState<GDPRRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchRequests();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchRequests = async () => {
|
||||
@@ -20,6 +36,10 @@ const GDPRPage: React.FC = () => {
|
||||
const response = await gdprService.getMyRequests();
|
||||
setRequests(response.data.requests || []);
|
||||
} catch (error: any) {
|
||||
// Don't show error if request was aborted
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
toast.error(error.response?.data?.message || 'Unable to load GDPR requests');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -47,7 +67,7 @@ const GDPRPage: React.FC = () => {
|
||||
|
||||
try {
|
||||
setRequesting(true);
|
||||
const response = await gdprService.requestDataDeletion();
|
||||
await gdprService.requestDataDeletion();
|
||||
toast.success('Deletion request created. Please check your email to confirm.');
|
||||
fetchRequests();
|
||||
} catch (error: any) {
|
||||
@@ -64,13 +84,18 @@ const GDPRPage: React.FC = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
// SECURITY/COMPLIANCE: Secure download of GDPR data export
|
||||
// Export is downloaded as blob (binary data) for security
|
||||
// Filename indicates JSON format (machine-readable as required by GDPR Article 20)
|
||||
const blob = await gdprService.downloadExport(request.id, request.verification_token);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
// GDPR Article 20: Data must be in machine-readable format (JSON)
|
||||
a.download = `user_data_export_${request.id}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
// SECURITY: Revoke object URL immediately after use
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
toast.success('Export downloaded');
|
||||
@@ -130,9 +155,12 @@ const GDPRPage: React.FC = () => {
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs sm:text-sm text-red-800 font-semibold mb-1">Warning: This action cannot be undone</p>
|
||||
<p className="text-xs sm:text-sm text-red-700">
|
||||
<p className="text-xs sm:text-sm text-red-700 mb-2">
|
||||
Requesting data deletion will permanently remove your account and all associated data including bookings, payments, and personal information. This action is irreversible.
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-red-700 font-medium">
|
||||
📧 <strong>Email Confirmation Required:</strong> After submitting your deletion request, you will receive an email with a confirmation link. You must click the link in the email to confirm the deletion. The link will expire after 24 hours for security reasons.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,6 +175,49 @@ const GDPRPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Retention Information */}
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-blue-200/60 p-4 sm:p-5 md:p-6 lg:p-8 animate-fade-in hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="flex flex-col sm:flex-row items-start gap-3 sm:gap-4 mb-3 sm:mb-4">
|
||||
<Info className="w-6 h-6 sm:w-7 sm:h-7 text-blue-500 flex-shrink-0 mt-1" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-semibold mb-2 text-slate-900">Data Retention Policy</h2>
|
||||
<p className="text-xs sm:text-sm md:text-base text-slate-600 mb-3 sm:mb-4">
|
||||
Understanding how long we retain your data and why:
|
||||
</p>
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg sm:rounded-xl p-3 sm:p-4">
|
||||
<p className="text-xs sm:text-sm font-semibold text-blue-900 mb-1">Personal Data</p>
|
||||
<p className="text-xs sm:text-sm text-blue-800">
|
||||
Deleted upon your request (GDPR Article 17 - Right to Erasure). You can request deletion at any time using the option above.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-r from-amber-50 to-yellow-50 border border-amber-200 rounded-lg sm:rounded-xl p-3 sm:p-4">
|
||||
<p className="text-xs sm:text-sm font-semibold text-amber-900 mb-1">Financial Records</p>
|
||||
<p className="text-xs sm:text-sm text-amber-800">
|
||||
Retained for 7 years as required by law for accounting and tax purposes. This includes invoices, payment records, and transaction history.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-r from-slate-50 to-gray-50 border border-slate-200 rounded-lg sm:rounded-xl p-3 sm:p-4">
|
||||
<p className="text-xs sm:text-sm font-semibold text-slate-900 mb-1">Booking Records</p>
|
||||
<p className="text-xs sm:text-sm text-slate-700">
|
||||
Retained for service history and customer support purposes. Personal identifiers can be anonymized upon request while maintaining service records.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-r from-purple-50 to-pink-50 border border-purple-200 rounded-lg sm:rounded-xl p-3 sm:p-4">
|
||||
<p className="text-xs sm:text-sm font-semibold text-purple-900 mb-1">Security Logs</p>
|
||||
<p className="text-xs sm:text-sm text-purple-800">
|
||||
Retained for security and fraud prevention purposes. Logs are automatically purged after the retention period.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-slate-500 mt-3 sm:mt-4 italic">
|
||||
Note: Some data may be retained longer if required by law, legal proceedings, or active disputes.
|
||||
For specific questions about data retention, please contact our support team.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Request History */}
|
||||
{requests.length > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 lg:p-8 animate-fade-in hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.2s' }}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Users, Calendar, Building2, ArrowRight } from 'lucide-react';
|
||||
import groupBookingService, { GroupBooking } from '../../features/bookings/services/groupBookingService';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -14,9 +14,25 @@ const GroupBookingPage: React.FC = () => {
|
||||
const [groupBookings, setGroupBookings] = useState<GroupBooking[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchGroupBookings();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchGroupBookings = async () => {
|
||||
@@ -25,6 +41,10 @@ const GroupBookingPage: React.FC = () => {
|
||||
const response = await groupBookingService.getMyGroupBookings();
|
||||
setGroupBookings(response.data.group_bookings);
|
||||
} catch (error: any) {
|
||||
// Don't show error if request was aborted
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
toast.error(error.response?.data?.message || 'Unable to load group bookings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { ArrowLeft, Download, FileText, CheckCircle, Clock, XCircle } from 'lucide-react';
|
||||
import invoiceService, { Invoice } from '../../features/payments/services/invoiceService';
|
||||
@@ -7,6 +7,7 @@ import Loading from '../../shared/components/Loading';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
import { formatDate } from '../../shared/utils/format';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import { validateInvoiceId } from '../../shared/utils/routeValidation';
|
||||
|
||||
const InvoicePage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -15,51 +16,58 @@ const InvoicePage: React.FC = () => {
|
||||
const { userInfo } = useAuthStore();
|
||||
const [invoice, setInvoice] = useState<Invoice | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Validate route parameter and fetch invoice
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
// Handle both string and number IDs, and check for invalid values like "undefined" or "null"
|
||||
if (id === 'undefined' || id === 'null' || id === 'NaN' || id === '') {
|
||||
// Don't show error toast for invalid URL parameters - just redirect
|
||||
setLoading(false);
|
||||
if (userInfo?.role === 'admin' || userInfo?.role === 'staff' || userInfo?.role === 'accountant') {
|
||||
navigate('/admin/bookings?createInvoice=true');
|
||||
} else {
|
||||
navigate('/bookings');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const invoiceId = Number(id);
|
||||
// Validate that the ID is a valid number
|
||||
if (!isNaN(invoiceId) && invoiceId > 0 && isFinite(invoiceId)) {
|
||||
fetchInvoice(invoiceId);
|
||||
} else {
|
||||
// Invalid ID format - redirect without showing error toast
|
||||
setLoading(false);
|
||||
if (userInfo?.role === 'admin' || userInfo?.role === 'staff' || userInfo?.role === 'accountant') {
|
||||
navigate('/admin/bookings?createInvoice=true');
|
||||
} else {
|
||||
navigate('/bookings');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No ID provided - redirect without error message
|
||||
const invoiceId = validateInvoiceId(id);
|
||||
|
||||
if (!invoiceId) {
|
||||
setLoading(false);
|
||||
// Redirect based on user role
|
||||
if (userInfo?.role === 'admin' || userInfo?.role === 'staff' || userInfo?.role === 'accountant') {
|
||||
navigate('/admin/bookings?createInvoice=true');
|
||||
} else {
|
||||
navigate('/bookings');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchInvoice(invoiceId);
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [id, navigate, userInfo?.role]);
|
||||
|
||||
const fetchInvoice = async (invoiceId: number, retryCount: number = 0) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await invoiceService.getInvoiceById(invoiceId);
|
||||
|
||||
if (response.status === 'success' && response.data?.invoice) {
|
||||
setInvoice(response.data.invoice);
|
||||
const invoiceData = response.data.invoice;
|
||||
|
||||
// Validate ownership for customer role
|
||||
if (userInfo?.role === 'customer' && invoiceData.booking?.user_id !== userInfo.id) {
|
||||
toast.error('You do not have permission to view this invoice');
|
||||
navigate('/bookings');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setInvoice(invoiceData);
|
||||
setLoading(false);
|
||||
} else {
|
||||
// Invoice not found in response - retry if first attempt
|
||||
@@ -72,6 +80,10 @@ const InvoicePage: React.FC = () => {
|
||||
handleInvoiceNotFound();
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Don't show error if request was aborted
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
// Check if it's the "Invalid invoice ID" error from validation
|
||||
const errorMessage = error.message || error.response?.data?.message || '';
|
||||
if (errorMessage.includes('Invalid invoice ID') || errorMessage.includes('Invalid Invoice ID')) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Star,
|
||||
Gift,
|
||||
@@ -40,12 +40,36 @@ const LoyaltyPage: React.FC = () => {
|
||||
const [anniversaryDate, setAnniversaryDate] = useState('');
|
||||
const [showRedemptionModal, setShowRedemptionModal] = useState(false);
|
||||
const [redemptionData, setRedemptionData] = useState<{ code: string; rewardName: string; pointsUsed: number } | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchLoyaltyStatus();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
if (activeTab === 'rewards') {
|
||||
fetchRewards();
|
||||
fetchRedemptions();
|
||||
@@ -54,6 +78,13 @@ const LoyaltyPage: React.FC = () => {
|
||||
} else if (activeTab === 'referrals') {
|
||||
fetchReferrals();
|
||||
}
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [activeTab]);
|
||||
|
||||
const fetchLoyaltyStatus = async () => {
|
||||
@@ -93,6 +124,10 @@ const LoyaltyPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't show error if request was aborted
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
// Only show toast for actual errors (not disabled state)
|
||||
toast.error(error.message || 'Failed to load loyalty status');
|
||||
} finally {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Calendar,
|
||||
@@ -8,10 +8,6 @@ import {
|
||||
Eye,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
DoorOpen,
|
||||
DoorClosed,
|
||||
Search,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
@@ -22,55 +18,62 @@ import {
|
||||
} from '../../features/bookings/services/bookingService';
|
||||
import CancelBookingModal from '../../features/bookings/components/CancelBookingModal';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import { useAuthModal } from '../../features/auth/contexts/AuthModalContext';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import EmptyState from '../../shared/components/EmptyState';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
import { parseDateLocal } from '../../shared/utils/format';
|
||||
import { getBookingStatusConfig, canCancelBooking } from '../../shared/utils/bookingUtils';
|
||||
|
||||
const MyBookingsPage: React.FC = () => {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
const { openModal } = useAuthModal();
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [filteredBookings, setFilteredBookings] =
|
||||
useState<Booking[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCancelModal, setShowCancelModal] = useState(false);
|
||||
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] =
|
||||
useState<string>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
|
||||
// Fetch bookings with request cancellation
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
toast.error('Please login to view your bookings');
|
||||
openModal('login');
|
||||
return;
|
||||
}
|
||||
}, [isAuthenticated, openModal]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
fetchBookings();
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchBookings();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
// Memoize filtered bookings to avoid recalculating on every render
|
||||
const filteredBookings = useMemo(() => {
|
||||
let filtered = [...bookings];
|
||||
|
||||
|
||||
// Filter by status
|
||||
if (statusFilter !== 'all') {
|
||||
filtered = filtered.filter(
|
||||
(b) => b.status === statusFilter
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
@@ -85,7 +88,7 @@ const MyBookingsPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredBookings(filtered);
|
||||
return filtered;
|
||||
}, [bookings, statusFilter, searchQuery]);
|
||||
|
||||
const fetchBookings = async () => {
|
||||
@@ -95,18 +98,17 @@ const MyBookingsPage: React.FC = () => {
|
||||
|
||||
const response = await getMyBookings();
|
||||
|
||||
if (
|
||||
response.success &&
|
||||
response.data?.bookings
|
||||
) {
|
||||
if (response.success && response.data?.bookings) {
|
||||
setBookings(response.data.bookings);
|
||||
} else {
|
||||
throw new Error(
|
||||
'Unable to load bookings list'
|
||||
);
|
||||
throw new Error('Unable to load bookings list');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching bookings:', err);
|
||||
// Don't show error if request was aborted
|
||||
if (err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
const message =
|
||||
err.response?.data?.message ||
|
||||
'Unable to load bookings list';
|
||||
@@ -149,52 +151,6 @@ const MyBookingsPage: React.FC = () => {
|
||||
|
||||
const formatPrice = (price: number) => formatCurrency(price);
|
||||
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return {
|
||||
icon: Clock,
|
||||
color: 'bg-yellow-100 text-yellow-800',
|
||||
text: 'Pending confirmation',
|
||||
};
|
||||
case 'confirmed':
|
||||
return {
|
||||
icon: CheckCircle,
|
||||
color: 'bg-green-100 text-green-800',
|
||||
text: 'Confirmed',
|
||||
};
|
||||
case 'cancelled':
|
||||
return {
|
||||
icon: XCircle,
|
||||
color: 'bg-red-100 text-red-800',
|
||||
text: '❌ Canceled',
|
||||
};
|
||||
case 'checked_in':
|
||||
return {
|
||||
icon: DoorOpen,
|
||||
color: 'bg-blue-100 text-blue-800',
|
||||
text: 'Checked in',
|
||||
};
|
||||
case 'checked_out':
|
||||
return {
|
||||
icon: DoorClosed,
|
||||
color: 'bg-gray-100 text-gray-800',
|
||||
text: 'Checked out',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
color: 'bg-gray-100 text-gray-800',
|
||||
text: status,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const canCancelBooking = (booking: Booking) => {
|
||||
|
||||
return booking.status === 'pending';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading fullScreen text="Loading..." />;
|
||||
}
|
||||
@@ -352,9 +308,7 @@ const MyBookingsPage: React.FC = () => {
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredBookings.map((booking) => {
|
||||
const statusConfig = getStatusConfig(
|
||||
booking.status
|
||||
);
|
||||
const statusConfig = getBookingStatusConfig(booking.status);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
const room = booking.room;
|
||||
const roomType = room?.room_type;
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { XCircle, ArrowLeft, Loader2 } from 'lucide-react';
|
||||
import { cancelPayPalPayment } from '../../features/payments/services/paymentService';
|
||||
import { toast } from 'react-toastify';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
|
||||
const PayPalCancelPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const bookingId = searchParams.get('bookingId');
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCancel = async () => {
|
||||
if (!bookingId) return;
|
||||
if (!bookingId) return;
|
||||
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
const handleCancel = async () => {
|
||||
try {
|
||||
setCancelling(true);
|
||||
const response = await cancelPayPalPayment(Number(bookingId));
|
||||
@@ -22,14 +32,24 @@ const PayPalCancelPage: React.FC = () => {
|
||||
toast.info('Payment canceled. Your booking has been automatically cancelled.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error canceling payment:', err);
|
||||
|
||||
// Don't show error if request was aborted
|
||||
if (err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
logger.error('Error canceling payment', err);
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
handleCancel();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [bookingId]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { capturePayPalPayment, cancelPayPalPayment } from '../../features/payments/services/paymentService';
|
||||
import { toast } from 'react-toastify';
|
||||
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
|
||||
const PayPalReturnPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -10,11 +11,20 @@ const PayPalReturnPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const orderId = searchParams.get('token');
|
||||
const bookingId = searchParams.get('bookingId');
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
const capturePayment = async () => {
|
||||
if (!orderId || !bookingId) {
|
||||
setError('Missing payment information');
|
||||
@@ -41,10 +51,14 @@ const PayPalReturnPage: React.FC = () => {
|
||||
try {
|
||||
await cancelPayPalPayment(Number(bookingId));
|
||||
} catch (cancelErr) {
|
||||
console.error('Error canceling payment after capture failure:', cancelErr);
|
||||
logger.error('Error canceling payment after capture failure', cancelErr);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Don't show error if request was aborted
|
||||
if (err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
const errorMessage = err.response?.data?.message || err.message || 'Failed to capture payment';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
@@ -54,6 +68,13 @@ const PayPalReturnPage: React.FC = () => {
|
||||
};
|
||||
|
||||
capturePayment();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [orderId, bookingId, navigate]);
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
useParams,
|
||||
useNavigate,
|
||||
@@ -26,13 +26,18 @@ import { useAuthModal } from '../../features/auth/contexts/AuthModalContext';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import PaymentStatusBadge from '../../shared/components/PaymentStatusBadge';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
import { validateBookingId } from '../../shared/utils/routeValidation';
|
||||
import { validateAndHandleBookingOwnership } from '../../shared/utils/ownershipValidation';
|
||||
import { getPaymentMethodLabel } from '../../shared/utils/paymentUtils';
|
||||
import { PAYMENT_METHOD, PAYMENT_STATUS } from '../../shared/constants/bookingConstants';
|
||||
|
||||
const PaymentConfirmationPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
const { isAuthenticated, userInfo } = useAuthStore();
|
||||
const { openModal } = useAuthModal();
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const [booking, setBooking] = useState<Booking | null>(
|
||||
null
|
||||
@@ -41,9 +46,7 @@ const PaymentConfirmationPage: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadSuccess, setUploadSuccess] = useState(false);
|
||||
const [selectedFile] =
|
||||
useState<File | null>(null);
|
||||
useState<string | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [copiedBookingNumber, setCopiedBookingNumber] =
|
||||
useState(false);
|
||||
|
||||
@@ -56,12 +59,47 @@ const PaymentConfirmationPage: React.FC = () => {
|
||||
}
|
||||
}, [isAuthenticated, openModal]);
|
||||
|
||||
// Validate route parameter
|
||||
useEffect(() => {
|
||||
if (id && isAuthenticated) {
|
||||
fetchBookingDetails(Number(id));
|
||||
const validatedId = validateBookingId(id);
|
||||
if (!validatedId) {
|
||||
setError('Invalid booking ID');
|
||||
setLoading(false);
|
||||
toast.error('Invalid booking ID');
|
||||
navigate('/bookings');
|
||||
return;
|
||||
}
|
||||
}, [id, navigate]);
|
||||
|
||||
// Fetch data with ownership validation and request cancellation
|
||||
useEffect(() => {
|
||||
const validatedId = validateBookingId(id);
|
||||
if (!validatedId || !isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchBookingDetails(validatedId);
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [id, isAuthenticated]);
|
||||
|
||||
/**
|
||||
* Fetches booking details with ownership validation and payment method checks
|
||||
* Only allows bank transfer payment confirmations on this page
|
||||
*/
|
||||
const fetchBookingDetails = async (bookingId: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -74,16 +112,23 @@ const PaymentConfirmationPage: React.FC = () => {
|
||||
response.data?.booking
|
||||
) {
|
||||
const bookingData = response.data.booking;
|
||||
|
||||
// Validate that the booking belongs to the current user
|
||||
if (!validateAndHandleBookingOwnership(bookingData, userInfo?.id, navigate)) {
|
||||
setLoading(false);
|
||||
return; // Ownership validation handles redirect and error message
|
||||
}
|
||||
|
||||
|
||||
if (bookingData.payment_status === 'paid') {
|
||||
// Prevent confirmation if booking is already paid
|
||||
if (bookingData.payment_status === PAYMENT_STATUS.PAID) {
|
||||
toast.info('This booking has already been paid');
|
||||
navigate(`/bookings/${bookingId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (bookingData.payment_method === 'cash') {
|
||||
// This page only handles bank transfer confirmations
|
||||
// Cash payments are handled on-site, not through this confirmation flow
|
||||
if (bookingData.payment_method === PAYMENT_METHOD.CASH) {
|
||||
toast.info(
|
||||
'This booking uses on-site payment method'
|
||||
);
|
||||
@@ -98,7 +143,11 @@ const PaymentConfirmationPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching booking:', err);
|
||||
// Handle AbortError silently (request was cancelled)
|
||||
if (err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
const message =
|
||||
err.response?.data?.message ||
|
||||
'Unable to load booking information';
|
||||
@@ -126,12 +175,17 @@ const PaymentConfirmationPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles payment confirmation by uploading receipt and notifying the system
|
||||
* Creates a unique transaction ID and sends the receipt for manual verification
|
||||
*/
|
||||
const handleConfirmPayment = async () => {
|
||||
if (!selectedFile || !booking) return;
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
|
||||
// Generate unique transaction ID for tracking
|
||||
const transactionId =
|
||||
`TXN-${booking.booking_number}-${Date.now()}`;
|
||||
|
||||
@@ -147,7 +201,7 @@ const PaymentConfirmationPage: React.FC = () => {
|
||||
);
|
||||
setUploadSuccess(true);
|
||||
|
||||
|
||||
// Redirect to booking details after showing success message
|
||||
setTimeout(() => {
|
||||
navigate(`/bookings/${booking.id}`);
|
||||
}, 2000);
|
||||
@@ -158,7 +212,6 @@ const PaymentConfirmationPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error confirming payment:', err);
|
||||
const message =
|
||||
err.response?.data?.message ||
|
||||
'Unable to send payment confirmation';
|
||||
@@ -399,7 +452,17 @@ const PaymentConfirmationPage: React.FC = () => {
|
||||
id="receipt-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
<span className="text-sm text-gray-600">
|
||||
{selectedFile ? selectedFile.name : 'Click to upload receipt'}
|
||||
</span>
|
||||
</label>
|
||||
{selectedFile && (
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
@@ -20,11 +20,18 @@ import {
|
||||
RefreshCw,
|
||||
KeyRound,
|
||||
LogOut,
|
||||
Monitor
|
||||
Monitor,
|
||||
FileText,
|
||||
Download,
|
||||
Trash2,
|
||||
Database,
|
||||
Smartphone,
|
||||
Tablet
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import authService from '../../features/auth/services/authService';
|
||||
import sessionService from '../../features/auth/services/sessionService';
|
||||
import gdprService, { GDPRRequest } from '../../features/compliance/services/gdprService';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import EmptyState from '../../shared/components/EmptyState';
|
||||
@@ -33,6 +40,7 @@ import { useGlobalLoading } from '../../shared/contexts/LoadingContext';
|
||||
import { normalizeImageUrl } from '../../shared/utils/imageUtils';
|
||||
import { formatDate } from '../../shared/utils/format';
|
||||
import { UserSession } from '../../features/auth/services/sessionService';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const profileValidationSchema = yup.object().shape({
|
||||
name: yup
|
||||
@@ -77,8 +85,10 @@ type PasswordFormData = yup.InferType<typeof passwordValidationSchema>;
|
||||
const ProfilePage: React.FC = () => {
|
||||
const { userInfo, setUser } = useAuthStore();
|
||||
const { setLoading } = useGlobalLoading();
|
||||
const [activeTab, setActiveTab] = useState<'profile' | 'password' | 'mfa' | 'sessions'>('profile');
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<'profile' | 'password' | 'mfa' | 'sessions' | 'gdpr'>('profile');
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
const [avatarError, setAvatarError] = useState<boolean>(false);
|
||||
const [showPassword, setShowPassword] = useState<{
|
||||
current: boolean;
|
||||
new: boolean;
|
||||
@@ -96,6 +106,9 @@ const ProfilePage: React.FC = () => {
|
||||
const [mfaVerificationToken, setMfaVerificationToken] = useState<string>('');
|
||||
const [showBackupCodes, setShowBackupCodes] = useState<string[] | null>(null);
|
||||
const [showMfaSecret, setShowMfaSecret] = useState<boolean>(false);
|
||||
const mfaAbortControllerRef = useRef<AbortController | null>(null);
|
||||
const sessionsAbortControllerRef = useRef<AbortController | null>(null);
|
||||
const gdprAbortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
|
||||
const fetchProfile = async () => {
|
||||
@@ -158,8 +171,12 @@ const ProfilePage: React.FC = () => {
|
||||
backup_codes_count: response.backup_codes_count || 0,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch MFA status:', error);
|
||||
} catch (error: any) {
|
||||
// Don't show error if request was aborted
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
// Error fetching MFA status - handled silently
|
||||
}
|
||||
};
|
||||
|
||||
@@ -175,8 +192,10 @@ const ProfilePage: React.FC = () => {
|
||||
if ((data as any)?.avatar) {
|
||||
|
||||
setAvatarPreview(normalizeImageUrl((data as any).avatar));
|
||||
setAvatarError(false); // Reset error state when new avatar is set
|
||||
} else {
|
||||
setAvatarPreview(null);
|
||||
setAvatarError(false);
|
||||
}
|
||||
}
|
||||
}, [profileData, userInfo, resetProfile]);
|
||||
@@ -184,9 +203,23 @@ const ProfilePage: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'mfa') {
|
||||
// Cancel previous request if exists
|
||||
if (mfaAbortControllerRef.current) {
|
||||
mfaAbortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
mfaAbortControllerRef.current = new AbortController();
|
||||
|
||||
fetchMFAStatus();
|
||||
}
|
||||
|
||||
// Cleanup: abort request on unmount or tab change
|
||||
return () => {
|
||||
if (mfaAbortControllerRef.current) {
|
||||
mfaAbortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [activeTab]);
|
||||
|
||||
|
||||
@@ -312,33 +345,49 @@ const ProfilePage: React.FC = () => {
|
||||
setLoading(true, 'Uploading avatar...');
|
||||
const response = await authService.uploadAvatar(file);
|
||||
|
||||
if (response.status === 'success' && response.data?.user) {
|
||||
const updatedUser = response.data.user;
|
||||
// Check for success response - backend returns {success: true, status: 'success', data: {...}}
|
||||
if ((response.status === 'success' || response.success) && response.data) {
|
||||
const updatedUser = response.data.user || response.data;
|
||||
const avatarUrl = (response.data as any).full_url || (response.data as any).avatar_url || normalizeImageUrl((updatedUser as any)?.avatar);
|
||||
|
||||
const avatarUrl = (response.data as any).full_url || normalizeImageUrl((updatedUser as any).avatar);
|
||||
setUser({
|
||||
id: updatedUser.id,
|
||||
name: (updatedUser as any).name || (updatedUser as any).full_name,
|
||||
email: updatedUser.email,
|
||||
phone: (updatedUser as any).phone || (updatedUser as any).phone_number,
|
||||
avatar: avatarUrl,
|
||||
role: updatedUser.role,
|
||||
});
|
||||
setAvatarPreview(avatarUrl || null);
|
||||
toast.success('Avatar uploaded successfully!');
|
||||
refetchProfile();
|
||||
if (updatedUser) {
|
||||
setUser({
|
||||
id: updatedUser.id,
|
||||
name: (updatedUser as any).name || (updatedUser as any).full_name,
|
||||
email: updatedUser.email,
|
||||
phone: (updatedUser as any).phone || (updatedUser as any).phone_number,
|
||||
avatar: avatarUrl,
|
||||
role: updatedUser.role,
|
||||
});
|
||||
setAvatarPreview(avatarUrl || null);
|
||||
setAvatarError(false); // Reset error state on successful upload
|
||||
toast.success(response.message || 'Avatar uploaded successfully!');
|
||||
refetchProfile();
|
||||
} else {
|
||||
throw new Error('Invalid response format');
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.message || 'Upload failed');
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage =
|
||||
error.response?.data?.detail ||
|
||||
error.response?.data?.message ||
|
||||
error.message ||
|
||||
'Failed to upload avatar';
|
||||
'Failed to upload avatar. Please try again.';
|
||||
toast.error(errorMessage);
|
||||
|
||||
setAvatarPreview(userInfo?.avatar ? normalizeImageUrl(userInfo.avatar) : null);
|
||||
// Revert to previous avatar or clear preview on error
|
||||
if (userInfo?.avatar) {
|
||||
setAvatarPreview(normalizeImageUrl(userInfo.avatar));
|
||||
setAvatarError(true); // Set error state so default avatar shows if current one fails
|
||||
} else {
|
||||
setAvatarPreview(null);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
// Clear file input
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
@@ -363,65 +412,79 @@ const ProfilePage: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-gray-100 to-gray-50 py-4 sm:py-6 lg:py-8 px-3 sm:px-4 lg:px-8">
|
||||
<div className="container mx-auto max-w-5xl">
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-slate-100 to-amber-50/30 py-4 sm:py-6 lg:py-8 px-3 sm:px-4 lg:px-8">
|
||||
<div className="container mx-auto max-w-6xl">
|
||||
{}
|
||||
<div className="mb-6 sm:mb-8 animate-fade-in">
|
||||
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-serif font-bold text-gray-900 tracking-tight mb-2">
|
||||
Profile Settings
|
||||
</h1>
|
||||
<p className="text-sm sm:text-base text-gray-600 font-light tracking-wide">
|
||||
Manage your account information and security preferences
|
||||
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
|
||||
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-[#d4af37] via-amber-400 to-[#d4af37] rounded-full"></div>
|
||||
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-serif font-bold bg-gradient-to-r from-gray-900 via-amber-900/90 to-gray-900 bg-clip-text text-transparent tracking-tight">
|
||||
Profile & Settings
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-sm sm:text-base text-gray-600 font-light tracking-wide ml-0 sm:ml-16">
|
||||
Manage your account information, security, and privacy preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="mb-4 sm:mb-6 border-b border-gray-200 overflow-x-auto">
|
||||
<div className="mb-4 sm:mb-6 border-b border-[#d4af37]/20 overflow-x-auto bg-white/50 backdrop-blur-sm rounded-t-lg sm:rounded-t-xl px-4 sm:px-6">
|
||||
<div className="flex space-x-4 sm:space-x-8 min-w-max">
|
||||
<button
|
||||
onClick={() => setActiveTab('profile')}
|
||||
className={`py-3 sm:py-4 px-2 sm:px-1 border-b-2 font-medium text-xs sm:text-sm transition-colors whitespace-nowrap ${
|
||||
className={`py-3 sm:py-4 px-2 sm:px-1 border-b-2 font-semibold text-xs sm:text-sm transition-all duration-300 whitespace-nowrap ${
|
||||
activeTab === 'profile'
|
||||
? 'border-[#d4af37] text-[#d4af37]'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
? 'border-[#d4af37] text-[#d4af37] shadow-sm'
|
||||
: 'border-transparent text-gray-500 hover:text-[#d4af37] hover:border-[#d4af37]/30'
|
||||
}`}
|
||||
>
|
||||
<User className="w-4 h-4 sm:w-5 sm:h-5 inline mr-2" />
|
||||
<User className={`w-4 h-4 sm:w-5 sm:h-5 inline mr-2 ${activeTab === 'profile' ? 'text-[#d4af37]' : ''}`} />
|
||||
Profile Information
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('password')}
|
||||
className={`py-3 sm:py-4 px-2 sm:px-1 border-b-2 font-medium text-xs sm:text-sm transition-colors whitespace-nowrap ${
|
||||
className={`py-3 sm:py-4 px-2 sm:px-1 border-b-2 font-semibold text-xs sm:text-sm transition-all duration-300 whitespace-nowrap ${
|
||||
activeTab === 'password'
|
||||
? 'border-[#d4af37] text-[#d4af37]'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
? 'border-[#d4af37] text-[#d4af37] shadow-sm'
|
||||
: 'border-transparent text-gray-500 hover:text-[#d4af37] hover:border-[#d4af37]/30'
|
||||
}`}
|
||||
>
|
||||
<KeyRound className="w-4 h-4 sm:w-5 sm:h-5 inline mr-2" />
|
||||
<KeyRound className={`w-4 h-4 sm:w-5 sm:h-5 inline mr-2 ${activeTab === 'password' ? 'text-[#d4af37]' : ''}`} />
|
||||
Change Password
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('mfa')}
|
||||
className={`py-3 sm:py-4 px-2 sm:px-1 border-b-2 font-medium text-xs sm:text-sm transition-colors whitespace-nowrap ${
|
||||
className={`py-3 sm:py-4 px-2 sm:px-1 border-b-2 font-semibold text-xs sm:text-sm transition-all duration-300 whitespace-nowrap ${
|
||||
activeTab === 'mfa'
|
||||
? 'border-[#d4af37] text-[#d4af37]'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
? 'border-[#d4af37] text-[#d4af37] shadow-sm'
|
||||
: 'border-transparent text-gray-500 hover:text-[#d4af37] hover:border-[#d4af37]/30'
|
||||
}`}
|
||||
>
|
||||
<Shield className="w-4 h-4 sm:w-5 sm:h-5 inline mr-2" />
|
||||
<Shield className={`w-4 h-4 sm:w-5 sm:h-5 inline mr-2 ${activeTab === 'mfa' ? 'text-[#d4af37]' : ''}`} />
|
||||
Two-Factor Authentication
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('sessions')}
|
||||
className={`py-3 sm:py-4 px-2 sm:px-1 border-b-2 font-medium text-xs sm:text-sm transition-colors whitespace-nowrap ${
|
||||
className={`py-3 sm:py-4 px-2 sm:px-1 border-b-2 font-semibold text-xs sm:text-sm transition-all duration-300 whitespace-nowrap ${
|
||||
activeTab === 'sessions'
|
||||
? 'border-[#d4af37] text-[#d4af37]'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
? 'border-[#d4af37] text-[#d4af37] shadow-sm'
|
||||
: 'border-transparent text-gray-500 hover:text-[#d4af37] hover:border-[#d4af37]/30'
|
||||
}`}
|
||||
>
|
||||
<Monitor className="w-4 h-4 sm:w-5 sm:h-5 inline mr-2" />
|
||||
<Monitor className={`w-4 h-4 sm:w-5 sm:h-5 inline mr-2 ${activeTab === 'sessions' ? 'text-[#d4af37]' : ''}`} />
|
||||
Active Sessions
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('gdpr')}
|
||||
className={`py-3 sm:py-4 px-2 sm:px-1 border-b-2 font-semibold text-xs sm:text-sm transition-all duration-300 whitespace-nowrap ${
|
||||
activeTab === 'gdpr'
|
||||
? 'border-[#d4af37] text-[#d4af37] shadow-sm'
|
||||
: 'border-transparent text-gray-500 hover:text-[#d4af37] hover:border-[#d4af37]/30'
|
||||
}`}
|
||||
>
|
||||
<Database className={`w-4 h-4 sm:w-5 sm:h-5 inline mr-2 ${activeTab === 'gdpr' ? 'text-[#d4af37]' : ''}`} />
|
||||
Data Privacy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -432,11 +495,15 @@ const ProfilePage: React.FC = () => {
|
||||
{}
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-4 sm:gap-6 pb-5 sm:pb-6 border-b border-gray-200">
|
||||
<div className="relative">
|
||||
{avatarPreview || userInfo?.avatar ? (
|
||||
{(avatarPreview || userInfo?.avatar) && !avatarError ? (
|
||||
<img
|
||||
src={avatarPreview || normalizeImageUrl(userInfo?.avatar)}
|
||||
alt="Profile"
|
||||
className="w-20 h-20 sm:w-24 sm:h-24 rounded-full object-cover ring-4 ring-[#d4af37]/20 shadow-lg"
|
||||
onError={() => {
|
||||
// If image fails to load, show default avatar
|
||||
setAvatarError(true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 sm:w-24 sm:h-24 rounded-full bg-gradient-to-br from-[#d4af37] to-[#c9a227] flex items-center justify-center ring-4 ring-[#d4af37]/20 shadow-lg">
|
||||
@@ -992,6 +1059,11 @@ const ProfilePage: React.FC = () => {
|
||||
{activeTab === 'sessions' && (
|
||||
<SessionsTab />
|
||||
)}
|
||||
|
||||
{/* GDPR Tab */}
|
||||
{activeTab === 'gdpr' && (
|
||||
<GDPRTab />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1000,44 +1072,110 @@ const ProfilePage: React.FC = () => {
|
||||
const SessionsTab: React.FC = () => {
|
||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchSessions();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchSessions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await sessionService.getMySessions();
|
||||
setSessions(response.data.sessions || []);
|
||||
setSessions(response.data?.sessions || []);
|
||||
} catch (error: any) {
|
||||
// Don't show error if request was aborted
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
toast.error(error.response?.data?.message || 'Unable to load sessions');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getDeviceIcon = (userAgent?: string) => {
|
||||
if (!userAgent) return <Monitor className="w-5 h-5 text-[#d4af37]" />;
|
||||
if (userAgent.includes('Mobile')) return <Smartphone className="w-5 h-5 text-blue-500" />;
|
||||
if (userAgent.includes('Tablet')) return <Tablet className="w-5 h-5 text-purple-500" />;
|
||||
return <Monitor className="w-5 h-5 text-[#d4af37]" />;
|
||||
};
|
||||
|
||||
const getDeviceName = (userAgent?: string) => {
|
||||
if (!userAgent) return 'Unknown Device';
|
||||
if (userAgent.includes('Chrome')) return 'Chrome Browser';
|
||||
if (userAgent.includes('Firefox')) return 'Firefox Browser';
|
||||
if (userAgent.includes('Safari')) return 'Safari Browser';
|
||||
if (userAgent.includes('Edge')) return 'Edge Browser';
|
||||
if (userAgent.includes('Mobile')) return 'Mobile Device';
|
||||
if (userAgent.includes('Tablet')) return 'Tablet Device';
|
||||
return 'Web Browser';
|
||||
};
|
||||
|
||||
const handleRevoke = async (sessionId: number) => {
|
||||
if (!confirm('Are you sure you want to revoke this session?')) return;
|
||||
|
||||
try {
|
||||
await sessionService.revokeSession(sessionId);
|
||||
toast.success('Session revoked');
|
||||
fetchSessions();
|
||||
const response = await sessionService.revokeSession(sessionId);
|
||||
if (response.data?.logout_required) {
|
||||
toast.warning('Your current session has been revoked. You will be logged out.');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
} else {
|
||||
toast.success('Session revoked successfully');
|
||||
fetchSessions();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to revoke session');
|
||||
if (error.response?.status === 401) {
|
||||
toast.warning('Your session has been revoked. You will be logged out.');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
} else {
|
||||
toast.error(error.response?.data?.message || 'Unable to revoke session');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeAll = async () => {
|
||||
if (!confirm('Are you sure you want to revoke all other sessions?')) return;
|
||||
if (!confirm('Are you sure you want to revoke all other sessions? This will also log you out.')) return;
|
||||
|
||||
try {
|
||||
await sessionService.revokeAllSessions();
|
||||
toast.success('All other sessions revoked');
|
||||
fetchSessions();
|
||||
const response = await sessionService.revokeAllSessions();
|
||||
if (response.data?.logout_required) {
|
||||
toast.warning(response.message || 'All sessions have been revoked. You will be logged out.');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
} else {
|
||||
toast.success(response.message || 'All sessions revoked');
|
||||
fetchSessions();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to revoke sessions');
|
||||
if (error.response?.status === 401) {
|
||||
toast.warning('All sessions have been revoked. You will be logged out.');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
} else {
|
||||
toast.error(error.response?.data?.message || 'Unable to revoke sessions');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1051,48 +1189,52 @@ const SessionsTab: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="luxury-glass rounded-sm p-4 sm:p-6 lg:p-8 border border-[#d4af37]/20 shadow-2xl animate-slide-up space-y-5 sm:space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl sm:text-2xl font-serif font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
||||
<Monitor className="w-5 h-5 sm:w-6 sm:h-6 text-[#d4af37]" />
|
||||
Active Sessions
|
||||
</h2>
|
||||
<p className="text-xs sm:text-sm text-gray-600 font-light">
|
||||
Manage your active sessions across different devices
|
||||
</p>
|
||||
</div>
|
||||
{sessions.length > 1 && (
|
||||
<button
|
||||
onClick={handleRevokeAll}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm"
|
||||
>
|
||||
Revoke All Others
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-xl sm:text-2xl font-serif font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
||||
<Monitor className="w-5 h-5 sm:w-6 sm:h-6 text-[#d4af37]" />
|
||||
Active Sessions
|
||||
</h2>
|
||||
<p className="text-xs sm:text-sm text-gray-600 font-light">
|
||||
Manage your active sessions across different devices
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-slate-500">No active sessions</p>
|
||||
<Monitor className="w-12 h-12 mx-auto text-gray-400 mb-3" />
|
||||
<p className="text-gray-500 text-sm font-light">No active sessions</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{sessions.length > 1 && (
|
||||
<div className="flex justify-end pb-2">
|
||||
<button
|
||||
onClick={handleRevokeAll}
|
||||
className="px-4 sm:px-6 py-2 sm:py-2.5 bg-gradient-to-r from-red-600 to-red-700 text-white rounded-sm font-semibold hover:from-red-700 hover:to-red-800 shadow-lg hover:shadow-xl transition-all duration-200 text-xs sm:text-sm flex items-center gap-2"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Revoke All Other Sessions
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow"
|
||||
className="bg-gradient-to-r from-slate-50 to-white border border-[#d4af37]/20 rounded-sm p-4 sm:p-5 hover:shadow-lg hover:border-[#d4af37]/40 transition-all duration-300"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4 flex-1">
|
||||
<Monitor className="w-5 h-5 text-gray-500 mt-1" />
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold mb-1">
|
||||
{session.user_agent || 'Unknown Device'}
|
||||
<div className="p-2 sm:p-3 bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/10 rounded-sm flex-shrink-0">
|
||||
{getDeviceIcon(session.user_agent)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-sm sm:text-base text-gray-900 mb-2">
|
||||
{getDeviceName(session.user_agent)}
|
||||
</p>
|
||||
<p className="text-gray-600 text-sm mb-1">
|
||||
IP: {session.ip_address || 'Unknown'}
|
||||
<p className="text-gray-600 text-xs sm:text-sm mb-1">
|
||||
IP Address: <span className="font-mono">{session.ip_address || 'Unknown'}</span>
|
||||
</p>
|
||||
<p className="text-gray-500 text-xs">
|
||||
<p className="text-gray-500 text-xs mb-1">
|
||||
Last Activity: {formatDate(session.last_activity)}
|
||||
</p>
|
||||
<p className="text-gray-500 text-xs">
|
||||
@@ -1102,7 +1244,7 @@ const SessionsTab: React.FC = () => {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRevoke(session.id)}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 flex items-center gap-2 text-sm"
|
||||
className="px-4 sm:px-5 py-2 sm:py-2.5 bg-gradient-to-r from-red-600 to-red-700 text-white rounded-sm hover:from-red-700 hover:to-red-800 shadow-md hover:shadow-lg transition-all duration-200 flex items-center gap-2 text-xs sm:text-sm font-semibold flex-shrink-0"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Revoke
|
||||
@@ -1116,4 +1258,232 @@ const SessionsTab: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const GDPRTab: React.FC = () => {
|
||||
const [requests, setRequests] = useState<GDPRRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchRequests();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchRequests = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await gdprService.getMyRequests();
|
||||
setRequests(response.data?.requests || []);
|
||||
} catch (error: any) {
|
||||
// Don't show error if request was aborted
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
toast.error(error.response?.data?.message || 'Unable to load GDPR requests');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestExport = async () => {
|
||||
if (!confirm('Request a copy of your personal data? You will receive an email when ready.')) return;
|
||||
|
||||
try {
|
||||
setRequesting(true);
|
||||
await gdprService.requestDataExport();
|
||||
toast.success('Data export request created. You will receive an email when ready.');
|
||||
fetchRequests();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to create export request');
|
||||
} finally {
|
||||
setRequesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestDeletion = async () => {
|
||||
if (!confirm('WARNING: This will permanently delete your account and all associated data. This action cannot be undone. Are you absolutely sure?')) return;
|
||||
if (!confirm('This is your last chance. Are you 100% certain you want to delete all your data?')) return;
|
||||
|
||||
try {
|
||||
setRequesting(true);
|
||||
await gdprService.requestDataDeletion();
|
||||
toast.success('Deletion request created. Please check your email to confirm.');
|
||||
fetchRequests();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to create deletion request');
|
||||
} finally {
|
||||
setRequesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (request: GDPRRequest) => {
|
||||
if (!request.verification_token) {
|
||||
toast.error('Verification token not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await gdprService.downloadExport(request.id, request.verification_token);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `user_data_export_${request.id}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
toast.success('Export downloaded successfully');
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to download export');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="luxury-glass rounded-sm p-4 sm:p-6 lg:p-8 border border-[#d4af37]/20 shadow-2xl">
|
||||
<Loading text="Loading privacy data..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="luxury-glass rounded-sm p-4 sm:p-6 lg:p-8 border border-[#d4af37]/20 shadow-2xl animate-slide-up space-y-5 sm:space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl sm:text-2xl font-serif font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
||||
<Database className="w-5 h-5 sm:w-6 sm:h-6 text-[#d4af37]" />
|
||||
Data Privacy & GDPR Rights
|
||||
</h2>
|
||||
<p className="text-xs sm:text-sm text-gray-600 font-light">
|
||||
Manage your personal data and exercise your privacy rights under GDPR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Data Export */}
|
||||
<div className="bg-gradient-to-r from-blue-50/50 to-indigo-50/50 border border-blue-200/60 rounded-sm p-4 sm:p-6 shadow-lg">
|
||||
<div className="flex flex-col sm:flex-row items-start gap-4 mb-4">
|
||||
<div className="p-3 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-sm flex-shrink-0">
|
||||
<Download className="w-6 h-6 sm:w-7 sm:h-7 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-gray-900 mb-2">Export Your Data</h3>
|
||||
<p className="text-xs sm:text-sm text-gray-600 font-light mb-4 leading-relaxed">
|
||||
Request a copy of all your personal data stored in our system. You will receive an email with a download link when your data is ready.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleRequestExport}
|
||||
disabled={requesting}
|
||||
className="btn-luxury-primary flex items-center gap-2 px-6 py-3 rounded-sm font-semibold text-sm sm:text-base relative disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<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>
|
||||
<Download className="w-5 h-5 relative z-10" />
|
||||
<span className="relative z-10">{requesting ? 'Requesting...' : 'Request Data Export'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Deletion */}
|
||||
<div className="bg-gradient-to-r from-red-50/50 to-rose-50/50 border-2 border-red-200/60 rounded-sm p-4 sm:p-6 shadow-lg">
|
||||
<div className="flex flex-col sm:flex-row items-start gap-4 mb-4">
|
||||
<div className="p-3 bg-gradient-to-br from-red-100 to-rose-100 rounded-sm flex-shrink-0">
|
||||
<Trash2 className="w-6 h-6 sm:w-7 sm:h-7 text-red-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-red-900 mb-2">Delete Your Data</h3>
|
||||
<div className="bg-white/80 border-2 border-red-200 rounded-sm p-3 sm:p-4 mb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs sm:text-sm text-red-800 font-semibold mb-1">Warning: This action cannot be undone</p>
|
||||
<p className="text-xs sm:text-sm text-red-700 font-light leading-relaxed">
|
||||
Requesting data deletion will permanently remove your account and all associated data including bookings, payments, and personal information. This action is irreversible.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRequestDeletion}
|
||||
disabled={requesting}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-red-600 to-red-700 text-white rounded-sm font-semibold hover:from-red-700 hover:to-red-800 shadow-lg hover:shadow-xl transition-all duration-200 text-sm sm:text-base disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
{requesting ? 'Requesting...' : 'Request Data Deletion'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Request History */}
|
||||
{requests.length > 0 && (
|
||||
<div className="border-t border-gray-200 pt-5 sm:pt-6">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-[#d4af37]" />
|
||||
Request History
|
||||
</h3>
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{requests.map((request) => (
|
||||
<div
|
||||
key={request.id}
|
||||
className="bg-gradient-to-r from-slate-50 to-white border border-[#d4af37]/20 rounded-sm p-4 sm:p-5 hover:shadow-md hover:border-[#d4af37]/40 transition-all duration-300"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-3 mb-2">
|
||||
{request.request_type === 'data_export' ? (
|
||||
<Download className="w-5 h-5 text-blue-500 flex-shrink-0" />
|
||||
) : (
|
||||
<Trash2 className="w-5 h-5 text-red-500 flex-shrink-0" />
|
||||
)}
|
||||
<h4 className="font-semibold text-sm sm:text-base text-gray-900">
|
||||
{request.request_type === 'data_export' ? 'Data Export' : 'Data Deletion'}
|
||||
</h4>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold border shadow-sm ${
|
||||
request.status === 'completed'
|
||||
? 'bg-gradient-to-r from-emerald-50 to-green-50 text-emerald-800 border-emerald-200'
|
||||
: request.status === 'pending' || request.status === 'processing'
|
||||
? 'bg-gradient-to-r from-amber-50 to-yellow-50 text-amber-800 border-amber-200'
|
||||
: 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200'
|
||||
}`}>
|
||||
{request.status.charAt(0).toUpperCase() + request.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-gray-600 font-light">
|
||||
Created: {formatDate(request.created_at)}
|
||||
{request.processed_at && ` • Processed: ${formatDate(request.processed_at)}`}
|
||||
</p>
|
||||
</div>
|
||||
{request.request_type === 'data_export' && request.status === 'completed' && request.verification_token && (
|
||||
<button
|
||||
onClick={() => handleDownload(request)}
|
||||
className="btn-luxury-primary flex items-center gap-2 px-4 sm:px-6 py-2 sm:py-2.5 rounded-sm font-semibold text-xs sm:text-sm relative flex-shrink-0"
|
||||
>
|
||||
<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>
|
||||
<Download className="w-4 h-4 relative z-10" />
|
||||
<span className="relative z-10">Download</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import {
|
||||
Users,
|
||||
@@ -20,6 +20,7 @@ import useAuthStore from '../../store/useAuthStore';
|
||||
import LuxuryBookingModal from '../../features/bookings/components/LuxuryBookingModal';
|
||||
import { useAuthModal } from '../../features/auth/contexts/AuthModalContext';
|
||||
import { toast } from 'react-toastify';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
|
||||
const RoomDetailPage: React.FC = () => {
|
||||
const { room_number } = useParams<{ room_number: string }>();
|
||||
@@ -33,11 +34,29 @@ const RoomDetailPage: React.FC = () => {
|
||||
const [showBookingModal, setShowBookingModal] = useState(false);
|
||||
const [nextAvailableDate, setNextAvailableDate] = useState<Date | null>(null);
|
||||
const [bookedUntilDate, setBookedUntilDate] = useState<Date | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (room_number) {
|
||||
fetchRoomDetail(room_number);
|
||||
if (!room_number) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchRoomDetail(room_number);
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [room_number]);
|
||||
|
||||
const fetchRoomDetail = async (roomNumber: string) => {
|
||||
@@ -46,14 +65,16 @@ const RoomDetailPage: React.FC = () => {
|
||||
setError(null);
|
||||
const response = await getRoomByNumber(roomNumber);
|
||||
|
||||
|
||||
if ((response as any).success || (response as any).status === 'success') {
|
||||
if (response.success || response.status === 'success') {
|
||||
if (response.data && response.data.room) {
|
||||
const fetchedRoom = response.data.room;
|
||||
|
||||
|
||||
if (fetchedRoom.room_number !== roomNumber) {
|
||||
console.error(`Room number mismatch: requested ${roomNumber}, got ${fetchedRoom.room_number}`);
|
||||
// Log room number mismatch for debugging
|
||||
logger.error('Room number mismatch', new Error('Room data mismatch'), {
|
||||
requested: roomNumber,
|
||||
received: fetchedRoom.room_number
|
||||
});
|
||||
throw new Error(`Room data mismatch: expected room number ${roomNumber} but got ${fetchedRoom.room_number}`);
|
||||
}
|
||||
|
||||
@@ -70,7 +91,11 @@ const RoomDetailPage: React.FC = () => {
|
||||
throw new Error('Failed to fetch room details');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching room:', err);
|
||||
// Don't show error if request was aborted
|
||||
if (err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
logger.error('Error fetching room', err);
|
||||
const message =
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
@@ -135,7 +160,11 @@ const RoomDetailPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching booked dates:', err);
|
||||
// Don't show error if request was aborted
|
||||
if (err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
logger.error('Error fetching booked dates', err);
|
||||
// Don't show error to user, just don't show availability info
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import RoomCard from '../../features/rooms/components/RoomCard';
|
||||
import RoomCardSkeleton from '../../features/rooms/components/RoomCardSkeleton';
|
||||
import Pagination from '../../shared/components/Pagination';
|
||||
import { ArrowLeft, Hotel, Filter, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
|
||||
const RoomListPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -21,6 +22,7 @@ const RoomListPage: React.FC = () => {
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
});
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,6 +35,14 @@ const RoomListPage: React.FC = () => {
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
const fetchRooms = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -57,7 +67,7 @@ const RoomListPage: React.FC = () => {
|
||||
|
||||
const response = await getRooms(params);
|
||||
|
||||
if (response.status === 'success' && response.data) {
|
||||
if ((response.status === 'success' || response.success) && response.data) {
|
||||
setRooms(response.data.rooms || []);
|
||||
if (response.data.pagination) {
|
||||
setPagination(response.data.pagination);
|
||||
@@ -65,8 +75,12 @@ const RoomListPage: React.FC = () => {
|
||||
} else {
|
||||
throw new Error('Failed to fetch rooms');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching rooms:', err);
|
||||
} catch (err: any) {
|
||||
// Don't show error if request was aborted
|
||||
if (err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
logger.error('Error fetching rooms', err);
|
||||
setError('Unable to load room list. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -74,6 +88,13 @@ const RoomListPage: React.FC = () => {
|
||||
};
|
||||
|
||||
fetchRooms();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
useSearchParams,
|
||||
useNavigate,
|
||||
@@ -19,6 +19,7 @@ import { searchAvailableRooms } from '../../features/rooms/services/roomService'
|
||||
import type { Room } from '../../features/rooms/services/roomService';
|
||||
import { toast } from 'react-toastify';
|
||||
import { parseDateLocal } from '../../shared/utils/format';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
|
||||
const SearchResultsPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -33,6 +34,7 @@ const SearchResultsPage: React.FC = () => {
|
||||
limit: 12,
|
||||
totalPages: 1,
|
||||
});
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
|
||||
const from = searchParams.get('from') || '';
|
||||
@@ -54,8 +56,23 @@ const SearchResultsPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchAvailableRooms();
|
||||
}, [from, to, type, capacity, page]);
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [from, to, type, capacity, page, navigate]);
|
||||
|
||||
const fetchAvailableRooms = async () => {
|
||||
try {
|
||||
@@ -95,7 +112,11 @@ const SearchResultsPage: React.FC = () => {
|
||||
throw new Error('Unable to search rooms');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error searching rooms:', err);
|
||||
// Don't show error if request was aborted
|
||||
if (err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
logger.error('Error searching rooms', err);
|
||||
const message =
|
||||
err.response?.data?.message ||
|
||||
'Unable to search available rooms';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { LogOut, Monitor, Smartphone, Tablet } from 'lucide-react';
|
||||
import sessionService, { UserSession } from '../../features/auth/services/sessionService';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -8,9 +8,25 @@ import { formatDate } from '../../shared/utils/format';
|
||||
const SessionManagementPage: React.FC = () => {
|
||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchSessions();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchSessions = async () => {
|
||||
@@ -19,6 +35,10 @@ const SessionManagementPage: React.FC = () => {
|
||||
const response = await sessionService.getMySessions();
|
||||
setSessions(response.data.sessions || []);
|
||||
} catch (error: any) {
|
||||
// Don't show error if request was aborted
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
toast.error(error.response?.data?.message || 'Unable to load sessions');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -29,23 +49,61 @@ const SessionManagementPage: React.FC = () => {
|
||||
if (!confirm('Are you sure you want to revoke this session?')) return;
|
||||
|
||||
try {
|
||||
await sessionService.revokeSession(sessionId);
|
||||
toast.success('Session revoked');
|
||||
fetchSessions();
|
||||
const response = await sessionService.revokeSession(sessionId);
|
||||
// Check if logout is required (current session was revoked)
|
||||
// Response structure: {success: true, data: {logout_required: true}, message: '...'}
|
||||
// sessionService returns response.data, so response is {success: true, data: {...}, message: '...'}
|
||||
if (response.data?.logout_required) {
|
||||
toast.warning('Your current session has been revoked. You will be logged out.');
|
||||
// Give user a moment to see the message, then logout
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
} else {
|
||||
toast.success(response.message || 'Session revoked');
|
||||
fetchSessions();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to revoke session');
|
||||
// Check if it's an unauthorized error (session was revoked)
|
||||
if (error.response?.status === 401) {
|
||||
toast.warning('Your session has been revoked. You will be logged out.');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
} else {
|
||||
toast.error(error.response?.data?.message || 'Unable to revoke session');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeAll = async () => {
|
||||
if (!confirm('Are you sure you want to revoke all other sessions?')) return;
|
||||
if (!confirm('Are you sure you want to revoke all other sessions? This will also log you out.')) return;
|
||||
|
||||
try {
|
||||
await sessionService.revokeAllSessions();
|
||||
toast.success('All other sessions revoked');
|
||||
fetchSessions();
|
||||
const response = await sessionService.revokeAllSessions();
|
||||
// Revoking all sessions always logs the user out
|
||||
// Response structure: {success: true, data: {logout_required: true, revoked_count: X}, message: '...'}
|
||||
// sessionService returns response.data, so response is {success: true, data: {...}, message: '...'}
|
||||
if (response.data?.logout_required) {
|
||||
toast.warning(response.message || 'All sessions have been revoked. You will be logged out.');
|
||||
// Give user a moment to see the message, then logout
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
} else {
|
||||
toast.success(response.message || 'All sessions revoked');
|
||||
fetchSessions();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to revoke sessions');
|
||||
// Check if it's an unauthorized error (sessions were revoked)
|
||||
if (error.response?.status === 401) {
|
||||
toast.warning('All sessions have been revoked. You will be logged out.');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
} else {
|
||||
toast.error(error.response?.data?.message || 'Unable to revoke sessions');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Hotel,
|
||||
Wrench,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
import advancedRoomService, {
|
||||
RoomStatusBoardItem,
|
||||
} from '../../features/rooms/services/advancedRoomService';
|
||||
@@ -63,7 +64,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
|
||||
setFloors(uniqueFloors);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch floors:', error);
|
||||
logger.error('Failed to fetch floors', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -252,7 +252,7 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
setTotalItems(response.data.pagination.total);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching audit logs:', error);
|
||||
logger.error('Error fetching audit logs', error);
|
||||
toast.error(error.response?.data?.message || 'Unable to load audit logs');
|
||||
} finally {
|
||||
setAuditLoading(false);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Search, Eye, XCircle, CheckCircle, Loader2, FileText, Plus } from 'lucide-react';
|
||||
import bookingService, { Booking } from '../../features/bookings/services/bookingService';
|
||||
import invoiceService from '../../features/payments/services/invoiceService';
|
||||
@@ -9,6 +9,8 @@ import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurren
|
||||
import { parseDateLocal } from '../../shared/utils/format';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import CreateBookingModal from '../../features/hotel_services/components/CreateBookingModal';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
import { validateBookingId } from '../../shared/utils/routeValidation';
|
||||
|
||||
const BookingManagementPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
@@ -29,13 +31,29 @@ const BookingManagementPage: React.FC = () => {
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const itemsPerPage = 5;
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchBookings();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [filters, currentPage]);
|
||||
|
||||
const fetchBookings = async () => {
|
||||
@@ -52,6 +70,11 @@ const BookingManagementPage: React.FC = () => {
|
||||
setTotalItems(response.data.pagination.total);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Handle AbortError silently
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
logger.error('Error fetching bookings', error);
|
||||
toast.error(error.response?.data?.message || 'Unable to load bookings list');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -89,7 +112,8 @@ const BookingManagementPage: React.FC = () => {
|
||||
const handleCreateInvoice = async (bookingId: number) => {
|
||||
try {
|
||||
// Validate bookingId before proceeding
|
||||
if (!bookingId || isNaN(bookingId) || bookingId <= 0) {
|
||||
const validatedId = validateBookingId(bookingId);
|
||||
if (!validatedId) {
|
||||
toast.error('Invalid booking ID');
|
||||
return;
|
||||
}
|
||||
@@ -120,7 +144,7 @@ const BookingManagementPage: React.FC = () => {
|
||||
navigate(`/staff/invoices/${invoiceId}`);
|
||||
}, 100);
|
||||
} else {
|
||||
console.error('Invalid invoice ID received from server', {
|
||||
logger.error('Invalid invoice ID received from server', {
|
||||
invoiceId,
|
||||
type: typeof invoiceId,
|
||||
response: response.data
|
||||
@@ -128,13 +152,13 @@ const BookingManagementPage: React.FC = () => {
|
||||
throw new Error(`Invalid invoice ID received from server: ${invoiceId}`);
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to create invoice - invalid response', { response });
|
||||
logger.error('Failed to create invoice - invalid response', { response });
|
||||
throw new Error(response.message || 'Failed to create invoice - invalid response from server');
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.detail || error.response?.data?.message || error.message || 'Unable to create invoice';
|
||||
toast.error(errorMessage);
|
||||
console.error('Invoice creation error:', error);
|
||||
logger.error('Invoice creation error', error);
|
||||
} finally {
|
||||
setCreatingInvoice(false);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import EmptyState from '../../shared/components/EmptyState';
|
||||
import { formatDate } from '../../shared/utils/format';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import { useChatNotifications } from '../../features/notifications/contexts/ChatNotificationContext';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
|
||||
const ChatManagementPage: React.FC = () => {
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
@@ -73,7 +74,7 @@ const ChatManagementPage: React.FC = () => {
|
||||
const websocket = new WebSocket(wsUrl);
|
||||
|
||||
websocket.onopen = () => {
|
||||
console.log('Notification WebSocket connected');
|
||||
logger.debug('Notification WebSocket connected');
|
||||
};
|
||||
|
||||
websocket.onmessage = (event) => {
|
||||
@@ -109,11 +110,11 @@ const ChatManagementPage: React.FC = () => {
|
||||
};
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
console.error('Notification WebSocket error:', error);
|
||||
logger.error('Notification WebSocket error', error);
|
||||
};
|
||||
|
||||
websocket.onclose = () => {
|
||||
console.log('Notification WebSocket disconnected');
|
||||
logger.debug('Notification WebSocket disconnected');
|
||||
setTimeout(() => {
|
||||
connectNotificationWebSocket();
|
||||
}, 5000);
|
||||
@@ -141,7 +142,7 @@ const ChatManagementPage: React.FC = () => {
|
||||
const websocket = new WebSocket(wsUrl);
|
||||
|
||||
websocket.onopen = () => {
|
||||
console.log('Chat WebSocket connected for chat', chatId);
|
||||
logger.debug('Chat WebSocket connected for chat', { chatId });
|
||||
};
|
||||
|
||||
websocket.onmessage = (event) => {
|
||||
@@ -169,19 +170,19 @@ const ChatManagementPage: React.FC = () => {
|
||||
fetchChats();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
logger.error('Error parsing WebSocket message', error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
console.error('Chat WebSocket error:', error);
|
||||
logger.error('Chat WebSocket error', error);
|
||||
};
|
||||
|
||||
websocket.onclose = (event) => {
|
||||
console.log('Chat WebSocket disconnected', event.code, event.reason);
|
||||
logger.debug('Chat WebSocket disconnected', { code: event.code, reason: event.reason });
|
||||
|
||||
if (event.code !== 1000 && selectedChat && selectedChat.id === chatId) {
|
||||
console.log('Attempting to reconnect chat WebSocket...');
|
||||
logger.debug('Attempting to reconnect chat WebSocket...');
|
||||
setTimeout(() => {
|
||||
if (selectedChat && selectedChat.id === chatId) {
|
||||
connectChatWebSocket(chatId);
|
||||
@@ -204,7 +205,7 @@ const ChatManagementPage: React.FC = () => {
|
||||
setMessages(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling messages:', error);
|
||||
logger.error('Error polling messages', error);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Hotel,
|
||||
Calendar,
|
||||
@@ -18,6 +18,9 @@ import { formatDate } from '../../shared/utils/format';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
import { useAsync } from '../../shared/hooks/useAsync';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
import { getPaymentStatusColor } from '../../shared/utils/paymentUtils';
|
||||
import { getBookingStatusConfig } from '../../shared/utils/bookingUtils';
|
||||
|
||||
const StaffDashboardPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
@@ -30,6 +33,8 @@ const StaffDashboardPage: React.FC = () => {
|
||||
const [recentBookings, setRecentBookings] = useState<Booking[]>([]);
|
||||
const [loadingPayments, setLoadingPayments] = useState(false);
|
||||
const [loadingBookings, setLoadingBookings] = useState(false);
|
||||
const paymentsAbortRef = useRef<AbortController | null>(null);
|
||||
const bookingsAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
const response = await reportService.getReports({
|
||||
@@ -54,6 +59,14 @@ const StaffDashboardPage: React.FC = () => {
|
||||
}, [dateRange]);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (paymentsAbortRef.current) {
|
||||
paymentsAbortRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
paymentsAbortRef.current = new AbortController();
|
||||
|
||||
const fetchPayments = async () => {
|
||||
try {
|
||||
setLoadingPayments(true);
|
||||
@@ -62,15 +75,34 @@ const StaffDashboardPage: React.FC = () => {
|
||||
setRecentPayments(response.data.payments);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching payments:', err);
|
||||
// Handle AbortError silently
|
||||
if (err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
logger.error('Error fetching payments', err);
|
||||
} finally {
|
||||
setLoadingPayments(false);
|
||||
}
|
||||
};
|
||||
fetchPayments();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (paymentsAbortRef.current) {
|
||||
paymentsAbortRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (bookingsAbortRef.current) {
|
||||
bookingsAbortRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
bookingsAbortRef.current = new AbortController();
|
||||
|
||||
const fetchBookings = async () => {
|
||||
try {
|
||||
setLoadingBookings(true);
|
||||
@@ -79,12 +111,23 @@ const StaffDashboardPage: React.FC = () => {
|
||||
setRecentBookings(response.data.bookings);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching bookings:', err);
|
||||
// Handle AbortError silently
|
||||
if (err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
logger.error('Error fetching bookings', err);
|
||||
} finally {
|
||||
setLoadingBookings(false);
|
||||
}
|
||||
};
|
||||
fetchBookings();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (bookingsAbortRef.current) {
|
||||
bookingsAbortRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRefresh = () => {
|
||||
@@ -92,74 +135,24 @@ const StaffDashboardPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const getPaymentStatusBadge = (status: string) => {
|
||||
const statusConfig: Record<string, { bg: string; text: string; label: string; border: string }> = {
|
||||
completed: {
|
||||
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
|
||||
text: 'text-emerald-800',
|
||||
label: '✅ Paid',
|
||||
border: 'border-emerald-200'
|
||||
},
|
||||
pending: {
|
||||
bg: 'bg-gradient-to-r from-amber-50 to-yellow-50',
|
||||
text: 'text-amber-800',
|
||||
label: '⏳ Pending',
|
||||
border: 'border-amber-200'
|
||||
},
|
||||
failed: {
|
||||
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
|
||||
text: 'text-rose-800',
|
||||
label: '❌ Failed',
|
||||
border: 'border-rose-200'
|
||||
},
|
||||
refunded: {
|
||||
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
|
||||
text: 'text-slate-700',
|
||||
label: '💰 Refunded',
|
||||
border: 'border-slate-200'
|
||||
},
|
||||
const colorClasses = getPaymentStatusColor(status);
|
||||
const labels: Record<string, string> = {
|
||||
completed: '✅ Paid',
|
||||
paid: '✅ Paid',
|
||||
pending: '⏳ Pending',
|
||||
failed: '❌ Failed',
|
||||
refunded: '💰 Refunded',
|
||||
};
|
||||
const config = statusConfig[status] || statusConfig.pending;
|
||||
const label = labels[status] || status.charAt(0).toUpperCase() + status.slice(1);
|
||||
return (
|
||||
<span className={`px-3 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${config.bg} ${config.text} ${config.border}`}>
|
||||
{config.label}
|
||||
<span className={`px-3 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${colorClasses}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getBookingStatusBadge = (status: string) => {
|
||||
const statusConfig: Record<string, { bg: string; text: string; label: string; border: string }> = {
|
||||
pending: {
|
||||
bg: 'bg-gradient-to-r from-amber-50 to-yellow-50',
|
||||
text: 'text-amber-800',
|
||||
label: 'Pending',
|
||||
border: 'border-amber-200'
|
||||
},
|
||||
confirmed: {
|
||||
bg: 'bg-gradient-to-r from-blue-50 to-indigo-50',
|
||||
text: 'text-blue-800',
|
||||
label: 'Confirmed',
|
||||
border: 'border-blue-200'
|
||||
},
|
||||
checked_in: {
|
||||
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
|
||||
text: 'text-emerald-800',
|
||||
label: 'Checked In',
|
||||
border: 'border-emerald-200'
|
||||
},
|
||||
checked_out: {
|
||||
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
|
||||
text: 'text-slate-700',
|
||||
label: 'Checked Out',
|
||||
border: 'border-slate-200'
|
||||
},
|
||||
cancelled: {
|
||||
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
|
||||
text: 'text-rose-800',
|
||||
label: '❌ Canceled',
|
||||
border: 'border-rose-200'
|
||||
},
|
||||
};
|
||||
const config = statusConfig[status] || statusConfig.pending;
|
||||
const config = getBookingStatusConfig(status);
|
||||
return (
|
||||
<span className={`px-3 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${config.bg} ${config.text} ${config.border}`}>
|
||||
{config.label}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { toast } from 'react-toastify';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import Pagination from '../../shared/components/Pagination';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
|
||||
type TabType = 'list' | 'profile';
|
||||
|
||||
@@ -88,7 +89,7 @@ const GuestProfilePage: React.FC = () => {
|
||||
const response = await guestProfileService.getAllTags();
|
||||
setAllTags(response.data.tags);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load tags:', error);
|
||||
logger.error('Failed to load tags', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -97,7 +98,7 @@ const GuestProfilePage: React.FC = () => {
|
||||
const response = await guestProfileService.getAllSegments();
|
||||
setAllSegments(response.data.segments);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load segments:', error);
|
||||
logger.error('Failed to load segments', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Award,
|
||||
Users,
|
||||
@@ -19,6 +19,7 @@ import EmptyState from '../../shared/components/EmptyState';
|
||||
import ConfirmationDialog from '../../shared/components/ConfirmationDialog';
|
||||
import loyaltyService, { LoyaltyTier, LoyaltyReward } from '../../features/loyalty/services/loyaltyService';
|
||||
import Pagination from '../../shared/components/Pagination';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
|
||||
type Tab = 'users' | 'tiers' | 'rewards';
|
||||
|
||||
@@ -116,7 +117,7 @@ const LoyaltyManagementPage: React.FC = () => {
|
||||
const response = await loyaltyService.getProgramStatus();
|
||||
setProgramEnabled(response.data.enabled);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load program status:', error);
|
||||
logger.error('Failed to load program status', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import paymentService from '../../features/payments/services/paymentService';
|
||||
import type { Payment } from '../../features/payments/services/paymentService';
|
||||
@@ -8,6 +8,8 @@ import Pagination from '../../shared/components/Pagination';
|
||||
import ExportButton from '../../shared/components/ExportButton';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
import { formatDate } from '../../shared/utils/format';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
import { getPaymentStatusColor, getPaymentMethodLabel } from '../../shared/utils/paymentUtils';
|
||||
|
||||
const PaymentManagementPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
@@ -23,13 +25,29 @@ const PaymentManagementPage: React.FC = () => {
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const itemsPerPage = 5;
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
// Cancel previous request if exists
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
fetchPayments();
|
||||
|
||||
// Cleanup: abort request on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [filters, currentPage]);
|
||||
|
||||
const fetchPayments = async () => {
|
||||
@@ -46,6 +64,11 @@ const PaymentManagementPage: React.FC = () => {
|
||||
setTotalItems(response.data.pagination.total);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Handle AbortError silently
|
||||
if (error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
logger.error('Error fetching payments', error);
|
||||
toast.error(error.response?.data?.message || 'Unable to load payments list');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -53,77 +76,55 @@ const PaymentManagementPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const getMethodBadge = (method: string) => {
|
||||
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
|
||||
const label = getPaymentMethodLabel(method);
|
||||
const badges: Record<string, { bg: string; text: string; border: string }> = {
|
||||
cash: {
|
||||
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
|
||||
text: 'text-emerald-800',
|
||||
label: 'Cash',
|
||||
border: 'border-emerald-200'
|
||||
},
|
||||
bank_transfer: {
|
||||
bg: 'bg-gradient-to-r from-blue-50 to-indigo-50',
|
||||
text: 'text-blue-800',
|
||||
label: 'Bank transfer',
|
||||
border: 'border-blue-200'
|
||||
},
|
||||
stripe: {
|
||||
bg: 'bg-gradient-to-r from-indigo-50 to-purple-50',
|
||||
text: 'text-indigo-800',
|
||||
label: 'Stripe',
|
||||
border: 'border-indigo-200'
|
||||
},
|
||||
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;
|
||||
return (
|
||||
<span className={`px-4 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${badge.bg} ${badge.text} ${badge.border}`}>
|
||||
{badge.label}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getPaymentStatusBadge = (status: string) => {
|
||||
const statusConfig: Record<string, { bg: string; text: string; label: string; border: string }> = {
|
||||
completed: {
|
||||
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
|
||||
text: 'text-emerald-800',
|
||||
label: '✅ Paid',
|
||||
border: 'border-emerald-200'
|
||||
},
|
||||
pending: {
|
||||
bg: 'bg-gradient-to-r from-amber-50 to-yellow-50',
|
||||
text: 'text-amber-800',
|
||||
label: '⏳ Pending',
|
||||
border: 'border-amber-200'
|
||||
},
|
||||
failed: {
|
||||
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
|
||||
text: 'text-rose-800',
|
||||
label: '❌ Failed',
|
||||
border: 'border-rose-200'
|
||||
},
|
||||
refunded: {
|
||||
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
|
||||
text: 'text-slate-700',
|
||||
label: '💰 Refunded',
|
||||
border: 'border-slate-200'
|
||||
},
|
||||
const colorClasses = getPaymentStatusColor(status);
|
||||
const labels: Record<string, string> = {
|
||||
completed: '✅ Paid',
|
||||
paid: '✅ Paid',
|
||||
pending: '⏳ Pending',
|
||||
failed: '❌ Failed',
|
||||
refunded: '💰 Refunded',
|
||||
};
|
||||
const config = statusConfig[status] || statusConfig.pending;
|
||||
const label = labels[status] || status.charAt(0).toUpperCase() + status.slice(1);
|
||||
return (
|
||||
<span className={`px-3 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${config.bg} ${config.text} ${config.border}`}>
|
||||
{config.label}
|
||||
<span className={`px-3 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${colorClasses}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
LogIn,
|
||||
LogOut,
|
||||
@@ -35,6 +35,7 @@ import apiClient from '../../shared/services/apiClient';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
import { parseDateLocal } from '../../shared/utils/format';
|
||||
import CreateBookingModal from '../../features/hotel_services/components/CreateBookingModal';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
|
||||
type ReceptionTab = 'overview' | 'check-in' | 'check-out' | 'bookings' | 'rooms' | 'services';
|
||||
|
||||
@@ -449,7 +450,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
setAvailableAmenities(response.data.amenities);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch amenities:', error);
|
||||
logger.error('Failed to fetch amenities', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -526,7 +527,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch page ${page}:`, err);
|
||||
logger.error(`Failed to fetch page ${page}`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -542,7 +543,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch room types:', err);
|
||||
logger.error('Failed to fetch room types', err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -569,7 +570,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
const updatedRoom = await roomService.getRoomByNumber(editingRoom.room_number);
|
||||
setEditingRoom(updatedRoom.data.room);
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh room data:', err);
|
||||
logger.error('Failed to refresh room data', err);
|
||||
}
|
||||
} else {
|
||||
const createData = {
|
||||
@@ -710,7 +711,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
|
||||
setEditingRoom(roomData);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch full room details:', error);
|
||||
logger.error('Failed to fetch full room details', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -851,7 +852,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
const response = await roomService.getRoomByNumber(editingRoom.room_number);
|
||||
setEditingRoom(response.data.room);
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting image:', error);
|
||||
logger.error('Error deleting image', error);
|
||||
toast.error(error.response?.data?.message || error.response?.data?.detail || 'Unable to delete image');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -38,12 +38,51 @@ const AnalyticsLoader: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
// Listen for consent updates to disable analytics when consent is withdrawn
|
||||
useEffect(() => {
|
||||
const handleConsentUpdate = () => {
|
||||
if (consent && config) {
|
||||
const analyticsAllowed =
|
||||
config.policy.analytics_enabled && consent.categories.analytics;
|
||||
// SECURITY/COMPLIANCE: Disable Google Analytics when consent is withdrawn
|
||||
if (gaLoadedRef.current && !analyticsAllowed && window.gtag) {
|
||||
window.gtag('consent', 'update', {
|
||||
'analytics_storage': 'denied'
|
||||
});
|
||||
}
|
||||
|
||||
const marketingAllowed =
|
||||
config.policy.marketing_enabled && consent.categories.marketing;
|
||||
// SECURITY/COMPLIANCE: Disable Facebook Pixel when consent is withdrawn
|
||||
if (fbLoadedRef.current && !marketingAllowed && window.fbq) {
|
||||
window.fbq('consent', 'revoke');
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('cookie-consent-updated', handleConsentUpdate);
|
||||
return () => {
|
||||
window.removeEventListener('cookie-consent-updated', handleConsentUpdate);
|
||||
};
|
||||
}, [consent, config]);
|
||||
|
||||
// Google Analytics - only load if analytics consent is given
|
||||
// SECURITY/COMPLIANCE: Respects user consent - only loads if explicitly allowed
|
||||
useEffect(() => {
|
||||
if (!config || !consent) return;
|
||||
const measurementId = config.integrations.ga_measurement_id;
|
||||
const analyticsAllowed =
|
||||
config.policy.analytics_enabled && consent.categories.analytics;
|
||||
|
||||
// If consent withdrawn, disable analytics
|
||||
if (gaLoadedRef.current && !analyticsAllowed) {
|
||||
if (window.gtag) {
|
||||
window.gtag('consent', 'update', {
|
||||
'analytics_storage': 'denied'
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!measurementId || !analyticsAllowed || gaLoadedRef.current) return;
|
||||
|
||||
|
||||
@@ -58,7 +97,15 @@ const AnalyticsLoader: React.FC = () => {
|
||||
}
|
||||
window.gtag = gtag;
|
||||
gtag('js', new Date());
|
||||
gtag('config', measurementId, { anonymize_ip: true });
|
||||
// SECURITY/COMPLIANCE: Set consent mode and anonymize IP for privacy
|
||||
gtag('consent', 'default', {
|
||||
'analytics_storage': 'granted',
|
||||
'ad_storage': 'denied', // Don't allow ad storage by default
|
||||
});
|
||||
gtag('config', measurementId, {
|
||||
anonymize_ip: true,
|
||||
allow_google_signals: false, // Disable Google signals for privacy
|
||||
});
|
||||
|
||||
gaLoadedRef.current = true;
|
||||
|
||||
@@ -77,11 +124,22 @@ const AnalyticsLoader: React.FC = () => {
|
||||
}, [location, config]);
|
||||
|
||||
|
||||
// Facebook Pixel - only load if marketing consent is given
|
||||
// SECURITY/COMPLIANCE: Respects user consent - only loads if explicitly allowed
|
||||
useEffect(() => {
|
||||
if (!config || !consent) return;
|
||||
const pixelId = config.integrations.fb_pixel_id;
|
||||
const marketingAllowed =
|
||||
config.policy.marketing_enabled && consent.categories.marketing;
|
||||
|
||||
// If consent withdrawn, disable Facebook Pixel
|
||||
if (fbLoadedRef.current && !marketingAllowed) {
|
||||
if (window.fbq) {
|
||||
window.fbq('consent', 'revoke');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pixelId || !marketingAllowed || fbLoadedRef.current) return;
|
||||
|
||||
|
||||
@@ -104,6 +162,8 @@ const AnalyticsLoader: React.FC = () => {
|
||||
}
|
||||
})(window, document, 'script');
|
||||
|
||||
// SECURITY/COMPLIANCE: Set consent before initializing
|
||||
window.fbq('consent', 'grant');
|
||||
window.fbq('init', pixelId);
|
||||
window.fbq('track', 'PageView');
|
||||
fbLoadedRef.current = true;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useCookieConsent } from '../contexts/CookieConsentContext';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const CookieConsentBanner: React.FC = () => {
|
||||
const { consent, isLoading, hasDecided, updateConsent } = useCookieConsent();
|
||||
@@ -86,13 +87,21 @@ const CookieConsentBanner: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[#d4af37] underline underline-offset-4 hover:text-[#f6e7b4]"
|
||||
onClick={() => setShowDetails((prev) => !prev)}
|
||||
>
|
||||
{showDetails ? 'Hide detailed preferences' : 'Fine-tune preferences'}
|
||||
</button>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[#d4af37] underline underline-offset-4 hover:text-[#f6e7b4]"
|
||||
onClick={() => setShowDetails((prev) => !prev)}
|
||||
>
|
||||
{showDetails ? 'Hide detailed preferences' : 'Fine-tune preferences'}
|
||||
</button>
|
||||
<Link
|
||||
to="/gdpr"
|
||||
className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[#d4af37] underline underline-offset-4 hover:text-[#f6e7b4]"
|
||||
>
|
||||
Data Privacy (GDPR)
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{showDetails && (
|
||||
<div className="mt-1.5 space-y-3 rounded-xl bg-black/40 p-3 ring-1 ring-zinc-800/80 backdrop-blur-md sm:p-4">
|
||||
|
||||
48
Frontend/src/shared/constants/bookingConstants.ts
Normal file
48
Frontend/src/shared/constants/bookingConstants.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Booking Status Constants
|
||||
*/
|
||||
export const BOOKING_STATUS = {
|
||||
PENDING: 'pending',
|
||||
CONFIRMED: 'confirmed',
|
||||
CANCELLED: 'cancelled',
|
||||
CHECKED_IN: 'checked_in',
|
||||
CHECKED_OUT: 'checked_out',
|
||||
} as const;
|
||||
|
||||
export type BookingStatus = typeof BOOKING_STATUS[keyof typeof BOOKING_STATUS];
|
||||
|
||||
/**
|
||||
* Payment Status Constants
|
||||
*/
|
||||
export const PAYMENT_STATUS = {
|
||||
UNPAID: 'unpaid',
|
||||
PAID: 'paid',
|
||||
REFUNDED: 'refunded',
|
||||
} as const;
|
||||
|
||||
export type PaymentStatus = typeof PAYMENT_STATUS[keyof typeof PAYMENT_STATUS];
|
||||
|
||||
/**
|
||||
* Payment Method Constants
|
||||
*/
|
||||
export const PAYMENT_METHOD = {
|
||||
CASH: 'cash',
|
||||
STRIPE: 'stripe',
|
||||
PAYPAL: 'paypal',
|
||||
CREDIT_CARD: 'credit_card',
|
||||
BANK_TRANSFER: 'bank_transfer',
|
||||
} as const;
|
||||
|
||||
export type PaymentMethod = typeof PAYMENT_METHOD[keyof typeof PAYMENT_METHOD];
|
||||
|
||||
/**
|
||||
* Payment Type Constants
|
||||
*/
|
||||
export const PAYMENT_TYPE = {
|
||||
FULL: 'full',
|
||||
DEPOSIT: 'deposit',
|
||||
REMAINING: 'remaining',
|
||||
} as const;
|
||||
|
||||
export type PaymentType = typeof PAYMENT_TYPE[keyof typeof PAYMENT_TYPE];
|
||||
|
||||
7
Frontend/src/shared/constants/index.ts
Normal file
7
Frontend/src/shared/constants/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Shared Constants Index
|
||||
* Central export point for all shared constants
|
||||
*/
|
||||
|
||||
export * from './bookingConstants';
|
||||
|
||||
@@ -66,12 +66,19 @@ export const CookieConsentProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
|
||||
const updateConsent = useCallback(
|
||||
async (payload: UpdateCookieConsentRequest) => {
|
||||
// SECURITY/COMPLIANCE: Update consent via backend API
|
||||
const updated = await privacyService.updateCookieConsent(payload);
|
||||
setConsent(updated);
|
||||
setHasDecided(true);
|
||||
// SECURITY: Store decision flag in localStorage (not sensitive data)
|
||||
// Actual consent preferences are stored securely on backend
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem('cookieConsentDecided', 'true');
|
||||
}
|
||||
// Trigger analytics reload to respect new consent
|
||||
window.dispatchEvent(new CustomEvent('cookie-consent-updated', {
|
||||
detail: updated
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
@@ -1,18 +1,52 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { logDebug } from '../utils/errorReporter';
|
||||
|
||||
type SetValue<T> = T | ((val: T) => T);
|
||||
|
||||
interface StoredValueWithExpiry<T> {
|
||||
value: T;
|
||||
expires?: number; // Timestamp in milliseconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for localStorage with optional expiration support
|
||||
* @param key - localStorage key
|
||||
* @param initialValue - Initial value if key doesn't exist
|
||||
* @param ttl - Time to live in milliseconds (optional)
|
||||
*/
|
||||
export function useLocalStorage<T>(
|
||||
key: string,
|
||||
initialValue: T
|
||||
initialValue: T,
|
||||
ttl?: number // Time to live in milliseconds
|
||||
): [T, (value: SetValue<T>) => void, () => void] {
|
||||
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
if (!item) return initialValue;
|
||||
|
||||
const parsed: StoredValueWithExpiry<T> | T = JSON.parse(item);
|
||||
|
||||
// Check if it has expiration metadata
|
||||
if (parsed && typeof parsed === 'object' && 'value' in parsed && 'expires' in parsed) {
|
||||
const withExpiry = parsed as StoredValueWithExpiry<T>;
|
||||
|
||||
// Check expiration
|
||||
if (withExpiry.expires && withExpiry.expires < Date.now()) {
|
||||
logDebug(`localStorage key "${key}" expired, removing`, {
|
||||
expiredAt: new Date(withExpiry.expires).toISOString(),
|
||||
});
|
||||
localStorage.removeItem(key);
|
||||
return initialValue;
|
||||
}
|
||||
|
||||
return withExpiry.value ?? initialValue;
|
||||
}
|
||||
|
||||
// Legacy format (no expiration)
|
||||
return parsed as T;
|
||||
} catch (error) {
|
||||
console.error(`Error reading localStorage key "${key}":`, error);
|
||||
logDebug(`Error reading localStorage key "${key}"`, { error: error instanceof Error ? error.message : String(error) });
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
@@ -22,17 +56,24 @@ export function useLocalStorage<T>(
|
||||
const setValue = useCallback(
|
||||
(value: SetValue<T>) => {
|
||||
try {
|
||||
|
||||
const valueToStore =
|
||||
value instanceof Function ? value(storedValue) : value;
|
||||
|
||||
// Store with expiration if ttl is provided
|
||||
const toStore: StoredValueWithExpiry<T> | T = ttl
|
||||
? {
|
||||
value: valueToStore,
|
||||
expires: Date.now() + ttl,
|
||||
}
|
||||
: valueToStore;
|
||||
|
||||
setStoredValue(valueToStore);
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
window.localStorage.setItem(key, JSON.stringify(toStore));
|
||||
} catch (error) {
|
||||
console.error(`Error setting localStorage key "${key}":`, error);
|
||||
logDebug(`Error setting localStorage key "${key}"`, { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
},
|
||||
[key, storedValue]
|
||||
[key, storedValue, ttl]
|
||||
);
|
||||
|
||||
const removeValue = useCallback(() => {
|
||||
@@ -40,7 +81,7 @@ export function useLocalStorage<T>(
|
||||
window.localStorage.removeItem(key);
|
||||
setStoredValue(initialValue);
|
||||
} catch (error) {
|
||||
console.error(`Error removing localStorage key "${key}":`, error);
|
||||
logDebug(`Error removing localStorage key "${key}"`, { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}, [key, initialValue]);
|
||||
|
||||
@@ -51,7 +92,7 @@ export function useLocalStorage<T>(
|
||||
try {
|
||||
setStoredValue(JSON.parse(e.newValue));
|
||||
} catch (error) {
|
||||
console.error(`Error parsing localStorage value for key "${key}":`, error);
|
||||
logDebug(`Error parsing localStorage value for key "${key}"`, { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import { logSecurityWarning, logDebug, logWarn } from '../utils/errorReporter';
|
||||
|
||||
const rawBase = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
const normalized = String(rawBase).replace(/\/$/, '');
|
||||
@@ -9,17 +10,10 @@ const API_BASE_URL = /\/api(\/?$)/i.test(normalized)
|
||||
|
||||
// Validate HTTPS in production
|
||||
if (import.meta.env.MODE === 'production' && !normalized.startsWith('https://')) {
|
||||
console.error(
|
||||
'⚠️ SECURITY WARNING: API URL is not using HTTPS in production!',
|
||||
'This exposes sensitive data to interception.',
|
||||
'Please configure VITE_API_URL to use HTTPS.'
|
||||
logSecurityWarning(
|
||||
'API URL is not using HTTPS in production! This exposes sensitive data to interception. Please configure VITE_API_URL to use HTTPS.',
|
||||
{ apiUrl: normalized }
|
||||
);
|
||||
|
||||
// Only show warning, don't block - some deployments may use reverse proxy
|
||||
// But log the security concern
|
||||
if (typeof window !== 'undefined') {
|
||||
console.warn('API calls will be made over HTTP. Consider using HTTPS.');
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
@@ -80,16 +74,13 @@ const retryRequest = async (
|
||||
const delay = RETRY_DELAY * Math.pow(2, retryCount);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
|
||||
console.log(`Retrying request (${retryCount + 1}/${MAX_RETRIES}): ${config.url}`);
|
||||
logDebug(`Retrying request (${retryCount + 1}/${MAX_RETRIES})`, { url: config.url });
|
||||
|
||||
return apiClient.request(config);
|
||||
};
|
||||
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// Get the original URL before normalization for checking
|
||||
const originalUrl = (config.url || '').toString();
|
||||
|
||||
// Note: We no longer check for tokens in localStorage since tokens are now
|
||||
// stored in httpOnly cookies. The backend will handle authentication via cookies.
|
||||
// We still need to ensure withCredentials is set (already done in apiClient config).
|
||||
@@ -125,9 +116,7 @@ apiClient.interceptors.request.use(
|
||||
} else {
|
||||
// CSRF token not found - this will be handled in error interceptor
|
||||
// The backend will return 403 and we'll retry after fetching the token
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('CSRF token not found in cookies. Request may fail and retry after fetching token.');
|
||||
}
|
||||
logDebug('CSRF token not found in cookies. Request may fail and retry after fetching token.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,14 +166,14 @@ apiClient.interceptors.response.use(
|
||||
if (config.metadata?.startTime) {
|
||||
const duration = new Date().getTime() - config.metadata.startTime.getTime();
|
||||
if (duration > 1000) {
|
||||
console.warn(`Slow request detected: ${config.url} took ${duration}ms`);
|
||||
logWarn(`Slow request detected`, { url: config.url, duration: `${duration}ms` });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const requestId = response.headers['x-request-id'];
|
||||
if (requestId && import.meta.env.DEV) {
|
||||
console.debug(`Request completed: ${config.url} [${requestId}]`);
|
||||
if (requestId) {
|
||||
logDebug(`Request completed`, { url: config.url, requestId });
|
||||
}
|
||||
|
||||
return response;
|
||||
@@ -221,7 +210,7 @@ apiClient.interceptors.response.use(
|
||||
return retryRequest(error);
|
||||
}
|
||||
|
||||
console.error('Network error:', error);
|
||||
logWarn('Network error', { error: error.message });
|
||||
return Promise.reject({
|
||||
...error,
|
||||
message: 'Network error. Please check your internet connection.',
|
||||
@@ -250,7 +239,8 @@ apiClient.interceptors.response.use(
|
||||
detail: { message: 'Session expired. Please login again.' }
|
||||
}));
|
||||
|
||||
const errorMessage = (error.response?.data as any)?.message || 'Session expired. Please login again.';
|
||||
// SECURITY: Sanitize error message to prevent information disclosure
|
||||
const errorMessage = 'Session expired. Please login again.';
|
||||
return Promise.reject({
|
||||
...error,
|
||||
message: errorMessage,
|
||||
@@ -259,7 +249,12 @@ apiClient.interceptors.response.use(
|
||||
|
||||
|
||||
if (status === 403) {
|
||||
const errorMessage = (error.response?.data as any)?.message || 'You do not have permission to access this resource.';
|
||||
// SECURITY: Sanitize error message - don't expose internal details
|
||||
const rawMessage = (error.response?.data as any)?.message || '';
|
||||
// Only show generic message to users, log details in dev mode
|
||||
const errorMessage = rawMessage.includes('CSRF token')
|
||||
? 'Security validation failed. Please refresh the page and try again.'
|
||||
: 'You do not have permission to access this resource.';
|
||||
|
||||
// Handle CSRF token missing/invalid errors - retry after getting token from error response
|
||||
if (errorMessage.includes('CSRF token') && originalRequest && !originalRequest._retry) {
|
||||
@@ -290,9 +285,7 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors from token fetch attempts
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('Failed to fetch CSRF token:', e);
|
||||
}
|
||||
logDebug('Failed to fetch CSRF token', { error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,8 +334,10 @@ apiClient.interceptors.response.use(
|
||||
return retryRequest(error);
|
||||
}
|
||||
|
||||
console.error(`Server error [${requestId || 'unknown'}]:`, error);
|
||||
const errorMessage = (error.response?.data as any)?.message || 'Server error. Please try again later.';
|
||||
// SECURITY: Log detailed error only in development
|
||||
logDebug(`Server error`, { requestId: requestId || 'unknown', error: error.message });
|
||||
// SECURITY: Don't expose internal error details to users
|
||||
const errorMessage = 'Server error. Please try again later.';
|
||||
return Promise.reject({
|
||||
...error,
|
||||
message: errorMessage,
|
||||
@@ -353,15 +348,23 @@ apiClient.interceptors.response.use(
|
||||
|
||||
if (status === 400) {
|
||||
const errorData = error.response.data as any;
|
||||
// SECURITY: Sanitize validation errors - only show safe messages
|
||||
const safeMessage = errorData?.message || errorData?.errors?.[0]?.message || 'Invalid request. Please check your input.';
|
||||
// Filter out sensitive error details
|
||||
const safeErrors = (errorData?.errors || []).map((err: any) => ({
|
||||
field: err.field || err.path,
|
||||
message: err.message || 'Invalid value',
|
||||
}));
|
||||
return Promise.reject({
|
||||
...error,
|
||||
message: errorData?.message || errorData?.errors?.[0]?.message || 'Invalid request. Please check your input.',
|
||||
errors: errorData?.errors || [],
|
||||
message: safeMessage,
|
||||
errors: safeErrors,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const errorMessage = (error.response.data as any)?.message || error.message || 'An error occurred';
|
||||
// SECURITY: Sanitize error message - don't expose internal details
|
||||
const errorMessage = 'An error occurred. Please try again.';
|
||||
return Promise.reject({
|
||||
...error,
|
||||
message: errorMessage,
|
||||
|
||||
74
Frontend/src/shared/utils/bookingUtils.ts
Normal file
74
Frontend/src/shared/utils/bookingUtils.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Clock, CheckCircle, XCircle, DoorOpen, DoorClosed, AlertCircle } from 'lucide-react';
|
||||
import { BookingStatus } from '../constants/bookingConstants';
|
||||
import type { Booking } from '../../features/bookings/services/bookingService';
|
||||
|
||||
/**
|
||||
* Status configuration for booking status badges
|
||||
*/
|
||||
export interface StatusConfig {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status configuration for a booking status
|
||||
*/
|
||||
export const getBookingStatusConfig = (status: string): StatusConfig => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return {
|
||||
icon: Clock,
|
||||
color: 'bg-yellow-100 text-yellow-800',
|
||||
text: 'Pending confirmation',
|
||||
};
|
||||
case 'confirmed':
|
||||
return {
|
||||
icon: CheckCircle,
|
||||
color: 'bg-green-100 text-green-800',
|
||||
text: 'Confirmed',
|
||||
};
|
||||
case 'cancelled':
|
||||
return {
|
||||
icon: XCircle,
|
||||
color: 'bg-red-100 text-red-800',
|
||||
text: 'Cancelled',
|
||||
};
|
||||
case 'checked_in':
|
||||
return {
|
||||
icon: DoorOpen,
|
||||
color: 'bg-blue-100 text-blue-800',
|
||||
text: 'Checked in',
|
||||
};
|
||||
case 'checked_out':
|
||||
return {
|
||||
icon: DoorClosed,
|
||||
color: 'bg-gray-100 text-gray-800',
|
||||
text: 'Checked out',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
color: 'bg-gray-100 text-gray-800',
|
||||
text: status,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a booking can be cancelled
|
||||
*/
|
||||
export const canCancelBooking = (booking: Booking): boolean => {
|
||||
return booking.status === 'pending';
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate number of nights for a booking
|
||||
*/
|
||||
export const calculateNights = (checkInDate: string, checkOutDate: string): number => {
|
||||
const checkIn = new Date(checkInDate);
|
||||
const checkOut = new Date(checkOutDate);
|
||||
const nights = Math.ceil((checkOut.getTime() - checkIn.getTime()) / (1000 * 60 * 60 * 24));
|
||||
return nights > 0 ? nights : 1;
|
||||
};
|
||||
|
||||
99
Frontend/src/shared/utils/errorReporter.ts
Normal file
99
Frontend/src/shared/utils/errorReporter.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Error Reporting Utility
|
||||
*
|
||||
* Provides centralized error reporting that respects environment settings.
|
||||
* In development: logs to console
|
||||
* In production: sends to error reporting service (when configured)
|
||||
*/
|
||||
|
||||
interface ErrorContext {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
class ErrorReporter {
|
||||
private isDevelopment = import.meta.env.DEV;
|
||||
private isProduction = import.meta.env.PROD;
|
||||
|
||||
/**
|
||||
* Log error with context
|
||||
*/
|
||||
error(message: string, error?: any, context?: ErrorContext): void {
|
||||
if (this.isDevelopment) {
|
||||
console.error(`[ERROR] ${message}`, error, context);
|
||||
} else {
|
||||
// In production, send to error reporting service
|
||||
// TODO: Integrate with error reporting service (Sentry, LogRocket, etc.)
|
||||
// Example:
|
||||
// if (window.Sentry) {
|
||||
// window.Sentry.captureException(error || new Error(message), {
|
||||
// extra: context,
|
||||
// });
|
||||
// }
|
||||
|
||||
// For now, silently log to prevent information disclosure
|
||||
// In production, errors should be reported to a monitoring service
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log warning
|
||||
*/
|
||||
warn(message: string, context?: ErrorContext): void {
|
||||
if (this.isDevelopment) {
|
||||
console.warn(`[WARN] ${message}`, context);
|
||||
}
|
||||
// Warnings typically don't need to be reported in production
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message
|
||||
*/
|
||||
info(message: string, context?: ErrorContext): void {
|
||||
if (this.isDevelopment) {
|
||||
console.info(`[INFO] ${message}`, context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log debug message (only in development)
|
||||
*/
|
||||
debug(message: string, context?: ErrorContext): void {
|
||||
if (this.isDevelopment) {
|
||||
console.debug(`[DEBUG] ${message}`, context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log security warning (always shown, but muted in production)
|
||||
*/
|
||||
securityWarning(message: string, context?: ErrorContext): void {
|
||||
if (this.isDevelopment) {
|
||||
console.warn(`[SECURITY WARNING] ${message}`, context);
|
||||
} else {
|
||||
// Security warnings should be logged to monitoring service in production
|
||||
// but not shown to users
|
||||
this.error(`Security Warning: ${message}`, undefined, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const errorReporter = new ErrorReporter();
|
||||
|
||||
// Export convenience methods
|
||||
export const logError = (message: string, error?: any, context?: ErrorContext) =>
|
||||
errorReporter.error(message, error, context);
|
||||
|
||||
export const logWarn = (message: string, context?: ErrorContext) =>
|
||||
errorReporter.warn(message, context);
|
||||
|
||||
export const logInfo = (message: string, context?: ErrorContext) =>
|
||||
errorReporter.info(message, context);
|
||||
|
||||
export const logDebug = (message: string, context?: ErrorContext) =>
|
||||
errorReporter.debug(message, context);
|
||||
|
||||
export const logSecurityWarning = (message: string, context?: ErrorContext) =>
|
||||
errorReporter.securityWarning(message, context);
|
||||
|
||||
export default errorReporter;
|
||||
|
||||
@@ -5,15 +5,21 @@ export const normalizeImageUrl = (imageUrl: string | null | undefined): string =
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
// If already a full URL, return as is
|
||||
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000';
|
||||
|
||||
// Ensure path starts with / and handle both /uploads/avatars/... and avatars/... cases
|
||||
let cleanPath = imageUrl.startsWith('/') ? imageUrl : `/${imageUrl}`;
|
||||
|
||||
// If path starts with /avatars/ but not /uploads/, add /uploads prefix
|
||||
if (cleanPath.startsWith('/avatars/') && !cleanPath.startsWith('/uploads/')) {
|
||||
cleanPath = `/uploads${cleanPath}`;
|
||||
}
|
||||
|
||||
const cleanPath = imageUrl.startsWith('/') ? imageUrl : `/${imageUrl}`;
|
||||
return `${apiBaseUrl}${cleanPath}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export * from './format';
|
||||
export * from './constants';
|
||||
export * from './validationSchemas';
|
||||
/**
|
||||
* Shared Utilities Index
|
||||
* Central export point for all shared utilities
|
||||
*/
|
||||
|
||||
export * from './routeValidation';
|
||||
export * from './bookingUtils';
|
||||
export * from './paymentUtils';
|
||||
export * from './ownershipValidation';
|
||||
|
||||
46
Frontend/src/shared/utils/ownershipValidation.ts
Normal file
46
Frontend/src/shared/utils/ownershipValidation.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Booking } from '../../features/bookings/services/bookingService';
|
||||
|
||||
/**
|
||||
* Validate that a booking belongs to the current user
|
||||
* @param booking - The booking to validate
|
||||
* @param currentUserId - The current user's ID
|
||||
* @returns true if booking belongs to user, false otherwise
|
||||
*/
|
||||
export const validateBookingOwnership = (
|
||||
booking: Booking | null,
|
||||
currentUserId: number | undefined
|
||||
): boolean => {
|
||||
if (!booking || !currentUserId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (booking.user_id !== currentUserId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate booking ownership and show error if invalid
|
||||
* @param booking - The booking to validate
|
||||
* @param currentUserId - The current user's ID
|
||||
* @param navigate - Navigation function
|
||||
* @param redirectPath - Path to redirect to if invalid
|
||||
* @returns true if valid, false if invalid (and redirects)
|
||||
*/
|
||||
export const validateAndHandleBookingOwnership = (
|
||||
booking: Booking | null,
|
||||
currentUserId: number | undefined,
|
||||
navigate: (path: string) => void,
|
||||
redirectPath: string = '/bookings'
|
||||
): boolean => {
|
||||
if (!validateBookingOwnership(booking, currentUserId)) {
|
||||
toast.error('You do not have permission to view this booking');
|
||||
navigate(redirectPath);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
40
Frontend/src/shared/utils/paymentUtils.ts
Normal file
40
Frontend/src/shared/utils/paymentUtils.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { PaymentStatus, PaymentMethod } from '../constants/bookingConstants';
|
||||
|
||||
/**
|
||||
* Get payment status color classes
|
||||
*/
|
||||
export const getPaymentStatusColor = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
case 'paid':
|
||||
return 'bg-gradient-to-r from-emerald-50 to-green-50 text-emerald-800 border-emerald-200';
|
||||
case 'pending':
|
||||
return 'bg-gradient-to-r from-amber-50 to-yellow-50 text-amber-800 border-amber-200';
|
||||
case 'failed':
|
||||
return 'bg-gradient-to-r from-rose-50 to-red-50 text-rose-800 border-rose-200';
|
||||
case 'refunded':
|
||||
return 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200';
|
||||
default:
|
||||
return 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get payment method label
|
||||
*/
|
||||
export const getPaymentMethodLabel = (method: string): string => {
|
||||
switch (method) {
|
||||
case 'stripe':
|
||||
case 'credit_card':
|
||||
return 'Card';
|
||||
case 'paypal':
|
||||
return 'PayPal';
|
||||
case 'bank_transfer':
|
||||
return 'Bank Transfer';
|
||||
case 'cash':
|
||||
return 'Cash';
|
||||
default:
|
||||
return method;
|
||||
}
|
||||
};
|
||||
|
||||
41
Frontend/src/shared/utils/routeValidation.ts
Normal file
41
Frontend/src/shared/utils/routeValidation.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Route Parameter Validation Utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validates if a route parameter ID is valid
|
||||
* @param id - The ID parameter from route
|
||||
* @returns Validated number ID or null if invalid
|
||||
*/
|
||||
export const validateRouteId = (id: string | undefined): number | null => {
|
||||
if (!id || id === 'undefined' || id === 'null' || id === 'NaN' || id === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numericId = Number(id);
|
||||
|
||||
if (isNaN(numericId) || numericId <= 0 || !isFinite(numericId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return numericId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates booking ID from route parameters
|
||||
* @param id - The booking ID parameter from route
|
||||
* @returns Validated booking ID or null if invalid
|
||||
*/
|
||||
export const validateBookingId = (id: string | undefined): number | null => {
|
||||
return validateRouteId(id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates invoice ID from route parameters
|
||||
* @param id - The invoice ID parameter from route
|
||||
* @returns Validated invoice ID or null if invalid
|
||||
*/
|
||||
export const validateInvoiceId = (id: string | undefined): number | null => {
|
||||
return validateRouteId(id);
|
||||
};
|
||||
|
||||
@@ -48,13 +48,23 @@ interface AuthState {
|
||||
|
||||
const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
// Tokens are now stored in httpOnly cookies, not localStorage
|
||||
// We only store userInfo in localStorage for UI purposes
|
||||
// SECURITY: Tokens are stored in httpOnly cookies, not localStorage
|
||||
// We only store minimal userInfo in localStorage for UI purposes
|
||||
// Note: localStorage is accessible to XSS attacks, so we minimize sensitive data
|
||||
token: null, // Token is in httpOnly cookie, not accessible from JS
|
||||
userInfo: localStorage.getItem('userInfo')
|
||||
? JSON.parse(localStorage.getItem('userInfo')!)
|
||||
: null,
|
||||
isAuthenticated: !!localStorage.getItem('userInfo'), // Check userInfo instead of token
|
||||
userInfo: (() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('userInfo');
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch {
|
||||
// Invalid JSON, clear it
|
||||
localStorage.removeItem('userInfo');
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
// SECURITY: Don't rely solely on localStorage for auth state
|
||||
// initializeAuth() will validate with backend
|
||||
isAuthenticated: false, // Will be set after validation
|
||||
isLoading: false,
|
||||
error: null,
|
||||
requiresMFA: false,
|
||||
@@ -88,8 +98,16 @@ const useAuthStore = create<AuthState>((set, get) => ({
|
||||
throw new Error(response.message || 'Login failed.');
|
||||
}
|
||||
|
||||
// Only store userInfo in localStorage (token is in httpOnly cookie)
|
||||
localStorage.setItem('userInfo', JSON.stringify(user));
|
||||
// SECURITY: Store minimal userInfo in localStorage (token is in httpOnly cookie)
|
||||
// Only store non-sensitive UI data - sensitive data should come from backend
|
||||
const minimalUserInfo = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
avatar: user.avatar,
|
||||
};
|
||||
localStorage.setItem('userInfo', JSON.stringify(minimalUserInfo));
|
||||
|
||||
|
||||
set({
|
||||
@@ -148,8 +166,15 @@ const useAuthStore = create<AuthState>((set, get) => ({
|
||||
throw new Error(response.message || 'MFA verification failed.');
|
||||
}
|
||||
|
||||
// Only store userInfo in localStorage (token is in httpOnly cookie)
|
||||
localStorage.setItem('userInfo', JSON.stringify(user));
|
||||
// SECURITY: Store minimal userInfo in localStorage (token is in httpOnly cookie)
|
||||
const minimalUserInfo = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
avatar: user.avatar,
|
||||
};
|
||||
localStorage.setItem('userInfo', JSON.stringify(minimalUserInfo));
|
||||
|
||||
|
||||
set({
|
||||
@@ -218,7 +243,10 @@ const useAuthStore = create<AuthState>((set, get) => ({
|
||||
try {
|
||||
await authService.logout();
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
// SECURITY: Don't log sensitive error details
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
} finally {
|
||||
// Clear userInfo from localStorage
|
||||
// Tokens in httpOnly cookies are deleted by backend on logout
|
||||
@@ -251,8 +279,16 @@ const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
|
||||
setUser: (user: UserInfo) => {
|
||||
localStorage.setItem('userInfo', JSON.stringify(user));
|
||||
set({ userInfo: user });
|
||||
// SECURITY: Store minimal userInfo
|
||||
const minimalUserInfo = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
avatar: user.avatar,
|
||||
};
|
||||
localStorage.setItem('userInfo', JSON.stringify(minimalUserInfo));
|
||||
set({ userInfo: minimalUserInfo });
|
||||
},
|
||||
|
||||
|
||||
@@ -266,9 +302,17 @@ const useAuthStore = create<AuthState>((set, get) => ({
|
||||
// We just need to update userInfo if it's in the response
|
||||
const user = response.data?.user;
|
||||
if (user) {
|
||||
localStorage.setItem('userInfo', JSON.stringify(user));
|
||||
// SECURITY: Store minimal userInfo
|
||||
const minimalUserInfo = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
avatar: user.avatar,
|
||||
};
|
||||
localStorage.setItem('userInfo', JSON.stringify(minimalUserInfo));
|
||||
set({
|
||||
userInfo: user,
|
||||
userInfo: minimalUserInfo,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -343,11 +387,19 @@ const useAuthStore = create<AuthState>((set, get) => ({
|
||||
// This will fail if cookies are invalid/expired
|
||||
const profile = await authService.getProfile();
|
||||
if (profile.data?.user) {
|
||||
// Update userInfo with latest data from server
|
||||
localStorage.setItem('userInfo', JSON.stringify(profile.data.user));
|
||||
// SECURITY: Update userInfo with minimal data from server
|
||||
const user = profile.data.user;
|
||||
const minimalUserInfo = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
avatar: user.avatar,
|
||||
};
|
||||
localStorage.setItem('userInfo', JSON.stringify(minimalUserInfo));
|
||||
set({
|
||||
token: null, // Token is in httpOnly cookie, not accessible
|
||||
userInfo: profile.data.user,
|
||||
userInfo: minimalUserInfo,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user