updates
This commit is contained in:
@@ -15,22 +15,21 @@ import OfflineIndicator from './components/common/OfflineIndicator';
|
||||
import CookieConsentBanner from './components/common/CookieConsentBanner';
|
||||
import AnalyticsLoader from './components/common/AnalyticsLoader';
|
||||
import Loading from './components/common/Loading';
|
||||
import ScrollToTop from './components/common/ScrollToTop';
|
||||
|
||||
// Store
|
||||
import useAuthStore from './store/useAuthStore';
|
||||
import useFavoritesStore from './store/useFavoritesStore';
|
||||
|
||||
// Layout Components
|
||||
import { LayoutMain } from './components/layout';
|
||||
import AdminLayout from './pages/AdminLayout';
|
||||
|
||||
// Auth Components
|
||||
import {
|
||||
ProtectedRoute,
|
||||
AdminRoute
|
||||
AdminRoute,
|
||||
StaffRoute,
|
||||
CustomerRoute
|
||||
} from './components/auth';
|
||||
|
||||
// Lazy load pages for code splitting
|
||||
const HomePage = lazy(() => import('./pages/HomePage'));
|
||||
const DashboardPage = lazy(() => import('./pages/customer/DashboardPage'));
|
||||
const RoomListPage = lazy(() => import('./pages/customer/RoomListPage'));
|
||||
@@ -56,18 +55,21 @@ const RegisterPage = lazy(() => import('./pages/auth/RegisterPage'));
|
||||
const ForgotPasswordPage = lazy(() => import('./pages/auth/ForgotPasswordPage'));
|
||||
const ResetPasswordPage = lazy(() => import('./pages/auth/ResetPasswordPage'));
|
||||
|
||||
// Lazy load admin pages
|
||||
const AdminDashboardPage = lazy(() => import('./pages/admin/DashboardPage'));
|
||||
const InvoiceManagementPage = lazy(() => import('./pages/admin/InvoiceManagementPage'));
|
||||
const PaymentManagementPage = lazy(() => import('./pages/admin/PaymentManagementPage'));
|
||||
const UserManagementPage = lazy(() => import('./pages/admin/UserManagementPage'));
|
||||
const BookingManagementPage = lazy(() => import('./pages/admin/BookingManagementPage'));
|
||||
const PageContentDashboardPage = lazy(() => import('./pages/admin/PageContentDashboard'));
|
||||
const AnalyticsDashboardPage = lazy(() => import('./pages/admin/AnalyticsDashboardPage'));
|
||||
const BusinessDashboardPage = lazy(() => import('./pages/admin/BusinessDashboardPage'));
|
||||
const SettingsPage = lazy(() => import('./pages/admin/SettingsPage'));
|
||||
const ReceptionDashboardPage = lazy(() => import('./pages/admin/ReceptionDashboardPage'));
|
||||
|
||||
// Demo component for pages not yet created
|
||||
const StaffDashboardPage = lazy(() => import('./pages/staff/DashboardPage'));
|
||||
const ChatManagementPage = lazy(() => import('./pages/staff/ChatManagementPage'));
|
||||
const StaffLayout = lazy(() => import('./pages/StaffLayout'));
|
||||
|
||||
const DemoPage: React.FC<{ title: string }> = ({ title }) => (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800">
|
||||
@@ -80,7 +82,7 @@ const DemoPage: React.FC<{ title: string }> = ({ title }) => (
|
||||
);
|
||||
|
||||
function App() {
|
||||
// Use Zustand store
|
||||
|
||||
const {
|
||||
isAuthenticated,
|
||||
userInfo,
|
||||
@@ -94,20 +96,20 @@ function App() {
|
||||
loadGuestFavorites,
|
||||
} = useFavoritesStore();
|
||||
|
||||
// Initialize auth state when app loads
|
||||
|
||||
useEffect(() => {
|
||||
initializeAuth();
|
||||
}, [initializeAuth]);
|
||||
|
||||
// Load favorites when authenticated or load guest favorites
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
// Sync guest favorites first, then fetch
|
||||
|
||||
syncGuestFavorites().then(() => {
|
||||
fetchFavorites();
|
||||
});
|
||||
} else {
|
||||
// Load guest favorites from localStorage
|
||||
|
||||
loadGuestFavorites();
|
||||
}
|
||||
}, [
|
||||
@@ -117,7 +119,7 @@ function App() {
|
||||
loadGuestFavorites,
|
||||
]);
|
||||
|
||||
// Handle logout
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
@@ -133,9 +135,10 @@ function App() {
|
||||
v7_relativeSplatPath: true,
|
||||
}}
|
||||
>
|
||||
<ScrollToTop />
|
||||
<Suspense fallback={<Loading fullScreen text="Loading page..." />}>
|
||||
<Routes>
|
||||
{/* Public Routes with Main Layout */}
|
||||
{}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
@@ -161,7 +164,11 @@ function App() {
|
||||
/>
|
||||
<Route
|
||||
path="favorites"
|
||||
element={<FavoritesPage />}
|
||||
element={
|
||||
<CustomerRoute>
|
||||
<FavoritesPage />
|
||||
</CustomerRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="payment-result"
|
||||
@@ -192,7 +199,7 @@ function App() {
|
||||
element={<ContactPage />}
|
||||
/>
|
||||
|
||||
{/* Protected Routes - Requires login */}
|
||||
{}
|
||||
<Route
|
||||
path="dashboard"
|
||||
element={
|
||||
@@ -204,57 +211,57 @@ function App() {
|
||||
<Route
|
||||
path="booking/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<CustomerRoute>
|
||||
<BookingPage />
|
||||
</ProtectedRoute>
|
||||
</CustomerRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="booking-success/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<CustomerRoute>
|
||||
<BookingSuccessPage />
|
||||
</ProtectedRoute>
|
||||
</CustomerRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="payment/deposit/:bookingId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<CustomerRoute>
|
||||
<DepositPaymentPage />
|
||||
</ProtectedRoute>
|
||||
</CustomerRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="bookings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<CustomerRoute>
|
||||
<MyBookingsPage />
|
||||
</ProtectedRoute>
|
||||
</CustomerRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="bookings/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<CustomerRoute>
|
||||
<BookingDetailPage />
|
||||
</ProtectedRoute>
|
||||
</CustomerRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="payment/:bookingId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<CustomerRoute>
|
||||
<FullPaymentPage />
|
||||
</ProtectedRoute>
|
||||
</CustomerRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="payment-confirmation/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<CustomerRoute>
|
||||
<PaymentConfirmationPage />
|
||||
</ProtectedRoute>
|
||||
</CustomerRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
@@ -267,7 +274,7 @@ function App() {
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{/* Auth Routes (no layout) */}
|
||||
{}
|
||||
<Route
|
||||
path="/login"
|
||||
element={<LoginPage />}
|
||||
@@ -285,7 +292,7 @@ function App() {
|
||||
element={<ResetPasswordPage />}
|
||||
/>
|
||||
|
||||
{/* Admin Routes - Only admin can access */}
|
||||
{}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
@@ -337,7 +344,43 @@ function App() {
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{/* 404 Route */}
|
||||
{}
|
||||
<Route
|
||||
path="/staff"
|
||||
element={
|
||||
<StaffRoute>
|
||||
<StaffLayout />
|
||||
</StaffRoute>
|
||||
}
|
||||
>
|
||||
<Route
|
||||
index
|
||||
element={<Navigate to="dashboard" replace />}
|
||||
/>
|
||||
<Route path="dashboard" element={<StaffDashboardPage />} />
|
||||
<Route
|
||||
path="bookings"
|
||||
element={<BookingManagementPage />}
|
||||
/>
|
||||
<Route
|
||||
path="reception"
|
||||
element={<ReceptionDashboardPage />}
|
||||
/>
|
||||
<Route
|
||||
path="payments"
|
||||
element={<PaymentManagementPage />}
|
||||
/>
|
||||
<Route
|
||||
path="reports"
|
||||
element={<AnalyticsDashboardPage />}
|
||||
/>
|
||||
<Route
|
||||
path="chats"
|
||||
element={<ChatManagementPage />}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{}
|
||||
<Route
|
||||
path="*"
|
||||
element={<DemoPage title="404 - Page not found" />}
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { useState, useMemo } from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
|
||||
// Popular icons for hotel/luxury content
|
||||
const popularIcons = [
|
||||
'Sparkles', 'Star', 'Award', 'Shield', 'Heart', 'Crown', 'Gem',
|
||||
'Zap', 'Wifi', 'Coffee', 'Utensils', 'Bed', 'Home', 'MapPin',
|
||||
@@ -45,7 +44,7 @@ const IconPicker: React.FC<IconPickerProps> = ({ value, onChange, label = 'Icon'
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Get all available Lucide icons
|
||||
|
||||
const allIcons = useMemo(() => {
|
||||
const icons: string[] = [];
|
||||
const excludedNames = new Set([
|
||||
@@ -61,17 +60,17 @@ const IconPicker: React.FC<IconPickerProps> = ({ value, onChange, label = 'Icon'
|
||||
]);
|
||||
|
||||
for (const iconName in LucideIcons) {
|
||||
// Skip non-icon exports
|
||||
|
||||
if (
|
||||
excludedNames.has(iconName) ||
|
||||
iconName.startsWith('_') ||
|
||||
iconName[0] !== iconName[0].toUpperCase() // Lucide icons start with uppercase
|
||||
iconName[0] !== iconName[0].toUpperCase()
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const iconComponent = (LucideIcons as any)[iconName];
|
||||
// Check if it's a React component (function)
|
||||
|
||||
if (typeof iconComponent === 'function') {
|
||||
icons.push(iconName);
|
||||
}
|
||||
@@ -81,10 +80,10 @@ const IconPicker: React.FC<IconPickerProps> = ({ value, onChange, label = 'Icon'
|
||||
return sorted;
|
||||
}, []);
|
||||
|
||||
// Filter icons based on search
|
||||
|
||||
const filteredIcons = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
// Show popular icons first, then others
|
||||
|
||||
const popular = popularIcons.filter(icon => allIcons.includes(icon));
|
||||
const others = allIcons.filter(icon => !popularIcons.includes(icon));
|
||||
return [...popular, ...others];
|
||||
|
||||
@@ -6,20 +6,13 @@ interface AdminRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* AdminRoute - Protects routes that are only for Admin
|
||||
*
|
||||
* Checks:
|
||||
* 1. Is user logged in → if not, redirect to /login
|
||||
* 2. Does user have admin role → if not, redirect to /
|
||||
*/
|
||||
const AdminRoute: React.FC<AdminRouteProps> = ({
|
||||
children
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, userInfo, isLoading } = useAuthStore();
|
||||
|
||||
// Loading auth state → show loading
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
@@ -39,7 +32,7 @@ const AdminRoute: React.FC<AdminRouteProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Not logged in → redirect to /login
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Navigate
|
||||
@@ -50,7 +43,7 @@ const AdminRoute: React.FC<AdminRouteProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Logged in but not admin → redirect to /
|
||||
|
||||
const isAdmin = userInfo?.role === 'admin';
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/" replace />;
|
||||
|
||||
61
Frontend/src/components/auth/CustomerRoute.tsx
Normal file
61
Frontend/src/components/auth/CustomerRoute.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
|
||||
interface CustomerRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const CustomerRoute: React.FC<CustomerRouteProps> = ({
|
||||
children
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, userInfo, isLoading } = useAuthStore();
|
||||
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center
|
||||
justify-center bg-gray-50"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="animate-spin rounded-full h-12 w-12
|
||||
border-b-2 border-indigo-600 mx-auto"
|
||||
/>
|
||||
<p className="mt-4 text-gray-600">
|
||||
Loading...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Navigate
|
||||
to="/login"
|
||||
state={{ from: location }}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const isCustomer = userInfo?.role !== 'admin' && userInfo?.role !== 'staff';
|
||||
if (!isCustomer) {
|
||||
if (userInfo?.role === 'admin') {
|
||||
return <Navigate to="/admin/dashboard" replace />;
|
||||
} else if (userInfo?.role === 'staff') {
|
||||
return <Navigate to="/staff/dashboard" replace />;
|
||||
}
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default CustomerRoute;
|
||||
|
||||
@@ -6,19 +6,13 @@ interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* ProtectedRoute - Protects routes that require authentication
|
||||
*
|
||||
* If user is not logged in, redirect to /login
|
||||
* and save current location to redirect back after login
|
||||
*/
|
||||
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
children
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, isLoading } = useAuthStore();
|
||||
|
||||
// Loading auth state → show loading
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
@@ -38,7 +32,7 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Not logged in → redirect to /login
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Navigate
|
||||
|
||||
56
Frontend/src/components/auth/StaffRoute.tsx
Normal file
56
Frontend/src/components/auth/StaffRoute.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
|
||||
interface StaffRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const StaffRoute: React.FC<StaffRouteProps> = ({
|
||||
children
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, userInfo, isLoading } = useAuthStore();
|
||||
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center
|
||||
justify-center bg-gray-50"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="animate-spin rounded-full h-12 w-12
|
||||
border-b-2 border-indigo-600 mx-auto"
|
||||
/>
|
||||
<p className="mt-4 text-gray-600">
|
||||
Authenticating...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Navigate
|
||||
to="/login"
|
||||
state={{ from: location }}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const isStaff = userInfo?.role === 'staff';
|
||||
if (!isStaff) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default StaffRoute;
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export { default as ProtectedRoute } from './ProtectedRoute';
|
||||
export { default as AdminRoute } from './AdminRoute';
|
||||
export { default as StaffRoute } from './StaffRoute';
|
||||
export { default as CustomerRoute } from './CustomerRoute';
|
||||
|
||||
516
Frontend/src/components/chat/ChatWidget.tsx
Normal file
516
Frontend/src/components/chat/ChatWidget.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { MessageCircle, X, Send, Minimize2, Maximize2 } from 'lucide-react';
|
||||
import { chatService, type Chat, type ChatMessage } from '../../services/api';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface ChatWidgetProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const ChatWidget: React.FC<ChatWidgetProps> = ({ onClose }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const [chat, setChat] = useState<Chat | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||
const [visitorInfo, setVisitorInfo] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: ''
|
||||
});
|
||||
const [showVisitorForm, setShowVisitorForm] = useState(false);
|
||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const { isAuthenticated, userInfo } = useAuthStore();
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
const validateVisitorForm = (): boolean => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!visitorInfo.name.trim()) {
|
||||
errors.name = 'Name is required';
|
||||
}
|
||||
|
||||
if (!visitorInfo.email.trim()) {
|
||||
errors.email = 'Email is required';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(visitorInfo.email)) {
|
||||
errors.email = 'Please enter a valid email address';
|
||||
}
|
||||
|
||||
if (!visitorInfo.phone.trim()) {
|
||||
errors.phone = 'Phone is required';
|
||||
}
|
||||
|
||||
setFormErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const createChat = async () => {
|
||||
if (!isAuthenticated) {
|
||||
if (!validateVisitorForm()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await chatService.createChat(
|
||||
isAuthenticated
|
||||
? undefined
|
||||
: {
|
||||
visitor_name: visitorInfo.name,
|
||||
visitor_email: visitorInfo.email,
|
||||
visitor_phone: visitorInfo.phone
|
||||
}
|
||||
);
|
||||
setChat(response.data);
|
||||
setShowVisitorForm(false);
|
||||
|
||||
const messagesResponse = await chatService.getMessages(response.data.id);
|
||||
setMessages(messagesResponse.data);
|
||||
|
||||
connectWebSocket(response.data.id);
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to start chat');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const connectWebSocket = (chatId: number) => {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
|
||||
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
const normalizedBase = baseUrl.replace(/\/$/, '');
|
||||
const wsProtocol = normalizedBase.startsWith('https') ? 'wss' : 'ws';
|
||||
const wsBase = normalizedBase.replace(/^https?/, wsProtocol);
|
||||
const wsUrl = `${wsBase}/api/chat/ws/${chatId}?user_type=visitor`;
|
||||
|
||||
const websocket = new WebSocket(wsUrl);
|
||||
|
||||
websocket.onopen = () => {
|
||||
console.log('WebSocket connected for chat', chatId);
|
||||
};
|
||||
|
||||
websocket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'new_message') {
|
||||
setMessages(prev => {
|
||||
const exists = prev.find(m => m.id === data.data.id);
|
||||
if (exists) return prev;
|
||||
return [...prev, data.data];
|
||||
});
|
||||
} else if (data.type === 'chat_accepted') {
|
||||
if (chat) {
|
||||
const updatedChat = {
|
||||
...chat,
|
||||
status: 'active' as const,
|
||||
staff_name: data.data.staff_name,
|
||||
staff_id: data.data.staff_id
|
||||
};
|
||||
setChat(updatedChat);
|
||||
toast.success(`Chat accepted by ${data.data.staff_name}`);
|
||||
}
|
||||
} else if (data.type === 'chat_closed') {
|
||||
toast.info('Chat has been closed');
|
||||
if (chat) {
|
||||
setChat({ ...chat, status: 'closed' });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
websocket.onclose = (event) => {
|
||||
console.log('WebSocket disconnected', event.code, event.reason);
|
||||
if (event.code !== 1000 && chat) {
|
||||
console.log('Attempting to reconnect WebSocket...');
|
||||
setTimeout(() => {
|
||||
if (chat) {
|
||||
connectWebSocket(chat.id);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
setWs(websocket);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!chat || !isOpen || isMinimized) return;
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const messagesResponse = await chatService.getMessages(chat.id);
|
||||
if (messagesResponse.success) {
|
||||
setMessages(messagesResponse.data);
|
||||
}
|
||||
|
||||
if (chat.status === 'pending') {
|
||||
try {
|
||||
const chatResponse = await chatService.getChat(chat.id);
|
||||
if (chatResponse.success) {
|
||||
const updatedChat = chatResponse.data;
|
||||
if (updatedChat.status !== chat.status ||
|
||||
updatedChat.staff_name !== chat.staff_name ||
|
||||
updatedChat.staff_id !== chat.staff_id) {
|
||||
setChat(updatedChat);
|
||||
if (updatedChat.status === 'active' && updatedChat.staff_name && chat.status === 'pending') {
|
||||
toast.success(`Chat accepted by ${updatedChat.staff_name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Could not poll chat status:', error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling chat updates:', error);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(pollInterval);
|
||||
}, [chat, isOpen, isMinimized]);
|
||||
|
||||
const handleOpen = () => {
|
||||
setIsOpen(true);
|
||||
setIsMinimized(false);
|
||||
if (isAuthenticated && !chat && !loading) {
|
||||
createChat();
|
||||
} else if (!isAuthenticated && !chat) {
|
||||
setShowVisitorForm(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
setWs(null);
|
||||
}
|
||||
setIsOpen(false);
|
||||
if (onClose) onClose();
|
||||
};
|
||||
|
||||
const handleEndChat = async () => {
|
||||
if (!chat) return;
|
||||
|
||||
if (window.confirm('Are you sure you want to end this chat?')) {
|
||||
try {
|
||||
await chatService.closeChat(chat.id);
|
||||
toast.success('Chat ended');
|
||||
if (ws) {
|
||||
ws.close();
|
||||
setWs(null);
|
||||
}
|
||||
setChat({ ...chat, status: 'closed' });
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to end chat');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!newMessage.trim() || !chat) return;
|
||||
|
||||
const messageText = newMessage.trim();
|
||||
setNewMessage('');
|
||||
|
||||
const tempMessage: ChatMessage = {
|
||||
id: Date.now(),
|
||||
chat_id: chat.id,
|
||||
sender_type: 'visitor',
|
||||
sender_name: isAuthenticated ? userInfo?.full_name : 'Guest',
|
||||
message: messageText,
|
||||
is_read: false,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
setMessages(prev => [...prev, tempMessage]);
|
||||
|
||||
try {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message',
|
||||
message: messageText
|
||||
}));
|
||||
} else {
|
||||
await chatService.sendMessage(chat.id, messageText);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error('Failed to send message');
|
||||
setMessages(prev => prev.filter(m => m.id !== tempMessage.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
className="fixed bottom-6 right-6 z-50 bg-gradient-to-r from-blue-600 to-blue-700 text-white p-4 rounded-full shadow-2xl hover:from-blue-700 hover:to-blue-800 transition-all duration-300 hover:scale-110 flex items-center justify-center group"
|
||||
aria-label="Open chat"
|
||||
>
|
||||
<MessageCircle className="w-6 h-6 group-hover:scale-110 transition-transform" />
|
||||
{chat && chat.status === 'pending' && (
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full animate-pulse"></span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-6 right-6 z-50 bg-white rounded-2xl shadow-2xl border border-gray-200 flex flex-col transition-all duration-300 ${
|
||||
isMinimized ? 'w-80 h-16' : 'w-96 h-[600px]'
|
||||
}`}
|
||||
>
|
||||
{}
|
||||
<div className="bg-gradient-to-r from-blue-600 to-blue-700 text-white p-4 rounded-t-2xl flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageCircle className="w-5 h-5" />
|
||||
<div>
|
||||
<h3 className="font-semibold">
|
||||
{chat?.status === 'active' && chat?.staff_name
|
||||
? `Chat with ${chat.staff_name}`
|
||||
: chat?.status === 'closed'
|
||||
? 'Chat Ended'
|
||||
: 'Support Chat'}
|
||||
</h3>
|
||||
{chat?.status === 'pending' && (
|
||||
<p className="text-xs text-blue-100">Waiting for staff...</p>
|
||||
)}
|
||||
{chat?.status === 'active' && (
|
||||
<p className="text-xs text-blue-100">Online</p>
|
||||
)}
|
||||
{chat?.status === 'closed' && (
|
||||
<p className="text-xs text-blue-100">This chat has ended</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{chat && chat.status !== 'closed' && (
|
||||
<button
|
||||
onClick={handleEndChat}
|
||||
className="px-3 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 rounded transition-colors border border-red-400/30"
|
||||
title="End chat"
|
||||
>
|
||||
End Chat
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
className="p-1 hover:bg-blue-700 rounded transition-colors"
|
||||
aria-label={isMinimized ? 'Maximize' : 'Minimize'}
|
||||
>
|
||||
{isMinimized ? (
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
) : (
|
||||
<Minimize2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-1 hover:bg-blue-700 rounded transition-colors"
|
||||
aria-label="Close chat widget"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isMinimized && (
|
||||
<>
|
||||
{}
|
||||
{showVisitorForm && !chat && (
|
||||
<div className="flex-1 overflow-y-auto p-4 bg-gray-50">
|
||||
<div className="max-w-md mx-auto">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Please provide your information to start chatting
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={visitorInfo.name}
|
||||
onChange={(e) => {
|
||||
setVisitorInfo({ ...visitorInfo, name: e.target.value });
|
||||
if (formErrors.name) setFormErrors({ ...formErrors, name: '' });
|
||||
}}
|
||||
className={`w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
formErrors.name ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Your full name"
|
||||
/>
|
||||
{formErrors.name && (
|
||||
<p className="text-xs text-red-500 mt-1">{formErrors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={visitorInfo.email}
|
||||
onChange={(e) => {
|
||||
setVisitorInfo({ ...visitorInfo, email: e.target.value });
|
||||
if (formErrors.email) setFormErrors({ ...formErrors, email: '' });
|
||||
}}
|
||||
className={`w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
formErrors.email ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="your.email@example.com"
|
||||
/>
|
||||
{formErrors.email && (
|
||||
<p className="text-xs text-red-500 mt-1">{formErrors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Phone <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={visitorInfo.phone}
|
||||
onChange={(e) => {
|
||||
setVisitorInfo({ ...visitorInfo, phone: e.target.value });
|
||||
if (formErrors.phone) setFormErrors({ ...formErrors, phone: '' });
|
||||
}}
|
||||
className={`w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
formErrors.phone ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="+1 (555) 123-4567"
|
||||
/>
|
||||
{formErrors.phone && (
|
||||
<p className="text-xs text-red-500 mt-1">{formErrors.phone}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={createChat}
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
{loading ? 'Starting chat...' : 'Start Chat'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{}
|
||||
{!showVisitorForm && (
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50">
|
||||
{loading && !chat ? (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
Starting chat...
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
No messages yet. Start the conversation!
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${
|
||||
message.sender_type === 'visitor' ? 'justify-end' : 'justify-start'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg p-3 ${
|
||||
message.sender_type === 'visitor'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-gray-800 border border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{message.sender_type === 'staff' && (
|
||||
<div className="text-xs font-semibold mb-1 text-blue-600">
|
||||
{message.sender_name || 'Staff'}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm whitespace-pre-wrap break-words">
|
||||
{message.message}
|
||||
</p>
|
||||
<p
|
||||
className={`text-xs mt-1 ${
|
||||
message.sender_type === 'visitor'
|
||||
? 'text-blue-100'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{new Date(message.created_at).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{}
|
||||
{!showVisitorForm && chat && chat.status !== 'closed' && (
|
||||
<div className="p-4 border-t border-gray-200 bg-white rounded-b-2xl">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Type your message..."
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!newMessage.trim()}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{chat?.status === 'pending' && (
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Waiting for a staff member to accept your chat. You can still send messages.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatWidget;
|
||||
|
||||
166
Frontend/src/components/chat/StaffChatNotification.tsx
Normal file
166
Frontend/src/components/chat/StaffChatNotification.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { MessageCircle, Bell } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'react-toastify';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import { chatService, type Chat } from '../../services/api';
|
||||
import { useChatNotifications } from '../../contexts/ChatNotificationContext';
|
||||
|
||||
const StaffChatNotification: React.FC = () => {
|
||||
const [notificationWs, setNotificationWs] = useState<WebSocket | null>(null);
|
||||
const [pendingChats, setPendingChats] = useState<Chat[]>([]);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const reconnectTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated, userInfo, token } = useAuthStore();
|
||||
const { unreadCount, refreshCount } = useChatNotifications();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || (userInfo?.role !== 'staff' && userInfo?.role !== 'admin')) {
|
||||
if (notificationWs) {
|
||||
notificationWs.close();
|
||||
setNotificationWs(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const authToken = token || localStorage.getItem('token');
|
||||
if (!authToken) return;
|
||||
|
||||
if (notificationWs && (notificationWs.readyState === WebSocket.CONNECTING || notificationWs.readyState === WebSocket.OPEN)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isConnecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const connectWebSocket = () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
const normalizedBase = baseUrl.replace(/\/$/, '');
|
||||
const wsProtocol = normalizedBase.startsWith('https') ? 'wss' : 'ws';
|
||||
const wsBase = normalizedBase.replace(/^https?/, wsProtocol);
|
||||
const wsUrl = `${wsBase}/api/chat/ws/staff/notifications?token=${encodeURIComponent(authToken)}`;
|
||||
|
||||
console.log('Connecting to WebSocket:', wsUrl.replace(authToken, 'TOKEN_HIDDEN'));
|
||||
|
||||
try {
|
||||
const websocket = new WebSocket(wsUrl);
|
||||
|
||||
websocket.onopen = () => {
|
||||
console.log('Staff notification WebSocket connected');
|
||||
setIsConnecting(false);
|
||||
};
|
||||
|
||||
websocket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'ping' || data.type === 'pong' || data.type === 'connected') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'new_chat') {
|
||||
const chatData = data.data;
|
||||
setPendingChats(prev => {
|
||||
const exists = prev.find(c => c.id === chatData.id);
|
||||
if (exists) return prev;
|
||||
return [...prev, chatData];
|
||||
});
|
||||
refreshCount();
|
||||
|
||||
toast.info(`New chat from ${chatData.visitor_name}`, {
|
||||
onClick: () => {
|
||||
navigate('/staff/chats');
|
||||
},
|
||||
autoClose: 10000
|
||||
});
|
||||
} else if (data.type === 'new_message_notification') {
|
||||
const chatData = data.data.chat;
|
||||
const messageData = data.data.message;
|
||||
refreshCount();
|
||||
|
||||
toast.info(`New message from ${chatData.visitor_name}: ${messageData.message.substring(0, 50)}${messageData.message.length > 50 ? '...' : ''}`, {
|
||||
onClick: () => {
|
||||
navigate('/staff/chats');
|
||||
},
|
||||
autoClose: 10000
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing notification:', error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
console.error('Notification WebSocket error:', error);
|
||||
setIsConnecting(false);
|
||||
};
|
||||
|
||||
websocket.onclose = (event) => {
|
||||
console.log('Notification WebSocket disconnected', event.code, event.reason);
|
||||
setIsConnecting(false);
|
||||
setNotificationWs(null);
|
||||
|
||||
if (event.code !== 1000 && isAuthenticated && (userInfo?.role === 'staff' || userInfo?.role === 'admin')) {
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
connectWebSocket();
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
setNotificationWs(websocket);
|
||||
} catch (error) {
|
||||
console.error('Error creating WebSocket:', error);
|
||||
setIsConnecting(false);
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
connectWebSocket();
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
connectWebSocket();
|
||||
|
||||
return () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
if (notificationWs) {
|
||||
notificationWs.close(1000, 'Component unmounting');
|
||||
setNotificationWs(null);
|
||||
}
|
||||
setIsConnecting(false);
|
||||
};
|
||||
}, [isAuthenticated, userInfo?.role]);
|
||||
|
||||
if (!isAuthenticated || (userInfo?.role !== 'staff' && userInfo?.role !== 'admin')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalUnread = unreadCount;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => navigate('/staff/chats')}
|
||||
className="fixed top-4 right-4 z-50 bg-gradient-to-r from-blue-600 to-blue-700 text-white p-3 rounded-full shadow-2xl hover:from-blue-700 hover:to-blue-800 transition-all duration-300 hover:scale-110 flex items-center justify-center group"
|
||||
title="Chat notifications"
|
||||
>
|
||||
<Bell className="w-6 h-6 group-hover:scale-110 transition-transform" />
|
||||
{totalUnread > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs font-bold rounded-full w-6 h-6 flex items-center justify-center animate-pulse">
|
||||
{totalUnread > 9 ? '9+' : totalUnread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default StaffChatNotification;
|
||||
|
||||
@@ -20,7 +20,7 @@ const AnalyticsLoader: React.FC = () => {
|
||||
const gaLoadedRef = useRef(false);
|
||||
const fbLoadedRef = useRef(false);
|
||||
|
||||
// Load public privacy config once
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const loadConfig = async () => {
|
||||
@@ -29,7 +29,7 @@ const AnalyticsLoader: React.FC = () => {
|
||||
if (!mounted) return;
|
||||
setConfig(cfg);
|
||||
} catch {
|
||||
// Fail silently in production; analytics are non-critical
|
||||
|
||||
}
|
||||
};
|
||||
void loadConfig();
|
||||
@@ -38,7 +38,7 @@ const AnalyticsLoader: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Load Google Analytics when allowed
|
||||
|
||||
useEffect(() => {
|
||||
if (!config || !consent) return;
|
||||
const measurementId = config.integrations.ga_measurement_id;
|
||||
@@ -46,12 +46,10 @@ const AnalyticsLoader: React.FC = () => {
|
||||
config.policy.analytics_enabled && consent.categories.analytics;
|
||||
if (!measurementId || !analyticsAllowed || gaLoadedRef.current) return;
|
||||
|
||||
// Inject GA script
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.async = true;
|
||||
script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(
|
||||
measurementId
|
||||
)}`;
|
||||
script.src = `https://www.googletagmanager.com/gtag/js?id=${measurementId}`;
|
||||
document.head.appendChild(script);
|
||||
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
@@ -65,11 +63,11 @@ const AnalyticsLoader: React.FC = () => {
|
||||
gaLoadedRef.current = true;
|
||||
|
||||
return () => {
|
||||
// We don't remove GA script on unmount; typical SPA behaviour is to keep it.
|
||||
|
||||
};
|
||||
}, [config, consent]);
|
||||
|
||||
// Track GA page views on route change
|
||||
|
||||
useEffect(() => {
|
||||
if (!gaLoadedRef.current || !config?.integrations.ga_measurement_id) return;
|
||||
if (typeof window.gtag !== 'function') return;
|
||||
@@ -78,7 +76,7 @@ const AnalyticsLoader: React.FC = () => {
|
||||
});
|
||||
}, [location, config]);
|
||||
|
||||
// Load Meta Pixel when allowed
|
||||
|
||||
useEffect(() => {
|
||||
if (!config || !consent) return;
|
||||
const pixelId = config.integrations.fb_pixel_id;
|
||||
@@ -86,22 +84,24 @@ const AnalyticsLoader: React.FC = () => {
|
||||
config.policy.marketing_enabled && consent.categories.marketing;
|
||||
if (!pixelId || !marketingAllowed || fbLoadedRef.current) return;
|
||||
|
||||
// Meta Pixel base code
|
||||
!(function (f: any, b, e, v, n?, t?, s?) {
|
||||
|
||||
(function (f: any, b: Document, e: string) {
|
||||
if (f.fbq) return;
|
||||
n = f.fbq = function () {
|
||||
const n: any = f.fbq = function () {
|
||||
(n.callMethod ? n.callMethod : n.queue.push).apply(n, arguments);
|
||||
};
|
||||
if (!f._fbq) f._fbq = n;
|
||||
(n as any).push = n;
|
||||
(n as any).loaded = true;
|
||||
(n as any).version = '2.0';
|
||||
(n as any).queue = [];
|
||||
t = b.createElement(e);
|
||||
n.push = n;
|
||||
n.loaded = true;
|
||||
n.version = '2.0';
|
||||
n.queue = [];
|
||||
const t = b.createElement(e) as HTMLScriptElement;
|
||||
t.async = true;
|
||||
t.src = 'https://connect.facebook.net/en_US/fbevents.js';
|
||||
s = b.getElementsByTagName(e)[0];
|
||||
s.parentNode?.insertBefore(t, s);
|
||||
const s = b.getElementsByTagName(e)[0];
|
||||
if (s && s.parentNode) {
|
||||
s.parentNode.insertBefore(t, s);
|
||||
}
|
||||
})(window, document, 'script');
|
||||
|
||||
window.fbq('init', pixelId);
|
||||
@@ -114,4 +114,3 @@ const AnalyticsLoader: React.FC = () => {
|
||||
|
||||
export default AnalyticsLoader;
|
||||
|
||||
|
||||
|
||||
@@ -26,22 +26,20 @@ const ConfirmationDialog: React.FC<ConfirmationDialogProps> = ({
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const variantStyles = {
|
||||
danger: {
|
||||
icon: 'text-red-600',
|
||||
button: 'bg-red-600 hover:bg-red-700 focus:ring-red-500',
|
||||
},
|
||||
warning: {
|
||||
icon: 'text-yellow-600',
|
||||
button: 'bg-yellow-600 hover:bg-yellow-700 focus:ring-yellow-500',
|
||||
},
|
||||
info: {
|
||||
icon: 'text-blue-600',
|
||||
button: 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500',
|
||||
},
|
||||
};
|
||||
|
||||
const styles = variantStyles[variant];
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -50,17 +48,17 @@ const ConfirmationDialog: React.FC<ConfirmationDialogProps> = ({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
{}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
{}
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<div className="relative transform overflow-hidden rounded-sm bg-gradient-to-b from-white to-gray-50 text-left shadow-2xl border border-[#d4af37]/20 transition-all sm:my-8 sm:w-full sm:max-w-lg animate-fade-in">
|
||||
{/* Close button */}
|
||||
{}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 text-gray-400 hover:text-[#d4af37] focus:outline-none focus:ring-2 focus:ring-[#d4af37]/50 rounded-sm p-1 transition-colors"
|
||||
|
||||
@@ -61,14 +61,14 @@ const CookieConsentBanner: React.FC = () => {
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-40 flex justify-center px-4 pb-4 sm:px-6 sm:pb-6">
|
||||
<div className="pointer-events-auto relative w-full max-w-4xl overflow-hidden rounded-2xl bg-gradient-to-r from-black/85 via-zinc-900/90 to-black/85 p-[1px] shadow-[0_24px_60px_rgba(0,0,0,0.8)]">
|
||||
{/* Gold inner border */}
|
||||
{}
|
||||
<div className="absolute inset-0 rounded-2xl border border-[#d4af37]/40" />
|
||||
|
||||
{/* Subtle glow */}
|
||||
{}
|
||||
<div className="pointer-events-none absolute -inset-8 bg-[radial-gradient(circle_at_top,_rgba(212,175,55,0.18),_transparent_55%),radial-gradient(circle_at_bottom,_rgba(0,0,0,0.8),_transparent_60%)] opacity-80" />
|
||||
|
||||
<div className="relative flex flex-col gap-4 bg-gradient-to-br from-zinc-950/80 via-zinc-900/90 to-black/90 px-4 py-4 sm:px-6 sm:py-5 lg:px-8 lg:py-6 sm:flex-row sm:items-start sm:justify-between">
|
||||
{/* Left: copy + details */}
|
||||
{}
|
||||
<div className="space-y-3 sm:max-w-xl">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-black/60 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.16em] text-[#d4af37]/90 ring-1 ring-[#d4af37]/30">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[#d4af37]" />
|
||||
@@ -163,7 +163,7 @@ const CookieConsentBanner: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: actions */}
|
||||
{}
|
||||
<div className="mt-2 flex flex-col gap-2 sm:mt-0 sm:w-56">
|
||||
<button
|
||||
type="button"
|
||||
@@ -197,4 +197,3 @@ const CookieConsentBanner: React.FC = () => {
|
||||
|
||||
export default CookieConsentBanner;
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ const CookiePreferencesLink: React.FC = () => {
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
// Dispatch a custom event listened by the banner to reopen details.
|
||||
|
||||
window.dispatchEvent(new CustomEvent('open-cookie-preferences'));
|
||||
};
|
||||
|
||||
@@ -26,4 +26,3 @@ const CookiePreferencesLink: React.FC = () => {
|
||||
|
||||
export default CookiePreferencesLink;
|
||||
|
||||
|
||||
|
||||
@@ -5,13 +5,9 @@ import { getCurrencySymbol } from '../../utils/format';
|
||||
interface CurrencyIconProps {
|
||||
className?: string;
|
||||
size?: number;
|
||||
currency?: string; // Optional: if not provided, uses currency from context
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic currency icon component that displays the currency symbol
|
||||
* instead of a hardcoded dollar sign icon
|
||||
*/
|
||||
const CurrencyIcon: React.FC<CurrencyIconProps> = ({
|
||||
className = '',
|
||||
size = 24,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useCurrency } from '../../contexts/CurrencyContext';
|
||||
import { Globe, Save } from 'lucide-react';
|
||||
import { Globe } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import systemSettingsService from '../../services/api/systemSettingsService';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
@@ -10,7 +10,7 @@ interface CurrencySelectorProps {
|
||||
className?: string;
|
||||
showLabel?: boolean;
|
||||
variant?: 'dropdown' | 'select';
|
||||
adminMode?: boolean; // If true, allows updating platform currency
|
||||
adminMode?: boolean;
|
||||
}
|
||||
|
||||
const CurrencySelector: React.FC<CurrencySelectorProps> = ({
|
||||
@@ -21,7 +21,7 @@ const CurrencySelector: React.FC<CurrencySelectorProps> = ({
|
||||
}) => {
|
||||
const { currency, supportedCurrencies, isLoading, refreshCurrency } = useCurrency();
|
||||
const { userInfo } = useAuthStore();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [, setSaving] = useState(false);
|
||||
const isAdmin = userInfo?.role === 'admin';
|
||||
|
||||
const currencyNames: Record<string, string> = {
|
||||
@@ -47,7 +47,7 @@ const CurrencySelector: React.FC<CurrencySelectorProps> = ({
|
||||
const handleChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newCurrency = e.target.value;
|
||||
|
||||
// If admin mode, update platform currency
|
||||
|
||||
if (adminMode && isAdmin) {
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
@@ -58,7 +58,7 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6">
|
||||
{/* Mobile */}
|
||||
{}
|
||||
<div className="flex flex-1 justify-between sm:hidden">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
@@ -84,7 +84,7 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Desktop */}
|
||||
{}
|
||||
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
@@ -96,7 +96,7 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
</div>
|
||||
<div>
|
||||
<nav className="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
||||
{/* Previous Button */}
|
||||
{}
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
@@ -110,7 +110,7 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
<ChevronLeft className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{/* Page Numbers */}
|
||||
{}
|
||||
{getPageNumbers().map((page, index) => {
|
||||
if (page === '...') {
|
||||
return (
|
||||
@@ -139,7 +139,7 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Next Button */}
|
||||
{}
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
|
||||
@@ -19,7 +19,7 @@ const PaymentMethodSelector: React.FC<
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Cash Payment */}
|
||||
{}
|
||||
<label
|
||||
className={`flex items-start p-4 border-2
|
||||
rounded-lg cursor-pointer transition-all
|
||||
@@ -62,7 +62,7 @@ const PaymentMethodSelector: React.FC<
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Stripe Payment */}
|
||||
{}
|
||||
<label
|
||||
className={`flex items-start p-4 border-2
|
||||
rounded-lg cursor-pointer transition-all
|
||||
@@ -118,7 +118,7 @@ const PaymentMethodSelector: React.FC<
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Additional Info */}
|
||||
{}
|
||||
<div className="mt-4 p-3 bg-blue-50 border
|
||||
border-blue-200 rounded-lg"
|
||||
>
|
||||
|
||||
17
Frontend/src/components/common/ScrollToTop.tsx
Normal file
17
Frontend/src/components/common/ScrollToTop.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
const ScrollToTop = () => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
|
||||
window.scrollTo(0, 0);
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ScrollToTop;
|
||||
|
||||
@@ -9,4 +9,5 @@ export { default as ConfirmationDialog } from './ConfirmationDialog';
|
||||
export { default as GlobalLoading } from './GlobalLoading';
|
||||
export { default as OfflineIndicator } from './OfflineIndicator';
|
||||
export { default as Skeleton } from './Skeleton';
|
||||
export { default as ScrollToTop } from './ScrollToTop';
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
LucideIcon
|
||||
} from 'lucide-react';
|
||||
import CookiePreferencesLink from '../common/CookiePreferencesLink';
|
||||
import ChatWidget from '../chat/ChatWidget';
|
||||
import { pageContentService } from '../../services/api';
|
||||
import type { PageContent } from '../../services/api/pageContentService';
|
||||
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
|
||||
@@ -43,26 +44,28 @@ const Footer: React.FC = () => {
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching footer content:', err);
|
||||
// Silently fail - use default content
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
fetchPageContent();
|
||||
}, []);
|
||||
|
||||
// Get phone, email, and address from centralized company settings
|
||||
|
||||
const displayPhone = settings.company_phone || null;
|
||||
const displayEmail = settings.company_email || null;
|
||||
const displayAddress = settings.company_address || '123 ABC Street, District 1\nHo Chi Minh City, Vietnam';
|
||||
const phoneNumber = displayPhone ? displayPhone.replace(/\s+/g, '').replace(/[()]/g, '') : '';
|
||||
const phoneHref = displayPhone ? 'tel:' + phoneNumber : '';
|
||||
|
||||
|
||||
// Get logo URL from centralized company settings
|
||||
const logoUrl = settings.company_logo_url
|
||||
? (settings.company_logo_url.startsWith('http')
|
||||
? settings.company_logo_url
|
||||
: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`)
|
||||
: null;
|
||||
|
||||
// Icon map for badges
|
||||
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
Award,
|
||||
Star,
|
||||
@@ -79,10 +82,10 @@ const Footer: React.FC = () => {
|
||||
TrendingUp,
|
||||
};
|
||||
|
||||
// Get badges from page content
|
||||
|
||||
const badges = pageContent?.badges || [];
|
||||
|
||||
// Default links
|
||||
|
||||
const defaultQuickLinks = [
|
||||
{ label: 'Home', url: '/' },
|
||||
{ label: 'Rooms & Suites', url: '/rooms' },
|
||||
@@ -107,18 +110,18 @@ const Footer: React.FC = () => {
|
||||
|
||||
return (
|
||||
<footer className="relative bg-gradient-to-b from-[#1a1a1a] via-[#0f0f0f] to-black text-gray-300 overflow-hidden">
|
||||
{/* Elegant top border with gradient */}
|
||||
{}
|
||||
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-[#d4af37]/50 to-transparent"></div>
|
||||
|
||||
{/* Subtle background pattern */}
|
||||
{}
|
||||
<div className="absolute inset-0 opacity-5" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9 0h2v20H9zM25 0h2v20h-2zM41 0h2v20h-2zM57 0h2v20h-2zM0 9h20v2H0zM0 25h20v2H0zM0 41h20v2H0zM0 57h20v2H0zM40 9h20v2H40zM40 25h20v2H40zM40 41h20v2H40zM40 57h20v2H40zM9 40h2v20H9zM25 40h2v20h-2zM41 40h2v20h-2zM57 40h2v20h-2z' fill='%23d4af37' opacity='0.05'/%3E%3C/svg%3E")`
|
||||
}}></div>
|
||||
|
||||
<div className="relative container mx-auto px-6 lg:px-8 py-16 lg:py-20">
|
||||
{/* Main Footer Content */}
|
||||
{}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-12 lg:gap-16 mb-16">
|
||||
{/* Company Info - Enhanced */}
|
||||
{}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="flex items-center space-x-3 mb-6">
|
||||
{logoUrl ? (
|
||||
@@ -148,7 +151,7 @@ const Footer: React.FC = () => {
|
||||
{pageContent?.description || 'Experience unparalleled luxury and world-class hospitality. Your journey to exceptional comfort begins here.'}
|
||||
</p>
|
||||
|
||||
{/* Premium Certifications */}
|
||||
{}
|
||||
{badges.length > 0 && badges.some(b => b.text) && (
|
||||
<div className="flex items-center space-x-6 mb-8">
|
||||
{badges.map((badge, index) => {
|
||||
@@ -164,7 +167,7 @@ const Footer: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Social Media - Premium Style */}
|
||||
{}
|
||||
<div className="flex items-center space-x-3">
|
||||
{pageContent?.social_links?.facebook && (
|
||||
<a
|
||||
@@ -229,7 +232,7 @@ const Footer: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links - Enhanced */}
|
||||
{}
|
||||
<div>
|
||||
<h3 className="text-white font-semibold text-lg mb-6 relative inline-block tracking-wide">
|
||||
<span className="relative z-10">Quick Links</span>
|
||||
@@ -250,7 +253,7 @@ const Footer: React.FC = () => {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Support - Enhanced */}
|
||||
{}
|
||||
<div>
|
||||
<h3 className="text-white font-semibold text-lg mb-6 relative inline-block tracking-wide">
|
||||
<span className="relative z-10">Guest Services</span>
|
||||
@@ -271,7 +274,7 @@ const Footer: React.FC = () => {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Contact Info - Premium Style */}
|
||||
{}
|
||||
<div>
|
||||
<h3 className="text-white font-semibold text-lg mb-6 relative inline-block tracking-wide">
|
||||
<span className="relative z-10">Contact</span>
|
||||
@@ -299,7 +302,7 @@ const Footer: React.FC = () => {
|
||||
<Phone className="w-5 h-5 text-[#d4af37]/80 group-hover:text-[#d4af37] transition-colors flex-shrink-0" />
|
||||
<div className="absolute inset-0 bg-[#d4af37]/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
</div>
|
||||
<a href={`tel:${displayPhone.replace(/\s+/g, '').replace(/[()]/g, '')}`} className="text-sm text-gray-400 group-hover:text-[#d4af37] transition-colors font-light tracking-wide">
|
||||
<a href={phoneHref} className="text-sm text-gray-400 group-hover:text-[#d4af37] transition-colors font-light tracking-wide">
|
||||
{displayPhone}
|
||||
</a>
|
||||
</li>
|
||||
@@ -317,7 +320,7 @@ const Footer: React.FC = () => {
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{/* Star Rating Display */}
|
||||
{}
|
||||
<div className="mt-6 pt-6 border-t border-gray-800/50">
|
||||
<div className="flex items-center space-x-1 mb-2">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
@@ -329,7 +332,7 @@ const Footer: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider with Elegance */}
|
||||
{}
|
||||
<div className="relative my-12">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-800"></div>
|
||||
@@ -341,13 +344,13 @@ const Footer: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copyright - Enhanced */}
|
||||
{}
|
||||
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
|
||||
<div className="text-sm text-gray-500 font-light tracking-wide">
|
||||
{(() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const copyrightText = pageContent?.copyright_text || '© {YEAR} Luxury Hotel. All rights reserved.';
|
||||
// Replace {YEAR} placeholder with current year
|
||||
|
||||
return copyrightText.replace(/{YEAR}/g, currentYear.toString());
|
||||
})()}
|
||||
</div>
|
||||
@@ -361,8 +364,11 @@ const Footer: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Elegant bottom accent */}
|
||||
{}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-[#d4af37]/30 to-transparent"></div>
|
||||
|
||||
{}
|
||||
<ChatWidget />
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -35,7 +35,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
}) => {
|
||||
const { settings } = useCompanySettings();
|
||||
|
||||
// Get phone and email from centralized company settings
|
||||
|
||||
const displayPhone = settings.company_phone || '+1 (234) 567-890';
|
||||
const displayEmail = settings.company_email || 'info@luxuryhotel.com';
|
||||
const logoUrl = settings.company_logo_url
|
||||
@@ -48,7 +48,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close user menu when clicking outside
|
||||
|
||||
useClickOutside(userMenuRef, () => {
|
||||
if (isUserMenuOpen) {
|
||||
setIsUserMenuOpen(false);
|
||||
@@ -73,7 +73,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
|
||||
return (
|
||||
<header className="bg-gradient-to-b from-[#1a1a1a] to-[#0f0f0f] sticky top-0 z-50 border-b border-[#d4af37]/20 shadow-2xl">
|
||||
{/* Top Bar - Contact Info */}
|
||||
{}
|
||||
<div className="hidden lg:block bg-[#0a0a0a]/50 border-b border-[#d4af37]/10">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2">
|
||||
<div className="flex items-center justify-end space-x-6 text-sm">
|
||||
@@ -93,10 +93,10 @@ const Header: React.FC<HeaderProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Header */}
|
||||
{}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
{}
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center space-x-3
|
||||
@@ -126,7 +126,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
{}
|
||||
<nav className="hidden md:flex items-center
|
||||
space-x-1"
|
||||
>
|
||||
@@ -168,7 +168,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Desktop Auth Section */}
|
||||
{}
|
||||
<div className="hidden md:flex items-center
|
||||
space-x-3"
|
||||
>
|
||||
@@ -233,7 +233,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* User Dropdown Menu */}
|
||||
{}
|
||||
{isUserMenuOpen && (
|
||||
<div className="absolute right-0 mt-2
|
||||
w-52 bg-gradient-to-b from-[#1a1a1a] to-[#0f0f0f]
|
||||
@@ -252,30 +252,34 @@ const Header: React.FC<HeaderProps> = ({
|
||||
<User className="w-4 h-4" />
|
||||
<span className="font-light tracking-wide">Profile</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/favorites"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
className="flex items-center space-x-3
|
||||
px-4 py-2.5 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
transition-all duration-300 border-l-2 border-transparent
|
||||
hover:border-[#d4af37]"
|
||||
>
|
||||
<Heart className="w-4 h-4" />
|
||||
<span className="font-light tracking-wide">Favorites</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/bookings"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
className="flex items-center space-x-3
|
||||
px-4 py-2.5 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
transition-all duration-300 border-l-2 border-transparent
|
||||
hover:border-[#d4af37]"
|
||||
>
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span className="font-light tracking-wide">My Bookings</span>
|
||||
</Link>
|
||||
{userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && (
|
||||
<>
|
||||
<Link
|
||||
to="/favorites"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
className="flex items-center space-x-3
|
||||
px-4 py-2.5 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
transition-all duration-300 border-l-2 border-transparent
|
||||
hover:border-[#d4af37]"
|
||||
>
|
||||
<Heart className="w-4 h-4" />
|
||||
<span className="font-light tracking-wide">Favorites</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/bookings"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
className="flex items-center space-x-3
|
||||
px-4 py-2.5 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
transition-all duration-300 border-l-2 border-transparent
|
||||
hover:border-[#d4af37]"
|
||||
>
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span className="font-light tracking-wide">My Bookings</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{userInfo?.role === 'admin' && (
|
||||
<Link
|
||||
to="/admin"
|
||||
@@ -292,6 +296,22 @@ const Header: React.FC<HeaderProps> = ({
|
||||
<span className="font-light tracking-wide">Admin</span>
|
||||
</Link>
|
||||
)}
|
||||
{userInfo?.role === 'staff' && (
|
||||
<Link
|
||||
to="/staff"
|
||||
onClick={() =>
|
||||
setIsUserMenuOpen(false)
|
||||
}
|
||||
className="flex items-center
|
||||
space-x-3 px-4 py-2.5 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
transition-all duration-300 border-l-2 border-transparent
|
||||
hover:border-[#d4af37]"
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
<span className="font-light tracking-wide">Staff Dashboard</span>
|
||||
</Link>
|
||||
)}
|
||||
<div className="border-t border-[#d4af37]/20 my-1"></div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
@@ -310,7 +330,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
{}
|
||||
<button
|
||||
onClick={toggleMobileMenu}
|
||||
className="md:hidden p-2 rounded-sm
|
||||
@@ -325,7 +345,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="md:hidden py-4 border-t
|
||||
border-[#d4af37]/20 mt-4 bg-[#0a0a0a]/50
|
||||
@@ -436,36 +456,40 @@ const Header: React.FC<HeaderProps> = ({
|
||||
<User className="w-4 h-4" />
|
||||
<span>Profile</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/favorites"
|
||||
onClick={() =>
|
||||
setIsMobileMenuOpen(false)
|
||||
}
|
||||
className="flex items-center
|
||||
space-x-2 px-4 py-3 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
rounded-sm transition-all duration-300
|
||||
border-l-2 border-transparent
|
||||
hover:border-[#d4af37] font-light tracking-wide"
|
||||
>
|
||||
<Heart className="w-4 h-4" />
|
||||
<span>Favorites</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/bookings"
|
||||
onClick={() =>
|
||||
setIsMobileMenuOpen(false)
|
||||
}
|
||||
className="flex items-center
|
||||
space-x-2 px-4 py-3 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
rounded-sm transition-all duration-300
|
||||
border-l-2 border-transparent
|
||||
hover:border-[#d4af37] font-light tracking-wide"
|
||||
>
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>My Bookings</span>
|
||||
</Link>
|
||||
{userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && (
|
||||
<>
|
||||
<Link
|
||||
to="/favorites"
|
||||
onClick={() =>
|
||||
setIsMobileMenuOpen(false)
|
||||
}
|
||||
className="flex items-center
|
||||
space-x-2 px-4 py-3 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
rounded-sm transition-all duration-300
|
||||
border-l-2 border-transparent
|
||||
hover:border-[#d4af37] font-light tracking-wide"
|
||||
>
|
||||
<Heart className="w-4 h-4" />
|
||||
<span>Favorites</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/bookings"
|
||||
onClick={() =>
|
||||
setIsMobileMenuOpen(false)
|
||||
}
|
||||
className="flex items-center
|
||||
space-x-2 px-4 py-3 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
rounded-sm transition-all duration-300
|
||||
border-l-2 border-transparent
|
||||
hover:border-[#d4af37] font-light tracking-wide"
|
||||
>
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>My Bookings</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{userInfo?.role === 'admin' && (
|
||||
<Link
|
||||
to="/admin"
|
||||
@@ -483,6 +507,23 @@ const Header: React.FC<HeaderProps> = ({
|
||||
<span>Admin</span>
|
||||
</Link>
|
||||
)}
|
||||
{userInfo?.role === 'staff' && (
|
||||
<Link
|
||||
to="/staff"
|
||||
onClick={() =>
|
||||
setIsMobileMenuOpen(false)
|
||||
}
|
||||
className="flex items-center
|
||||
space-x-2 px-4 py-3 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
rounded-sm transition-all duration-300
|
||||
border-l-2 border-transparent
|
||||
hover:border-[#d4af37] font-light tracking-wide"
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
<span>Staff Dashboard</span>
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center
|
||||
|
||||
@@ -21,21 +21,21 @@ const LayoutMain: React.FC<LayoutMainProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
{/* Header with Navigation and Auth */}
|
||||
{}
|
||||
<Header
|
||||
isAuthenticated={isAuthenticated}
|
||||
userInfo={userInfo}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
|
||||
{/* Main Content Area - Outlet renders child routes */}
|
||||
{}
|
||||
<main className="flex-1 bg-gradient-to-b from-gray-50 to-gray-100/50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
{}
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,17 +3,12 @@ import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Hotel,
|
||||
Calendar,
|
||||
CreditCard,
|
||||
Settings,
|
||||
FileText,
|
||||
BarChart3,
|
||||
Tag,
|
||||
Globe,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Star,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Menu,
|
||||
@@ -49,10 +44,10 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Check if mobile on mount and resize
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 1024); // lg breakpoint
|
||||
setIsMobile(window.innerWidth < 1024);
|
||||
if (window.innerWidth >= 1024) {
|
||||
setIsMobileOpen(false);
|
||||
}
|
||||
@@ -125,23 +120,23 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
|
||||
];
|
||||
|
||||
const isActive = (path: string) => {
|
||||
// Exact match
|
||||
|
||||
if (location.pathname === path) return true;
|
||||
// For settings, analytics, business, reception, and page-content paths, only match exact
|
||||
|
||||
if (path === '/admin/settings' || path === '/admin/analytics' || path === '/admin/business' || path === '/admin/reception' || path === '/admin/page-content') {
|
||||
return location.pathname === path;
|
||||
}
|
||||
// For reception path, also match when inside it (for tabs)
|
||||
|
||||
if (path === '/admin/reception') {
|
||||
return location.pathname === path || location.pathname.startsWith(`${path}/`);
|
||||
}
|
||||
// For other paths, check if it starts with the path followed by /
|
||||
|
||||
return location.pathname.startsWith(`${path}/`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Menu Button */}
|
||||
{}
|
||||
{isMobile && (
|
||||
<button
|
||||
onClick={handleMobileToggle}
|
||||
@@ -156,7 +151,7 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile Overlay */}
|
||||
{}
|
||||
{isMobile && isMobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 lg:hidden"
|
||||
@@ -164,7 +159,7 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
{}
|
||||
<aside
|
||||
className={`
|
||||
fixed lg:static inset-y-0 left-0 z-40
|
||||
@@ -180,7 +175,7 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
|
||||
border-r border-slate-700/50
|
||||
`}
|
||||
>
|
||||
{/* Luxury Sidebar Header */}
|
||||
{}
|
||||
<div className="p-6 border-b border-slate-700/50 flex items-center justify-between bg-gradient-to-r from-slate-800/50 to-slate-900/50 backdrop-blur-sm">
|
||||
{!isCollapsed && (
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -212,7 +207,7 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Menu Items */}
|
||||
{}
|
||||
<nav className="flex-1 overflow-y-auto py-4 px-3 custom-scrollbar">
|
||||
<ul className="space-y-2">
|
||||
{menuItems.map((item) => {
|
||||
@@ -262,7 +257,7 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Logout Button */}
|
||||
{}
|
||||
<div className="p-4 border-t border-slate-700/50">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
@@ -289,7 +284,7 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Luxury Sidebar Footer */}
|
||||
{}
|
||||
<div className="p-4 border-t border-slate-700/50 bg-gradient-to-r from-slate-800/50 to-slate-900/50 backdrop-blur-sm">
|
||||
{(!isCollapsed || isMobile) ? (
|
||||
<div className="text-xs text-slate-400 text-center space-y-1">
|
||||
|
||||
302
Frontend/src/components/layout/SidebarStaff.tsx
Normal file
302
Frontend/src/components/layout/SidebarStaff.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FileText,
|
||||
BarChart3,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
CreditCard,
|
||||
MessageCircle
|
||||
} from 'lucide-react';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import { useChatNotifications } from '../../contexts/ChatNotificationContext';
|
||||
|
||||
interface SidebarStaffProps {
|
||||
isCollapsed?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
const SidebarStaff: React.FC<SidebarStaffProps> = ({
|
||||
isCollapsed: controlledCollapsed,
|
||||
onToggle
|
||||
}) => {
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { logout } = useAuthStore();
|
||||
const { unreadCount } = useChatNotifications();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
if (isMobile) {
|
||||
setIsMobileOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 1024);
|
||||
if (window.innerWidth >= 1024) {
|
||||
setIsMobileOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
const isCollapsed =
|
||||
controlledCollapsed !== undefined
|
||||
? controlledCollapsed
|
||||
: internalCollapsed;
|
||||
|
||||
const handleToggle = () => {
|
||||
if (onToggle) {
|
||||
onToggle();
|
||||
} else {
|
||||
setInternalCollapsed(!internalCollapsed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMobileToggle = () => {
|
||||
setIsMobileOpen(!isMobileOpen);
|
||||
};
|
||||
|
||||
const handleLinkClick = () => {
|
||||
if (isMobile) {
|
||||
setIsMobileOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
path: '/staff/dashboard',
|
||||
icon: LayoutDashboard,
|
||||
label: 'Dashboard'
|
||||
},
|
||||
{
|
||||
path: '/staff/bookings',
|
||||
icon: FileText,
|
||||
label: 'Bookings'
|
||||
},
|
||||
{
|
||||
path: '/staff/reception',
|
||||
icon: LogIn,
|
||||
label: 'Reception'
|
||||
},
|
||||
{
|
||||
path: '/staff/payments',
|
||||
icon: CreditCard,
|
||||
label: 'Payments'
|
||||
},
|
||||
{
|
||||
path: '/staff/chats',
|
||||
icon: MessageCircle,
|
||||
label: 'Chat Support'
|
||||
},
|
||||
{
|
||||
path: '/staff/reports',
|
||||
icon: BarChart3,
|
||||
label: 'Reports'
|
||||
},
|
||||
];
|
||||
|
||||
const isActive = (path: string) => {
|
||||
|
||||
if (location.pathname === path) return true;
|
||||
|
||||
return location.pathname.startsWith(`${path}/`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{}
|
||||
{isMobile && (
|
||||
<button
|
||||
onClick={handleMobileToggle}
|
||||
className="fixed top-4 left-4 z-50 lg:hidden p-3 bg-gradient-to-r from-blue-900 to-blue-800 text-white rounded-xl shadow-2xl border border-blue-700 hover:from-blue-800 hover:to-blue-700 transition-all duration-200"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{isMobileOpen ? (
|
||||
<X className="w-6 h-6" />
|
||||
) : (
|
||||
<Menu className="w-6 h-6" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{}
|
||||
{isMobile && isMobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 lg:hidden"
|
||||
onClick={handleMobileToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{}
|
||||
<aside
|
||||
className={`
|
||||
fixed lg:static inset-y-0 left-0 z-40
|
||||
bg-gradient-to-b from-blue-900 via-blue-800 to-blue-900
|
||||
text-white shadow-2xl
|
||||
transition-all duration-300 ease-in-out flex flex-col
|
||||
${isMobile
|
||||
? (isMobileOpen ? 'translate-x-0' : '-translate-x-full')
|
||||
: ''
|
||||
}
|
||||
${!isMobile && (isCollapsed ? 'w-20' : 'w-72')}
|
||||
${isMobile ? 'w-72' : ''}
|
||||
border-r border-blue-700/50
|
||||
`}
|
||||
>
|
||||
{}
|
||||
<div className="p-6 border-b border-blue-700/50 flex items-center justify-between bg-gradient-to-r from-blue-800/50 to-blue-900/50 backdrop-blur-sm">
|
||||
{!isCollapsed && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-1 w-12 bg-gradient-to-r from-blue-400 to-blue-600 rounded-full"></div>
|
||||
<h2 className="text-xl font-bold bg-gradient-to-r from-blue-100 to-blue-200 bg-clip-text text-transparent">
|
||||
Staff Panel
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
{isCollapsed && !isMobile && (
|
||||
<div className="w-full flex justify-center">
|
||||
<div className="h-8 w-8 bg-gradient-to-br from-blue-400 to-blue-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<span className="text-white font-bold text-sm">S</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className="p-2.5 rounded-xl bg-blue-800/50 hover:bg-blue-700/50 border border-blue-700/50 hover:border-blue-500/50 transition-all duration-200 ml-auto shadow-lg hover:shadow-xl"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="w-5 h-5 text-blue-200" />
|
||||
) : (
|
||||
<ChevronLeft className="w-5 h-5 text-blue-200" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{}
|
||||
<nav className="flex-1 overflow-y-auto py-4 px-3 custom-scrollbar">
|
||||
<ul className="space-y-2">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.path);
|
||||
|
||||
return (
|
||||
<li key={item.path}>
|
||||
<Link
|
||||
to={item.path}
|
||||
onClick={handleLinkClick}
|
||||
className={`
|
||||
flex items-center
|
||||
space-x-3 px-4 py-3.5 rounded-xl
|
||||
transition-all duration-200 group relative
|
||||
${active
|
||||
? 'bg-gradient-to-r from-blue-500/20 to-blue-600/20 text-blue-100 shadow-lg border border-blue-500/30'
|
||||
: 'text-blue-300 hover:bg-blue-800/50 hover:text-blue-100 border border-transparent hover:border-blue-700/50'
|
||||
}
|
||||
${isCollapsed && !isMobile ? 'justify-center' : ''}
|
||||
`}
|
||||
title={isCollapsed && !isMobile ? item.label : undefined}
|
||||
>
|
||||
{active && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-gradient-to-b from-blue-400 to-blue-600 rounded-r-full"></div>
|
||||
)}
|
||||
<Icon className={`
|
||||
flex-shrink-0 transition-transform duration-200
|
||||
${active ? 'text-blue-400' : 'text-blue-400 group-hover:text-blue-300'}
|
||||
${isCollapsed && !isMobile ? 'w-6 h-6' : 'w-5 h-5'}
|
||||
`} />
|
||||
{(!isCollapsed || isMobile) && (
|
||||
<span className={`
|
||||
font-semibold transition-all duration-200
|
||||
${active ? 'text-blue-100' : 'group-hover:text-blue-100'}
|
||||
`}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
{item.path === '/staff/chats' && unreadCount > 0 && (
|
||||
<span className="ml-auto bg-red-500 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
{active && !isCollapsed && item.path !== '/staff/chats' && (
|
||||
<div className="ml-auto w-2 h-2 bg-blue-400 rounded-full animate-pulse"></div>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{}
|
||||
<div className="p-4 border-t border-blue-700/50">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={`
|
||||
w-full flex items-center
|
||||
space-x-3 px-4 py-3.5 rounded-xl
|
||||
transition-all duration-200 group relative
|
||||
text-blue-300 hover:bg-gradient-to-r hover:from-rose-600/20 hover:to-rose-700/20
|
||||
hover:text-rose-100 border border-transparent hover:border-rose-500/30
|
||||
${isCollapsed && !isMobile ? 'justify-center' : ''}
|
||||
`}
|
||||
title={isCollapsed && !isMobile ? 'Logout' : undefined}
|
||||
>
|
||||
<LogOut className={`
|
||||
flex-shrink-0 transition-transform duration-200
|
||||
text-blue-400 group-hover:text-rose-400 group-hover:rotate-12
|
||||
${isCollapsed && !isMobile ? 'w-6 h-6' : 'w-5 h-5'}
|
||||
`} />
|
||||
{(!isCollapsed || isMobile) && (
|
||||
<span className="font-semibold transition-all duration-200 group-hover:text-rose-100">
|
||||
Logout
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="p-4 border-t border-blue-700/50 bg-gradient-to-r from-blue-800/50 to-blue-900/50 backdrop-blur-sm">
|
||||
{(!isCollapsed || isMobile) ? (
|
||||
<div className="text-xs text-blue-400 text-center space-y-1">
|
||||
<p className="font-semibold text-blue-200/80">Staff Dashboard</p>
|
||||
<p className="text-blue-500">
|
||||
© {new Date().getFullYear()} Luxury Hotel
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
<div className="w-3 h-3 bg-gradient-to-br from-blue-400 to-blue-600 rounded-full shadow-lg animate-pulse"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarStaff;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { default as Header } from './Header';
|
||||
export { default as Footer } from './Footer';
|
||||
export { default as SidebarAdmin } from './SidebarAdmin';
|
||||
export { default as SidebarStaff } from './SidebarStaff';
|
||||
export { default as LayoutMain } from './LayoutMain';
|
||||
|
||||
@@ -16,21 +16,21 @@ const PayPalPaymentWrapper: React.FC<PayPalPaymentWrapperProps> = ({
|
||||
currency: propCurrency,
|
||||
onError,
|
||||
}) => {
|
||||
// Get currency from context if not provided as prop
|
||||
|
||||
const { currency: contextCurrency } = useFormatCurrency();
|
||||
const currency = propCurrency || contextCurrency || 'USD';
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [approvalUrl, setApprovalUrl] = useState<string | null>(null);
|
||||
|
||||
// Initialize PayPal order
|
||||
|
||||
useEffect(() => {
|
||||
const initializePayPal = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Get current URL for return/cancel URLs
|
||||
|
||||
const currentUrl = window.location.origin;
|
||||
const returnUrl = `${currentUrl}/payment/paypal/return?bookingId=${bookingId}`;
|
||||
const cancelUrl = `${currentUrl}/payment/paypal/cancel?bookingId=${bookingId}`;
|
||||
@@ -71,7 +71,7 @@ const PayPalPaymentWrapper: React.FC<PayPalPaymentWrapperProps> = ({
|
||||
|
||||
const handlePayPalClick = () => {
|
||||
if (approvalUrl) {
|
||||
// Redirect to PayPal approval page
|
||||
|
||||
window.location.href = approvalUrl;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -22,19 +22,19 @@ const StripePaymentWrapper: React.FC<StripePaymentWrapperProps> = ({
|
||||
}) => {
|
||||
const [stripePromise, setStripePromise] = useState<Promise<any> | null>(null);
|
||||
const [clientSecret, setClientSecret] = useState<string | null>(null);
|
||||
const [publishableKey, setPublishableKey] = useState<string | null>(null);
|
||||
const [, setPublishableKey] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [paymentCompleted, setPaymentCompleted] = useState(false);
|
||||
|
||||
// Initialize Stripe payment intent
|
||||
|
||||
useEffect(() => {
|
||||
// Don't initialize if payment is already completed
|
||||
|
||||
if (paymentCompleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// First, create payment intent to get publishable key
|
||||
|
||||
const initializeStripe = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -62,12 +62,12 @@ const StripePaymentWrapper: React.FC<StripePaymentWrapperProps> = ({
|
||||
setPublishableKey(publishable_key);
|
||||
setClientSecret(client_secret);
|
||||
|
||||
// Initialize Stripe with publishable key
|
||||
// loadStripe returns a Promise, so we don't need to wrap it
|
||||
|
||||
|
||||
const stripePromise = loadStripe(publishable_key);
|
||||
setStripePromise(stripePromise);
|
||||
|
||||
// Wait for Stripe to load before proceeding
|
||||
|
||||
const stripe = await stripePromise;
|
||||
if (!stripe) {
|
||||
throw new Error('Failed to load Stripe');
|
||||
@@ -90,7 +90,7 @@ const StripePaymentWrapper: React.FC<StripePaymentWrapperProps> = ({
|
||||
initializeStripe();
|
||||
}, [bookingId, amount, currency, onError, paymentCompleted]);
|
||||
|
||||
// Debug logging - must be before any conditional returns
|
||||
|
||||
useEffect(() => {
|
||||
if (clientSecret && stripePromise) {
|
||||
console.log('Stripe initialized successfully', { hasClientSecret: !!clientSecret, hasStripePromise: !!stripePromise });
|
||||
@@ -101,7 +101,7 @@ const StripePaymentWrapper: React.FC<StripePaymentWrapperProps> = ({
|
||||
|
||||
const handlePaymentSuccess = async (paymentIntentId: string) => {
|
||||
try {
|
||||
// Mark payment as completed to prevent re-initialization
|
||||
|
||||
setPaymentCompleted(true);
|
||||
|
||||
const response = await confirmStripePayment(paymentIntentId, bookingId);
|
||||
@@ -109,13 +109,13 @@ const StripePaymentWrapper: React.FC<StripePaymentWrapperProps> = ({
|
||||
if (response.success) {
|
||||
onSuccess();
|
||||
} else {
|
||||
// Reset payment completed flag if confirmation failed
|
||||
|
||||
setPaymentCompleted(false);
|
||||
throw new Error(response.message || 'Payment confirmation failed');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error confirming payment:', err);
|
||||
// Reset payment completed flag on error
|
||||
|
||||
setPaymentCompleted(false);
|
||||
const errorMessage = err.response?.data?.message || err.message || 'Payment confirmation failed';
|
||||
setError(errorMessage);
|
||||
@@ -132,9 +132,9 @@ const StripePaymentWrapper: React.FC<StripePaymentWrapperProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Don't show error if payment is completed
|
||||
|
||||
if (paymentCompleted) {
|
||||
return null; // Component will be unmounted by parent
|
||||
return null;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -14,7 +14,7 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
// Auto-slide every 5 seconds
|
||||
|
||||
useEffect(() => {
|
||||
if (banners.length <= 1) return;
|
||||
|
||||
@@ -52,8 +52,8 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
||||
setTimeout(() => setIsAnimating(false), 800);
|
||||
};
|
||||
|
||||
// Default fallback banner if no banners provided
|
||||
const defaultBanner = {
|
||||
|
||||
const defaultBanner: Banner = {
|
||||
id: 0,
|
||||
title: 'Welcome to Hotel Booking',
|
||||
image_url: '/images/default-banner.jpg',
|
||||
@@ -62,6 +62,8 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
||||
is_active: true,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
description: undefined,
|
||||
link_url: undefined,
|
||||
};
|
||||
|
||||
const displayBanners = banners.length > 0
|
||||
@@ -73,7 +75,7 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
||||
<div
|
||||
className="relative w-full h-[400px] sm:h-[500px] md:h-[600px] lg:h-[700px] xl:h-[800px] overflow-hidden"
|
||||
>
|
||||
{/* Banner Image with smooth transitions */}
|
||||
{}
|
||||
<div className="relative w-full h-full">
|
||||
{displayBanners.map((banner, index) => (
|
||||
<div
|
||||
@@ -111,14 +113,14 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Overlay - Enhanced for luxury text readability */}
|
||||
{}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-t
|
||||
from-black/70 via-black/30 via-black/15 to-black/5
|
||||
transition-opacity duration-1000 ease-in-out"
|
||||
/>
|
||||
|
||||
{/* Animated gradient overlay for luxury effect */}
|
||||
{}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-br
|
||||
from-transparent via-transparent to-black/10
|
||||
@@ -128,7 +130,7 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Title - Positioned higher up on the banner */}
|
||||
{}
|
||||
{currentBanner.title && (
|
||||
<div
|
||||
key={currentIndex}
|
||||
@@ -142,7 +144,7 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
||||
}}
|
||||
>
|
||||
|
||||
{/* Animated border glow */}
|
||||
{}
|
||||
<div
|
||||
className="absolute inset-0
|
||||
rounded-2xl
|
||||
@@ -156,7 +158,7 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
||||
/>
|
||||
|
||||
<div className="relative w-full flex flex-col items-center">
|
||||
{/* Animated decorative line above title */}
|
||||
{}
|
||||
<div
|
||||
className="w-0 h-0.5 bg-gradient-to-r from-transparent via-[#d4af37] to-transparent mb-4 sm:mb-6 opacity-90
|
||||
animate-lineExpand"
|
||||
@@ -194,7 +196,7 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
{/* Description text - centered below title */}
|
||||
{}
|
||||
{currentBanner.description && (
|
||||
<p
|
||||
className="text-sm sm:text-base md:text-lg lg:text-xl xl:text-2xl
|
||||
@@ -214,7 +216,7 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Animated decorative line below title/description */}
|
||||
{}
|
||||
<div
|
||||
className="w-0 h-0.5 bg-gradient-to-r from-transparent via-[#d4af37] to-transparent mt-3 sm:mt-4 opacity-90
|
||||
animate-lineExpand"
|
||||
@@ -225,7 +227,7 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Enhanced luxury gold accent glow with animation */}
|
||||
{}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
|
||||
@@ -239,7 +241,7 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Floating particles effect */}
|
||||
{}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div
|
||||
@@ -257,7 +259,7 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Form Overlay - Centered in lower third */}
|
||||
{}
|
||||
{children && (
|
||||
<div className="absolute inset-0 flex items-end justify-center z-30 px-2 sm:px-4 md:px-6 lg:px-8 pb-2 sm:pb-4 md:pb-8 lg:pb-12 xl:pb-16 pointer-events-none">
|
||||
<div className="w-full max-w-6xl pointer-events-auto">
|
||||
@@ -269,7 +271,7 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons - Enhanced luxury style */}
|
||||
{}
|
||||
{displayBanners.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
@@ -312,7 +314,7 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dots Indicator - Enhanced luxury style */}
|
||||
{}
|
||||
{displayBanners.length > 1 && (
|
||||
<div
|
||||
className="absolute bottom-2 sm:bottom-4 left-1/2
|
||||
@@ -338,7 +340,7 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CSS Animations */}
|
||||
{}
|
||||
<style>{`
|
||||
@keyframes luxuryFadeInUp {
|
||||
from {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Heart } from 'lucide-react';
|
||||
import useFavoritesStore from '../../store/useFavoritesStore';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
|
||||
interface FavoriteButtonProps {
|
||||
roomId: number;
|
||||
@@ -15,19 +16,23 @@ const FavoriteButton: React.FC<FavoriteButtonProps> = ({
|
||||
showTooltip = true,
|
||||
className = '',
|
||||
}) => {
|
||||
const { userInfo } = useAuthStore();
|
||||
const {
|
||||
isFavorited,
|
||||
addToFavorites,
|
||||
removeFromFavorites,
|
||||
} = useFavoritesStore();
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [showTooltipText, setShowTooltipText] =
|
||||
useState(false);
|
||||
|
||||
if (userInfo?.role === 'admin' || userInfo?.role === 'staff') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const favorited = isFavorited(roomId);
|
||||
|
||||
// Size classes
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-6 h-6 p-1',
|
||||
md: 'w-10 h-10 p-2',
|
||||
@@ -118,7 +123,7 @@ const FavoriteButton: React.FC<FavoriteButtonProps> = ({
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Tooltip */}
|
||||
{}
|
||||
{showTooltip && showTooltipText && (
|
||||
<div
|
||||
className="absolute bottom-full left-1/2
|
||||
|
||||
@@ -18,37 +18,37 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
const handlePageChange = (page: number) => {
|
||||
if (page < 1 || page > totalPages) return;
|
||||
|
||||
// Update URL params
|
||||
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set('page', String(page));
|
||||
setSearchParams(newParams);
|
||||
|
||||
// Callback
|
||||
|
||||
onPageChange?.(page);
|
||||
|
||||
// Scroll to top
|
||||
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// Generate page numbers to show
|
||||
|
||||
const getPageNumbers = () => {
|
||||
const pages: (number | string)[] = [];
|
||||
const maxVisible = 7; // Max page buttons to show
|
||||
const maxVisible = 7;
|
||||
|
||||
if (totalPages <= maxVisible) {
|
||||
// Show all pages if total is small
|
||||
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
// Always show first page
|
||||
|
||||
pages.push(1);
|
||||
|
||||
if (currentPage > 3) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Show current page and neighbors
|
||||
|
||||
const start = Math.max(2, currentPage - 1);
|
||||
const end = Math.min(totalPages - 1, currentPage + 1);
|
||||
|
||||
@@ -60,7 +60,7 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Always show last page
|
||||
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center gap-2 mt-8">
|
||||
{/* Previous Button */}
|
||||
{}
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
@@ -87,7 +87,7 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
|
||||
{/* Page Numbers */}
|
||||
{}
|
||||
{getPageNumbers().map((page, index) => {
|
||||
if (page === '...') {
|
||||
return (
|
||||
@@ -121,7 +121,7 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Next Button */}
|
||||
{}
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
|
||||
@@ -90,7 +90,7 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify reCAPTCHA if enabled
|
||||
|
||||
if (recaptchaToken) {
|
||||
try {
|
||||
const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
|
||||
@@ -143,7 +143,7 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Rating Summary */}
|
||||
{}
|
||||
<div className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg border border-[#d4af37]/20 p-3 sm:p-4 backdrop-blur-xl shadow-lg shadow-[#d4af37]/5">
|
||||
<h3 className="text-sm sm:text-base font-serif font-semibold text-white mb-3 tracking-wide">
|
||||
Customer Reviews
|
||||
@@ -168,7 +168,7 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Review Form */}
|
||||
{}
|
||||
{isAuthenticated ? (
|
||||
<div className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg border border-[#d4af37]/20 p-3 sm:p-4 backdrop-blur-xl shadow-lg shadow-[#d4af37]/5">
|
||||
<h4 className="text-xs sm:text-sm font-serif font-semibold text-white mb-3 tracking-wide">
|
||||
@@ -224,7 +224,7 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* reCAPTCHA */}
|
||||
{}
|
||||
<div className="flex justify-center">
|
||||
<Recaptcha
|
||||
onChange={(token) => setRecaptchaToken(token)}
|
||||
@@ -269,7 +269,7 @@ const ReviewSection: React.FC<ReviewSectionProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reviews List */}
|
||||
{}
|
||||
<div>
|
||||
<h4 className="text-xs sm:text-sm font-serif font-semibold text-white mb-3 tracking-wide">
|
||||
All Reviews ({totalReviews})
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
Sparkles,
|
||||
Flame,
|
||||
Lock,
|
||||
Baby,
|
||||
Heart,
|
||||
MapPin,
|
||||
Building,
|
||||
@@ -31,13 +30,10 @@ import {
|
||||
Laptop,
|
||||
Smartphone,
|
||||
Monitor,
|
||||
Radio,
|
||||
Gamepad,
|
||||
Headphones,
|
||||
UtensilsCrossed as Restaurant,
|
||||
Briefcase,
|
||||
Printer,
|
||||
Mail,
|
||||
Clock,
|
||||
Sunrise,
|
||||
Moon,
|
||||
@@ -61,32 +57,32 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
if (Array.isArray(input)) return input;
|
||||
if (!input) return [];
|
||||
if (typeof input === 'string') {
|
||||
// Try JSON.parse first (stringified JSON)
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(input);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch (e) {
|
||||
// ignore
|
||||
|
||||
}
|
||||
|
||||
// Fallback: comma separated list
|
||||
|
||||
return input
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// If it's an object with values as amenities
|
||||
|
||||
if (typeof input === 'object') {
|
||||
try {
|
||||
// Convert object values to array if possible
|
||||
|
||||
const vals = Object.values(input);
|
||||
if (Array.isArray(vals) && vals.length > 0) {
|
||||
// flatten nested arrays
|
||||
|
||||
return vals.flat().map((v: any) => String(v).trim()).filter(Boolean);
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,16 +91,16 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
|
||||
const safeAmenities = normalizeAmenities(amenities);
|
||||
|
||||
// Icon mapping for comprehensive amenities
|
||||
|
||||
const amenityIcons: Record<string, React.ReactNode> = {
|
||||
// Basic & Internet
|
||||
|
||||
wifi: <Wifi className="w-5 h-5" />,
|
||||
'wi-fi': <Wifi className="w-5 h-5" />,
|
||||
'free wifi': <Wifi className="w-5 h-5" />,
|
||||
'wifi in room': <Wifi className="w-5 h-5" />,
|
||||
'high-speed internet': <Wifi className="w-5 h-5" />,
|
||||
|
||||
// Entertainment
|
||||
|
||||
tv: <Tv className="w-5 h-5" />,
|
||||
television: <Tv className="w-5 h-5" />,
|
||||
'flat-screen tv': <Tv className="w-5 h-5" />,
|
||||
@@ -125,7 +121,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'surround sound': <Headphones className="w-5 h-5" />,
|
||||
'music system': <Music className="w-5 h-5" />,
|
||||
|
||||
// Climate
|
||||
|
||||
'air-conditioning': <Wind className="w-5 h-5" />,
|
||||
'air conditioning': <Wind className="w-5 h-5" />,
|
||||
ac: <Wind className="w-5 h-5" />,
|
||||
@@ -134,7 +130,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'ceiling fan': <Wind className="w-5 h-5" />,
|
||||
'air purifier': <Wind className="w-5 h-5" />,
|
||||
|
||||
// Bathroom
|
||||
|
||||
'private bathroom': <Bath className="w-5 h-5" />,
|
||||
'ensuite bathroom': <Bath className="w-5 h-5" />,
|
||||
bathtub: <Bath className="w-5 h-5" />,
|
||||
@@ -153,7 +149,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'premium toiletries': <Bath className="w-5 h-5" />,
|
||||
towels: <Bath className="w-5 h-5" />,
|
||||
|
||||
// Food & Beverage
|
||||
|
||||
'mini bar': <Coffee className="w-5 h-5" />,
|
||||
minibar: <Coffee className="w-5 h-5" />,
|
||||
refrigerator: <Coffee className="w-5 h-5" />,
|
||||
@@ -173,7 +169,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'complimentary water': <Coffee className="w-5 h-5" />,
|
||||
'bottled water': <Coffee className="w-5 h-5" />,
|
||||
|
||||
// Furniture
|
||||
|
||||
desk: <Briefcase className="w-5 h-5" />,
|
||||
'writing desk': <Briefcase className="w-5 h-5" />,
|
||||
'office desk': <Briefcase className="w-5 h-5" />,
|
||||
@@ -190,7 +186,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'full-length mirror': <Sparkles className="w-5 h-5" />,
|
||||
'seating area': <Sofa className="w-5 h-5" />,
|
||||
|
||||
// Bed & Sleep
|
||||
|
||||
'king size bed': <Bed className="w-5 h-5" />,
|
||||
'queen size bed': <Bed className="w-5 h-5" />,
|
||||
'double bed': <Bed className="w-5 h-5" />,
|
||||
@@ -202,7 +198,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'blackout curtains': <Moon className="w-5 h-5" />,
|
||||
soundproofing: <Shield className="w-5 h-5" />,
|
||||
|
||||
// Safety & Security
|
||||
|
||||
safe: <Shield className="w-5 h-5" />,
|
||||
'in-room safe': <Shield className="w-5 h-5" />,
|
||||
'safety deposit box': <Shield className="w-5 h-5" />,
|
||||
@@ -212,7 +208,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'key card access': <Key className="w-5 h-5" />,
|
||||
'door lock': <Lock className="w-5 h-5" />,
|
||||
|
||||
// Technology
|
||||
|
||||
'usb charging ports': <Zap className="w-5 h-5" />,
|
||||
'usb ports': <Zap className="w-5 h-5" />,
|
||||
'usb outlets': <Zap className="w-5 h-5" />,
|
||||
@@ -226,7 +222,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'alarm clock': <Clock className="w-5 h-5" />,
|
||||
'digital clock': <Clock className="w-5 h-5" />,
|
||||
|
||||
// View & Outdoor
|
||||
|
||||
balcony: <MapPin className="w-5 h-5" />,
|
||||
'private balcony': <MapPin className="w-5 h-5" />,
|
||||
terrace: <MapPin className="w-5 h-5" />,
|
||||
@@ -242,7 +238,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'large windows': <Eye className="w-5 h-5" />,
|
||||
'floor-to-ceiling windows': <Eye className="w-5 h-5" />,
|
||||
|
||||
// Services
|
||||
|
||||
'24-hour front desk': <Building className="w-5 h-5" />,
|
||||
'24 hour front desk': <Building className="w-5 h-5" />,
|
||||
'24/7 front desk': <Building className="w-5 h-5" />,
|
||||
@@ -268,7 +264,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'car rental': <Car className="w-5 h-5" />,
|
||||
'taxi service': <Car className="w-5 h-5" />,
|
||||
|
||||
// Fitness & Wellness
|
||||
|
||||
'gym access': <Dumbbell className="w-5 h-5" />,
|
||||
'fitness center': <Dumbbell className="w-5 h-5" />,
|
||||
'fitness room': <Dumbbell className="w-5 h-5" />,
|
||||
@@ -281,7 +277,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'massage service': <Heart className="w-5 h-5" />,
|
||||
'beauty services': <Sparkles className="w-5 h-5" />,
|
||||
|
||||
// Recreation
|
||||
|
||||
'swimming pool': <Waves className="w-5 h-5" />,
|
||||
pool: <Waves className="w-5 h-5" />,
|
||||
'indoor pool': <Waves className="w-5 h-5" />,
|
||||
@@ -293,7 +289,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'beach access': <Waves className="w-5 h-5" />,
|
||||
'water sports': <Waves className="w-5 h-5" />,
|
||||
|
||||
// Business
|
||||
|
||||
'business center': <Briefcase className="w-5 h-5" />,
|
||||
'meeting room': <Briefcase className="w-5 h-5" />,
|
||||
'conference room': <Briefcase className="w-5 h-5" />,
|
||||
@@ -302,7 +298,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'printing service': <Printer className="w-5 h-5" />,
|
||||
'secretarial services': <Briefcase className="w-5 h-5" />,
|
||||
|
||||
// Accessibility
|
||||
|
||||
'wheelchair accessible': <Accessibility className="w-5 h-5" />,
|
||||
'accessible room': <Accessibility className="w-5 h-5" />,
|
||||
'elevator access': <Accessibility className="w-5 h-5" />,
|
||||
@@ -313,7 +309,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'hearing accessible': <Ear className="w-5 h-5" />,
|
||||
'visual alarm': <Eye className="w-5 h-5" />,
|
||||
|
||||
// Family & Pets
|
||||
|
||||
'family room': <Users className="w-5 h-5" />,
|
||||
'kids welcome': <BabyIcon className="w-5 h-5" />,
|
||||
'baby crib': <BabyIcon className="w-5 h-5" />,
|
||||
@@ -324,7 +320,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
pets: <PawPrint className="w-5 h-5" />,
|
||||
'pet friendly': <PawPrint className="w-5 h-5" />,
|
||||
|
||||
// Additional
|
||||
|
||||
'smoking room': <Cigarette className="w-5 h-5" />,
|
||||
'non-smoking room': <Shield className="w-5 h-5" />,
|
||||
'no smoking': <Cigarette className="w-5 h-5" />,
|
||||
@@ -345,7 +341,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
umbrella: <Home className="w-5 h-5" />,
|
||||
'shoe shine service': <Sparkles className="w-5 h-5" />,
|
||||
|
||||
// Luxury
|
||||
|
||||
fireplace: <Fireplace className="w-5 h-5" />,
|
||||
jacuzzi: <Waves className="w-5 h-5" />,
|
||||
'spa bath': <Bath className="w-5 h-5" />,
|
||||
@@ -360,10 +356,10 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'private elevator': <Building className="w-5 h-5" />,
|
||||
'panic button': <Shield className="w-5 h-5" />,
|
||||
|
||||
// Restaurant
|
||||
|
||||
restaurant: <Restaurant className="w-5 h-5" />,
|
||||
|
||||
// Special
|
||||
|
||||
library: <Briefcase className="w-5 h-5" />,
|
||||
'reading room': <Briefcase className="w-5 h-5" />,
|
||||
'study room': <Briefcase className="w-5 h-5" />,
|
||||
@@ -377,14 +373,14 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
};
|
||||
|
||||
const amenityLabels: Record<string, string> = {
|
||||
// Basic & Internet
|
||||
|
||||
wifi: 'Wi‑Fi',
|
||||
'wi-fi': 'Wi‑Fi',
|
||||
'free wifi': 'Free WiFi',
|
||||
'wifi in room': 'WiFi in Room',
|
||||
'high-speed internet': 'High-Speed Internet',
|
||||
|
||||
// Entertainment
|
||||
|
||||
tv: 'TV',
|
||||
television: 'TV',
|
||||
'flat-screen tv': 'Flat-Screen TV',
|
||||
@@ -405,7 +401,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'surround sound': 'Surround Sound',
|
||||
'music system': 'Music System',
|
||||
|
||||
// Climate
|
||||
|
||||
'air-conditioning': 'Air Conditioning',
|
||||
'air conditioning': 'Air Conditioning',
|
||||
ac: 'Air Conditioning',
|
||||
@@ -414,7 +410,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'ceiling fan': 'Ceiling Fan',
|
||||
'air purifier': 'Air Purifier',
|
||||
|
||||
// Bathroom
|
||||
|
||||
'private bathroom': 'Private Bathroom',
|
||||
'ensuite bathroom': 'Ensuite Bathroom',
|
||||
bathtub: 'Bathtub',
|
||||
@@ -433,7 +429,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'premium toiletries': 'Premium Toiletries',
|
||||
towels: 'Towels',
|
||||
|
||||
// Food & Beverage
|
||||
|
||||
'mini bar': 'Mini Bar',
|
||||
minibar: 'Mini Bar',
|
||||
refrigerator: 'Refrigerator',
|
||||
@@ -453,7 +449,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'complimentary water': 'Complimentary Water',
|
||||
'bottled water': 'Bottled Water',
|
||||
|
||||
// Furniture
|
||||
|
||||
desk: 'Desk',
|
||||
'writing desk': 'Writing Desk',
|
||||
'office desk': 'Office Desk',
|
||||
@@ -470,7 +466,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'full-length mirror': 'Full-Length Mirror',
|
||||
'seating area': 'Seating Area',
|
||||
|
||||
// Bed & Sleep
|
||||
|
||||
'king size bed': 'King Size Bed',
|
||||
'queen size bed': 'Queen Size Bed',
|
||||
'double bed': 'Double Bed',
|
||||
@@ -482,7 +478,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'blackout curtains': 'Blackout Curtains',
|
||||
soundproofing: 'Soundproofing',
|
||||
|
||||
// Safety & Security
|
||||
|
||||
safe: 'Safe',
|
||||
'in-room safe': 'In-Room Safe',
|
||||
'safety deposit box': 'Safety Deposit Box',
|
||||
@@ -492,7 +488,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'key card access': 'Key Card Access',
|
||||
'door lock': 'Door Lock',
|
||||
|
||||
// Technology
|
||||
|
||||
'usb charging ports': 'USB Charging Ports',
|
||||
'usb ports': 'USB Ports',
|
||||
'usb outlets': 'USB Outlets',
|
||||
@@ -506,7 +502,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'alarm clock': 'Alarm Clock',
|
||||
'digital clock': 'Digital Clock',
|
||||
|
||||
// View & Outdoor
|
||||
|
||||
balcony: 'Balcony',
|
||||
'private balcony': 'Private Balcony',
|
||||
terrace: 'Terrace',
|
||||
@@ -522,7 +518,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'large windows': 'Large Windows',
|
||||
'floor-to-ceiling windows': 'Floor-to-Ceiling Windows',
|
||||
|
||||
// Services
|
||||
|
||||
'24-hour front desk': '24/7 Front Desk',
|
||||
'24 hour front desk': '24/7 Front Desk',
|
||||
'24/7 front desk': '24/7 Front Desk',
|
||||
@@ -548,7 +544,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'car rental': 'Car Rental',
|
||||
'taxi service': 'Taxi Service',
|
||||
|
||||
// Fitness & Wellness
|
||||
|
||||
'gym access': 'Gym Access',
|
||||
'fitness center': 'Fitness Center',
|
||||
'fitness room': 'Fitness Room',
|
||||
@@ -561,7 +557,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'massage service': 'Massage Service',
|
||||
'beauty services': 'Beauty Services',
|
||||
|
||||
// Recreation
|
||||
|
||||
'swimming pool': 'Swimming Pool',
|
||||
pool: 'Swimming Pool',
|
||||
'indoor pool': 'Indoor Pool',
|
||||
@@ -573,7 +569,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'beach access': 'Beach Access',
|
||||
'water sports': 'Water Sports',
|
||||
|
||||
// Business
|
||||
|
||||
'business center': 'Business Center',
|
||||
'meeting room': 'Meeting Room',
|
||||
'conference room': 'Conference Room',
|
||||
@@ -582,7 +578,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'printing service': 'Printing Service',
|
||||
'secretarial services': 'Secretarial Services',
|
||||
|
||||
// Accessibility
|
||||
|
||||
'wheelchair accessible': 'Wheelchair Accessible',
|
||||
'accessible room': 'Accessible Room',
|
||||
'elevator access': 'Elevator Access',
|
||||
@@ -593,7 +589,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'hearing accessible': 'Hearing Accessible',
|
||||
'visual alarm': 'Visual Alarm',
|
||||
|
||||
// Family & Pets
|
||||
|
||||
'family room': 'Family Room',
|
||||
'kids welcome': 'Kids Welcome',
|
||||
'baby crib': 'Baby Crib',
|
||||
@@ -604,7 +600,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
pets: 'Pets Allowed',
|
||||
'pet friendly': 'Pet Friendly',
|
||||
|
||||
// Additional
|
||||
|
||||
'smoking room': 'Smoking Room',
|
||||
'non-smoking room': 'Non-Smoking Room',
|
||||
'no smoking': 'No Smoking',
|
||||
@@ -625,7 +621,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
umbrella: 'Umbrella',
|
||||
'shoe shine service': 'Shoe Shine Service',
|
||||
|
||||
// Luxury
|
||||
|
||||
fireplace: 'Fireplace',
|
||||
jacuzzi: 'Jacuzzi',
|
||||
'spa bath': 'Spa Bath',
|
||||
@@ -640,10 +636,10 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
'private elevator': 'Private Elevator',
|
||||
'panic button': 'Panic Button',
|
||||
|
||||
// Restaurant
|
||||
|
||||
restaurant: 'Restaurant',
|
||||
|
||||
// Special
|
||||
|
||||
library: 'Library',
|
||||
'reading room': 'Reading Room',
|
||||
'study room': 'Study Room',
|
||||
@@ -684,7 +680,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
|
||||
const getLabel = (amenity: string) => {
|
||||
const key = amenity.toLowerCase().trim();
|
||||
if (amenityLabels[key]) return amenityLabels[key];
|
||||
// Fallback: capitalize words and replace dashes/underscores
|
||||
|
||||
return amenity
|
||||
.toLowerCase()
|
||||
.replace(/[_-]/g, ' ')
|
||||
|
||||
@@ -27,15 +27,15 @@ const RoomCard: React.FC<RoomCardProps> = ({ room, compact = false }) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get first image - prioritize room-specific images over room type images
|
||||
|
||||
const imageUrl = (room.images && room.images.length > 0)
|
||||
? room.images[0]
|
||||
: (roomType.images?.[0] || '/images/room-placeholder.jpg');
|
||||
|
||||
// Format price using currency context - use room price if available, otherwise room type base price
|
||||
|
||||
const formattedPrice = formatCurrency(room.price || roomType.base_price);
|
||||
|
||||
// Prefer room-level amenities when available, otherwise use room type
|
||||
|
||||
const normalizeAmenities = (input: any): string[] => {
|
||||
if (Array.isArray(input)) return input;
|
||||
if (!input) return [];
|
||||
@@ -60,10 +60,10 @@ const RoomCard: React.FC<RoomCardProps> = ({ room, compact = false }) => {
|
||||
? normalizeAmenities(room.amenities)
|
||||
: normalizeAmenities(roomType.amenities);
|
||||
|
||||
// Get amenities (limit to 3 for display)
|
||||
|
||||
const amenities = amenitiesSource.slice(0, 3);
|
||||
|
||||
// Amenity icons mapping
|
||||
|
||||
const amenityIcons: Record<string, React.ReactNode> = {
|
||||
wifi: <Wifi className="w-4 h-4" />,
|
||||
tv: <Tv className="w-4 h-4" />,
|
||||
@@ -79,7 +79,7 @@ const RoomCard: React.FC<RoomCardProps> = ({ room, compact = false }) => {
|
||||
: 'border-transparent hover:border-[#d4af37] hover:shadow-luxury-gold'
|
||||
}`}
|
||||
>
|
||||
{/* Image */}
|
||||
{}
|
||||
<div className={`relative overflow-hidden
|
||||
bg-gradient-to-br from-gray-200 to-gray-300
|
||||
${compact ? 'h-32 sm:h-36' : 'h-40 sm:h-44 md:h-48 lg:h-52'}`}
|
||||
@@ -96,19 +96,19 @@ const RoomCard: React.FC<RoomCardProps> = ({ room, compact = false }) => {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Overlay gradient on hover */}
|
||||
{}
|
||||
<div className="absolute inset-0 bg-gradient-to-t
|
||||
from-black/60 via-transparent to-transparent
|
||||
opacity-0 group-hover:opacity-100 transition-opacity
|
||||
duration-300"
|
||||
/>
|
||||
|
||||
{/* Favorite Button */}
|
||||
{}
|
||||
<div className="absolute top-3 right-3 z-10">
|
||||
<FavoriteButton roomId={room.id} size="md" />
|
||||
</div>
|
||||
|
||||
{/* Featured Badge with Crown */}
|
||||
{}
|
||||
{room.featured && (
|
||||
<div
|
||||
className="absolute top-3 left-3 z-20
|
||||
@@ -123,7 +123,7 @@ const RoomCard: React.FC<RoomCardProps> = ({ room, compact = false }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Badge */}
|
||||
{}
|
||||
<div
|
||||
className={`absolute bottom-3 left-3 px-3 py-1.5
|
||||
rounded-sm text-xs font-medium tracking-wide
|
||||
@@ -144,9 +144,9 @@ const RoomCard: React.FC<RoomCardProps> = ({ room, compact = false }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{}
|
||||
<div className={`flex-1 flex flex-col ${compact ? 'p-3' : 'p-4 sm:p-5'}`}>
|
||||
{/* Room Type Name */}
|
||||
{}
|
||||
<h3 className={`font-serif font-semibold text-gray-900 mb-1.5 tracking-tight
|
||||
${compact ? 'text-base' : 'text-lg sm:text-xl'}
|
||||
flex items-center gap-2`}>
|
||||
@@ -160,7 +160,7 @@ const RoomCard: React.FC<RoomCardProps> = ({ room, compact = false }) => {
|
||||
<span>{roomType.name}</span>
|
||||
</h3>
|
||||
|
||||
{/* Room Number & Floor */}
|
||||
{}
|
||||
<div
|
||||
className={`flex items-center text-gray-600 font-light tracking-wide
|
||||
${compact ? 'text-xs mb-1.5' : 'text-xs sm:text-sm mb-2'}`}
|
||||
@@ -171,7 +171,7 @@ const RoomCard: React.FC<RoomCardProps> = ({ room, compact = false }) => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Description (truncated) - Show room-specific description first */}
|
||||
{}
|
||||
{(room.description || roomType.description) && !compact && (
|
||||
<p className="text-gray-600 text-xs sm:text-sm mb-3 line-clamp-2
|
||||
leading-relaxed font-light">
|
||||
@@ -179,7 +179,7 @@ const RoomCard: React.FC<RoomCardProps> = ({ room, compact = false }) => {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Capacity & Rating */}
|
||||
{}
|
||||
<div className={`flex items-center justify-between ${compact ? 'mb-1.5' : 'mb-3'}`}>
|
||||
<div className="flex items-center text-gray-700">
|
||||
<Users className={`${compact ? 'w-3 h-3' : 'w-4 h-4'} mr-1.5 text-[#d4af37]`} />
|
||||
@@ -206,7 +206,7 @@ const RoomCard: React.FC<RoomCardProps> = ({ room, compact = false }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Amenities */}
|
||||
{}
|
||||
{amenities.length > 0 && !compact && (
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 mb-3 sm:mb-4 flex-wrap">
|
||||
{amenities.map((amenity, index) => (
|
||||
@@ -229,7 +229,7 @@ const RoomCard: React.FC<RoomCardProps> = ({ room, compact = false }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Price & Action */}
|
||||
{}
|
||||
<div className={`flex flex-col sm:flex-row items-start sm:items-center justify-between mt-auto
|
||||
border-t border-gray-200
|
||||
${compact ? 'gap-2 pt-2' : 'gap-2 sm:gap-3 pt-3'}`}>
|
||||
|
||||
@@ -7,37 +7,37 @@ const RoomCardSkeleton: React.FC = () => {
|
||||
rounded-xl border border-[#d4af37]/20
|
||||
overflow-hidden animate-pulse shadow-lg shadow-[#d4af37]/5"
|
||||
>
|
||||
{/* Image Skeleton */}
|
||||
{}
|
||||
<div className="h-40 sm:h-44 md:h-48 lg:h-52 bg-gradient-to-br from-gray-800 to-gray-900" />
|
||||
|
||||
{/* Content Skeleton */}
|
||||
{}
|
||||
<div className="p-4 sm:p-5">
|
||||
{/* Title */}
|
||||
{}
|
||||
<div className="h-5 sm:h-6 bg-gray-800 rounded w-3/4 mb-2" />
|
||||
|
||||
{/* Room Number */}
|
||||
{}
|
||||
<div className="h-3 sm:h-4 bg-gray-800 rounded w-1/2 mb-2" />
|
||||
|
||||
{/* Description */}
|
||||
{}
|
||||
<div className="space-y-1.5 mb-3">
|
||||
<div className="h-3 bg-gray-800 rounded w-full" />
|
||||
<div className="h-3 bg-gray-800 rounded w-5/6" />
|
||||
</div>
|
||||
|
||||
{/* Capacity & Rating */}
|
||||
{}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="h-3 sm:h-4 bg-gray-800 rounded w-20" />
|
||||
<div className="h-3 sm:h-4 bg-gray-800 rounded w-16" />
|
||||
</div>
|
||||
|
||||
{/* Amenities */}
|
||||
{}
|
||||
<div className="flex gap-1.5 sm:gap-2 mb-3 sm:mb-4">
|
||||
<div className="h-5 sm:h-6 bg-gray-800 rounded w-14 sm:w-16" />
|
||||
<div className="h-5 sm:h-6 bg-gray-800 rounded w-14 sm:w-16" />
|
||||
<div className="h-5 sm:h-6 bg-gray-800 rounded w-14 sm:w-16" />
|
||||
</div>
|
||||
|
||||
{/* Price & Button */}
|
||||
{}
|
||||
<div
|
||||
className="flex items-center justify-between
|
||||
pt-3 border-t border-[#d4af37]/20"
|
||||
|
||||
@@ -5,7 +5,7 @@ import RoomCard from './RoomCard';
|
||||
|
||||
interface RoomCarouselProps {
|
||||
rooms: Room[];
|
||||
autoSlideInterval?: number; // in milliseconds, default 4000
|
||||
autoSlideInterval?: number;
|
||||
showNavigation?: boolean;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ const RoomCarousel: React.FC<RoomCarouselProps> = ({
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
// Auto-slide functionality
|
||||
|
||||
useEffect(() => {
|
||||
if (rooms.length <= 1) return;
|
||||
|
||||
@@ -59,33 +59,33 @@ const RoomCarousel: React.FC<RoomCarouselProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate transform for responsive carousel
|
||||
// Mobile: show 1 card (100% width), Tablet: show 2 cards (50% width), Desktop: show 3 cards (33.33% width)
|
||||
|
||||
|
||||
const getTransform = () => {
|
||||
if (rooms.length === 1) {
|
||||
return 'translateX(0)';
|
||||
}
|
||||
|
||||
// For desktop (3 cards): use 33.33% per card
|
||||
// For tablet (2 cards): use 50% per card
|
||||
// For mobile (1 card): use 100% per card
|
||||
|
||||
// We calculate for desktop (3 cards) as base, CSS will handle responsive widths
|
||||
|
||||
|
||||
|
||||
|
||||
let offset = 0;
|
||||
if (rooms.length <= 3) {
|
||||
offset = 0;
|
||||
} else if (currentIndex === 0) {
|
||||
offset = 0; // Show first cards
|
||||
offset = 0;
|
||||
} else if (currentIndex === rooms.length - 1) {
|
||||
offset = (rooms.length - 3) * 33.33; // Show last 3 cards
|
||||
offset = (rooms.length - 3) * 33.33;
|
||||
} else {
|
||||
offset = (currentIndex - 1) * 33.33; // Center the current card
|
||||
offset = (currentIndex - 1) * 33.33;
|
||||
}
|
||||
|
||||
return `translateX(-${offset}%)`;
|
||||
};
|
||||
|
||||
// Determine which card should be highlighted as center (for desktop 3-card view)
|
||||
|
||||
const getCenterIndex = () => {
|
||||
if (rooms.length === 1) {
|
||||
return 0;
|
||||
@@ -94,24 +94,24 @@ const RoomCarousel: React.FC<RoomCarouselProps> = ({
|
||||
return currentIndex === 0 ? 0 : 1;
|
||||
}
|
||||
if (rooms.length === 3) {
|
||||
return 1; // Always highlight middle card when showing all 3
|
||||
return 1;
|
||||
}
|
||||
if (currentIndex === 0) {
|
||||
return 1; // Second card when showing first 3
|
||||
return 1;
|
||||
}
|
||||
if (currentIndex === rooms.length - 1) {
|
||||
return rooms.length - 2; // Second to last when showing last 3
|
||||
return rooms.length - 2;
|
||||
}
|
||||
return currentIndex; // Current card is center
|
||||
return currentIndex;
|
||||
};
|
||||
|
||||
const centerIndex = getCenterIndex();
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-6xl mx-auto">
|
||||
{/* Carousel Container */}
|
||||
{}
|
||||
<div className="relative overflow-hidden px-2 sm:px-3 md:px-4">
|
||||
{/* Room Cards Container */}
|
||||
{}
|
||||
<div
|
||||
className="flex transition-transform duration-500 ease-in-out"
|
||||
style={{
|
||||
@@ -119,7 +119,7 @@ const RoomCarousel: React.FC<RoomCarouselProps> = ({
|
||||
}}
|
||||
>
|
||||
{rooms.map((room, index) => {
|
||||
// For mobile: all cards are "center", for tablet/desktop: use centerIndex
|
||||
|
||||
const isCenter = index === centerIndex || rooms.length <= 2;
|
||||
|
||||
return (
|
||||
@@ -142,7 +142,7 @@ const RoomCarousel: React.FC<RoomCarouselProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Arrows */}
|
||||
{}
|
||||
{showNavigation && rooms.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
@@ -187,7 +187,7 @@ const RoomCarousel: React.FC<RoomCarouselProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dots Indicator */}
|
||||
{}
|
||||
{rooms.length > 1 && (
|
||||
<div className="flex justify-center items-center gap-1.5 sm:gap-2 mt-3 sm:mt-4">
|
||||
{rooms.map((_, index) => (
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'react-datepicker/dist/react-datepicker.css';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
import { Calendar, DollarSign, Users, X } from 'lucide-react';
|
||||
// no debounce needed when apply-on-submit is used
|
||||
|
||||
interface RoomFilterProps {
|
||||
onFilterChange?: (filters: FilterValues) => void;
|
||||
@@ -52,9 +51,9 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
searchParams.get('to') ? new Date(searchParams.get('to')!) : null
|
||||
);
|
||||
|
||||
// no debounce needed — apply on submit
|
||||
|
||||
|
||||
// Sync filters with URL on mount and URL changes
|
||||
|
||||
useEffect(() => {
|
||||
const type = searchParams.get('type') || '';
|
||||
const minPrice = searchParams.get('minPrice')
|
||||
@@ -71,12 +70,12 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
|
||||
setFilters({ type, minPrice, maxPrice, capacity, from, to });
|
||||
|
||||
// Sync local date state
|
||||
|
||||
const checkIn = from ? new Date(from) : null;
|
||||
const checkOut = to ? new Date(to) : null;
|
||||
|
||||
setCheckInDate(checkIn);
|
||||
// Validate checkout date - if it's before or equal to check-in, reset it
|
||||
|
||||
if (checkIn && checkOut && checkOut <= checkIn) {
|
||||
setCheckOutDate(null);
|
||||
} else {
|
||||
@@ -84,14 +83,14 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Reset checkout date if it's invalid when check-in changes
|
||||
|
||||
useEffect(() => {
|
||||
if (checkInDate && checkOutDate && checkOutDate <= checkInDate) {
|
||||
setCheckOutDate(null);
|
||||
}
|
||||
}, [checkInDate, checkOutDate]);
|
||||
|
||||
// Load amenities from API
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
import('../../services/api/roomService').then((mod) => {
|
||||
@@ -115,7 +114,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Use formatCurrency from hook which uses platform currency
|
||||
|
||||
const formatCurrency = (n?: number): string => {
|
||||
if (n == null) return '';
|
||||
return formatCurrencyUtil(n);
|
||||
@@ -126,13 +125,13 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
// Room type select
|
||||
|
||||
if (name === 'type') {
|
||||
setFilters((prev) => ({ ...prev, type: value || '' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Capacity input
|
||||
|
||||
if (name === 'capacity') {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
@@ -141,14 +140,14 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Price inputs: allow formatted VN style with dots
|
||||
|
||||
if (name === 'minPrice' || name === 'maxPrice') {
|
||||
const parsed = parseCurrency(value);
|
||||
setFilters((prev) => ({ ...prev, [name]: parsed }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback numeric parsing
|
||||
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
[name]: value === '' ? undefined : Number(value),
|
||||
@@ -157,19 +156,19 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
|
||||
const formatDate = (d: Date) => d.toISOString().split('T')[0];
|
||||
|
||||
// Filters are applied only when user clicks "Áp dụng".
|
||||
// Debounced values are kept for UX but won't auto-submit.
|
||||
|
||||
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Build new search params
|
||||
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
|
||||
// Reset page to 1 when filters change
|
||||
|
||||
newParams.set('page', '1');
|
||||
|
||||
// Update search params with filter values
|
||||
|
||||
if (filters.type) {
|
||||
newParams.set('type', filters.type);
|
||||
} else {
|
||||
@@ -194,7 +193,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
newParams.delete('capacity');
|
||||
}
|
||||
|
||||
// Dates
|
||||
|
||||
if (checkInDate) {
|
||||
newParams.set('from', formatDate(checkInDate));
|
||||
} else {
|
||||
@@ -206,7 +205,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
} else {
|
||||
newParams.delete('to');
|
||||
}
|
||||
// Amenities
|
||||
|
||||
if (selectedAmenities.length > 0) {
|
||||
newParams.set('amenities', selectedAmenities.join(','));
|
||||
} else {
|
||||
@@ -218,7 +217,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
...filters,
|
||||
from: checkInDate ? formatDate(checkInDate) : undefined,
|
||||
to: checkOutDate ? formatDate(checkOutDate) : undefined,
|
||||
// include amenities
|
||||
|
||||
...(selectedAmenities.length > 0 ? { amenities: selectedAmenities.join(',') } : {}),
|
||||
});
|
||||
};
|
||||
@@ -237,7 +236,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
setCheckOutDate(null);
|
||||
setSelectedAmenities([]);
|
||||
|
||||
// Reset URL params but keep the base /rooms path
|
||||
|
||||
setSearchParams({});
|
||||
onFilterChange?.({});
|
||||
};
|
||||
@@ -267,7 +266,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
</h2>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
||||
{/* Room Type */}
|
||||
{}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="type"
|
||||
@@ -305,7 +304,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Range */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
@@ -320,7 +319,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
selected={checkInDate}
|
||||
onChange={(date: Date | null) => {
|
||||
setCheckInDate(date);
|
||||
// Reset checkout if it becomes invalid
|
||||
|
||||
if (date && checkOutDate && checkOutDate <= date) {
|
||||
setCheckOutDate(null);
|
||||
}
|
||||
@@ -377,7 +376,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
endDate={checkOutDate}
|
||||
minDate={
|
||||
checkInDate
|
||||
? new Date(checkInDate.getTime() + 24 * 60 * 60 * 1000) // Next day after check-in
|
||||
? new Date(checkInDate.getTime() + 24 * 60 * 60 * 1000)
|
||||
: new Date()
|
||||
}
|
||||
disabled={!checkInDate}
|
||||
@@ -416,7 +415,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Range */}
|
||||
{}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-200 mb-3 tracking-wide flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4 text-[#d4af37]" />
|
||||
@@ -492,7 +491,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Capacity */}
|
||||
{}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="capacity"
|
||||
@@ -526,7 +525,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amenities */}
|
||||
{}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-200 mb-3 tracking-wide">
|
||||
Amenities
|
||||
@@ -569,7 +568,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
{}
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -40,9 +40,9 @@ const RoomGallery: React.FC<RoomGalleryProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Main Gallery */}
|
||||
{}
|
||||
<div className="grid grid-cols-4 gap-2 h-96">
|
||||
{/* Main Image */}
|
||||
{}
|
||||
<div
|
||||
className="col-span-4 md:col-span-3 relative
|
||||
overflow-hidden rounded-lg cursor-pointer
|
||||
@@ -67,12 +67,12 @@ const RoomGallery: React.FC<RoomGalleryProps> = ({
|
||||
opacity-0 group-hover:opacity-100
|
||||
transition-opacity"
|
||||
>
|
||||
Xem ảnh lớn
|
||||
View large image
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail Grid */}
|
||||
{}
|
||||
<div
|
||||
className="hidden md:flex flex-col gap-2
|
||||
col-span-1"
|
||||
@@ -99,7 +99,7 @@ const RoomGallery: React.FC<RoomGalleryProps> = ({
|
||||
justify-center"
|
||||
>
|
||||
<span className="text-white font-semibold">
|
||||
+{safeImages.length - 4} ảnh
|
||||
+{safeImages.length - 4} images
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -108,7 +108,7 @@ const RoomGallery: React.FC<RoomGalleryProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Lightbox */}
|
||||
{}
|
||||
{isModalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black
|
||||
|
||||
@@ -20,7 +20,7 @@ const SearchRoomForm: React.FC<SearchRoomFormProps> = ({
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
// Check if mobile on mount and resize
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 640);
|
||||
@@ -30,14 +30,14 @@ const SearchRoomForm: React.FC<SearchRoomFormProps> = ({
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
// Set minimum date to today
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validation
|
||||
|
||||
if (!checkInDate) {
|
||||
toast.error('Please select check-in date');
|
||||
return;
|
||||
@@ -48,7 +48,7 @@ const SearchRoomForm: React.FC<SearchRoomFormProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if check-in is not in the past
|
||||
|
||||
const checkInStart = new Date(checkInDate);
|
||||
checkInStart.setHours(0, 0, 0, 0);
|
||||
|
||||
@@ -59,7 +59,7 @@ const SearchRoomForm: React.FC<SearchRoomFormProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if check-out is after check-in
|
||||
|
||||
if (checkOutDate <= checkInDate) {
|
||||
toast.error(
|
||||
'Check-out date must be after check-in date'
|
||||
@@ -67,12 +67,12 @@ const SearchRoomForm: React.FC<SearchRoomFormProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Format dates to YYYY-MM-DD
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
// Build search params
|
||||
|
||||
const params = new URLSearchParams({
|
||||
from: formatDate(checkInDate),
|
||||
to: formatDate(checkOutDate),
|
||||
@@ -82,29 +82,29 @@ const SearchRoomForm: React.FC<SearchRoomFormProps> = ({
|
||||
params.append('type', roomType.trim());
|
||||
}
|
||||
|
||||
// Append guest count (capacity)
|
||||
|
||||
if (guestCount && guestCount > 0) {
|
||||
params.append('capacity', String(guestCount));
|
||||
}
|
||||
|
||||
// Navigate to search results
|
||||
|
||||
setIsSearching(true);
|
||||
navigate(`/rooms/search?${params.toString()}`);
|
||||
};
|
||||
|
||||
// Reset helper (kept for potential future use)
|
||||
// const handleReset = () => {
|
||||
// setCheckInDate(null);
|
||||
// setCheckOutDate(null);
|
||||
// setRoomType('');
|
||||
// setGuestCount(1);
|
||||
// };
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const isOverlay = className.includes('overlay');
|
||||
|
||||
return (
|
||||
<div className={`w-full ${isOverlay ? 'bg-transparent shadow-none border-none p-0' : 'luxury-glass rounded-sm shadow-2xl p-6 border border-[#d4af37]/20'} ${className}`}>
|
||||
{/* Title - Hidden on mobile when in overlay mode */}
|
||||
{}
|
||||
{(!isOverlay || !isMobile) && (
|
||||
<div className={`flex items-center justify-center gap-2 sm:gap-3 ${isOverlay ? 'mb-2 sm:mb-3 md:mb-4 lg:mb-6' : 'mb-6'}`}>
|
||||
<div className={`${isOverlay ? 'w-0.5 sm:w-1' : 'w-1'} ${isOverlay ? 'h-4 sm:h-6 md:h-8' : 'h-8'} bg-gradient-to-b from-[#d4af37] to-[#c9a227]`}></div>
|
||||
|
||||
65
Frontend/src/contexts/ChatNotificationContext.tsx
Normal file
65
Frontend/src/contexts/ChatNotificationContext.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React, { createContext, useContext, useState, ReactNode, useEffect } from 'react';
|
||||
import useAuthStore from '../store/useAuthStore';
|
||||
import { chatService } from '../services/api';
|
||||
|
||||
interface ChatNotificationContextType {
|
||||
unreadCount: number;
|
||||
pendingChatsCount: number;
|
||||
refreshCount: () => Promise<void>;
|
||||
}
|
||||
|
||||
const ChatNotificationContext = createContext<ChatNotificationContextType | undefined>(undefined);
|
||||
|
||||
export const useChatNotifications = () => {
|
||||
const context = useContext(ChatNotificationContext);
|
||||
if (!context) {
|
||||
return { unreadCount: 0, pendingChatsCount: 0, refreshCount: async () => {} };
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface ChatNotificationProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ChatNotificationProvider: React.FC<ChatNotificationProviderProps> = ({ children }) => {
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [pendingChatsCount, setPendingChatsCount] = useState(0);
|
||||
const { isAuthenticated, userInfo } = useAuthStore();
|
||||
|
||||
const refreshCount = async () => {
|
||||
if (!isAuthenticated || (userInfo?.role !== 'staff' && userInfo?.role !== 'admin')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await chatService.listChats('pending');
|
||||
if (response.success) {
|
||||
setPendingChatsCount(response.data.length);
|
||||
setUnreadCount(response.data.length);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching pending chats:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && (userInfo?.role === 'staff' || userInfo?.role === 'admin')) {
|
||||
refreshCount();
|
||||
|
||||
const interval = setInterval(refreshCount, 30000);
|
||||
return () => clearInterval(interval);
|
||||
} else {
|
||||
setUnreadCount(0);
|
||||
setPendingChatsCount(0);
|
||||
}
|
||||
|
||||
}, [isAuthenticated, userInfo?.role]);
|
||||
|
||||
return (
|
||||
<ChatNotificationContext.Provider value={{ unreadCount, pendingChatsCount, refreshCount }}>
|
||||
{children}
|
||||
</ChatNotificationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -51,7 +51,7 @@ export const CompanySettingsProvider: React.FC<CompanySettingsProviderProps> = (
|
||||
const [settings, setSettings] = useState<CompanySettings>(defaultSettings);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
// Load company settings from system settings
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@@ -67,31 +67,31 @@ export const CompanySettingsProvider: React.FC<CompanySettingsProviderProps> = (
|
||||
company_address: response.data.company_address || defaultSettings.company_address,
|
||||
});
|
||||
|
||||
// Update favicon if available
|
||||
|
||||
if (response.data.company_favicon_url) {
|
||||
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
const faviconUrl = response.data.company_favicon_url.startsWith('http')
|
||||
? response.data.company_favicon_url
|
||||
: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${response.data.company_favicon_url}`;
|
||||
: `${baseUrl}${response.data.company_favicon_url}`;
|
||||
|
||||
// Remove existing favicon links
|
||||
const existingLinks = document.querySelectorAll("link[rel~='icon']");
|
||||
existingLinks.forEach(link => link.remove());
|
||||
|
||||
// Add new favicon
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
link.href = faviconUrl;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
// Update page title if company name is set
|
||||
|
||||
if (response.data.company_name) {
|
||||
document.title = response.data.company_name;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading company settings:', error);
|
||||
// Keep default settings
|
||||
|
||||
setSettings(defaultSettings);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -101,7 +101,7 @@ export const CompanySettingsProvider: React.FC<CompanySettingsProviderProps> = (
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
|
||||
// Listen for refresh events from settings page
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadSettings();
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import React, {
|
||||
useState,
|
||||
} from 'react';
|
||||
import privacyService, {
|
||||
CookieCategoryPreferences,
|
||||
CookieConsent,
|
||||
UpdateCookieConsentRequest,
|
||||
} from '../services/api/privacyService';
|
||||
@@ -38,7 +37,7 @@ export const CookieConsentProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
if (!isMounted) return;
|
||||
setConsent(data);
|
||||
|
||||
// Prefer explicit local decision flag, fall back to server flag
|
||||
|
||||
const localFlag =
|
||||
typeof window !== 'undefined'
|
||||
? window.localStorage.getItem('cookieConsentDecided')
|
||||
@@ -48,7 +47,7 @@ export const CookieConsentProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
setHasDecided(decided);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.error('Failed to load cookie consent', error);
|
||||
}
|
||||
} finally {
|
||||
@@ -94,14 +93,14 @@ export const CookieConsentProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
export const useCookieConsent = (): CookieConsentContextValue => {
|
||||
const ctx = useContext(CookieConsentContext);
|
||||
if (!ctx) {
|
||||
// Fallback to a safe default instead of throwing, to avoid crashes
|
||||
// if components are rendered outside the provider (e.g. during hot reload).
|
||||
|
||||
|
||||
return {
|
||||
consent: null,
|
||||
isLoading: false,
|
||||
hasDecided: false,
|
||||
updateConsent: async () => {
|
||||
// no-op when context is not yet available
|
||||
|
||||
return;
|
||||
},
|
||||
};
|
||||
@@ -109,4 +108,3 @@ export const useCookieConsent = (): CookieConsentContextValue => {
|
||||
return ctx;
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export const CurrencyProvider: React.FC<CurrencyProviderProps> = ({ children })
|
||||
'CAD',
|
||||
];
|
||||
|
||||
// Load platform currency from system settings
|
||||
|
||||
const loadCurrency = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@@ -55,16 +55,16 @@ export const CurrencyProvider: React.FC<CurrencyProviderProps> = ({ children })
|
||||
if (response.data?.currency && supportedCurrencies.includes(response.data.currency)) {
|
||||
const platformCurrency = response.data.currency;
|
||||
setCurrencyState(platformCurrency);
|
||||
// Store in localStorage so formatCurrency can access it
|
||||
|
||||
localStorage.setItem('currency', platformCurrency);
|
||||
} else {
|
||||
// Fallback to default
|
||||
|
||||
setCurrencyState(CURRENCY.VND);
|
||||
localStorage.setItem('currency', CURRENCY.VND);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading platform currency:', error);
|
||||
// Fallback to default
|
||||
|
||||
setCurrencyState(CURRENCY.VND);
|
||||
localStorage.setItem('currency', CURRENCY.VND);
|
||||
} finally {
|
||||
@@ -78,7 +78,7 @@ export const CurrencyProvider: React.FC<CurrencyProviderProps> = ({ children })
|
||||
|
||||
const refreshCurrency = async () => {
|
||||
await loadCurrency();
|
||||
// Dispatch a custom event to notify all components of currency change
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('currencyChanged', {
|
||||
detail: { currency: localStorage.getItem('currency') || CURRENCY.VND }
|
||||
|
||||
@@ -1,64 +1,57 @@
|
||||
// Seed data for luxury hotel content
|
||||
export const luxuryContentSeed = {
|
||||
import { UpdatePageContentData } from '../services/api';
|
||||
|
||||
export const luxuryContentSeed: {
|
||||
home: Partial<UpdatePageContentData>;
|
||||
} = {
|
||||
home: {
|
||||
luxury_section_title: 'Experience Unparalleled Luxury',
|
||||
luxury_section_subtitle: 'Where elegance meets comfort in every detail',
|
||||
luxury_section_image: '',
|
||||
luxury_features: [
|
||||
{
|
||||
icon: 'Sparkles',
|
||||
title: 'Premium Amenities',
|
||||
description: 'World-class facilities designed for your comfort and relaxation'
|
||||
},
|
||||
{
|
||||
icon: 'Crown',
|
||||
title: 'Royal Service',
|
||||
description: 'Dedicated concierge service available 24/7 for all your needs'
|
||||
title: 'Premium Accommodations',
|
||||
description: 'Indulge in our exquisitely designed suites and rooms, each thoughtfully crafted to provide the ultimate in comfort and sophistication.'
|
||||
},
|
||||
{
|
||||
icon: 'Award',
|
||||
title: 'Award-Winning',
|
||||
description: 'Recognized for excellence in hospitality and guest satisfaction'
|
||||
icon: 'Sparkles',
|
||||
title: 'World-Class Service',
|
||||
description: 'Our dedicated team of hospitality professionals is committed to exceeding your expectations with personalized, attentive service.'
|
||||
},
|
||||
{
|
||||
icon: 'Shield',
|
||||
title: 'Secure & Private',
|
||||
description: 'Your privacy and security are our top priorities'
|
||||
icon: 'Wine',
|
||||
title: 'Fine Dining',
|
||||
description: 'Savor exceptional culinary experiences at our award-winning restaurants, featuring world-renowned chefs and the finest ingredients.'
|
||||
},
|
||||
{
|
||||
icon: 'Heart',
|
||||
title: 'Personalized Care',
|
||||
description: 'Tailored experiences crafted just for you'
|
||||
},
|
||||
{
|
||||
icon: 'Gem',
|
||||
title: 'Luxury Design',
|
||||
description: 'Elegantly designed spaces with attention to every detail'
|
||||
icon: 'Umbrella',
|
||||
title: 'Exclusive Amenities',
|
||||
description: 'Enjoy access to our state-of-the-art spa, fitness center, and exclusive concierge services designed to enhance your stay.'
|
||||
}
|
||||
],
|
||||
luxury_gallery: [],
|
||||
luxury_testimonials: [
|
||||
{
|
||||
name: 'Sarah Johnson',
|
||||
title: 'Business Executive',
|
||||
quote: 'An absolutely stunning experience. The attention to detail and level of service exceeded all expectations.',
|
||||
title: 'Luxury Travel Enthusiast',
|
||||
quote: 'An absolutely breathtaking experience. Every detail was perfect, from the elegant decor to the impeccable service. This is what true luxury hospitality means.',
|
||||
image: ''
|
||||
},
|
||||
{
|
||||
name: 'Michael Chen',
|
||||
title: 'Travel Enthusiast',
|
||||
quote: 'The epitome of luxury. Every moment was perfect, from check-in to check-out.',
|
||||
title: 'Business Executive',
|
||||
quote: 'The perfect blend of sophistication and comfort. The attention to detail and personalized service made my stay truly memorable. Highly recommended.',
|
||||
image: ''
|
||||
},
|
||||
{
|
||||
name: 'Emma Williams',
|
||||
title: 'Luxury Traveler',
|
||||
quote: 'This hotel redefines what luxury means. I will definitely return.',
|
||||
title: 'Travel Blogger',
|
||||
quote: 'From the moment I arrived, I was treated like royalty. The luxurious amenities and world-class service exceeded all my expectations. A truly exceptional experience.',
|
||||
image: ''
|
||||
}
|
||||
],
|
||||
about_preview_title: 'About Our Luxury Hotel',
|
||||
about_preview_content: 'Discover a world of refined elegance and exceptional service. Our hotel combines timeless luxury with modern amenities to create an unforgettable experience.',
|
||||
about_preview_title: 'Discover Our Story',
|
||||
about_preview_content: 'For decades, we have been dedicated to creating extraordinary experiences for our guests. Our commitment to excellence, attention to detail, and passion for hospitality have made us a destination of choice for discerning travelers seeking the finest in luxury accommodations.',
|
||||
about_preview_image: ''
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
/**
|
||||
* Example: How to use useAuthStore in components
|
||||
*
|
||||
* This file is for reference only, should not be used
|
||||
* in production
|
||||
*/
|
||||
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useAuthStore from '../store/useAuthStore';
|
||||
|
||||
// ============================================
|
||||
// Example 1: Login Component
|
||||
// ============================================
|
||||
export const LoginExample = () => {
|
||||
const { login, isLoading, error } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogin = async (
|
||||
email: string,
|
||||
password: string
|
||||
) => {
|
||||
try {
|
||||
await login({ email, password });
|
||||
navigate('/dashboard');
|
||||
} catch (error) {
|
||||
// Error has been handled in store
|
||||
console.error('Login failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && <p className="text-red-500">{error}</p>}
|
||||
<button
|
||||
onClick={() => handleLogin(
|
||||
'user@example.com',
|
||||
'password123'
|
||||
)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Processing...' : 'Login'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Example 2: Register Component
|
||||
// ============================================
|
||||
export const RegisterExample = () => {
|
||||
const { register, isLoading } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleRegister = async () => {
|
||||
try {
|
||||
await register({
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
password: 'password123',
|
||||
phone: '0123456789'
|
||||
});
|
||||
navigate('/login');
|
||||
} catch (error) {
|
||||
console.error('Register failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleRegister}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Processing...' : 'Register'}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Example 3: User Profile Display
|
||||
// ============================================
|
||||
export const UserProfileExample = () => {
|
||||
const { userInfo, isAuthenticated } = useAuthStore();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <p>Please login</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>User Information</h2>
|
||||
<p>Name: {userInfo?.name}</p>
|
||||
<p>Email: {userInfo?.email}</p>
|
||||
<p>Role: {userInfo?.role}</p>
|
||||
{userInfo?.avatar && (
|
||||
<img
|
||||
src={userInfo.avatar}
|
||||
alt={userInfo.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Example 4: Logout Button
|
||||
// ============================================
|
||||
export const LogoutButtonExample = () => {
|
||||
const { logout, isLoading } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Processing...' : 'Logout'}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Example 5: Forgot Password
|
||||
// ============================================
|
||||
export const ForgotPasswordExample = () => {
|
||||
const { forgotPassword, isLoading } = useAuthStore();
|
||||
|
||||
const handleForgotPassword = async (
|
||||
email: string
|
||||
) => {
|
||||
try {
|
||||
await forgotPassword({ email });
|
||||
// Toast will display success message
|
||||
} catch (error) {
|
||||
console.error('Forgot password failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() =>
|
||||
handleForgotPassword('user@example.com')
|
||||
}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Send password reset email
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Example 6: Reset Password
|
||||
// ============================================
|
||||
export const ResetPasswordExample = () => {
|
||||
const { resetPassword, isLoading } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleResetPassword = async (
|
||||
token: string,
|
||||
password: string
|
||||
) => {
|
||||
try {
|
||||
await resetPassword({
|
||||
token,
|
||||
password,
|
||||
confirmPassword: password
|
||||
});
|
||||
navigate('/login');
|
||||
} catch (error) {
|
||||
console.error('Reset password failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() =>
|
||||
handleResetPassword(
|
||||
'reset-token-123',
|
||||
'newpassword123'
|
||||
)
|
||||
}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Reset Password
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Example 7: Conditional Rendering by Role
|
||||
// ============================================
|
||||
export const RoleBasedComponentExample = () => {
|
||||
const { userInfo } = useAuthStore();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{userInfo?.role === 'admin' && (
|
||||
<button>Admin Panel</button>
|
||||
)}
|
||||
{userInfo?.role === 'staff' && (
|
||||
<button>Staff Tools</button>
|
||||
)}
|
||||
{userInfo?.role === 'customer' && (
|
||||
<button>Customer Dashboard</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Example 8: Auth State Check
|
||||
// ============================================
|
||||
export const AuthStateCheckExample = () => {
|
||||
const {
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
token
|
||||
} = useAuthStore();
|
||||
|
||||
if (isLoading) {
|
||||
return <p>Loading...</p>;
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !token) {
|
||||
return <p>You are not logged in</p>;
|
||||
}
|
||||
|
||||
return <p>You are logged in</p>;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Example 9: Update User Info
|
||||
// ============================================
|
||||
export const UpdateUserInfoExample = () => {
|
||||
const { userInfo, setUser } = useAuthStore();
|
||||
|
||||
const handleUpdateProfile = () => {
|
||||
if (userInfo) {
|
||||
setUser({
|
||||
...userInfo,
|
||||
name: 'New Name',
|
||||
avatar: 'https://example.com/avatar.jpg'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={handleUpdateProfile}>
|
||||
Update Information
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Example 10: Clear Error
|
||||
// ============================================
|
||||
export const ErrorHandlingExample = () => {
|
||||
const { error, clearError } = useAuthStore();
|
||||
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-red-100 p-4 rounded">
|
||||
<p className="text-red-800">{error}</p>
|
||||
<button
|
||||
onClick={clearError}
|
||||
className="mt-2 text-sm text-red-600"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -14,9 +14,6 @@ interface UseAsyncState<T> {
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for handling async operations with loading and error states
|
||||
*/
|
||||
export function useAsync<T>(
|
||||
asyncFunction: (...args: any[]) => Promise<T>,
|
||||
options: UseAsyncOptions<T> = {}
|
||||
@@ -79,7 +76,7 @@ export function useAsync<T>(
|
||||
if (immediate) {
|
||||
execute();
|
||||
}
|
||||
}, [immediate]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [immediate]);
|
||||
|
||||
return { data, loading, error, execute, reset };
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { useEffect, RefObject } from 'react';
|
||||
|
||||
/**
|
||||
* Hook to detect clicks outside of a ref element
|
||||
*/
|
||||
export function useClickOutside<T extends HTMLElement>(
|
||||
ref: RefObject<T>,
|
||||
handler: (event: MouseEvent | TouchEvent) => void
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const listener = (event: MouseEvent | TouchEvent) => {
|
||||
// Do nothing if clicking ref's element or descendent elements
|
||||
|
||||
if (!ref.current || ref.current.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Custom hook for debouncing values
|
||||
* Delays updating the debounced value until after the delay period
|
||||
* @param value - The value to debounce
|
||||
* @param delay - Delay in milliseconds (default: 500ms)
|
||||
* @returns The debounced value
|
||||
*/
|
||||
function useDebounce<T>(value: T, delay: number = 500): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
// Set up the timeout
|
||||
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
// Clean up the timeout if value changes before delay
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
|
||||
@@ -2,9 +2,6 @@ import { useMemo } from 'react';
|
||||
import { useCurrency } from '../contexts/CurrencyContext';
|
||||
import { formatCurrency as formatCurrencyUtil } from '../utils/format';
|
||||
|
||||
/**
|
||||
* Hook to format currency using the current currency from CurrencyContext
|
||||
*/
|
||||
export const useFormatCurrency = () => {
|
||||
const { currency } = useCurrency();
|
||||
|
||||
|
||||
@@ -2,14 +2,11 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
type SetValue<T> = T | ((val: T) => T);
|
||||
|
||||
/**
|
||||
* Hook for managing localStorage with React state
|
||||
*/
|
||||
export function useLocalStorage<T>(
|
||||
key: string,
|
||||
initialValue: T
|
||||
): [T, (value: SetValue<T>) => void, () => void] {
|
||||
// State to store our value
|
||||
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
@@ -20,12 +17,12 @@ export function useLocalStorage<T>(
|
||||
}
|
||||
});
|
||||
|
||||
// Return a wrapped version of useState's setter function that
|
||||
// persists the new value to localStorage.
|
||||
|
||||
|
||||
const setValue = useCallback(
|
||||
(value: SetValue<T>) => {
|
||||
try {
|
||||
// Allow value to be a function so we have the same API as useState
|
||||
|
||||
const valueToStore =
|
||||
value instanceof Function ? value(storedValue) : value;
|
||||
|
||||
@@ -47,7 +44,7 @@ export function useLocalStorage<T>(
|
||||
}
|
||||
}, [key, initialValue]);
|
||||
|
||||
// Listen for changes to the key in other tabs/windows
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === key && e.newValue) {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Hook to detect if the user is offline
|
||||
*/
|
||||
export function useOffline(): boolean {
|
||||
const [isOffline, setIsOffline] = useState(() => {
|
||||
if (typeof navigator !== 'undefined' && 'onLine' in navigator) {
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Custom hook for monitoring page performance
|
||||
* Logs page load times in development mode
|
||||
* @param pageName - Name of the page/component to monitor
|
||||
*/
|
||||
function usePagePerformance(pageName: string) {
|
||||
const startTimeRef = useRef<number>(Date.now());
|
||||
|
||||
@@ -15,7 +10,7 @@ function usePagePerformance(pageName: string) {
|
||||
`[Performance] ${pageName} loaded in ${loadTime}ms`
|
||||
);
|
||||
|
||||
// Report Web Vitals if available
|
||||
|
||||
if ('performance' in window) {
|
||||
const perfData = window.performance.timing;
|
||||
const pageLoadTime =
|
||||
|
||||
@@ -25,7 +25,7 @@ const AboutPage: React.FC = () => {
|
||||
if (response.status === 'success' && response.data?.page_content) {
|
||||
setPageContent(response.data.page_content);
|
||||
|
||||
// Update document title and meta tags
|
||||
|
||||
if (response.data.page_content.meta_title) {
|
||||
document.title = response.data.page_content.meta_title;
|
||||
}
|
||||
@@ -41,19 +41,19 @@ const AboutPage: React.FC = () => {
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching page content:', err);
|
||||
// Silently fail - use default content
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
fetchPageContent();
|
||||
}, []);
|
||||
|
||||
// Get phone, email, and address from centralized company settings
|
||||
|
||||
const displayPhone = settings.company_phone || '+1 (234) 567-890';
|
||||
const displayEmail = settings.company_email || 'info@luxuryhotel.com';
|
||||
const displayAddress = settings.company_address || '123 Luxury Street\nCity, State 12345\nCountry';
|
||||
|
||||
// Default values
|
||||
|
||||
const defaultValues = [
|
||||
{
|
||||
icon: 'Heart',
|
||||
@@ -111,7 +111,7 @@ const AboutPage: React.FC = () => {
|
||||
}))
|
||||
: defaultFeatures;
|
||||
|
||||
// Parse JSON fields
|
||||
|
||||
const team = pageContent?.team && typeof pageContent.team === 'string'
|
||||
? JSON.parse(pageContent.team)
|
||||
: (Array.isArray(pageContent?.team) ? pageContent.team : []);
|
||||
@@ -122,7 +122,7 @@ const AboutPage: React.FC = () => {
|
||||
? JSON.parse(pageContent.achievements)
|
||||
: (Array.isArray(pageContent?.achievements) ? pageContent.achievements : []);
|
||||
|
||||
// Helper to get icon component
|
||||
|
||||
const getIconComponent = (iconName?: string) => {
|
||||
if (!iconName) return Heart;
|
||||
const IconComponent = (LucideIcons as any)[iconName] || Heart;
|
||||
@@ -131,12 +131,12 @@ const AboutPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 via-white to-slate-50">
|
||||
{/* Hero Section */}
|
||||
{}
|
||||
<div className={`relative ${pageContent?.about_hero_image ? '' : 'bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900'} text-white py-20 md:py-24 ${pageContent?.about_hero_image ? 'h-[400px] md:h-[450px] lg:h-[500px]' : ''} overflow-hidden`}>
|
||||
{pageContent?.about_hero_image && (
|
||||
<div className="absolute inset-0">
|
||||
<img
|
||||
src={pageContent.about_hero_image.startsWith('http') ? pageContent.about_hero_image : `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}${pageContent.about_hero_image}`}
|
||||
src={pageContent.about_hero_image.startsWith('http') ? pageContent.about_hero_image : `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}/${pageContent.about_hero_image}`}
|
||||
alt="About Hero"
|
||||
className="w-full h-full object-cover scale-105 transition-transform duration-[20s] ease-out hover:scale-100"
|
||||
/>
|
||||
@@ -184,7 +184,7 @@ const AboutPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Our Story Section */}
|
||||
{}
|
||||
<section className="py-20 md:py-28 bg-white relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[#d4af37]/30 to-transparent"></div>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
@@ -233,7 +233,7 @@ const AboutPage: React.FC = () => {
|
||||
<div className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[#d4af37]/30 to-transparent"></div>
|
||||
</section>
|
||||
|
||||
{/* Values Section */}
|
||||
{}
|
||||
<section className="py-20 md:py-28 bg-gradient-to-b from-slate-50 via-white to-slate-50 relative">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_50%,rgba(212,175,55,0.03),transparent_50%)]"></div>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
@@ -280,7 +280,7 @@ const AboutPage: React.FC = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
{}
|
||||
<section className="py-20 md:py-28 bg-white relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(135deg,transparent_0%,rgba(212,175,55,0.02)_50%,transparent_100%)]"></div>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
@@ -327,7 +327,7 @@ const AboutPage: React.FC = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mission & Vision Section */}
|
||||
{}
|
||||
{(pageContent?.mission || pageContent?.vision) && (
|
||||
<section className="py-20 md:py-28 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 relative overflow-hidden">
|
||||
<div className="absolute inset-0">
|
||||
@@ -365,7 +365,7 @@ const AboutPage: React.FC = () => {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Team Section */}
|
||||
{}
|
||||
{team && team.length > 0 && (
|
||||
<section className="py-20 md:py-28 bg-gradient-to-b from-white via-slate-50 to-white relative">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
@@ -393,7 +393,7 @@ const AboutPage: React.FC = () => {
|
||||
{member.image && (
|
||||
<div className="relative overflow-hidden h-72">
|
||||
<img
|
||||
src={member.image.startsWith('http') ? member.image : `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}${member.image}`}
|
||||
src={member.image.startsWith('http') ? member.image : `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}/${member.image}`}
|
||||
alt={member.name}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
|
||||
/>
|
||||
@@ -437,7 +437,7 @@ const AboutPage: React.FC = () => {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Timeline Section */}
|
||||
{}
|
||||
{timeline && timeline.length > 0 && (
|
||||
<section className="py-20 md:py-28 bg-gradient-to-b from-slate-50 via-white to-slate-50 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_70%_50%,rgba(212,175,55,0.03),transparent_50%)]"></div>
|
||||
@@ -473,7 +473,7 @@ const AboutPage: React.FC = () => {
|
||||
{event.image && (
|
||||
<div className="mt-6 overflow-hidden rounded-xl">
|
||||
<img
|
||||
src={event.image.startsWith('http') ? event.image : `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}${event.image}`}
|
||||
src={event.image.startsWith('http') ? event.image : `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}/${event.image}`}
|
||||
alt={event.title}
|
||||
className="w-full h-56 md:h-64 object-cover group-hover:scale-110 transition-transform duration-700"
|
||||
/>
|
||||
@@ -490,7 +490,7 @@ const AboutPage: React.FC = () => {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Achievements Section */}
|
||||
{}
|
||||
{achievements && achievements.length > 0 && (
|
||||
<section className="py-20 md:py-28 bg-white relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(135deg,transparent_0%,rgba(212,175,55,0.02)_50%,transparent_100%)]"></div>
|
||||
@@ -532,7 +532,7 @@ const AboutPage: React.FC = () => {
|
||||
{achievement.image && (
|
||||
<div className="mt-6 overflow-hidden rounded-xl">
|
||||
<img
|
||||
src={achievement.image.startsWith('http') ? achievement.image : `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}${achievement.image}`}
|
||||
src={achievement.image.startsWith('http') ? achievement.image : `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}/${achievement.image}`}
|
||||
alt={achievement.title}
|
||||
className="w-full h-40 object-cover group-hover:scale-110 transition-transform duration-700"
|
||||
/>
|
||||
@@ -548,7 +548,7 @@ const AboutPage: React.FC = () => {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Contact Section */}
|
||||
{}
|
||||
<section className="py-20 md:py-28 bg-gradient-to-br from-slate-50 via-white to-slate-50 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(212,175,55,0.03),transparent_70%)]"></div>
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
|
||||
@@ -5,10 +5,10 @@ import { SidebarAdmin } from '../components/layout';
|
||||
const AdminLayout: React.FC = () => {
|
||||
return (
|
||||
<div className="flex h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
|
||||
{/* Admin Sidebar */}
|
||||
{}
|
||||
<SidebarAdmin />
|
||||
|
||||
{/* Admin Content Area */}
|
||||
{}
|
||||
<div className="flex-1 overflow-auto lg:ml-0">
|
||||
<div className="min-h-screen pt-20 lg:pt-0">
|
||||
<Outlet />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCompanySettings } from '../contexts/CompanySettingsContext';
|
||||
import { toast } from 'react-toastify';
|
||||
import Recaptcha from '../components/common/Recaptcha';
|
||||
import { recaptchaService } from '../services/api/systemSettingsService';
|
||||
import ChatWidget from '../components/chat/ChatWidget';
|
||||
|
||||
const ContactPage: React.FC = () => {
|
||||
const { settings } = useCompanySettings();
|
||||
@@ -56,7 +57,7 @@ const ContactPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify reCAPTCHA if enabled
|
||||
|
||||
if (recaptchaToken) {
|
||||
try {
|
||||
const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
|
||||
@@ -77,7 +78,7 @@ const ContactPage: React.FC = () => {
|
||||
await submitContactForm(formData);
|
||||
toast.success('Thank you for contacting us! We will get back to you soon.');
|
||||
|
||||
// Reset form
|
||||
|
||||
setFormData({
|
||||
name: '',
|
||||
email: '',
|
||||
@@ -103,7 +104,7 @@ const ContactPage: React.FC = () => {
|
||||
if (response.status === 'success' && response.data?.page_content) {
|
||||
setPageContent(response.data.page_content);
|
||||
|
||||
// Update document title and meta tags
|
||||
|
||||
if (response.data.page_content.meta_title) {
|
||||
document.title = response.data.page_content.meta_title;
|
||||
}
|
||||
@@ -119,14 +120,14 @@ const ContactPage: React.FC = () => {
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching page content:', err);
|
||||
// Silently fail - use default content
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
fetchPageContent();
|
||||
}, []);
|
||||
|
||||
// Get phone, email, and address from centralized company settings
|
||||
|
||||
const displayPhone = settings.company_phone || 'Available 24/7 for your convenience';
|
||||
const displayEmail = settings.company_email || "We'll respond within 24 hours";
|
||||
const displayAddress = settings.company_address || 'Visit us at our hotel reception';
|
||||
@@ -135,7 +136,7 @@ const ContactPage: React.FC = () => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
|
||||
// Clear error when user starts typing
|
||||
|
||||
if (errors[name]) {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
@@ -147,9 +148,9 @@ const ContactPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-full" style={{ width: '100vw', position: 'relative', left: '50%', right: '50%', marginLeft: '-50vw', marginRight: '-50vw', marginTop: '-1.5rem', marginBottom: '-1.5rem' }}>
|
||||
{/* Full-width hero section */}
|
||||
{}
|
||||
<div className="w-full bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a] border-b border-[#d4af37]/10 pt-6 sm:pt-7 md:pt-8 overflow-hidden relative">
|
||||
{/* Decorative Elements */}
|
||||
{}
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-10 left-10 w-32 sm:w-48 h-32 sm:h-48 bg-[#d4af37] rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-10 right-10 w-40 sm:w-64 h-40 sm:h-64 bg-[#c9a227] rounded-full blur-3xl"></div>
|
||||
@@ -180,17 +181,17 @@ const ContactPage: React.FC = () => {
|
||||
<div className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[#d4af37]/30 to-transparent"></div>
|
||||
</div>
|
||||
|
||||
{/* Full-width content area */}
|
||||
{}
|
||||
<div className="w-full py-4 sm:py-6 md:py-8 lg:py-10 xl:py-12">
|
||||
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 sm:gap-5 md:gap-6 lg:gap-7 xl:gap-8 2xl:gap-10 max-w-7xl mx-auto">
|
||||
{/* Contact Info Section */}
|
||||
{}
|
||||
<div className="lg:col-span-4">
|
||||
<div className="bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#0a0a0a]
|
||||
rounded-xl sm:rounded-2xl border-2 border-[#d4af37]/30 p-5 sm:p-6 md:p-8 lg:p-10
|
||||
shadow-2xl shadow-[#d4af37]/10 backdrop-blur-xl
|
||||
relative overflow-hidden h-full group hover:border-[#d4af37]/50 transition-all duration-500">
|
||||
{/* Subtle background gradient */}
|
||||
{}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37]/5 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
|
||||
<div className="relative z-10">
|
||||
@@ -240,7 +241,7 @@ const ContactPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Google Maps */}
|
||||
{}
|
||||
{pageContent?.map_url && (
|
||||
<div className="mt-6 sm:mt-7 md:mt-8 pt-6 sm:pt-7 md:pt-8 border-t border-[#d4af37]/30">
|
||||
<h3 className="text-sm sm:text-base font-medium text-[#d4af37] mb-3 sm:mb-4 tracking-wide">
|
||||
@@ -271,13 +272,13 @@ const ContactPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Form Section */}
|
||||
{}
|
||||
<div className="lg:col-span-8">
|
||||
<div className="bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#0a0a0a]
|
||||
rounded-xl sm:rounded-2xl border-2 border-[#d4af37]/30 p-5 sm:p-6 md:p-8 lg:p-10
|
||||
shadow-2xl shadow-[#d4af37]/10 backdrop-blur-xl
|
||||
relative overflow-hidden">
|
||||
{/* Subtle background pattern */}
|
||||
{}
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div className="absolute top-0 right-0 w-48 sm:w-64 md:w-96 h-48 sm:h-64 md:h-96 bg-[#d4af37] rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
@@ -292,7 +293,7 @@ const ContactPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5 sm:space-y-6 md:space-y-7">
|
||||
{/* Name Field */}
|
||||
{}
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-xs sm:text-sm font-medium text-gray-300 mb-2 sm:mb-3 tracking-wide">
|
||||
<span className="flex items-center gap-1.5 sm:gap-2">
|
||||
@@ -318,9 +319,9 @@ const ContactPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email and Phone Row */}
|
||||
{}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-5 md:gap-6 lg:gap-7">
|
||||
{/* Email Field */}
|
||||
{}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-xs sm:text-sm font-medium text-gray-300 mb-2 sm:mb-3 tracking-wide">
|
||||
<span className="flex items-center gap-1.5 sm:gap-2">
|
||||
@@ -346,7 +347,7 @@ const ContactPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone Field */}
|
||||
{}
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-xs sm:text-sm font-medium text-gray-300 mb-2 sm:mb-3 tracking-wide">
|
||||
<span className="flex items-center gap-1.5 sm:gap-2">
|
||||
@@ -369,7 +370,7 @@ const ContactPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subject Field */}
|
||||
{}
|
||||
<div>
|
||||
<label htmlFor="subject" className="block text-xs sm:text-sm font-medium text-gray-300 mb-2 sm:mb-3 tracking-wide">
|
||||
<span className="flex items-center gap-1.5 sm:gap-2">
|
||||
@@ -395,7 +396,7 @@ const ContactPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message Field */}
|
||||
{}
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-xs sm:text-sm font-medium text-gray-300 mb-2 sm:mb-3 tracking-wide">
|
||||
<span className="flex items-center gap-1.5 sm:gap-2">
|
||||
@@ -421,7 +422,7 @@ const ContactPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* reCAPTCHA */}
|
||||
{}
|
||||
<div className="pt-2 sm:pt-3">
|
||||
<Recaptcha
|
||||
onChange={(token) => setRecaptchaToken(token)}
|
||||
@@ -435,7 +436,7 @@ const ContactPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
{}
|
||||
<div className="pt-2 sm:pt-3 md:pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -473,6 +474,9 @@ const ContactPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<ChatWidget />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Link } from 'react-router-dom';
|
||||
import {
|
||||
ArrowRight,
|
||||
AlertCircle,
|
||||
Star,
|
||||
X,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -13,7 +12,6 @@ import * as LucideIcons from 'lucide-react';
|
||||
import {
|
||||
BannerCarousel,
|
||||
BannerSkeleton,
|
||||
RoomCard,
|
||||
RoomCardSkeleton,
|
||||
RoomCarousel,
|
||||
SearchRoomForm,
|
||||
@@ -36,13 +34,13 @@ const HomePage: React.FC = () => {
|
||||
useState(true);
|
||||
const [isLoadingRooms, setIsLoadingRooms] = useState(true);
|
||||
const [isLoadingNewest, setIsLoadingNewest] = useState(true);
|
||||
const [isLoadingContent, setIsLoadingContent] = useState(true);
|
||||
const [, setIsLoadingContent] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||
const [lightboxImages, setLightboxImages] = useState<string[]>([]);
|
||||
|
||||
// Handle keyboard navigation for lightbox
|
||||
|
||||
useEffect(() => {
|
||||
if (!lightboxOpen) return;
|
||||
|
||||
@@ -57,7 +55,7 @@ const HomePage: React.FC = () => {
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
// Prevent body scroll when lightbox is open
|
||||
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
@@ -66,16 +64,16 @@ const HomePage: React.FC = () => {
|
||||
};
|
||||
}, [lightboxOpen, lightboxImages.length]);
|
||||
|
||||
// Combine featured and newest rooms, removing duplicates
|
||||
|
||||
const combinedRooms = useMemo(() => {
|
||||
const roomMap = new Map<number, Room>();
|
||||
|
||||
// Add featured rooms first (they take priority)
|
||||
|
||||
featuredRooms.forEach(room => {
|
||||
roomMap.set(room.id, room);
|
||||
});
|
||||
|
||||
// Add newest rooms that aren't already in the map
|
||||
|
||||
newestRooms.forEach(room => {
|
||||
if (!roomMap.has(room.id)) {
|
||||
roomMap.set(room.id, room);
|
||||
@@ -85,7 +83,7 @@ const HomePage: React.FC = () => {
|
||||
return Array.from(roomMap.values());
|
||||
}, [featuredRooms, newestRooms]);
|
||||
|
||||
// Fetch page content
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPageContent = async () => {
|
||||
try {
|
||||
@@ -94,18 +92,18 @@ const HomePage: React.FC = () => {
|
||||
if (response.status === 'success' && response.data?.page_content) {
|
||||
const content = response.data.page_content;
|
||||
|
||||
// Parse JSON fields if they come as strings (backward compatibility)
|
||||
|
||||
if (typeof content.features === 'string') {
|
||||
try {
|
||||
content.features = JSON.parse(content.features);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
content.features = [];
|
||||
}
|
||||
}
|
||||
if (typeof content.amenities === 'string') {
|
||||
try {
|
||||
content.amenities = JSON.parse(content.amenities);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
content.amenities = [];
|
||||
}
|
||||
}
|
||||
@@ -130,7 +128,7 @@ const HomePage: React.FC = () => {
|
||||
content.stats = [];
|
||||
}
|
||||
}
|
||||
// Parse luxury fields
|
||||
|
||||
if (typeof content.luxury_features === 'string') {
|
||||
try {
|
||||
content.luxury_features = JSON.parse(content.luxury_features);
|
||||
@@ -146,7 +144,7 @@ const HomePage: React.FC = () => {
|
||||
content.luxury_gallery = [];
|
||||
}
|
||||
}
|
||||
// Ensure luxury_gallery is an array and filter out empty values
|
||||
|
||||
if (Array.isArray(content.luxury_gallery)) {
|
||||
content.luxury_gallery = content.luxury_gallery.filter(img => img && typeof img === 'string' && img.trim() !== '');
|
||||
} else {
|
||||
@@ -190,7 +188,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
setPageContent(content);
|
||||
|
||||
// Update document title and meta tags
|
||||
|
||||
if (content.meta_title) {
|
||||
document.title = content.meta_title;
|
||||
}
|
||||
@@ -206,7 +204,7 @@ const HomePage: React.FC = () => {
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching page content:', err);
|
||||
// Silently fail - use default content
|
||||
|
||||
} finally {
|
||||
setIsLoadingContent(false);
|
||||
}
|
||||
@@ -215,7 +213,7 @@ const HomePage: React.FC = () => {
|
||||
fetchPageContent();
|
||||
}, []);
|
||||
|
||||
// Fetch banners
|
||||
|
||||
useEffect(() => {
|
||||
const fetchBanners = async () => {
|
||||
try {
|
||||
@@ -223,7 +221,7 @@ const HomePage: React.FC = () => {
|
||||
const response = await bannerService
|
||||
.getBannersByPosition('home');
|
||||
|
||||
// Handle both response formats
|
||||
|
||||
if (
|
||||
response.success ||
|
||||
response.status === 'success'
|
||||
@@ -232,8 +230,8 @@ const HomePage: React.FC = () => {
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching banners:', err);
|
||||
// Don't show error for banners, just use fallback
|
||||
// Silently fail - banners are not critical for page functionality
|
||||
|
||||
|
||||
} finally {
|
||||
setIsLoadingBanners(false);
|
||||
}
|
||||
@@ -242,7 +240,7 @@ const HomePage: React.FC = () => {
|
||||
fetchBanners();
|
||||
}, []);
|
||||
|
||||
// Fetch featured rooms
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFeaturedRooms = async () => {
|
||||
try {
|
||||
@@ -253,19 +251,19 @@ const HomePage: React.FC = () => {
|
||||
limit: 6,
|
||||
});
|
||||
|
||||
// Handle both response formats
|
||||
|
||||
if (
|
||||
response.success ||
|
||||
response.status === 'success'
|
||||
) {
|
||||
const rooms = response.data?.rooms || [];
|
||||
setFeaturedRooms(rooms);
|
||||
// If no rooms found but request succeeded, don't show error
|
||||
|
||||
if (rooms.length === 0) {
|
||||
setError(null);
|
||||
}
|
||||
} else {
|
||||
// Response didn't indicate success
|
||||
|
||||
setError(
|
||||
response.message ||
|
||||
'Unable to load room list'
|
||||
@@ -274,7 +272,7 @@ const HomePage: React.FC = () => {
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching rooms:', err);
|
||||
|
||||
// Check if it's a rate limit error
|
||||
|
||||
if (err.response?.status === 429) {
|
||||
setError(
|
||||
'Too many requests. Please wait a moment and refresh the page.'
|
||||
@@ -294,7 +292,7 @@ const HomePage: React.FC = () => {
|
||||
fetchFeaturedRooms();
|
||||
}, []);
|
||||
|
||||
// Fetch newest rooms
|
||||
|
||||
useEffect(() => {
|
||||
const fetchNewestRooms = async () => {
|
||||
try {
|
||||
@@ -305,7 +303,7 @@ const HomePage: React.FC = () => {
|
||||
sort: 'newest',
|
||||
});
|
||||
|
||||
// Handle both response formats
|
||||
|
||||
if (
|
||||
response.success ||
|
||||
response.status === 'success'
|
||||
@@ -314,7 +312,7 @@ const HomePage: React.FC = () => {
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching newest rooms:', err);
|
||||
// Silently fail for newest rooms section - not critical
|
||||
|
||||
} finally {
|
||||
setIsLoadingNewest(false);
|
||||
}
|
||||
@@ -325,7 +323,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Banner Section - Full Width, breaks out of container */}
|
||||
{}
|
||||
<section
|
||||
className="relative w-screen -mt-6"
|
||||
style={{
|
||||
@@ -345,13 +343,13 @@ const HomePage: React.FC = () => {
|
||||
</section>
|
||||
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50/30 relative overflow-hidden">
|
||||
{/* Subtle background pattern */}
|
||||
{}
|
||||
<div className="fixed inset-0 opacity-[0.015] bg-[radial-gradient(circle_at_1px_1px,#d4af37_1px,transparent_0)] bg-[length:60px_60px] pointer-events-none"></div>
|
||||
<div className="relative z-10">
|
||||
|
||||
{/* Featured & Newest Rooms Section - Combined Carousel */}
|
||||
{}
|
||||
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 md:py-12 lg:py-16">
|
||||
{/* Section Header - Centered */}
|
||||
{}
|
||||
<div className="text-center animate-fade-in mb-6 md:mb-8">
|
||||
<div className="inline-block mb-3">
|
||||
<div className="h-0.5 w-16 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto rounded-full"></div>
|
||||
@@ -363,7 +361,7 @@ const HomePage: React.FC = () => {
|
||||
{pageContent?.hero_subtitle || pageContent?.description || 'Discover our most popular accommodations and latest additions'}
|
||||
</p>
|
||||
|
||||
{/* View All Rooms Button - Golden, Centered */}
|
||||
{}
|
||||
<div className="mt-6 md:mt-8 flex justify-center">
|
||||
<Link
|
||||
to="/rooms"
|
||||
@@ -376,7 +374,7 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{}
|
||||
{(isLoadingRooms || isLoadingNewest) && (
|
||||
<div className="flex justify-center">
|
||||
<div className="max-w-md w-full">
|
||||
@@ -385,7 +383,7 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{}
|
||||
{error && !isLoadingRooms && !isLoadingNewest && (
|
||||
<div
|
||||
className="luxury-card p-8 text-center animate-fade-in
|
||||
@@ -413,7 +411,7 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Combined Rooms Carousel */}
|
||||
{}
|
||||
{!isLoadingRooms && !isLoadingNewest && (
|
||||
<>
|
||||
{combinedRooms.length > 0 ? (
|
||||
@@ -436,9 +434,9 @@ const HomePage: React.FC = () => {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Features Section - Dynamic from page content */}
|
||||
{}
|
||||
{(() => {
|
||||
// Filter out empty features (no title or description)
|
||||
|
||||
const validFeatures = pageContent?.features?.filter(
|
||||
(f: any) => f && (f.title || f.description)
|
||||
) || [];
|
||||
@@ -446,11 +444,11 @@ const HomePage: React.FC = () => {
|
||||
return (validFeatures.length > 0 || !pageContent) && (
|
||||
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
|
||||
<div className="relative bg-white rounded-xl md:rounded-2xl shadow-xl shadow-[#d4af37]/5 p-6 md:p-8 lg:p-10 animate-fade-in overflow-hidden border border-gray-100/50">
|
||||
{/* Decorative gold accents */}
|
||||
{}
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[#d4af37] via-[#f5d76e] to-[#d4af37]"></div>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-[#d4af37] via-[#f5d76e] to-[#d4af37]"></div>
|
||||
|
||||
{/* Subtle background pattern */}
|
||||
{}
|
||||
<div className="absolute inset-0 opacity-[0.015] bg-[radial-gradient(circle_at_1px_1px,#d4af37_1px,transparent_0)] bg-[length:40px_40px]"></div>
|
||||
|
||||
<div className="relative grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
||||
@@ -565,7 +563,7 @@ const HomePage: React.FC = () => {
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Luxury Section - Dynamic from page content */}
|
||||
{}
|
||||
{(pageContent?.luxury_section_title || (pageContent?.luxury_features && pageContent.luxury_features.length > 0)) && (
|
||||
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16 bg-gradient-to-b from-white via-gray-50/20 to-white">
|
||||
<div className="text-center mb-8 md:mb-10 animate-fade-in">
|
||||
@@ -620,7 +618,7 @@ const HomePage: React.FC = () => {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Luxury Gallery Section - Dynamic from page content */}
|
||||
{}
|
||||
{pageContent?.luxury_gallery && Array.isArray(pageContent.luxury_gallery) && pageContent.luxury_gallery.length > 0 && (
|
||||
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
|
||||
<div className="text-center mb-8 md:mb-10 animate-fade-in">
|
||||
@@ -638,11 +636,11 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2.5 md:gap-3 lg:gap-4 px-4">
|
||||
{pageContent.luxury_gallery.map((image, index) => {
|
||||
// Normalize image URL - if it's a relative path, prepend the API URL
|
||||
|
||||
const imageUrl = image && typeof image === 'string'
|
||||
? (image.startsWith('http://') || image.startsWith('https://')
|
||||
? (image.startsWith('http://') || image.startsWith('https://')
|
||||
? image
|
||||
: `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}${image.startsWith('/') ? image : '/' + image}`)
|
||||
: `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}/${image}`)
|
||||
: '';
|
||||
|
||||
if (!imageUrl) return null;
|
||||
@@ -653,12 +651,12 @@ const HomePage: React.FC = () => {
|
||||
className="relative group overflow-hidden rounded-lg md:rounded-xl aspect-square animate-fade-in shadow-md shadow-gray-900/5 hover:shadow-xl hover:shadow-[#d4af37]/15 transition-all duration-300 cursor-pointer"
|
||||
style={{ animationDelay: `${index * 0.05}s` }}
|
||||
onClick={() => {
|
||||
const normalizedImages = pageContent.luxury_gallery
|
||||
const normalizedImages = (pageContent.luxury_gallery || [])
|
||||
.map(img => {
|
||||
if (!img || typeof img !== 'string') return null;
|
||||
return img.startsWith('http://') || img.startsWith('https://')
|
||||
return img.startsWith('http://') || img.startsWith('https://')
|
||||
? img
|
||||
: `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}${img.startsWith('/') ? img : '/' + img}`;
|
||||
: `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}/${img}`
|
||||
})
|
||||
.filter(Boolean) as string[];
|
||||
setLightboxImages(normalizedImages);
|
||||
@@ -688,7 +686,7 @@ const HomePage: React.FC = () => {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Luxury Testimonials Section - Dynamic from page content */}
|
||||
{}
|
||||
{pageContent?.luxury_testimonials && pageContent.luxury_testimonials.length > 0 && (
|
||||
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16 bg-gradient-to-b from-white via-gray-50/20 to-white">
|
||||
<div className="text-center mb-8 md:mb-10 animate-fade-in">
|
||||
@@ -739,11 +737,11 @@ const HomePage: React.FC = () => {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Stats Section - Dynamic from page content */}
|
||||
{}
|
||||
{pageContent?.stats && Array.isArray(pageContent.stats) && pageContent.stats.length > 0 && (
|
||||
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
|
||||
<div className="relative bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 rounded-xl md:rounded-2xl p-6 md:p-8 lg:p-10 shadow-xl shadow-black/30 animate-fade-in overflow-hidden border border-[#d4af37]/15">
|
||||
{/* Decorative elements */}
|
||||
{}
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-[#d4af37] via-[#f5d76e] to-[#d4af37]"></div>
|
||||
<div className="absolute inset-0 opacity-8 bg-[radial-gradient(circle_at_1px_1px,#d4af37_1px,transparent_0)] bg-[length:40px_40px]"></div>
|
||||
|
||||
@@ -778,7 +776,7 @@ const HomePage: React.FC = () => {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Amenities Section - Dynamic from page content */}
|
||||
{}
|
||||
{pageContent?.amenities && pageContent.amenities.length > 0 && (
|
||||
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
|
||||
<div className="text-center mb-8 md:mb-10 animate-fade-in">
|
||||
@@ -823,7 +821,7 @@ const HomePage: React.FC = () => {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Testimonials Section - Dynamic from page content */}
|
||||
{}
|
||||
{pageContent?.testimonials && pageContent.testimonials.length > 0 && (
|
||||
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16 bg-gradient-to-b from-white via-gray-50/20 to-white">
|
||||
<div className="text-center mb-8 md:mb-10 animate-fade-in">
|
||||
@@ -872,12 +870,11 @@ const HomePage: React.FC = () => {
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
{/* About Preview Section - Dynamic from page content */}
|
||||
{}
|
||||
{(pageContent?.about_preview_title || pageContent?.about_preview_content) && (
|
||||
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
|
||||
<div className="relative bg-white rounded-xl md:rounded-2xl shadow-xl shadow-[#d4af37]/5 overflow-hidden animate-fade-in border border-gray-100/50">
|
||||
{/* Decorative gold accents */}
|
||||
{}
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[#d4af37] via-[#f5d76e] to-[#d4af37]"></div>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-[#d4af37] via-[#f5d76e] to-[#d4af37]"></div>
|
||||
|
||||
@@ -923,7 +920,7 @@ const HomePage: React.FC = () => {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Luxury Services Section */}
|
||||
{}
|
||||
{pageContent?.luxury_services && pageContent.luxury_services.length > 0 && (
|
||||
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
|
||||
<div className="text-center mb-8 md:mb-10 animate-fade-in">
|
||||
@@ -970,7 +967,7 @@ const HomePage: React.FC = () => {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Luxury Experiences Section */}
|
||||
{}
|
||||
{pageContent?.luxury_experiences && pageContent.luxury_experiences.length > 0 && (
|
||||
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16 bg-gradient-to-b from-white via-gray-50/20 to-white">
|
||||
<div className="text-center mb-8 md:mb-10 animate-fade-in">
|
||||
@@ -1017,7 +1014,7 @@ const HomePage: React.FC = () => {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Awards Section */}
|
||||
{}
|
||||
{pageContent?.awards && pageContent.awards.length > 0 && (
|
||||
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
|
||||
<div className="text-center mb-8 md:mb-10 animate-fade-in">
|
||||
@@ -1069,7 +1066,7 @@ const HomePage: React.FC = () => {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* CTA Section */}
|
||||
{}
|
||||
{(pageContent?.cta_title || pageContent?.cta_subtitle) && (
|
||||
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
|
||||
<div className="relative bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 rounded-xl md:rounded-2xl p-8 md:p-12 lg:p-16 shadow-xl shadow-black/30 animate-fade-in overflow-hidden border border-[#d4af37]/15">
|
||||
@@ -1106,7 +1103,7 @@ const HomePage: React.FC = () => {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Partners Section */}
|
||||
{}
|
||||
{pageContent?.partners && pageContent.partners.length > 0 && (
|
||||
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16 bg-gradient-to-b from-white via-gray-50/20 to-white">
|
||||
<div className="text-center mb-8 md:mb-10 animate-fade-in">
|
||||
@@ -1151,13 +1148,13 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Luxury Gallery Lightbox Modal */}
|
||||
{}
|
||||
{lightboxOpen && lightboxImages.length > 0 && (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] bg-black/95 backdrop-blur-md flex items-center justify-center animate-fade-in"
|
||||
onClick={() => setLightboxOpen(false)}
|
||||
>
|
||||
{/* Close Button */}
|
||||
{}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -1169,7 +1166,7 @@ const HomePage: React.FC = () => {
|
||||
<X className="w-6 h-6 md:w-7 md:h-7" />
|
||||
</button>
|
||||
|
||||
{/* Previous Button */}
|
||||
{}
|
||||
{lightboxImages.length > 1 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -1183,7 +1180,7 @@ const HomePage: React.FC = () => {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Next Button */}
|
||||
{}
|
||||
{lightboxImages.length > 1 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -1197,7 +1194,7 @@ const HomePage: React.FC = () => {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Image Container */}
|
||||
{}
|
||||
<div
|
||||
className="max-w-7xl max-h-[95vh] w-full h-full flex items-center justify-center p-4 md:p-8"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -1207,19 +1204,19 @@ const HomePage: React.FC = () => {
|
||||
src={lightboxImages[lightboxIndex]}
|
||||
alt={`Luxury gallery image ${lightboxIndex + 1}`}
|
||||
className="max-w-full max-h-full object-contain rounded-lg shadow-2xl animate-scale-in"
|
||||
onError={(e) => {
|
||||
onError={() => {
|
||||
console.error(`Failed to load lightbox image: ${lightboxImages[lightboxIndex]}`);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Image Counter */}
|
||||
{}
|
||||
{lightboxImages.length > 1 && (
|
||||
<div className="absolute bottom-4 md:bottom-6 left-1/2 transform -translate-x-1/2 bg-black/60 backdrop-blur-md text-white px-4 py-2 rounded-full text-sm md:text-base font-medium border border-white/20 shadow-lg">
|
||||
{lightboxIndex + 1} / {lightboxImages.length}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thumbnail Strip (if more than 1 image) */}
|
||||
{}
|
||||
{lightboxImages.length > 1 && lightboxImages.length <= 10 && (
|
||||
<div className="absolute bottom-20 md:bottom-24 left-1/2 transform -translate-x-1/2 flex gap-2 max-w-full overflow-x-auto px-4">
|
||||
{lightboxImages.map((thumb, idx) => (
|
||||
|
||||
29
Frontend/src/pages/StaffLayout.tsx
Normal file
29
Frontend/src/pages/StaffLayout.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { SidebarStaff } from '../components/layout';
|
||||
import StaffChatNotification from '../components/chat/StaffChatNotification';
|
||||
import { ChatNotificationProvider } from '../contexts/ChatNotificationContext';
|
||||
|
||||
const StaffLayout: React.FC = () => {
|
||||
return (
|
||||
<ChatNotificationProvider>
|
||||
<div className="flex h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
|
||||
{}
|
||||
<SidebarStaff />
|
||||
|
||||
{}
|
||||
<div className="flex-1 overflow-auto lg:ml-0">
|
||||
<div className="min-h-screen pt-20 lg:pt-0">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<StaffChatNotification />
|
||||
</div>
|
||||
</ChatNotificationProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default StaffLayout;
|
||||
|
||||
@@ -38,14 +38,14 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const [activeTab, setActiveTab] = useState<AnalyticsTab>('overview');
|
||||
|
||||
// Reports State
|
||||
|
||||
const [dateRange, setDateRange] = useState({
|
||||
from: '',
|
||||
to: '',
|
||||
});
|
||||
const [reportType, setReportType] = useState<'daily' | 'weekly' | 'monthly' | 'yearly' | ''>('');
|
||||
|
||||
// Audit Logs State
|
||||
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [auditLoading, setAuditLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@@ -60,7 +60,7 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
limit: itemsPerPage,
|
||||
});
|
||||
|
||||
// Reviews State
|
||||
|
||||
const [reviews, setReviews] = useState<Review[]>([]);
|
||||
const [reviewsLoading, setReviewsLoading] = useState(true);
|
||||
const [reviewsFilters, setReviewsFilters] = useState({
|
||||
@@ -199,7 +199,7 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Reviews Functions
|
||||
|
||||
const fetchReviews = async () => {
|
||||
try {
|
||||
setReviewsLoading(true);
|
||||
@@ -279,7 +279,7 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-10 animate-fade-in">
|
||||
{/* Luxury Header */}
|
||||
{}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-blue-400/5 via-transparent to-indigo-600/5 rounded-3xl blur-3xl"></div>
|
||||
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-2xl border border-blue-200/30 p-8 md:p-10">
|
||||
@@ -306,7 +306,7 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Premium Tab Navigation */}
|
||||
{}
|
||||
<div className="mt-10 pt-8 border-t border-gradient-to-r from-transparent via-blue-200/30 to-transparent">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{tabs.map((tab) => {
|
||||
@@ -342,7 +342,7 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8">
|
||||
<div
|
||||
@@ -446,7 +446,7 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reports Tab */}
|
||||
{}
|
||||
{activeTab === 'reports' && (
|
||||
<div className="space-y-8">
|
||||
{reportsLoading && !reportData ? (
|
||||
@@ -464,7 +464,7 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Section Header */}
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
||||
<div className="space-y-3">
|
||||
@@ -491,7 +491,7 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{}
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 rounded-xl bg-gradient-to-br from-amber-500/10 to-amber-600/10 border border-amber-200/40">
|
||||
@@ -545,7 +545,7 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
|
||||
{reportData && (
|
||||
<>
|
||||
{/* Summary Cards */}
|
||||
{}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{[
|
||||
{ icon: Calendar, label: 'Total Bookings', value: reportData.total_bookings || 0, color: 'blue', border: 'border-blue-500' },
|
||||
@@ -553,12 +553,13 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
{ icon: Users, label: 'Total Customers', value: reportData.total_customers || 0, color: 'purple', border: 'border-purple-500' },
|
||||
{ icon: Hotel, label: 'Available Rooms', value: reportData.available_rooms || 0, color: 'orange', border: 'border-orange-500', subtitle: `${reportData.occupied_rooms || 0} occupied` },
|
||||
].map(({ icon: Icon, label, value, color, border, subtitle }) => {
|
||||
const colorClasses = {
|
||||
const colorClassesMap = {
|
||||
blue: { bg: 'bg-blue-100', text: 'text-blue-600', gradient: 'from-blue-500 to-blue-600' },
|
||||
emerald: { bg: 'bg-emerald-100', text: 'text-emerald-600', gradient: 'from-emerald-500 to-emerald-600' },
|
||||
purple: { bg: 'bg-purple-100', text: 'text-purple-600', gradient: 'from-purple-500 to-purple-600' },
|
||||
orange: { bg: 'bg-orange-100', text: 'text-orange-600', gradient: 'from-orange-500 to-orange-600' },
|
||||
}[color];
|
||||
} as const;
|
||||
const colorClasses = colorClassesMap[color as keyof typeof colorClassesMap] || colorClassesMap.blue;
|
||||
|
||||
return (
|
||||
<div key={label} className={`relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border-l-4 ${border} p-6 hover:shadow-2xl transition-all duration-300`}>
|
||||
@@ -584,7 +585,7 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bookings by Status */}
|
||||
{}
|
||||
{reportData.bookings_by_status && (
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
@@ -604,7 +605,7 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Revenue by Date */}
|
||||
{}
|
||||
{reportData.revenue_by_date && reportData.revenue_by_date.length > 0 && (
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
@@ -638,7 +639,7 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Rooms */}
|
||||
{}
|
||||
{reportData.top_rooms && reportData.top_rooms.length > 0 && (
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Top Performing Rooms</h2>
|
||||
@@ -665,7 +666,7 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service Usage */}
|
||||
{}
|
||||
{reportData.service_usage && reportData.service_usage.length > 0 && (
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Service Usage</h2>
|
||||
@@ -698,10 +699,10 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audit Logs Tab */}
|
||||
{}
|
||||
{activeTab === 'audit-logs' && (
|
||||
<div className="space-y-8">
|
||||
{/* Section Header */}
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -716,7 +717,7 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{}
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 rounded-xl bg-gradient-to-br from-amber-500/10 to-amber-600/10 border border-amber-200/40">
|
||||
@@ -792,7 +793,7 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Summary */}
|
||||
{}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -818,7 +819,7 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs Table */}
|
||||
{}
|
||||
{auditLoading && logs.length === 0 ? (
|
||||
<Loading fullScreen text="Loading audit logs..." />
|
||||
) : logs.length === 0 ? (
|
||||
@@ -890,7 +891,7 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center">
|
||||
<Pagination
|
||||
@@ -905,10 +906,10 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reviews Tab */}
|
||||
{}
|
||||
{activeTab === 'reviews' && (
|
||||
<div className="space-y-8">
|
||||
{/* Section Header */}
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -923,7 +924,7 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{}
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 rounded-xl bg-gradient-to-br from-amber-500/10 to-amber-600/10 border border-amber-200/40">
|
||||
@@ -955,7 +956,7 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reviews Table */}
|
||||
{}
|
||||
{reviewsLoading && reviews.length === 0 ? (
|
||||
<Loading fullScreen text="Loading reviews..." />
|
||||
) : reviews.length === 0 ? (
|
||||
@@ -1051,7 +1052,7 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{}
|
||||
{reviewsTotalPages > 1 && (
|
||||
<div className="flex justify-center">
|
||||
<Pagination
|
||||
@@ -1068,7 +1069,7 @@ const AnalyticsDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details Modal */}
|
||||
{}
|
||||
{showDetails && selectedLog && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl max-w-3xl w-full max-h-[90vh] overflow-y-auto shadow-2xl border border-gray-200">
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
Search,
|
||||
Filter,
|
||||
Eye,
|
||||
Calendar,
|
||||
User,
|
||||
Activity,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
@@ -114,7 +112,7 @@ const AuditLogsPage: React.FC = () => {
|
||||
<p className="text-gray-600">View all system activity and actions</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{}
|
||||
<div className="mb-6 bg-white p-4 rounded-lg shadow-md">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<Filter className="w-5 h-5 text-gray-500" />
|
||||
@@ -200,7 +198,7 @@ const AuditLogsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Summary */}
|
||||
{}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white p-4 rounded-lg shadow-md">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -222,7 +220,7 @@ const AuditLogsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs Table */}
|
||||
{}
|
||||
{logs.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No Audit Logs Found"
|
||||
@@ -316,7 +314,7 @@ const AuditLogsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6">
|
||||
<Pagination
|
||||
@@ -327,7 +325,7 @@ const AuditLogsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details Modal */}
|
||||
{}
|
||||
{showDetails && selectedLog && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-3xl w-full max-h-[90vh] overflow-y-auto">
|
||||
|
||||
@@ -7,8 +7,6 @@ import { ConfirmationDialog } from '../../components/common';
|
||||
import bannerServiceModule from '../../services/api/bannerService';
|
||||
import type { Banner } from '../../services/api/bannerService';
|
||||
|
||||
// Extract functions from default export - workaround for TypeScript cache issue
|
||||
// All functions are properly exported in bannerService.ts
|
||||
const {
|
||||
getAllBanners,
|
||||
createBanner,
|
||||
@@ -69,7 +67,7 @@ const BannerManagementPage: React.FC = () => {
|
||||
|
||||
let allBanners = response.data?.banners || [];
|
||||
|
||||
// Filter by search if provided
|
||||
|
||||
if (filters.search) {
|
||||
allBanners = allBanners.filter((banner: Banner) =>
|
||||
banner.title.toLowerCase().includes(filters.search.toLowerCase())
|
||||
@@ -89,13 +87,13 @@ const BannerManagementPage: React.FC = () => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('Image size must be less than 5MB');
|
||||
return;
|
||||
@@ -103,14 +101,14 @@ const BannerManagementPage: React.FC = () => {
|
||||
|
||||
setImageFile(file);
|
||||
|
||||
// Create preview
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setImagePreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Upload image immediately
|
||||
|
||||
try {
|
||||
setUploadingImage(true);
|
||||
const response = await uploadBannerImage(file);
|
||||
@@ -130,14 +128,14 @@ const BannerManagementPage: React.FC = () => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate image URL or file
|
||||
|
||||
if (!formData.image_url && !imageFile) {
|
||||
toast.error('Please upload an image or provide an image URL');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// If there's a file but no URL yet, upload it first
|
||||
|
||||
let imageUrl = formData.image_url;
|
||||
if (imageFile && !imageUrl) {
|
||||
setUploadingImage(true);
|
||||
@@ -179,7 +177,7 @@ const BannerManagementPage: React.FC = () => {
|
||||
title: banner.title || '',
|
||||
description: '',
|
||||
image_url: banner.image_url || '',
|
||||
link: banner.link || '',
|
||||
link: banner.link_url || '',
|
||||
position: banner.position || 'home',
|
||||
display_order: banner.display_order || 0,
|
||||
is_active: banner.is_active ?? true,
|
||||
@@ -187,14 +185,14 @@ const BannerManagementPage: React.FC = () => {
|
||||
end_date: banner.end_date ? banner.end_date.split('T')[0] : '',
|
||||
});
|
||||
setImageFile(null);
|
||||
// Normalize image URL for preview (handle both relative and absolute URLs)
|
||||
|
||||
const previewUrl = banner.image_url
|
||||
? (banner.image_url.startsWith('http')
|
||||
? banner.image_url
|
||||
: `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}${banner.image_url}`)
|
||||
: `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}/${banner.image_url}`)
|
||||
: null;
|
||||
setImagePreview(previewUrl);
|
||||
setUseFileUpload(false); // When editing, show URL by default
|
||||
setUseFileUpload(false);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
@@ -264,7 +262,7 @@ const BannerManagementPage: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{}
|
||||
<div className="mb-6 bg-white p-4 rounded-lg shadow-md">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
@@ -300,7 +298,7 @@ const BannerManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Banners Table */}
|
||||
{}
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
@@ -406,7 +404,7 @@ const BannerManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6">
|
||||
<Pagination
|
||||
@@ -417,7 +415,7 @@ const BannerManagementPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
@@ -448,13 +446,13 @@ const BannerManagementPage: React.FC = () => {
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
{/* Image Upload/URL Section */}
|
||||
{}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Banner Image *
|
||||
</label>
|
||||
|
||||
{/* Toggle between file upload and URL */}
|
||||
{}
|
||||
<div className="flex space-x-4 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
@@ -530,7 +528,6 @@ const BannerManagementPage: React.FC = () => {
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleImageFileChange}
|
||||
disabled={uploadingImage}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -538,28 +535,52 @@ const BannerManagementPage: React.FC = () => {
|
||||
<div>
|
||||
<input
|
||||
type="url"
|
||||
required={!imageFile}
|
||||
value={formData.image_url}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, image_url: e.target.value });
|
||||
setImagePreview(e.target.value || null);
|
||||
}}
|
||||
onChange={(e) => setFormData({ ...formData, image_url: e.target.value })}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
{imagePreview && (
|
||||
<div className="mt-3 relative">
|
||||
{formData.image_url && (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={imagePreview}
|
||||
src={formData.image_url}
|
||||
alt="Preview"
|
||||
className="w-full h-32 object-cover rounded-lg border border-gray-300"
|
||||
onError={() => setImagePreview(null)}
|
||||
className="w-full h-32 object-cover rounded-lg"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Link URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.link}
|
||||
onChange={(e) => setFormData({ ...formData, link: e.target.value })}
|
||||
placeholder="https://example.com"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
@@ -587,17 +608,7 @@ const BannerManagementPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Link URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.link}
|
||||
onChange={(e) => setFormData({ ...formData, link: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
@@ -622,35 +633,37 @@ const BannerManagementPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{editingBanner && (
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
className="rounded border-gray-300 text-[#d4af37] focus:ring-[#d4af37]"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Active</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end space-x-4 pt-4">
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_active"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
className="w-4 h-4 text-[#d4af37] border-gray-300 rounded focus:ring-[#d4af37]"
|
||||
/>
|
||||
<label htmlFor="is_active" className="ml-2 text-sm font-medium text-gray-700">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
}}
|
||||
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50"
|
||||
className="px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-[#d4af37] text-white rounded-lg hover:bg-[#c9a227]"
|
||||
disabled={uploadingImage}
|
||||
className="px-4 py-2 bg-[#d4af37] text-white rounded-lg hover:bg-[#c9a227] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{editingBanner ? 'Update' : 'Create'}
|
||||
{uploadingImage ? 'Uploading...' : editingBanner ? 'Update Banner' : 'Create Banner'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -658,7 +671,6 @@ const BannerManagementPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<ConfirmationDialog
|
||||
isOpen={deleteConfirm.show}
|
||||
onClose={() => setDeleteConfirm({ show: false, id: null })}
|
||||
|
||||
@@ -86,7 +86,7 @@ const BookingManagementPage: React.FC = () => {
|
||||
const handleCreateInvoice = async (bookingId: number) => {
|
||||
try {
|
||||
setCreatingInvoice(true);
|
||||
// Ensure bookingId is a number
|
||||
|
||||
const invoiceData = {
|
||||
booking_id: Number(bookingId),
|
||||
};
|
||||
@@ -138,7 +138,7 @@ const BookingManagementPage: React.FC = () => {
|
||||
cancelled: {
|
||||
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
|
||||
text: 'text-rose-800',
|
||||
label: 'Cancelled',
|
||||
label: '❌ Canceled',
|
||||
border: 'border-rose-200'
|
||||
},
|
||||
};
|
||||
@@ -150,14 +150,13 @@ const BookingManagementPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
|
||||
{/* Luxury Header */}
|
||||
{}
|
||||
<div className="animate-fade-in">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
|
||||
@@ -168,7 +167,7 @@ const BookingManagementPage: React.FC = () => {
|
||||
<p className="text-slate-600 mt-3 text-lg font-light">Manage and track all hotel bookings with precision</p>
|
||||
</div>
|
||||
|
||||
{/* Luxury Filter Card */}
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="relative group">
|
||||
@@ -191,12 +190,12 @@ const BookingManagementPage: React.FC = () => {
|
||||
<option value="confirmed">Confirmed</option>
|
||||
<option value="checked_in">Checked in</option>
|
||||
<option value="checked_out">Checked out</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option value="cancelled">Canceled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Luxury Table Card */}
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
@@ -348,11 +347,11 @@ const BookingManagementPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Luxury Detail Modal */}
|
||||
{}
|
||||
{showDetailModal && selectedBooking && (
|
||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4 animate-fade-in">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden animate-scale-in border border-slate-200">
|
||||
{/* Modal Header */}
|
||||
{}
|
||||
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-8 py-6 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
@@ -368,10 +367,10 @@ const BookingManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
{}
|
||||
<div className="p-8 overflow-y-auto max-h-[calc(90vh-120px)]">
|
||||
<div className="space-y-6">
|
||||
{/* Booking Number & Status */}
|
||||
{}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
|
||||
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Booking Number</label>
|
||||
@@ -383,7 +382,7 @@ const BookingManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer Information */}
|
||||
{}
|
||||
<div className="bg-gradient-to-br from-amber-50/50 to-yellow-50/50 p-6 rounded-xl border border-amber-100">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
|
||||
<div className="w-1 h-4 bg-gradient-to-b from-amber-400 to-amber-600 rounded-full"></div>
|
||||
@@ -396,7 +395,7 @@ const BookingManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Room Information */}
|
||||
{}
|
||||
<div className="bg-gradient-to-br from-blue-50/50 to-indigo-50/50 p-6 rounded-xl border border-blue-100">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
|
||||
<div className="w-1 h-4 bg-gradient-to-b from-blue-400 to-blue-600 rounded-full"></div>
|
||||
@@ -409,7 +408,7 @@ const BookingManagementPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Dates & Guests */}
|
||||
{}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white p-5 rounded-xl border border-slate-200">
|
||||
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 block">Check-in Date</label>
|
||||
@@ -426,7 +425,7 @@ const BookingManagementPage: React.FC = () => {
|
||||
<p className="text-lg font-semibold text-slate-900">{selectedBooking.guest_count} guest{selectedBooking.guest_count !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
|
||||
{/* Payment Method & Status */}
|
||||
{}
|
||||
<div className="bg-gradient-to-br from-indigo-50/50 to-purple-50/50 p-6 rounded-xl border border-indigo-100">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
|
||||
<div className="w-1 h-4 bg-gradient-to-b from-indigo-400 to-indigo-600 rounded-full"></div>
|
||||
@@ -448,33 +447,31 @@ const BookingManagementPage: React.FC = () => {
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Payment Status</p>
|
||||
<p className={`text-base font-semibold ${
|
||||
selectedBooking.payment_status === 'paid' || selectedBooking.payment_status === 'completed'
|
||||
selectedBooking.payment_status === 'paid'
|
||||
? 'text-green-600'
|
||||
: selectedBooking.payment_status === 'pending'
|
||||
? 'text-yellow-600'
|
||||
: selectedBooking.payment_status === 'refunded'
|
||||
? 'text-orange-600'
|
||||
: 'text-red-600'
|
||||
}`}>
|
||||
{selectedBooking.payment_status === 'paid' || selectedBooking.payment_status === 'completed'
|
||||
{selectedBooking.payment_status === 'paid'
|
||||
? '✅ Paid'
|
||||
: selectedBooking.payment_status === 'pending'
|
||||
? '⏳ Pending'
|
||||
: selectedBooking.payment_status === 'failed'
|
||||
? '❌ Failed'
|
||||
: selectedBooking.payment_status || 'Unpaid'}
|
||||
: selectedBooking.payment_status === 'refunded'
|
||||
? '💰 Refunded'
|
||||
: '❌ Unpaid'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Usages */}
|
||||
{selectedBooking.service_usages && selectedBooking.service_usages.length > 0 && (
|
||||
{}
|
||||
{(selectedBooking as any).service_usages && (selectedBooking as any).service_usages.length > 0 && (
|
||||
<div className="bg-gradient-to-br from-purple-50/50 to-pink-50/50 p-6 rounded-xl border border-purple-100">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
|
||||
<div className="w-1 h-4 bg-gradient-to-b from-purple-400 to-purple-600 rounded-full"></div>
|
||||
Additional Services
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{selectedBooking.service_usages.map((service: any, idx: number) => (
|
||||
{(selectedBooking as any).service_usages.map((service: any, idx: number) => (
|
||||
<div key={service.id || idx} className="flex justify-between items-center py-2 border-b border-purple-100 last:border-0">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">{service.service_name || service.name || 'Service'}</p>
|
||||
@@ -491,7 +488,7 @@ const BookingManagementPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Breakdown */}
|
||||
{}
|
||||
{(() => {
|
||||
const completedPayments = selectedBooking.payments?.filter(
|
||||
(p) => p.payment_status === 'completed'
|
||||
@@ -549,7 +546,7 @@ const BookingManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Payment Summary - Always show, even if no payments */}
|
||||
{}
|
||||
<div className="bg-gradient-to-br from-green-50 via-emerald-50 to-green-50 p-6 rounded-xl border-2 border-green-200 shadow-lg mb-4">
|
||||
<label className="text-xs font-semibold text-green-700 uppercase tracking-wider mb-2 block">Amount Paid</label>
|
||||
<p className="text-3xl font-bold bg-gradient-to-r from-green-600 via-emerald-700 to-green-600 bg-clip-text text-transparent">
|
||||
@@ -570,7 +567,7 @@ const BookingManagementPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remaining Due - Show prominently if there's remaining balance */}
|
||||
{}
|
||||
{remainingDue > 0 && (
|
||||
<div className="bg-gradient-to-br from-amber-50 via-yellow-50 to-amber-50 p-6 rounded-xl border-2 border-amber-200 shadow-lg mb-4">
|
||||
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wider mb-2 block">Remaining Due (To be paid)</label>
|
||||
@@ -585,7 +582,7 @@ const BookingManagementPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Total Booking Price - Show as reference */}
|
||||
{}
|
||||
<div className="bg-gradient-to-br from-slate-50 to-gray-50 p-6 rounded-xl border-2 border-slate-200 shadow-lg">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2 block">Total Booking Price</label>
|
||||
<p className="text-2xl font-bold text-slate-700">
|
||||
@@ -599,7 +596,7 @@ const BookingManagementPage: React.FC = () => {
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Booking Metadata */}
|
||||
{}
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white p-6 rounded-xl border border-slate-200">
|
||||
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-4 block flex items-center gap-2">
|
||||
<div className="w-1 h-4 bg-gradient-to-b from-slate-400 to-slate-600 rounded-full"></div>
|
||||
@@ -641,7 +638,7 @@ const BookingManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{}
|
||||
{selectedBooking.notes && (
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white p-6 rounded-xl border border-slate-200">
|
||||
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3 block">Special Notes</label>
|
||||
@@ -650,7 +647,7 @@ const BookingManagementPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
{}
|
||||
<div className="mt-8 pt-6 border-t border-slate-200 flex justify-between items-center">
|
||||
<button
|
||||
onClick={() => handleCreateInvoice(selectedBooking.id)}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
Sparkles,
|
||||
ChevronRight,
|
||||
X,
|
||||
Download
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Loading, EmptyState } from '../../components/common';
|
||||
@@ -21,7 +20,8 @@ import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
import { useCurrency } from '../../contexts/CurrencyContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { invoiceService, Invoice } from '../../services/api';
|
||||
import { paymentService, Payment } from '../../services/api';
|
||||
import { paymentService } from '../../services/api';
|
||||
import type { Payment } from '../../services/api/paymentService';
|
||||
import { promotionService, Promotion } from '../../services/api';
|
||||
import { formatDate } from '../../utils/format';
|
||||
|
||||
@@ -33,7 +33,7 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<BusinessTab>('overview');
|
||||
|
||||
// Invoices State
|
||||
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||
const [invoicesLoading, setInvoicesLoading] = useState(true);
|
||||
const [invoiceFilters, setInvoiceFilters] = useState({
|
||||
@@ -45,7 +45,7 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
const [invoicesTotalItems, setInvoicesTotalItems] = useState(0);
|
||||
const invoicesPerPage = 10;
|
||||
|
||||
// Payments State
|
||||
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [paymentsLoading, setPaymentsLoading] = useState(true);
|
||||
const [paymentFilters, setPaymentFilters] = useState({
|
||||
@@ -59,7 +59,7 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
const [paymentsTotalItems, setPaymentsTotalItems] = useState(0);
|
||||
const paymentsPerPage = 5;
|
||||
|
||||
// Promotions State
|
||||
|
||||
const [promotions, setPromotions] = useState<Promotion[]>([]);
|
||||
const [promotionsLoading, setPromotionsLoading] = useState(true);
|
||||
const [showPromotionModal, setShowPromotionModal] = useState(false);
|
||||
@@ -116,7 +116,7 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
}
|
||||
}, [promotionFilters]);
|
||||
|
||||
// Invoices Functions
|
||||
|
||||
const fetchInvoices = async () => {
|
||||
try {
|
||||
setInvoicesLoading(true);
|
||||
@@ -168,12 +168,12 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
sent: { bg: 'bg-gradient-to-r from-blue-50 to-indigo-50', text: 'text-blue-800', label: 'Sent', border: 'border-blue-200' },
|
||||
paid: { bg: 'bg-gradient-to-r from-emerald-50 to-green-50', text: 'text-emerald-800', label: 'Paid', border: 'border-emerald-200' },
|
||||
overdue: { bg: 'bg-gradient-to-r from-rose-50 to-red-50', text: 'text-rose-800', label: 'Overdue', border: 'border-rose-200' },
|
||||
cancelled: { bg: 'bg-gradient-to-r from-slate-50 to-gray-50', text: 'text-slate-700', label: 'Cancelled', 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' },
|
||||
};
|
||||
return badges[status] || badges.draft;
|
||||
};
|
||||
|
||||
// Payments Functions
|
||||
|
||||
const fetchPayments = async () => {
|
||||
try {
|
||||
setPaymentsLoading(true);
|
||||
@@ -210,7 +210,42 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Promotions Functions
|
||||
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 config = statusConfig[status] || statusConfig.pending;
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const fetchPromotions = async () => {
|
||||
try {
|
||||
setPromotionsLoading(true);
|
||||
@@ -320,7 +355,7 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-10 animate-fade-in">
|
||||
{/* Luxury Header */}
|
||||
{}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-emerald-400/5 via-transparent to-purple-600/5 rounded-3xl blur-3xl"></div>
|
||||
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-2xl border border-emerald-200/30 p-8 md:p-10">
|
||||
@@ -347,7 +382,7 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Premium Tab Navigation */}
|
||||
{}
|
||||
<div className="mt-10 pt-8 border-t border-gradient-to-r from-transparent via-emerald-200/30 to-transparent">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{tabs.map((tab) => {
|
||||
@@ -383,7 +418,7 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8">
|
||||
<div
|
||||
@@ -487,10 +522,10 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invoices Tab */}
|
||||
{}
|
||||
{activeTab === 'invoices' && (
|
||||
<div className="space-y-8">
|
||||
{/* Section Header */}
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
||||
<div className="space-y-3">
|
||||
@@ -517,7 +552,7 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{}
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 rounded-xl bg-gradient-to-br from-amber-500/10 to-amber-600/10 border border-amber-200/40">
|
||||
@@ -546,7 +581,7 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
<option value="sent">Sent</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="overdue">Overdue</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option value="cancelled">Canceled</option>
|
||||
</select>
|
||||
<div className="flex items-center gap-3 px-4 py-3.5 bg-gradient-to-r from-gray-50 to-white border-2 border-gray-200 rounded-xl">
|
||||
<Filter className="w-5 h-5 text-emerald-600" />
|
||||
@@ -557,7 +592,7 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoices Table */}
|
||||
{}
|
||||
{invoicesLoading && invoices.length === 0 ? (
|
||||
<Loading fullScreen text="Loading invoices..." />
|
||||
) : (
|
||||
@@ -664,10 +699,10 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payments Tab */}
|
||||
{}
|
||||
{activeTab === 'payments' && (
|
||||
<div className="space-y-8">
|
||||
{/* Section Header */}
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -682,7 +717,7 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{}
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 rounded-xl bg-gradient-to-br from-amber-500/10 to-amber-600/10 border border-amber-200/40">
|
||||
@@ -728,7 +763,7 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payments Table */}
|
||||
{}
|
||||
{paymentsLoading && payments.length === 0 ? (
|
||||
<Loading fullScreen text="Loading payments..." />
|
||||
) : (
|
||||
@@ -743,6 +778,7 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Customer</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Method</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Type</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Status</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Amount</th>
|
||||
<th className="px-8 py-5 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Payment Date</th>
|
||||
</tr>
|
||||
@@ -758,7 +794,7 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{payment.booking?.user?.name || payment.booking?.user?.full_name || 'N/A'}
|
||||
{payment.booking?.user?.name || 'N/A'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
@@ -779,6 +815,9 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
{getPaymentStatusBadge(payment.payment_status)}
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
|
||||
{formatCurrency(payment.amount)}
|
||||
@@ -786,7 +825,7 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-600">
|
||||
{new Date(payment.payment_date || payment.createdAt).toLocaleDateString('en-US')}
|
||||
{new Date(payment.payment_date || payment.createdAt || '').toLocaleDateString('en-US')}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -803,18 +842,22 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary Card */}
|
||||
{}
|
||||
<div className="bg-gradient-to-r from-emerald-500 via-emerald-600 to-purple-600 rounded-2xl shadow-2xl p-8 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2 text-emerald-100">Total Revenue</h3>
|
||||
<p className="text-4xl font-bold">
|
||||
{formatCurrency(payments.reduce((sum, p) => sum + p.amount, 0))}
|
||||
{formatCurrency(payments
|
||||
.filter(p => p.payment_status === 'completed')
|
||||
.reduce((sum, p) => sum + p.amount, 0))}
|
||||
</p>
|
||||
<p className="text-sm mt-3 text-emerald-100/90">
|
||||
Total {payments.filter(p => p.payment_status === 'completed').length} paid transaction{payments.filter(p => p.payment_status === 'completed').length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<p className="text-sm mt-3 text-emerald-100/90">Total {payments.length} transaction{payments.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.length}</div>
|
||||
<div className="text-5xl font-bold text-white/80">{payments.filter(p => p.payment_status === 'completed').length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -823,10 +866,10 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Promotions Tab */}
|
||||
{}
|
||||
{activeTab === 'promotions' && (
|
||||
<div className="space-y-8">
|
||||
{/* Section Header */}
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
||||
<div className="space-y-3">
|
||||
@@ -856,7 +899,7 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{}
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 rounded-xl bg-gradient-to-br from-amber-500/10 to-amber-600/10 border border-amber-200/40">
|
||||
@@ -896,7 +939,7 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Promotions Table */}
|
||||
{}
|
||||
{promotionsLoading && promotions.length === 0 ? (
|
||||
<Loading fullScreen text="Loading promotions..." />
|
||||
) : (
|
||||
@@ -986,11 +1029,11 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Promotion Modal */}
|
||||
{}
|
||||
{showPromotionModal && (
|
||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden border border-gray-200">
|
||||
{/* Modal Header */}
|
||||
{}
|
||||
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-8 py-6 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
@@ -1010,7 +1053,7 @@ const BusinessDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
{}
|
||||
<div className="p-8 overflow-y-auto max-h-[calc(90vh-120px)] custom-scrollbar">
|
||||
<form onSubmit={handlePromotionSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
|
||||
@@ -31,7 +31,7 @@ const CheckInPage: React.FC = () => {
|
||||
const [children, setChildren] = useState(0);
|
||||
const [additionalFee, setAdditionalFee] = useState(0);
|
||||
|
||||
// Fetch bookings for the selected date
|
||||
|
||||
useEffect(() => {
|
||||
fetchBookingsForDate();
|
||||
}, [selectedDate, searchQuery]);
|
||||
@@ -46,15 +46,15 @@ const CheckInPage: React.FC = () => {
|
||||
const nextDay = new Date(date);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
|
||||
// Fetch all bookings (we'll filter on client side for accuracy)
|
||||
|
||||
const params: any = {
|
||||
limit: 200, // Fetch more to ensure we get all bookings for the date range
|
||||
limit: 200,
|
||||
};
|
||||
if (searchQuery) {
|
||||
params.search = searchQuery;
|
||||
}
|
||||
|
||||
// Fetch bookings around the selected date (3 days before and after to be safe)
|
||||
|
||||
const startDate = new Date(date);
|
||||
startDate.setDate(startDate.getDate() - 3);
|
||||
const endDate = new Date(date);
|
||||
@@ -66,7 +66,7 @@ const CheckInPage: React.FC = () => {
|
||||
const response = await bookingService.getAllBookings(params);
|
||||
const allBookings = response.data.bookings || [];
|
||||
|
||||
// Filter check-ins: confirmed bookings with check_in_date matching selected date
|
||||
|
||||
const filteredCheckIns = allBookings.filter((booking) => {
|
||||
if (!booking.check_in_date || booking.status !== 'confirmed') return false;
|
||||
const checkInDate = new Date(booking.check_in_date);
|
||||
@@ -75,7 +75,7 @@ const CheckInPage: React.FC = () => {
|
||||
});
|
||||
setCheckInBookings(filteredCheckIns);
|
||||
|
||||
// Filter check-outs: checked_in bookings with check_out_date matching selected date
|
||||
|
||||
const filteredCheckOuts = allBookings.filter((booking) => {
|
||||
if (!booking.check_out_date || booking.status !== 'checked_in') return false;
|
||||
const checkOutDate = new Date(booking.check_out_date);
|
||||
@@ -199,7 +199,7 @@ const CheckInPage: React.FC = () => {
|
||||
toast.success('Check-in successful');
|
||||
}
|
||||
|
||||
// Reset form and refresh bookings
|
||||
|
||||
setBooking(null);
|
||||
setSearchQuery('');
|
||||
setActualRoomNumber('');
|
||||
@@ -238,7 +238,7 @@ const CheckInPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date and Search Filters */}
|
||||
{}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
@@ -291,10 +291,10 @@ const CheckInPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Check-ins and Check-outs Lists */}
|
||||
{}
|
||||
{!booking && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Check-ins for Today */}
|
||||
{}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
@@ -346,7 +346,7 @@ const CheckInPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Check-outs for Today */}
|
||||
{}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
@@ -400,7 +400,7 @@ const CheckInPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Booking Info and Check-in Form */}
|
||||
{}
|
||||
{booking && (
|
||||
<>
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
@@ -462,7 +462,7 @@ const CheckInPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Warning Alert */}
|
||||
{}
|
||||
{booking.payment_balance && booking.payment_balance.remaining_balance > 0.01 && (
|
||||
<div className="mt-6 p-4 bg-gradient-to-r from-amber-50 to-orange-50 rounded-lg border-2 border-amber-400">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -497,7 +497,7 @@ const CheckInPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Information */}
|
||||
{}
|
||||
<div className="mt-6 p-4 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-200">
|
||||
<h3 className="text-md font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
@@ -521,19 +521,17 @@ const CheckInPage: React.FC = () => {
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Payment Status:</span>
|
||||
<span className={`font-semibold ${
|
||||
booking.payment_status === 'paid' || booking.payment_status === 'completed'
|
||||
booking.payment_status === 'paid'
|
||||
? 'text-green-600'
|
||||
: booking.payment_status === 'pending'
|
||||
? 'text-yellow-600'
|
||||
: booking.payment_status === 'refunded'
|
||||
? 'text-orange-600'
|
||||
: 'text-red-600'
|
||||
}`}>
|
||||
{booking.payment_status === 'paid' || booking.payment_status === 'completed'
|
||||
{booking.payment_status === 'paid'
|
||||
? '✅ Paid'
|
||||
: booking.payment_status === 'pending'
|
||||
? '⏳ Pending'
|
||||
: booking.payment_status === 'failed'
|
||||
? '❌ Failed'
|
||||
: booking.payment_status || 'Unpaid'}
|
||||
: booking.payment_status === 'refunded'
|
||||
? '💰 Refunded'
|
||||
: '❌ Unpaid'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -603,7 +601,6 @@ const CheckInPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{booking.status !== 'confirmed' && (
|
||||
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg flex items-start gap-2">
|
||||
@@ -619,7 +616,7 @@ const CheckInPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assign Room */}
|
||||
{}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Hotel className="w-5 h-5 text-blue-600" />
|
||||
@@ -642,7 +639,7 @@ const CheckInPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guest Information */}
|
||||
{}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-purple-600" />
|
||||
@@ -714,7 +711,7 @@ const CheckInPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Charges */}
|
||||
{}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">5. Additional Fees (if any)</h2>
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
@@ -761,7 +758,7 @@ const CheckInPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary & Action */}
|
||||
{}
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 p-6 rounded-lg border border-blue-200">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
|
||||
@@ -42,12 +42,6 @@ const CheckOutPage: React.FC = () => {
|
||||
|
||||
setBooking(foundBooking);
|
||||
|
||||
// Mock services data - in production will fetch from API
|
||||
setServices([
|
||||
{ service_name: 'Laundry', quantity: 2, price: 50000, total: 100000 },
|
||||
{ service_name: 'Minibar', quantity: 1, price: 150000, total: 150000 },
|
||||
]);
|
||||
|
||||
toast.success('Booking found');
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Booking not found');
|
||||
@@ -67,12 +61,10 @@ const CheckOutPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const calculateAdditionalFee = () => {
|
||||
// Additional fees from check-in (children, extra person)
|
||||
return 0; // In production will get from booking data
|
||||
return 0;
|
||||
};
|
||||
|
||||
const calculateDeposit = () => {
|
||||
// Deposit already paid
|
||||
return booking?.total_price ? booking.total_price * 0.3 : 0;
|
||||
};
|
||||
|
||||
@@ -92,7 +84,6 @@ const CheckOutPage: React.FC = () => {
|
||||
return calculateTotal() - calculateDeposit();
|
||||
};
|
||||
|
||||
|
||||
const handleCheckOut = async () => {
|
||||
if (!booking) return;
|
||||
|
||||
@@ -104,14 +95,10 @@ const CheckOutPage: React.FC = () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Update booking status
|
||||
await bookingService.updateBooking(booking.id, {
|
||||
status: 'checked_out',
|
||||
} as any);
|
||||
|
||||
// Create payment record (if needed)
|
||||
// await paymentService.createPayment({...});
|
||||
|
||||
toast.success('Check-out successful');
|
||||
setShowInvoice(true);
|
||||
} catch (error: any) {
|
||||
@@ -147,7 +134,6 @@ const CheckOutPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Booking */}
|
||||
{!showInvoice && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">1. Search booking</h2>
|
||||
@@ -174,10 +160,8 @@ const CheckOutPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invoice */}
|
||||
{booking && !showInvoice && (
|
||||
<>
|
||||
{/* Booking Info */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">2. Booking information</h2>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
@@ -216,14 +200,12 @@ const CheckOutPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bill Details */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-blue-600" />
|
||||
3. Invoice details
|
||||
</h2>
|
||||
|
||||
{/* Room Fee */}
|
||||
<div className="mb-4">
|
||||
<h3 className="font-medium text-gray-700 mb-2">Room fee</h3>
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
@@ -234,7 +216,6 @@ const CheckOutPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Fee */}
|
||||
{services.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="font-medium text-gray-700 mb-2">Services used</h3>
|
||||
@@ -255,7 +236,6 @@ const CheckOutPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional Fee */}
|
||||
{calculateAdditionalFee() > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="font-medium text-gray-700 mb-2">Additional fees</h3>
|
||||
@@ -268,7 +248,6 @@ const CheckOutPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discount */}
|
||||
<div className="mb-4">
|
||||
<h3 className="font-medium text-gray-700 mb-2">Discount</h3>
|
||||
<div className="flex gap-4">
|
||||
@@ -282,7 +261,6 @@ const CheckOutPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="border-t-2 border-gray-300 pt-4 space-y-2">
|
||||
<div className="flex justify-between text-lg">
|
||||
<span>Subtotal:</span>
|
||||
@@ -309,7 +287,6 @@ const CheckOutPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<CreditCard className="w-5 h-5 text-green-600" />
|
||||
@@ -341,7 +318,6 @@ const CheckOutPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<div className="bg-gradient-to-r from-green-50 to-emerald-50 p-6 rounded-lg border border-green-200">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
@@ -363,7 +339,6 @@ const CheckOutPage: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Invoice Display */}
|
||||
{showInvoice && booking && (
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg">
|
||||
<div className="text-center mb-6">
|
||||
@@ -419,7 +394,6 @@ const CheckOutPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!booking && !searching && !showInvoice && (
|
||||
<div className="bg-gray-50 rounded-lg p-12 text-center">
|
||||
<Search className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
|
||||
@@ -113,7 +113,7 @@ const CookieSettingsPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-10 pb-8 animate-fade-in">
|
||||
{/* Header */}
|
||||
{}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-6 pb-6 border-b border-gray-200/60">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -139,7 +139,7 @@ const CookieSettingsPage: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info card */}
|
||||
{}
|
||||
<div className="enterprise-card flex gap-5 p-6 bg-gradient-to-br from-blue-50/50 to-indigo-50/30 border-blue-100/60">
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
<div className="p-2.5 rounded-lg bg-gradient-to-br from-blue-500/10 to-indigo-500/10 border border-blue-200/40">
|
||||
@@ -177,7 +177,7 @@ const CookieSettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggles */}
|
||||
{}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="enterprise-card p-6 space-y-4 group">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
@@ -299,7 +299,7 @@ const CookieSettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Integration IDs */}
|
||||
{}
|
||||
<div className="enterprise-card p-6 space-y-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between pb-4 border-b border-gray-200/60">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -400,4 +400,3 @@ const CookieSettingsPage: React.FC = () => {
|
||||
|
||||
export default CookieSettingsPage;
|
||||
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ const CurrencySettingsPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-10 pb-8 animate-fade-in">
|
||||
{/* Header */}
|
||||
{}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-6 pb-6 border-b border-gray-200/60">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -100,7 +100,7 @@ const CurrencySettingsPage: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info card */}
|
||||
{}
|
||||
<div className="enterprise-card flex gap-5 p-6 bg-gradient-to-br from-blue-50/50 to-indigo-50/30 border-blue-100/60">
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
<div className="p-2.5 rounded-lg bg-gradient-to-br from-blue-500/10 to-indigo-500/10 border border-blue-200/40">
|
||||
@@ -138,7 +138,7 @@ const CurrencySettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Currency Selection */}
|
||||
{}
|
||||
<div className="enterprise-card p-6 space-y-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between pb-4 border-b border-gray-200/60">
|
||||
<div className="flex items-start gap-3">
|
||||
|
||||
@@ -6,11 +6,11 @@ import {
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
RefreshCw,
|
||||
TrendingDown,
|
||||
CreditCard,
|
||||
LogOut
|
||||
} from 'lucide-react';
|
||||
import { reportService, ReportData, paymentService, Payment } from '../../services/api';
|
||||
import { reportService, ReportData, paymentService } from '../../services/api';
|
||||
import type { Payment } from '../../services/api/paymentService';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Loading, EmptyState } from '../../components/common';
|
||||
import CurrencyIcon from '../../components/common/CurrencyIcon';
|
||||
@@ -60,7 +60,7 @@ const DashboardPage: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
execute();
|
||||
}, [dateRange]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [dateRange]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPayments = async () => {
|
||||
@@ -135,7 +135,7 @@ const DashboardPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
|
||||
{/* Luxury Header */}
|
||||
{}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-6 animate-fade-in">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
@@ -147,7 +147,7 @@ const DashboardPage: React.FC = () => {
|
||||
<p className="text-slate-600 mt-3 text-lg font-light">Hotel operations overview and analytics</p>
|
||||
</div>
|
||||
|
||||
{/* Date Range Filter & Actions */}
|
||||
{}
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
|
||||
<div className="flex gap-3 items-center">
|
||||
<input
|
||||
@@ -185,9 +185,9 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Luxury Stats Cards */}
|
||||
{}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{/* Total Revenue */}
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 border-l-4 border-l-emerald-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -207,7 +207,7 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Bookings */}
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 border-l-4 border-l-blue-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -227,7 +227,7 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available Rooms */}
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 border-l-4 border-l-purple-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.3s' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -247,7 +247,7 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Customers */}
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 border-l-4 border-l-amber-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.4s' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -268,9 +268,9 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts Section */}
|
||||
{}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Revenue Chart */}
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
|
||||
<h2 className="text-xl font-bold text-slate-900">Daily Revenue</h2>
|
||||
@@ -312,7 +312,7 @@ const DashboardPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bookings by Status */}
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
|
||||
<h2 className="text-xl font-bold text-slate-900">Booking Status</h2>
|
||||
@@ -334,7 +334,7 @@ const DashboardPage: React.FC = () => {
|
||||
confirmed: 'Confirmed',
|
||||
checked_in: 'Checked in',
|
||||
checked_out: 'Checked out',
|
||||
cancelled: 'Cancelled',
|
||||
cancelled: '❌ Canceled',
|
||||
};
|
||||
return (
|
||||
<div key={status} className="flex items-center justify-between p-3 bg-gradient-to-r from-slate-50 to-white rounded-xl hover:shadow-md transition-all duration-200 border border-slate-100">
|
||||
@@ -356,9 +356,9 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Rooms, Services & Recent Payments */}
|
||||
{}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Top Rooms */}
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
|
||||
<h2 className="text-xl font-bold text-slate-900">Top Booked Rooms</h2>
|
||||
@@ -393,7 +393,7 @@ const DashboardPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Service Usage */}
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
|
||||
<h2 className="text-xl font-bold text-slate-900">Services Used</h2>
|
||||
@@ -423,7 +423,7 @@ const DashboardPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Payments */}
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.3s' }}>
|
||||
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
|
||||
<h2 className="text-xl font-bold text-slate-900">Recent Payments</h2>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Search, Plus, Edit, Trash2, Eye, Download, FileText, Filter } from 'lucide-react';
|
||||
import { Search, Plus, Edit, Trash2, Eye, FileText, Filter } from 'lucide-react';
|
||||
import { invoiceService, Invoice } from '../../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
@@ -42,7 +42,7 @@ const InvoiceManagementPage: React.FC = () => {
|
||||
if (response.status === 'success' && response.data) {
|
||||
let invoiceList = response.data.invoices || [];
|
||||
|
||||
// Apply search filter
|
||||
|
||||
if (filters.search) {
|
||||
invoiceList = invoiceList.filter((inv) =>
|
||||
inv.invoice_number.toLowerCase().includes(filters.search.toLowerCase()) ||
|
||||
@@ -119,7 +119,7 @@ const InvoiceManagementPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
|
||||
{/* Luxury Header */}
|
||||
{}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 animate-fade-in">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
@@ -139,7 +139,7 @@ const InvoiceManagementPage: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Luxury Filters */}
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
<div className="relative group">
|
||||
@@ -173,7 +173,7 @@ const InvoiceManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Luxury Invoices Table */}
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
@@ -310,7 +310,7 @@ const InvoiceManagementPage: React.FC = () => {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-6 py-4 border-t border-gray-200">
|
||||
<Pagination
|
||||
|
||||
@@ -25,7 +25,6 @@ import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import { ConfirmationDialog } from '../../components/common';
|
||||
import IconPicker from '../../components/admin/IconPicker';
|
||||
import { luxuryContentSeed } from '../../data/luxuryContentSeed';
|
||||
|
||||
type ContentTab = 'overview' | 'home' | 'contact' | 'about' | 'footer' | 'seo';
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { paymentService, Payment } from '../../services/api';
|
||||
import { paymentService } from '../../services/api';
|
||||
import type { Payment } from '../../services/api/paymentService';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../components/common/Loading';
|
||||
import Pagination from '../../components/common/Pagination';
|
||||
@@ -49,7 +50,6 @@ const PaymentManagementPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const getMethodBadge = (method: string) => {
|
||||
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
|
||||
cash: {
|
||||
@@ -91,13 +91,48 @@ const PaymentManagementPage: 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 config = statusConfig[status] || statusConfig.pending;
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
|
||||
{/* Luxury Header */}
|
||||
{}
|
||||
<div className="animate-fade-in">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-1 w-16 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
|
||||
@@ -108,7 +143,7 @@ const PaymentManagementPage: React.FC = () => {
|
||||
<p className="text-slate-600 mt-3 text-lg font-light">Track payment transactions</p>
|
||||
</div>
|
||||
|
||||
{/* Luxury Filter Card */}
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-5">
|
||||
<div className="relative group">
|
||||
@@ -148,7 +183,7 @@ const PaymentManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Luxury Table Card */}
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
@@ -197,6 +232,9 @@ const PaymentManagementPage: React.FC = () => {
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
{getPaymentStatusBadge(payment.payment_status)}
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
|
||||
{formatCurrency(payment.amount)}
|
||||
@@ -204,7 +242,7 @@ const PaymentManagementPage: React.FC = () => {
|
||||
</td>
|
||||
<td className="px-8 py-5 whitespace-nowrap">
|
||||
<div className="text-sm text-slate-600">
|
||||
{new Date(payment.payment_date || payment.createdAt).toLocaleDateString('en-US')}
|
||||
{new Date(payment.payment_date || payment.createdAt || '').toLocaleDateString('en-US')}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -221,18 +259,22 @@ const PaymentManagementPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Luxury Summary Card */}
|
||||
{}
|
||||
<div className="bg-gradient-to-r from-amber-500 via-amber-600 to-amber-700 rounded-2xl shadow-2xl p-8 text-white animate-fade-in" style={{ animationDelay: '0.3s' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2 text-amber-100">Total Revenue</h3>
|
||||
<p className="text-4xl font-bold">
|
||||
{formatCurrency(payments.reduce((sum, p) => sum + p.amount, 0))}
|
||||
{formatCurrency(payments
|
||||
.filter(p => 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' : ''}
|
||||
</p>
|
||||
<p className="text-sm mt-3 text-amber-100/90">Total {payments.length} transaction{payments.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.length}</div>
|
||||
<div className="text-5xl font-bold text-white/80">{payments.filter(p => p.payment_status === 'completed').length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -131,7 +131,6 @@ const PromotionManagementPage: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
|
||||
active: {
|
||||
@@ -167,7 +166,7 @@ const PromotionManagementPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
|
||||
{/* Luxury Header */}
|
||||
{}
|
||||
<div className="flex justify-between items-center animate-fade-in">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
@@ -190,7 +189,7 @@ const PromotionManagementPage: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Luxury Filters */}
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-5">
|
||||
<div className="relative group">
|
||||
@@ -224,7 +223,7 @@ const PromotionManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Luxury Table */}
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
@@ -314,11 +313,11 @@ const PromotionManagementPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Luxury Modal */}
|
||||
{}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4 animate-fade-in">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden border border-slate-200 animate-scale-in">
|
||||
{/* Modal Header */}
|
||||
{}
|
||||
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-8 py-6 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
@@ -338,7 +337,7 @@ const PromotionManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
{}
|
||||
<div className="p-8 overflow-y-auto max-h-[calc(90vh-120px)] custom-scrollbar">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
Edit,
|
||||
Trash2,
|
||||
X,
|
||||
Upload,
|
||||
Image as ImageIcon,
|
||||
Check,
|
||||
Calendar,
|
||||
@@ -53,7 +52,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const [activeTab, setActiveTab] = useState<ReceptionTab>('overview');
|
||||
|
||||
// Check-in State
|
||||
|
||||
const [checkInBookingNumber, setCheckInBookingNumber] = useState('');
|
||||
const [checkInBooking, setCheckInBooking] = useState<Booking | null>(null);
|
||||
const [checkInLoading, setCheckInLoading] = useState(false);
|
||||
@@ -64,7 +63,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
const [children, setChildren] = useState(0);
|
||||
const [additionalFee, setAdditionalFee] = useState(0);
|
||||
|
||||
// Check-out State
|
||||
|
||||
const [checkOutBookingNumber, setCheckOutBookingNumber] = useState('');
|
||||
const [checkOutBooking, setCheckOutBooking] = useState<Booking | null>(null);
|
||||
const [checkOutLoading, setCheckOutLoading] = useState(false);
|
||||
@@ -74,7 +73,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
const [discount, setDiscount] = useState(0);
|
||||
const [showInvoice, setShowInvoice] = useState(false);
|
||||
|
||||
// Bookings Management State
|
||||
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [bookingsLoading, setBookingsLoading] = useState(true);
|
||||
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
|
||||
@@ -90,7 +89,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
const [bookingTotalItems, setBookingTotalItems] = useState(0);
|
||||
const bookingItemsPerPage = 5;
|
||||
|
||||
// Rooms Management State
|
||||
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [roomsLoading, setRoomsLoading] = useState(true);
|
||||
const [showRoomModal, setShowRoomModal] = useState(false);
|
||||
@@ -123,7 +122,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
const [uploadingImages, setUploadingImages] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
|
||||
// Services Management State
|
||||
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [servicesLoading, setServicesLoading] = useState(true);
|
||||
const [showServiceModal, setShowServiceModal] = useState(false);
|
||||
@@ -144,7 +143,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
});
|
||||
|
||||
// Check-in Functions
|
||||
|
||||
const handleCheckInSearch = async () => {
|
||||
if (!checkInBookingNumber.trim()) {
|
||||
toast.error('Please enter booking number');
|
||||
@@ -189,7 +188,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
return total;
|
||||
};
|
||||
|
||||
// Calculate additional fee when extraPersons or children change
|
||||
|
||||
useEffect(() => {
|
||||
const extraPersonFee = extraPersons * 200000;
|
||||
const childrenFee = children * 100000;
|
||||
@@ -235,7 +234,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Check-out Functions
|
||||
|
||||
const handleCheckOutSearch = async () => {
|
||||
if (!checkOutBookingNumber.trim()) {
|
||||
toast.error('Please enter booking number');
|
||||
@@ -338,7 +337,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
setShowInvoice(false);
|
||||
};
|
||||
|
||||
// Bookings Management Functions
|
||||
|
||||
const fetchBookings = useCallback(async () => {
|
||||
try {
|
||||
setBookingsLoading(true);
|
||||
@@ -426,7 +425,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
cancelled: {
|
||||
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
|
||||
text: 'text-rose-800',
|
||||
label: 'Cancelled',
|
||||
label: '❌ Canceled',
|
||||
border: 'border-rose-200'
|
||||
},
|
||||
};
|
||||
@@ -438,7 +437,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Rooms Management Functions
|
||||
|
||||
const fetchAvailableAmenities = useCallback(async () => {
|
||||
try {
|
||||
const response = await roomService.getAmenities();
|
||||
@@ -546,7 +545,6 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
fetchAllRoomTypes();
|
||||
}, [activeTab, editingRoom]);
|
||||
|
||||
|
||||
const handleRoomSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
@@ -883,7 +881,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Services Management Functions
|
||||
|
||||
const fetchServices = useCallback(async () => {
|
||||
try {
|
||||
setServicesLoading(true);
|
||||
@@ -991,7 +989,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-10 animate-fade-in">
|
||||
{/* Luxury Header */}
|
||||
{}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-emerald-400/5 via-transparent to-teal-600/5 rounded-3xl blur-3xl"></div>
|
||||
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-2xl border border-emerald-200/30 p-8 md:p-10">
|
||||
@@ -1018,7 +1016,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Premium Tab Navigation */}
|
||||
{}
|
||||
<div className="mt-10 pt-8 border-t border-gradient-to-r from-transparent via-emerald-200/30 to-transparent">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{tabs.map((tab) => {
|
||||
@@ -1054,7 +1052,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8">
|
||||
<div
|
||||
@@ -1224,14 +1222,14 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Check-in Tab */}
|
||||
{}
|
||||
{activeTab === 'check-in' && (
|
||||
<div className="space-y-8">
|
||||
{checkInLoading && (
|
||||
<Loading fullScreen text="Processing check-in..." />
|
||||
)}
|
||||
|
||||
{/* Section Header */}
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -1246,7 +1244,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Booking */}
|
||||
{}
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-100 flex items-center justify-center">
|
||||
@@ -1280,7 +1278,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Booking Info */}
|
||||
{}
|
||||
{checkInBooking && (
|
||||
<>
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
@@ -1329,7 +1327,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Information */}
|
||||
{}
|
||||
<div className="mt-6 p-6 bg-gradient-to-r from-green-50 to-emerald-50 rounded-xl border border-green-200">
|
||||
<h3 className="text-md font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
@@ -1353,19 +1351,17 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-gray-600 font-medium">Payment Status:</span>
|
||||
<span className={`font-semibold ${
|
||||
checkInBooking.payment_status === 'paid' || checkInBooking.payment_status === 'completed'
|
||||
checkInBooking.payment_status === 'paid'
|
||||
? 'text-green-600'
|
||||
: checkInBooking.payment_status === 'pending'
|
||||
? 'text-yellow-600'
|
||||
: checkInBooking.payment_status === 'refunded'
|
||||
? 'text-orange-600'
|
||||
: 'text-red-600'
|
||||
}`}>
|
||||
{checkInBooking.payment_status === 'paid' || checkInBooking.payment_status === 'completed'
|
||||
{checkInBooking.payment_status === 'paid'
|
||||
? '✅ Paid'
|
||||
: checkInBooking.payment_status === 'pending'
|
||||
? '⏳ Pending'
|
||||
: checkInBooking.payment_status === 'failed'
|
||||
? '❌ Failed'
|
||||
: checkInBooking.payment_status || 'Unpaid'}
|
||||
: checkInBooking.payment_status === 'refunded'
|
||||
? '💰 Refunded'
|
||||
: '❌ Unpaid'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1436,7 +1432,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assign Room */}
|
||||
{}
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-100 flex items-center justify-center">
|
||||
@@ -1461,7 +1457,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guest Information */}
|
||||
{}
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-100 flex items-center justify-center">
|
||||
@@ -1535,7 +1531,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Charges */}
|
||||
{}
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6">Additional Fees (if any)</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
@@ -1576,7 +1572,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary & Action */}
|
||||
{}
|
||||
<div className="relative bg-gradient-to-r from-emerald-500 via-emerald-600 to-teal-600 rounded-2xl shadow-2xl p-8 text-white overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-white/10 rounded-full -mr-32 -mt-32"></div>
|
||||
<div className="relative flex flex-col md:flex-row justify-between items-start md:items-center gap-6">
|
||||
@@ -1603,7 +1599,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{}
|
||||
{!checkInBooking && !checkInSearching && (
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-12 text-center">
|
||||
<div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-emerald-100 to-emerald-200 flex items-center justify-center">
|
||||
@@ -1618,14 +1614,14 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Check-out Tab */}
|
||||
{}
|
||||
{activeTab === 'check-out' && (
|
||||
<div className="space-y-8">
|
||||
{checkOutLoading && (
|
||||
<Loading fullScreen text="Processing check-out..." />
|
||||
)}
|
||||
|
||||
{/* Section Header */}
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -1640,7 +1636,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Booking */}
|
||||
{}
|
||||
{!showInvoice && (
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-3">
|
||||
@@ -1676,10 +1672,10 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invoice */}
|
||||
{}
|
||||
{checkOutBooking && !showInvoice && (
|
||||
<>
|
||||
{/* Booking Info */}
|
||||
{}
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-teal-100 flex items-center justify-center">
|
||||
@@ -1723,7 +1719,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bill Details */}
|
||||
{}
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-100 flex items-center justify-center">
|
||||
@@ -1732,7 +1728,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
Invoice Details
|
||||
</h2>
|
||||
|
||||
{/* Room Fee */}
|
||||
{}
|
||||
<div className="mb-6">
|
||||
<h3 className="font-semibold text-gray-700 mb-3">Room Fee</h3>
|
||||
<div className="bg-gradient-to-br from-gray-50 to-white p-5 rounded-xl border border-gray-200">
|
||||
@@ -1743,7 +1739,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Fee */}
|
||||
{}
|
||||
{checkOutServices.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="font-semibold text-gray-700 mb-3">Services Used</h3>
|
||||
@@ -1764,7 +1760,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional Fee */}
|
||||
{}
|
||||
{calculateCheckOutAdditionalFee() > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="font-semibold text-gray-700 mb-3">Additional Fees</h3>
|
||||
@@ -1777,7 +1773,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discount */}
|
||||
{}
|
||||
<div className="mb-6">
|
||||
<h3 className="font-semibold text-gray-700 mb-3">Discount</h3>
|
||||
<input
|
||||
@@ -1789,7 +1785,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{}
|
||||
<div className="border-t-2 border-gray-300 pt-6 space-y-4">
|
||||
<div className="flex justify-between items-center text-lg">
|
||||
<span className="text-gray-700 font-medium">Subtotal:</span>
|
||||
@@ -1818,7 +1814,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
{}
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-green-100 flex items-center justify-center">
|
||||
@@ -1852,7 +1848,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
{}
|
||||
<div className="relative bg-gradient-to-r from-teal-500 via-teal-600 to-cyan-600 rounded-2xl shadow-2xl p-8 text-white overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-white/10 rounded-full -mr-32 -mt-32"></div>
|
||||
<div className="relative flex flex-col md:flex-row justify-between items-start md:items-center gap-6">
|
||||
@@ -1875,7 +1871,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Invoice Display */}
|
||||
{}
|
||||
{showInvoice && checkOutBooking && (
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-2xl border border-gray-200/50 p-8">
|
||||
<div className="text-center mb-8">
|
||||
@@ -1931,7 +1927,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{}
|
||||
{!checkOutBooking && !checkOutSearching && !showInvoice && (
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-12 text-center">
|
||||
<div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-teal-100 to-teal-200 flex items-center justify-center">
|
||||
@@ -1946,12 +1942,12 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bookings Tab */}
|
||||
{}
|
||||
{activeTab === 'bookings' && (
|
||||
<div className="space-y-8">
|
||||
{bookingsLoading && <Loading />}
|
||||
|
||||
{/* Section Header */}
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -1966,7 +1962,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="relative group">
|
||||
@@ -1989,12 +1985,12 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
<option value="confirmed">Confirmed</option>
|
||||
<option value="checked_in">Checked in</option>
|
||||
<option value="checked_out">Checked out</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option value="cancelled">Canceled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bookings Table */}
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
@@ -2117,7 +2113,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Booking Detail Modal */}
|
||||
{}
|
||||
{showBookingDetailModal && selectedBooking && (
|
||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden">
|
||||
@@ -2189,7 +2185,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
<p className="text-lg font-semibold text-slate-900">{selectedBooking.guest_count} guest{selectedBooking.guest_count !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
|
||||
{/* Payment Method & Status */}
|
||||
{}
|
||||
<div className="bg-gradient-to-br from-indigo-50/50 to-purple-50/50 p-6 rounded-xl border border-indigo-100">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
|
||||
<div className="w-1 h-4 bg-gradient-to-b from-indigo-400 to-indigo-600 rounded-full"></div>
|
||||
@@ -2211,25 +2207,23 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Payment Status</p>
|
||||
<p className={`text-base font-semibold ${
|
||||
selectedBooking.payment_status === 'paid' || selectedBooking.payment_status === 'completed'
|
||||
selectedBooking.payment_status === 'paid'
|
||||
? 'text-green-600'
|
||||
: selectedBooking.payment_status === 'pending'
|
||||
? 'text-yellow-600'
|
||||
: selectedBooking.payment_status === 'refunded'
|
||||
? 'text-orange-600'
|
||||
: 'text-red-600'
|
||||
}`}>
|
||||
{selectedBooking.payment_status === 'paid' || selectedBooking.payment_status === 'completed'
|
||||
{selectedBooking.payment_status === 'paid'
|
||||
? '✅ Paid'
|
||||
: selectedBooking.payment_status === 'pending'
|
||||
? '⏳ Pending'
|
||||
: selectedBooking.payment_status === 'failed'
|
||||
? '❌ Failed'
|
||||
: selectedBooking.payment_status || 'Unpaid'}
|
||||
: selectedBooking.payment_status === 'refunded'
|
||||
? '💰 Refunded'
|
||||
: '❌ Unpaid'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment History */}
|
||||
{}
|
||||
{selectedBooking.payments && selectedBooking.payments.length > 0 && (
|
||||
<div className="bg-gradient-to-br from-teal-50/50 to-cyan-50/50 p-6 rounded-xl border border-teal-100">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 block flex items-center gap-2">
|
||||
@@ -2274,7 +2268,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Breakdown */}
|
||||
{}
|
||||
{(() => {
|
||||
const completedPayments = selectedBooking.payments?.filter(
|
||||
(p) => p.payment_status === 'completed'
|
||||
@@ -2288,7 +2282,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Payment Summary */}
|
||||
{}
|
||||
<div className="bg-gradient-to-br from-green-50 via-emerald-50 to-green-50 p-6 rounded-xl border-2 border-green-200 shadow-lg mb-4">
|
||||
<label className="text-xs font-semibold text-green-700 uppercase tracking-wider mb-2 block">Amount Paid</label>
|
||||
<p className="text-3xl font-bold bg-gradient-to-r from-green-600 via-emerald-700 to-green-600 bg-clip-text text-transparent">
|
||||
@@ -2309,7 +2303,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remaining Due */}
|
||||
{}
|
||||
{remainingDue > 0 && (
|
||||
<div className="bg-gradient-to-br from-amber-50 via-yellow-50 to-amber-50 p-6 rounded-xl border-2 border-amber-200 shadow-lg mb-4">
|
||||
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wider mb-2 block">Remaining Due (To be paid)</label>
|
||||
@@ -2324,7 +2318,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Total Booking Price */}
|
||||
{}
|
||||
<div className="bg-gradient-to-br from-slate-50 to-gray-50 p-6 rounded-xl border-2 border-slate-200 shadow-lg">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2 block">Total Booking Price</label>
|
||||
{selectedBooking.original_price && selectedBooking.discount_amount && selectedBooking.discount_amount > 0 ? (
|
||||
@@ -2384,12 +2378,12 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rooms Tab */}
|
||||
{}
|
||||
{activeTab === 'rooms' && (
|
||||
<div className="space-y-8">
|
||||
{roomsLoading && <Loading />}
|
||||
|
||||
{/* Section Header */}
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="space-y-3">
|
||||
@@ -2427,7 +2421,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
<div className="relative group">
|
||||
@@ -2465,7 +2459,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rooms Table */}
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
@@ -2560,7 +2554,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Room Modal - Simplified version, will need full implementation */}
|
||||
{}
|
||||
{showRoomModal && (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a] rounded-xl border border-[#d4af37]/30 backdrop-blur-xl shadow-2xl shadow-[#d4af37]/20 p-8 w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
@@ -2582,7 +2576,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleRoomSubmit} className="space-y-6">
|
||||
{/* Basic Information Section */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-serif font-semibold text-[#d4af37] mb-4 flex items-center gap-2">
|
||||
<div className="w-1 h-6 bg-gradient-to-b from-[#d4af37] to-[#c9a227]" />
|
||||
@@ -2740,7 +2734,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amenities Selection */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-serif font-semibold text-[#d4af37] mb-4 flex items-center gap-2">
|
||||
<div className="w-1 h-6 bg-gradient-to-b from-[#d4af37] to-[#c9a227]" />
|
||||
@@ -2796,7 +2790,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
{}
|
||||
<div className="flex gap-4 pt-4 border-t border-[#d4af37]/20">
|
||||
<button
|
||||
type="button"
|
||||
@@ -2814,7 +2808,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Image Upload Section - Only show when editing */}
|
||||
{}
|
||||
{editingRoom && (
|
||||
<div className="mt-8 pt-8 border-t border-[#d4af37]/20">
|
||||
<h3 className="text-lg font-serif font-semibold text-[#d4af37] mb-6 flex items-center gap-2">
|
||||
@@ -2823,30 +2817,30 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
Room Images
|
||||
</h3>
|
||||
|
||||
{/* Helper function to normalize image URLs */}
|
||||
{}
|
||||
{(() => {
|
||||
// Get API base URL from environment or default
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000';
|
||||
|
||||
const normalizeImageUrl = (img: string): string => {
|
||||
if (!img) return '';
|
||||
// If already a full URL, return as-is
|
||||
|
||||
if (img.startsWith('http://') || img.startsWith('https://')) {
|
||||
return img;
|
||||
}
|
||||
// Normalize relative paths
|
||||
|
||||
const cleanPath = img.startsWith('/') ? img : `/${img}`;
|
||||
return `${apiBaseUrl}${cleanPath}`;
|
||||
};
|
||||
|
||||
// Get all images - prioritize room images over room type images
|
||||
|
||||
const roomImages = editingRoom.images || [];
|
||||
const roomTypeImages = editingRoom.room_type?.images || [];
|
||||
|
||||
// Normalize all image paths for comparison
|
||||
|
||||
const normalizeForComparison = (img: string): string => {
|
||||
if (!img) return '';
|
||||
// Extract just the path part for comparison
|
||||
|
||||
if (img.startsWith('http://') || img.startsWith('https://')) {
|
||||
try {
|
||||
const url = new URL(img);
|
||||
@@ -2859,7 +2853,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
return img.startsWith('/') ? img : `/${img}`;
|
||||
};
|
||||
|
||||
// Combine images: room images first, then room type images that aren't already in room images
|
||||
|
||||
const normalizedRoomImages = roomImages.map(normalizeForComparison);
|
||||
const allImages = [
|
||||
...roomImages,
|
||||
@@ -2871,7 +2865,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Current Images */}
|
||||
{}
|
||||
{allImages.length > 0 ? (
|
||||
<div className="mb-6">
|
||||
<p className="text-sm text-gray-400 mb-4 font-light">
|
||||
@@ -2880,7 +2874,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{allImages.map((img, index) => {
|
||||
const imageUrl = normalizeImageUrl(img);
|
||||
// Check if this is a room image by comparing normalized paths
|
||||
|
||||
const normalizedImg = normalizeForComparison(img);
|
||||
const normalizedRoomImgs = roomImages.map(normalizeForComparison);
|
||||
const isRoomImage = normalizedRoomImgs.includes(normalizedImg);
|
||||
@@ -2895,7 +2889,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
className="w-full h-32 object-cover group-hover:scale-110
|
||||
transition-transform duration-300"
|
||||
onError={(e) => {
|
||||
// Fallback if image fails to load
|
||||
|
||||
(e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iIzMzMzMzMyIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmb250LWZhbWlseT0iQXJpYWwiIGZvbnQtc2l6ZT0iMTQiIGZpbGw9IiM2NjY2NjYiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGR5PSIuM2VtIj5JbWFnZSBub3QgZm91bmQ8L3RleHQ+PC9zdmc+';
|
||||
}}
|
||||
/>
|
||||
@@ -2936,7 +2930,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Upload New Images */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Add New Images (max 5 images):
|
||||
@@ -2948,29 +2942,36 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
className="w-full text-sm text-gray-400 file:mr-4 file:py-3 file:px-6 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-gradient-to-r file:from-[#d4af37]/20 file:to-[#c9a227]/20 file:text-[#d4af37] file:border file:border-[#d4af37]/30 hover:file:from-[#d4af37]/30 hover:file:to-[#c9a227]/30 hover:file:border-[#d4af37] file:cursor-pointer transition-all duration-300 bg-[#0a0a0a] rounded-lg"
|
||||
className="w-full text-sm text-gray-400
|
||||
file:mr-4 file:py-3 file:px-6 file:rounded-lg file:border-0
|
||||
file:text-sm file:font-semibold
|
||||
file:bg-gradient-to-r file:from-[#d4af37]/20 file:to-[#c9a227]/20
|
||||
file:text-[#d4af37] file:border file:border-[#d4af37]/30
|
||||
hover:file:from-[#d4af37]/30 hover:file:to-[#c9a227]/30
|
||||
hover:file:border-[#d4af37] file:cursor-pointer
|
||||
transition-all duration-300 bg-[#0a0a0a] rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUploadImages}
|
||||
disabled={selectedFiles.length === 0 || uploadingImages}
|
||||
className="px-6 py-3 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-lg hover:from-green-500 hover:to-green-600 disabled:from-gray-600 disabled:to-gray-700 disabled:cursor-not-allowed flex items-center gap-2 font-semibold shadow-lg shadow-green-600/30 hover:shadow-xl hover:shadow-green-600/40 transition-all duration-300"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
{uploadingImages ? 'Uploading...' : 'Upload'}
|
||||
</button>
|
||||
</div>
|
||||
{selectedFiles.length > 0 && (
|
||||
<p className="text-sm text-gray-400 font-light italic">
|
||||
{selectedFiles.length} file(s) selected
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<p className="text-sm text-gray-400 font-light italic">
|
||||
{selectedFiles.length} file(s) selected
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUploadImages}
|
||||
disabled={uploadingImages}
|
||||
className="px-6 py-2 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 font-semibold shadow-lg shadow-[#d4af37]/30 hover:shadow-xl hover:shadow-[#d4af37]/40 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{uploadingImages ? 'Uploading...' : 'Upload Images'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Cancel/Close Button */}
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-[#d4af37]/20 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
@@ -2989,12 +2990,12 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Services Tab */}
|
||||
{}
|
||||
{activeTab === 'services' && (
|
||||
<div className="space-y-8">
|
||||
{servicesLoading && <Loading />}
|
||||
|
||||
{/* Section Header */}
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="space-y-3">
|
||||
@@ -3021,7 +3022,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="relative group">
|
||||
@@ -3046,7 +3047,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services Table */}
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
@@ -3115,11 +3116,11 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Service Modal */}
|
||||
{}
|
||||
{showServiceModal && (
|
||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-hidden border border-slate-200">
|
||||
{/* Modal Header */}
|
||||
{}
|
||||
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-6 py-5 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
@@ -3142,7 +3143,7 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
{}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-120px)]">
|
||||
<form onSubmit={handleServiceSubmit} className="space-y-5">
|
||||
<div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Calendar,
|
||||
Users,
|
||||
@@ -108,7 +108,7 @@ const ReportsPage: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{}
|
||||
<div className="mb-6 bg-white p-4 rounded-lg shadow-md">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<Filter className="w-5 h-5 text-gray-500" />
|
||||
@@ -166,7 +166,7 @@ const ReportsPage: React.FC = () => {
|
||||
|
||||
{reportData && (
|
||||
<>
|
||||
{/* Summary Cards */}
|
||||
{}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div className="bg-white p-6 rounded-lg shadow-md border-l-4 border-blue-500">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -231,7 +231,7 @@ const ReportsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bookings by Status */}
|
||||
{}
|
||||
{reportData.bookings_by_status && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md mb-8">
|
||||
<div className="flex items-center space-x-2 mb-6">
|
||||
@@ -249,7 +249,7 @@ const ReportsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Revenue by Date */}
|
||||
{}
|
||||
{reportData.revenue_by_date && reportData.revenue_by_date.length > 0 && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md mb-8">
|
||||
<div className="flex items-center space-x-2 mb-6">
|
||||
@@ -291,7 +291,7 @@ const ReportsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Rooms */}
|
||||
{}
|
||||
{reportData.top_rooms && reportData.top_rooms.length > 0 && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md mb-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6">Top Performing Rooms</h2>
|
||||
@@ -330,7 +330,7 @@ const ReportsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service Usage */}
|
||||
{}
|
||||
{reportData.service_usage && reportData.service_usage.length > 0 && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6">Service Usage</h2>
|
||||
|
||||
@@ -53,11 +53,11 @@ const RoomManagementPage: React.FC = () => {
|
||||
fetchAvailableAmenities();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
// Fetch room types on component mount
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAllRoomTypes = async () => {
|
||||
try {
|
||||
// Fetch with max allowed limit (100) to get room types
|
||||
|
||||
const response = await roomService.getRooms({ limit: 100, page: 1 });
|
||||
const allUniqueRoomTypes = new Map<number, { id: number; name: string }>();
|
||||
response.data.rooms.forEach((room: Room) => {
|
||||
@@ -69,7 +69,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
}
|
||||
});
|
||||
|
||||
// If there are more pages, fetch them to get all room types
|
||||
|
||||
if (response.data.pagination && response.data.pagination.totalPages > 1) {
|
||||
const totalPages = response.data.pagination.totalPages;
|
||||
for (let page = 2; page <= totalPages; page++) {
|
||||
@@ -84,7 +84,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
// Continue with other pages if one fails
|
||||
|
||||
console.error(`Failed to fetch page ${page}:`, err);
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
if (allUniqueRoomTypes.size > 0) {
|
||||
const roomTypesList = Array.from(allUniqueRoomTypes.values());
|
||||
setRoomTypes(roomTypesList);
|
||||
// Set default room_type_id to first room type if form is empty
|
||||
|
||||
if (!editingRoom && formData.room_type_id === 1 && roomTypesList.length > 0) {
|
||||
setFormData(prev => ({ ...prev, room_type_id: roomTypesList[0].id }));
|
||||
}
|
||||
@@ -130,7 +130,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
setTotalItems(response.data.pagination.total);
|
||||
}
|
||||
|
||||
// Extract unique room types from rooms
|
||||
|
||||
const uniqueRoomTypes = new Map<number, { id: number; name: string }>();
|
||||
response.data.rooms.forEach((room: Room) => {
|
||||
if (room.room_type && !uniqueRoomTypes.has(room.room_type.id)) {
|
||||
@@ -142,10 +142,10 @@ const RoomManagementPage: React.FC = () => {
|
||||
});
|
||||
setRoomTypes(Array.from(uniqueRoomTypes.values()));
|
||||
|
||||
// Also fetch more rooms to get all room types (not just paginated ones)
|
||||
|
||||
if (roomTypes.length === 0 && response.data.pagination) {
|
||||
try {
|
||||
// Fetch first page with max limit (100) to get room types
|
||||
|
||||
const allRoomsResponse = await roomService.getRooms({ limit: 100, page: 1 });
|
||||
const allUniqueRoomTypes = new Map<number, { id: number; name: string }>();
|
||||
allRoomsResponse.data.rooms.forEach((room: Room) => {
|
||||
@@ -157,10 +157,10 @@ const RoomManagementPage: React.FC = () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch additional pages if needed
|
||||
|
||||
if (allRoomsResponse.data.pagination && allRoomsResponse.data.pagination.totalPages > 1) {
|
||||
const totalPages = allRoomsResponse.data.pagination.totalPages;
|
||||
for (let page = 2; page <= Math.min(totalPages, 10); page++) { // Limit to 10 pages max
|
||||
for (let page = 2; page <= Math.min(totalPages, 10); page++) {
|
||||
try {
|
||||
const pageResponse = await roomService.getRooms({ limit: 100, page });
|
||||
pageResponse.data.rooms.forEach((room: Room) => {
|
||||
@@ -172,7 +172,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
// Continue with other pages
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,7 +181,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
setRoomTypes(Array.from(allUniqueRoomTypes.values()));
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore error, use room types from current page
|
||||
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -195,7 +195,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingRoom) {
|
||||
// Update room - ensure amenities are sent as array
|
||||
|
||||
const updateData = {
|
||||
...formData,
|
||||
price: formData.price ? parseFloat(formData.price) : undefined,
|
||||
@@ -208,10 +208,10 @@ const RoomManagementPage: React.FC = () => {
|
||||
await roomService.updateRoom(editingRoom.id, updateData);
|
||||
toast.success('Room updated successfully');
|
||||
|
||||
// Refresh the room list to get updated amenities
|
||||
|
||||
await fetchRooms();
|
||||
|
||||
// Update editing room with fresh data using room_number
|
||||
|
||||
try {
|
||||
const updatedRoom = await roomService.getRoomByNumber(editingRoom.room_number);
|
||||
setEditingRoom(updatedRoom.data.room);
|
||||
@@ -219,7 +219,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
console.error('Failed to refresh room data:', err);
|
||||
}
|
||||
} else {
|
||||
// Create room - ensure amenities are sent as array
|
||||
|
||||
const createData = {
|
||||
...formData,
|
||||
price: formData.price ? parseFloat(formData.price) : undefined,
|
||||
@@ -232,9 +232,9 @@ const RoomManagementPage: React.FC = () => {
|
||||
const response = await roomService.createRoom(createData);
|
||||
toast.success('Room added successfully');
|
||||
|
||||
// After creating, switch to edit mode to allow image upload
|
||||
|
||||
if (response.data?.room) {
|
||||
// If there are files selected, upload them immediately
|
||||
|
||||
if (selectedFiles.length > 0) {
|
||||
try {
|
||||
setUploadingImages(true);
|
||||
@@ -252,25 +252,25 @@ const RoomManagementPage: React.FC = () => {
|
||||
toast.success('Images uploaded successfully');
|
||||
setSelectedFiles([]);
|
||||
|
||||
// Refresh room data and set as editing
|
||||
|
||||
const updatedRoom = await roomService.getRoomByNumber(response.data.room.room_number);
|
||||
setEditingRoom(updatedRoom.data.room);
|
||||
// Keep modal open for image upload section
|
||||
|
||||
} catch (uploadError: any) {
|
||||
toast.error(uploadError.response?.data?.message || 'Room created but failed to upload images');
|
||||
// Still set editing room so user can upload images manually
|
||||
|
||||
const updatedRoom = await roomService.getRoomByNumber(response.data.room.room_number);
|
||||
setEditingRoom(updatedRoom.data.room);
|
||||
} finally {
|
||||
setUploadingImages(false);
|
||||
}
|
||||
} else {
|
||||
// No files to upload, but set room for editing to allow image upload
|
||||
|
||||
const updatedRoom = await roomService.getRoomByNumber(response.data.room.room_number);
|
||||
setEditingRoom(updatedRoom.data.room);
|
||||
}
|
||||
|
||||
// Update form data with created room data
|
||||
|
||||
setFormData({
|
||||
room_number: response.data.room.room_number,
|
||||
floor: response.data.room.floor,
|
||||
@@ -285,13 +285,13 @@ const RoomManagementPage: React.FC = () => {
|
||||
amenities: response.data.room.amenities || [],
|
||||
});
|
||||
|
||||
// Refresh rooms list
|
||||
|
||||
await fetchRooms();
|
||||
// Keep modal open so user can upload images
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Close modal and reset after update (or if creation failed)
|
||||
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
fetchRooms();
|
||||
@@ -303,22 +303,23 @@ const RoomManagementPage: React.FC = () => {
|
||||
const handleEdit = async (room: Room) => {
|
||||
setEditingRoom(room);
|
||||
|
||||
// Normalize amenities from the room object we have
|
||||
|
||||
let amenitiesArray: string[] = [];
|
||||
if (room.amenities) {
|
||||
if (Array.isArray(room.amenities)) {
|
||||
amenitiesArray = room.amenities;
|
||||
} else if (typeof room.amenities === 'string') {
|
||||
const roomAmenities = room.amenities as string[] | string | undefined;
|
||||
if (roomAmenities) {
|
||||
if (Array.isArray(roomAmenities)) {
|
||||
amenitiesArray = roomAmenities;
|
||||
} else if (typeof roomAmenities === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(room.amenities);
|
||||
const parsed = JSON.parse(roomAmenities);
|
||||
amenitiesArray = Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
amenitiesArray = room.amenities.split(',').map((a: string) => a.trim()).filter(Boolean);
|
||||
amenitiesArray = roomAmenities.split(',').map((a: string) => a.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Populate form immediately with available room data
|
||||
|
||||
setFormData({
|
||||
room_number: room.room_number,
|
||||
floor: room.floor,
|
||||
@@ -335,28 +336,29 @@ const RoomManagementPage: React.FC = () => {
|
||||
|
||||
setShowModal(true);
|
||||
|
||||
// Fetch full room details to get complete information and update form
|
||||
|
||||
try {
|
||||
const fullRoom = await roomService.getRoomByNumber(room.room_number);
|
||||
const roomData = fullRoom.data.room;
|
||||
|
||||
// Normalize amenities - ensure it's always an array
|
||||
|
||||
let updatedAmenitiesArray: string[] = [];
|
||||
if (roomData.amenities) {
|
||||
if (Array.isArray(roomData.amenities)) {
|
||||
updatedAmenitiesArray = roomData.amenities;
|
||||
} else if (typeof roomData.amenities === 'string') {
|
||||
const roomDataAmenities = roomData.amenities as string[] | string | undefined;
|
||||
if (roomDataAmenities) {
|
||||
if (Array.isArray(roomDataAmenities)) {
|
||||
updatedAmenitiesArray = roomDataAmenities;
|
||||
} else if (typeof roomDataAmenities === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(roomData.amenities);
|
||||
const parsed = JSON.parse(roomDataAmenities);
|
||||
updatedAmenitiesArray = Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
// If not JSON, treat as comma-separated
|
||||
updatedAmenitiesArray = roomData.amenities.split(',').map((a: string) => a.trim()).filter(Boolean);
|
||||
|
||||
updatedAmenitiesArray = roomDataAmenities.split(',').map((a: string) => a.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update form with complete room data
|
||||
|
||||
setFormData({
|
||||
room_number: roomData.room_number,
|
||||
floor: roomData.floor,
|
||||
@@ -371,11 +373,11 @@ const RoomManagementPage: React.FC = () => {
|
||||
amenities: updatedAmenitiesArray,
|
||||
});
|
||||
|
||||
// Update editing room with full data
|
||||
|
||||
setEditingRoom(roomData);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch full room details:', error);
|
||||
// Form already has the basic data, so we can continue
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
@@ -481,7 +483,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
setSelectedFiles([]);
|
||||
fetchRooms();
|
||||
|
||||
// Refresh editing room data using room_number
|
||||
|
||||
const response = await roomService.getRoomByNumber(editingRoom.room_number);
|
||||
setEditingRoom(response.data.room);
|
||||
} catch (error: any) {
|
||||
@@ -496,22 +498,22 @@ const RoomManagementPage: React.FC = () => {
|
||||
if (!window.confirm('Are you sure you want to delete this image?')) return;
|
||||
|
||||
try {
|
||||
// Extract the relative path from the imageUrl
|
||||
// Handle both full URLs and relative paths
|
||||
|
||||
|
||||
let imagePath = imageUrl;
|
||||
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
|
||||
// Extract the path part from the full URL
|
||||
|
||||
try {
|
||||
const url = new URL(imageUrl);
|
||||
imagePath = url.pathname;
|
||||
} catch (e) {
|
||||
// If URL parsing fails, try to extract path manually
|
||||
|
||||
const match = imageUrl.match(/(\/uploads\/.*)/);
|
||||
imagePath = match ? match[1] : imageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Send image_url as query parameter
|
||||
|
||||
await apiClient.delete(`/rooms/${editingRoom.id}/images`, {
|
||||
params: { image_url: imagePath },
|
||||
});
|
||||
@@ -519,7 +521,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
toast.success('Image deleted successfully');
|
||||
fetchRooms();
|
||||
|
||||
// Refresh editing room data using room_number
|
||||
|
||||
const response = await roomService.getRoomByNumber(editingRoom.room_number);
|
||||
setEditingRoom(response.data.room);
|
||||
} catch (error: any) {
|
||||
@@ -563,7 +565,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
|
||||
{/* Luxury Header */}
|
||||
{}
|
||||
<div className="flex justify-between items-center animate-fade-in">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
@@ -597,7 +599,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Luxury Filters */}
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
<div className="relative group">
|
||||
@@ -635,7 +637,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Luxury Table */}
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
@@ -738,7 +740,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
@@ -747,7 +749,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
{}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a]
|
||||
@@ -755,7 +757,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
backdrop-blur-xl shadow-2xl shadow-[#d4af37]/20
|
||||
p-8 w-full max-w-4xl max-h-[90vh] overflow-y-auto custom-scrollbar">
|
||||
|
||||
{/* Header */}
|
||||
{}
|
||||
<div className="flex justify-between items-center mb-6 pb-6 border-b border-[#d4af37]/20">
|
||||
<div>
|
||||
<h2 className="text-3xl font-serif font-semibold
|
||||
@@ -777,7 +779,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic Information */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-serif font-semibold text-[#d4af37] mb-4 flex items-center gap-2">
|
||||
<div className="w-1 h-6 bg-gradient-to-b from-[#d4af37] to-[#c9a227]" />
|
||||
@@ -979,7 +981,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amenities Selection */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-serif font-semibold text-[#d4af37] mb-4 flex items-center gap-2">
|
||||
<div className="w-1 h-6 bg-gradient-to-b from-[#d4af37] to-[#c9a227]" />
|
||||
@@ -1039,7 +1041,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image Selection - Show when creating new room */}
|
||||
{}
|
||||
{!editingRoom && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-serif font-semibold text-[#d4af37] mb-4 flex items-center gap-2">
|
||||
@@ -1070,40 +1072,14 @@ const RoomManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-4 mt-4">
|
||||
{selectedFiles.map((file, index) => (
|
||||
<div key={index} className="relative group">
|
||||
<div className="overflow-hidden rounded-lg border border-[#d4af37]/20">
|
||||
<img
|
||||
src={URL.createObjectURL(file)}
|
||||
alt={`Preview ${index + 1}`}
|
||||
className="w-full h-32 object-cover"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedFiles(selectedFiles.filter((_, i) => i !== index))}
|
||||
className="absolute top-2 right-2 bg-red-600/90 backdrop-blur-sm
|
||||
text-white p-2 rounded-full opacity-0 group-hover:opacity-100
|
||||
transition-all duration-300 hover:bg-red-600 shadow-lg
|
||||
hover:scale-110 transform"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selectedFiles.length > 0 && (
|
||||
<p className="text-xs text-gray-400 font-light italic">
|
||||
{selectedFiles.length} image(s) selected - will be uploaded automatically after room creation
|
||||
<p className="text-sm text-gray-400 font-light italic">
|
||||
{selectedFiles.length} file(s) selected
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-4 pt-4 border-t border-[#d4af37]/20">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1126,7 +1102,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Image Upload Section - Show when editing or after room creation */}
|
||||
{}
|
||||
{editingRoom && (
|
||||
<div className="mt-8 pt-8 border-t border-[#d4af37]/20">
|
||||
<h3 className="text-lg font-serif font-semibold text-[#d4af37] mb-6 flex items-center gap-2">
|
||||
@@ -1135,30 +1111,31 @@ const RoomManagementPage: React.FC = () => {
|
||||
Room Images
|
||||
</h3>
|
||||
|
||||
{/* Helper function to normalize image URLs */}
|
||||
{}
|
||||
{(() => {
|
||||
// Get API base URL from environment or default
|
||||
if (!editingRoom) return null;
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000';
|
||||
|
||||
const normalizeImageUrl = (img: string): string => {
|
||||
if (!img) return '';
|
||||
// If already a full URL, return as-is
|
||||
|
||||
if (img.startsWith('http://') || img.startsWith('https://')) {
|
||||
return img;
|
||||
}
|
||||
// Normalize relative paths
|
||||
|
||||
const cleanPath = img.startsWith('/') ? img : `/${img}`;
|
||||
return `${apiBaseUrl}${cleanPath}`;
|
||||
};
|
||||
|
||||
// Get all images - prioritize room images over room type images
|
||||
|
||||
const roomImages = editingRoom.images || [];
|
||||
const roomTypeImages = editingRoom.room_type?.images || [];
|
||||
|
||||
// Normalize all image paths for comparison
|
||||
|
||||
const normalizeForComparison = (img: string): string => {
|
||||
if (!img) return '';
|
||||
// Extract just the path part for comparison
|
||||
|
||||
if (img.startsWith('http://') || img.startsWith('https://')) {
|
||||
try {
|
||||
const url = new URL(img);
|
||||
@@ -1171,11 +1148,11 @@ const RoomManagementPage: React.FC = () => {
|
||||
return img.startsWith('/') ? img : `/${img}`;
|
||||
};
|
||||
|
||||
// Combine images: room images first, then room type images that aren't already in room images
|
||||
|
||||
const normalizedRoomImages = roomImages.map(normalizeForComparison);
|
||||
const allImages = [
|
||||
...roomImages,
|
||||
...roomTypeImages.filter(img => {
|
||||
...roomTypeImages.filter((img: string) => {
|
||||
const normalized = normalizeForComparison(img);
|
||||
return !normalizedRoomImages.includes(normalized);
|
||||
})
|
||||
@@ -1183,7 +1160,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Current Images */}
|
||||
{}
|
||||
{allImages.length > 0 ? (
|
||||
<div className="mb-6">
|
||||
<p className="text-sm text-gray-400 mb-4 font-light">
|
||||
@@ -1192,7 +1169,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{allImages.map((img, index) => {
|
||||
const imageUrl = normalizeImageUrl(img);
|
||||
// Check if this is a room image by comparing normalized paths
|
||||
|
||||
const normalizedImg = normalizeForComparison(img);
|
||||
const normalizedRoomImgs = roomImages.map(normalizeForComparison);
|
||||
const isRoomImage = normalizedRoomImgs.includes(normalizedImg);
|
||||
@@ -1207,7 +1184,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
className="w-full h-32 object-cover group-hover:scale-110
|
||||
transition-transform duration-300"
|
||||
onError={(e) => {
|
||||
// Fallback if image fails to load
|
||||
|
||||
(e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iIzMzMzMzMyIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmb250LWZhbWlseT0iQXJpYWwiIGZvbnQtc2l6ZT0iMTQiIGZpbGw9IiM2NjY2NjYiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGR5PSIuM2VtIj5JbWFnZSBub3QgZm91bmQ8L3RleHQ+PC9zdmc+';
|
||||
}}
|
||||
/>
|
||||
@@ -1246,7 +1223,7 @@ const RoomManagementPage: React.FC = () => {
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Upload New Images */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Add New Images (max 5 images):
|
||||
|
||||
@@ -110,14 +110,13 @@ const ServiceManagementPage: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
|
||||
{/* Luxury Header */}
|
||||
{}
|
||||
<div className="flex justify-between items-center animate-fade-in">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
@@ -140,7 +139,7 @@ const ServiceManagementPage: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Luxury Filter Card */}
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="relative group">
|
||||
@@ -165,7 +164,7 @@ const ServiceManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Luxury Table Card */}
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
@@ -239,11 +238,11 @@ const ServiceManagementPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Luxury Modal */}
|
||||
{}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4 animate-fade-in">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-hidden border border-slate-200 animate-scale-in">
|
||||
{/* Modal Header */}
|
||||
{}
|
||||
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-6 py-5 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
@@ -263,7 +262,7 @@ const ServiceManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
{}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-120px)]">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
|
||||
@@ -47,7 +47,7 @@ const SettingsPage: React.FC = () => {
|
||||
const { currency, supportedCurrencies, refreshCurrency } = useCurrency();
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('general');
|
||||
|
||||
// Cookie Settings State
|
||||
|
||||
const [policy, setPolicy] = useState<CookiePolicySettings>({
|
||||
analytics_enabled: true,
|
||||
marketing_enabled: true,
|
||||
@@ -61,10 +61,10 @@ const SettingsPage: React.FC = () => {
|
||||
Pick<CookiePolicySettingsResponse, 'updated_at' | 'updated_by'> | null
|
||||
>(null);
|
||||
|
||||
// Currency Settings State
|
||||
|
||||
const [selectedCurrency, setSelectedCurrency] = useState<string>(currency);
|
||||
|
||||
// Stripe Settings State
|
||||
|
||||
const [stripeSettings, setStripeSettings] = useState<StripeSettingsResponse['data'] | null>(null);
|
||||
const [formData, setFormData] = useState<UpdateStripeSettingsRequest>({
|
||||
stripe_secret_key: '',
|
||||
@@ -74,7 +74,7 @@ const SettingsPage: React.FC = () => {
|
||||
const [showSecretKey, setShowSecretKey] = useState(false);
|
||||
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
|
||||
|
||||
// PayPal Settings State
|
||||
|
||||
const [paypalSettings, setPaypalSettings] = useState<PayPalSettingsResponse['data'] | null>(null);
|
||||
const [paypalFormData, setPaypalFormData] = useState<UpdatePayPalSettingsRequest>({
|
||||
paypal_client_id: '',
|
||||
@@ -83,7 +83,7 @@ const SettingsPage: React.FC = () => {
|
||||
});
|
||||
const [showPayPalSecret, setShowPayPalSecret] = useState(false);
|
||||
|
||||
// SMTP Settings State
|
||||
|
||||
const [smtpSettings, setSmtpSettings] = useState<SmtpSettingsResponse['data'] | null>(null);
|
||||
const [smtpFormData, setSmtpFormData] = useState<UpdateSmtpSettingsRequest>({
|
||||
smtp_host: '',
|
||||
@@ -98,7 +98,7 @@ const SettingsPage: React.FC = () => {
|
||||
const [testingEmail, setTestingEmail] = useState(false);
|
||||
const [testEmailAddress, setTestEmailAddress] = useState('email@example.com');
|
||||
|
||||
// Company Settings State
|
||||
|
||||
const [companySettings, setCompanySettings] = useState<CompanySettingsResponse['data'] | null>(null);
|
||||
const [companyFormData, setCompanyFormData] = useState<UpdateCompanySettingsRequest>({
|
||||
company_name: '',
|
||||
@@ -113,7 +113,7 @@ const SettingsPage: React.FC = () => {
|
||||
const [uploadingLogo, setUploadingLogo] = useState(false);
|
||||
const [uploadingFavicon, setUploadingFavicon] = useState(false);
|
||||
|
||||
// reCAPTCHA Settings State
|
||||
|
||||
const [recaptchaSettings, setRecaptchaSettings] = useState<RecaptchaSettingsAdminResponse['data'] | null>(null);
|
||||
const [recaptchaFormData, setRecaptchaFormData] = useState<UpdateRecaptchaSettingsRequest>({
|
||||
recaptcha_site_key: '',
|
||||
@@ -145,7 +145,7 @@ const SettingsPage: React.FC = () => {
|
||||
return `${name} (${symbol})`;
|
||||
};
|
||||
|
||||
// Load all settings
|
||||
|
||||
useEffect(() => {
|
||||
loadAllSettings();
|
||||
}, []);
|
||||
@@ -236,7 +236,7 @@ const SettingsPage: React.FC = () => {
|
||||
tax_rate: companyRes.data.tax_rate || 0,
|
||||
});
|
||||
|
||||
// Set previews if URLs exist
|
||||
|
||||
if (companyRes.data.company_logo_url) {
|
||||
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
const logoUrl = companyRes.data.company_logo_url.startsWith('http')
|
||||
@@ -257,7 +257,7 @@ const SettingsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Cookie Settings Handlers
|
||||
|
||||
const handleToggle = (key: keyof CookiePolicySettings) => {
|
||||
setPolicy((prev) => ({
|
||||
...prev,
|
||||
@@ -286,7 +286,7 @@ const SettingsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Currency Settings Handlers
|
||||
|
||||
const handleSaveCurrency = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
@@ -301,7 +301,7 @@ const SettingsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Stripe Settings Handlers
|
||||
|
||||
const handleSaveStripe = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
@@ -340,7 +340,7 @@ const SettingsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// PayPal Settings Handlers
|
||||
|
||||
const handleSavePayPal = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
@@ -379,7 +379,7 @@ const SettingsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// SMTP Settings Handlers
|
||||
|
||||
const handleSaveSmtp = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
@@ -433,14 +433,14 @@ const SettingsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Test Email Handler
|
||||
|
||||
const handleTestEmail = async () => {
|
||||
if (!testEmailAddress || !testEmailAddress.trim()) {
|
||||
toast.error('Please enter an email address');
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(testEmailAddress.trim())) {
|
||||
toast.error('Please enter a valid email address');
|
||||
@@ -462,13 +462,13 @@ const SettingsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Company Settings Handlers
|
||||
|
||||
const handleSaveCompany = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
await systemSettingsService.updateCompanySettings(companyFormData);
|
||||
await loadCompanySettings();
|
||||
// Refresh company settings context to update all components
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('refreshCompanySettings'));
|
||||
}
|
||||
@@ -488,19 +488,19 @@ const SettingsPage: React.FC = () => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 2MB)
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error('Logo size must be less than 2MB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setLogoPreview(reader.result as string);
|
||||
@@ -512,7 +512,7 @@ const SettingsPage: React.FC = () => {
|
||||
const response = await systemSettingsService.uploadCompanyLogo(file);
|
||||
if (response.status === 'success') {
|
||||
await loadCompanySettings();
|
||||
// Refresh company settings context to update all components
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('refreshCompanySettings'));
|
||||
}
|
||||
@@ -527,7 +527,7 @@ const SettingsPage: React.FC = () => {
|
||||
setLogoPreview(null);
|
||||
} finally {
|
||||
setUploadingLogo(false);
|
||||
// Reset input
|
||||
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
@@ -536,7 +536,7 @@ const SettingsPage: React.FC = () => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type (ico, png, svg)
|
||||
|
||||
const validTypes = ['image/x-icon', 'image/vnd.microsoft.icon', 'image/png', 'image/svg+xml'];
|
||||
const validExtensions = ['.ico', '.png', '.svg'];
|
||||
const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.'));
|
||||
@@ -546,13 +546,13 @@ const SettingsPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 500KB)
|
||||
|
||||
if (file.size > 500 * 1024) {
|
||||
toast.error('Favicon size must be less than 500KB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setFaviconPreview(reader.result as string);
|
||||
@@ -564,12 +564,12 @@ const SettingsPage: React.FC = () => {
|
||||
const response = await systemSettingsService.uploadCompanyFavicon(file);
|
||||
if (response.status === 'success') {
|
||||
await loadCompanySettings();
|
||||
// Refresh company settings context to update all components
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('refreshCompanySettings'));
|
||||
}
|
||||
toast.success('Favicon uploaded successfully');
|
||||
// Update favicon in document
|
||||
|
||||
const link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
|
||||
if (link) {
|
||||
link.href = response.data.full_url;
|
||||
@@ -589,7 +589,7 @@ const SettingsPage: React.FC = () => {
|
||||
setFaviconPreview(null);
|
||||
} finally {
|
||||
setUploadingFavicon(false);
|
||||
// Reset input
|
||||
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
@@ -646,9 +646,9 @@ const SettingsPage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-10 animate-fade-in">
|
||||
{/* Luxury Header */}
|
||||
{}
|
||||
<div className="relative">
|
||||
{/* Background decorative elements */}
|
||||
{}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-amber-400/5 via-transparent to-amber-600/5 rounded-3xl blur-3xl"></div>
|
||||
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-2xl border border-amber-200/30 p-8 md:p-10">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-8">
|
||||
@@ -674,7 +674,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Premium Tab Navigation */}
|
||||
{}
|
||||
<div className="mt-10 pt-8 border-t border-gradient-to-r from-transparent via-amber-200/30 to-transparent">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{tabs.map((tab) => {
|
||||
@@ -710,7 +710,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* General Overview Tab */}
|
||||
{}
|
||||
{activeTab === 'general' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
|
||||
<div
|
||||
@@ -904,10 +904,10 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cookie & Privacy Tab */}
|
||||
{}
|
||||
{activeTab === 'cookie' && (
|
||||
<div className="space-y-8">
|
||||
{/* Section Header */}
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
||||
<div className="space-y-3">
|
||||
@@ -936,7 +936,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Card */}
|
||||
{}
|
||||
<div className="relative bg-gradient-to-br from-blue-50/80 via-indigo-50/60 to-blue-50/80 backdrop-blur-xl rounded-2xl shadow-xl border border-blue-200/50 p-8 overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-gradient-to-br from-blue-400/20 to-transparent rounded-bl-full"></div>
|
||||
<div className="relative flex gap-6">
|
||||
@@ -956,7 +956,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cookie Toggles */}
|
||||
{}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{[
|
||||
{ key: 'analytics_enabled' as keyof CookiePolicySettings, label: 'Analytics Cookies', desc: 'Anonymous traffic and performance measurement', color: 'emerald' as const, icon: SlidersHorizontal },
|
||||
@@ -1023,7 +1023,7 @@ const SettingsPage: React.FC = () => {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Integration IDs */}
|
||||
{}
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-start gap-4 pb-6 border-b border-gray-200/60">
|
||||
@@ -1086,10 +1086,10 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Currency Tab */}
|
||||
{}
|
||||
{activeTab === 'currency' && (
|
||||
<div className="space-y-8">
|
||||
{/* Section Header */}
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
||||
<div className="space-y-3">
|
||||
@@ -1118,7 +1118,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Card */}
|
||||
{}
|
||||
<div className="relative bg-gradient-to-br from-emerald-50/80 via-green-50/60 to-emerald-50/80 backdrop-blur-xl rounded-2xl shadow-xl border border-emerald-200/50 p-8 overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-gradient-to-br from-emerald-400/20 to-transparent rounded-bl-full"></div>
|
||||
<div className="relative flex gap-6">
|
||||
@@ -1138,7 +1138,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Currency Selection */}
|
||||
{}
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-start gap-4 pb-6 border-b border-gray-200/60">
|
||||
@@ -1180,10 +1180,10 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Tab */}
|
||||
{}
|
||||
{activeTab === 'payment' && (
|
||||
<div className="space-y-8">
|
||||
{/* Section Header */}
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
||||
<div className="space-y-3">
|
||||
@@ -1212,7 +1212,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Card */}
|
||||
{}
|
||||
<div className="relative bg-gradient-to-br from-indigo-50/80 via-purple-50/60 to-indigo-50/80 backdrop-blur-xl rounded-2xl shadow-xl border border-indigo-200/50 p-8 overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-gradient-to-br from-indigo-400/20 to-transparent rounded-bl-full"></div>
|
||||
<div className="relative flex gap-6">
|
||||
@@ -1237,7 +1237,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stripe Settings Form */}
|
||||
{}
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-start gap-4 pb-6 border-b border-gray-200/60">
|
||||
@@ -1261,7 +1261,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Secret Key */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<Lock className="w-4 h-4 text-gray-600" />
|
||||
@@ -1306,7 +1306,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Publishable Key */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<Globe className="w-4 h-4 text-gray-600" />
|
||||
@@ -1337,7 +1337,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Webhook Secret */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<Lock className="w-4 h-4 text-gray-600" />
|
||||
@@ -1385,7 +1385,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Webhook URL Info */}
|
||||
{}
|
||||
<div className="relative mt-8 p-6 bg-gradient-to-br from-yellow-50 to-amber-50/50 border border-yellow-200/60 rounded-xl overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-yellow-400/20 to-transparent rounded-bl-full"></div>
|
||||
<div className="relative space-y-3">
|
||||
@@ -1393,7 +1393,7 @@ const SettingsPage: React.FC = () => {
|
||||
<p className="text-sm text-yellow-800">
|
||||
Configure this URL in your{' '}
|
||||
<a
|
||||
href="https://dashboard.stripe.com/webhooks"
|
||||
href="https://dashboard.stripe.com/test/apikeys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline font-medium hover:text-yellow-900"
|
||||
@@ -1414,7 +1414,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PayPal Payment Settings Section */}
|
||||
{}
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-6 mb-8">
|
||||
<div className="space-y-3">
|
||||
@@ -1442,7 +1442,7 @@ const SettingsPage: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info Card */}
|
||||
{}
|
||||
<div className="relative bg-gradient-to-br from-blue-50/80 via-cyan-50/60 to-blue-50/80 backdrop-blur-xl rounded-2xl shadow-xl border border-blue-200/50 p-8 overflow-hidden mb-8">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-gradient-to-br from-blue-400/20 to-transparent rounded-bl-full"></div>
|
||||
<div className="relative flex gap-6">
|
||||
@@ -1467,7 +1467,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PayPal Settings Form */}
|
||||
{}
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-start gap-4 pb-6 border-b border-gray-200/60">
|
||||
<div className="p-3 rounded-xl bg-gradient-to-br from-blue-500/10 to-cyan-500/10 border border-blue-200/40">
|
||||
@@ -1478,7 +1478,7 @@ const SettingsPage: React.FC = () => {
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
Get these credentials from your{' '}
|
||||
<a
|
||||
href="https://developer.paypal.com/dashboard"
|
||||
href="https://dashboard.stripe.com/test/apikeys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-700 underline font-medium"
|
||||
@@ -1490,7 +1490,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Client ID */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<Globe className="w-4 h-4 text-gray-600" />
|
||||
@@ -1520,7 +1520,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Client Secret */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<Lock className="w-4 h-4 text-gray-600" />
|
||||
@@ -1563,7 +1563,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<Globe className="w-4 h-4 text-gray-600" />
|
||||
@@ -1590,10 +1590,10 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SMTP Email Server Tab */}
|
||||
{}
|
||||
{activeTab === 'smtp' && (
|
||||
<div className="space-y-8">
|
||||
{/* Section Header */}
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
||||
<div className="space-y-3">
|
||||
@@ -1624,7 +1624,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Email Section */}
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="p-3 rounded-xl bg-gradient-to-br from-teal-500/10 to-cyan-500/10 border border-teal-200/40">
|
||||
@@ -1672,7 +1672,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Card */}
|
||||
{}
|
||||
<div className="relative bg-gradient-to-br from-teal-50/80 via-cyan-50/60 to-teal-50/80 backdrop-blur-xl rounded-2xl shadow-xl border border-teal-200/50 p-8 overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-gradient-to-br from-teal-400/20 to-transparent rounded-bl-full"></div>
|
||||
<div className="relative flex gap-6">
|
||||
@@ -1697,7 +1697,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SMTP Settings Form */}
|
||||
{}
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-start gap-4 pb-6 border-b border-gray-200/60">
|
||||
@@ -1713,7 +1713,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* SMTP Host */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<Globe className="w-4 h-4 text-gray-600" />
|
||||
@@ -1744,7 +1744,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SMTP Port */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<Key className="w-4 h-4 text-gray-600" />
|
||||
@@ -1771,7 +1771,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* SMTP User */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<Mail className="w-4 h-4 text-gray-600" />
|
||||
@@ -1802,7 +1802,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SMTP Password */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<Lock className="w-4 h-4 text-gray-600" />
|
||||
@@ -1848,7 +1848,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* From Email */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<Mail className="w-4 h-4 text-gray-600" />
|
||||
@@ -1872,7 +1872,7 @@ const SettingsPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* From Name */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<Globe className="w-4 h-4 text-gray-600" />
|
||||
@@ -1897,7 +1897,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TLS/SSL Toggle */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<Lock className="w-4 h-4 text-gray-600" />
|
||||
@@ -1932,7 +1932,7 @@ const SettingsPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Common Providers Info */}
|
||||
{}
|
||||
<div className="relative mt-8 p-6 bg-gradient-to-br from-yellow-50 to-amber-50/50 border border-yellow-200/60 rounded-xl overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-yellow-400/20 to-transparent rounded-bl-full"></div>
|
||||
<div className="relative space-y-3">
|
||||
@@ -1950,10 +1950,10 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Company Info Tab */}
|
||||
{}
|
||||
{activeTab === 'company' && (
|
||||
<div className="space-y-8">
|
||||
{/* Section Header */}
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
||||
<div className="space-y-3">
|
||||
@@ -1982,9 +1982,9 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logo & Favicon Upload Section */}
|
||||
{}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Logo Upload */}
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="p-3 rounded-xl bg-gradient-to-br from-purple-500/10 to-pink-500/10 border border-purple-200/40">
|
||||
@@ -2025,17 +2025,9 @@ const SettingsPage: React.FC = () => {
|
||||
disabled={uploadingLogo}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{uploadingLogo && (
|
||||
<div className="flex items-center justify-center text-sm text-gray-600">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-purple-600 mr-2"></div>
|
||||
Uploading logo...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Favicon Upload */}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="p-3 rounded-xl bg-gradient-to-br from-purple-500/10 to-pink-500/10 border border-purple-200/40">
|
||||
@@ -2087,7 +2079,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Company Information Form */}
|
||||
{}
|
||||
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-start gap-4 pb-6 border-b border-gray-200/60">
|
||||
@@ -2103,7 +2095,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{/* Company Name */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<Building2 className="w-4 h-4 text-gray-600" />
|
||||
@@ -2123,7 +2115,7 @@ const SettingsPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Company Tagline */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<Sparkles className="w-4 h-4 text-gray-600" />
|
||||
@@ -2145,7 +2137,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Phone */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<Globe className="w-4 h-4 text-gray-600" />
|
||||
@@ -2165,7 +2157,7 @@ const SettingsPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<Mail className="w-4 h-4 text-gray-600" />
|
||||
@@ -2186,7 +2178,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<Globe className="w-4 h-4 text-gray-600" />
|
||||
@@ -2206,7 +2198,7 @@ const SettingsPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tax Rate */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<DollarSign className="w-4 h-4 text-gray-600" />
|
||||
@@ -2231,7 +2223,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Card */}
|
||||
{}
|
||||
<div className="relative bg-gradient-to-br from-purple-50/80 via-pink-50/60 to-purple-50/80 backdrop-blur-xl rounded-2xl shadow-xl border border-purple-200/50 p-8 overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-gradient-to-br from-purple-400/20 to-transparent rounded-bl-full"></div>
|
||||
<div className="relative flex gap-6">
|
||||
@@ -2255,7 +2247,7 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
{activeTab === 'recaptcha' && (
|
||||
<div className="space-y-8">
|
||||
{/* Section Header */}
|
||||
{}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
|
||||
@@ -2268,10 +2260,10 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* reCAPTCHA Settings Form */}
|
||||
{}
|
||||
<div className="bg-white rounded-xl shadow-lg border border-gray-200 p-8">
|
||||
<div className="space-y-6">
|
||||
{/* Enable/Disable Toggle */}
|
||||
{}
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-gray-900">
|
||||
@@ -2297,7 +2289,7 @@ const SettingsPage: React.FC = () => {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Site Key */}
|
||||
{}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<Key className="w-4 h-4 text-gray-600" />
|
||||
@@ -2328,7 +2320,7 @@ const SettingsPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Secret Key */}
|
||||
{}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
|
||||
<Lock className="w-4 h-4 text-gray-600" />
|
||||
@@ -2369,7 +2361,7 @@ const SettingsPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
{}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
@@ -2383,7 +2375,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
{}
|
||||
<div className="flex justify-end pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={handleSaveRecaptcha}
|
||||
|
||||
@@ -31,7 +31,7 @@ const StripeSettingsPage: React.FC = () => {
|
||||
const response = await systemSettingsService.getStripeSettings();
|
||||
setSettings(response.data);
|
||||
|
||||
// Pre-fill form with current values (empty for security)
|
||||
|
||||
setFormData({
|
||||
stripe_secret_key: '',
|
||||
stripe_publishable_key: response.data.stripe_publishable_key || '',
|
||||
@@ -48,7 +48,7 @@ const StripeSettingsPage: React.FC = () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
// Only send keys that were actually changed (not empty)
|
||||
|
||||
const updateData: UpdateStripeSettingsRequest = {};
|
||||
|
||||
if (formData.stripe_secret_key && formData.stripe_secret_key.trim()) {
|
||||
@@ -66,7 +66,7 @@ const StripeSettingsPage: React.FC = () => {
|
||||
await systemSettingsService.updateStripeSettings(updateData);
|
||||
await loadStripeSettings();
|
||||
|
||||
// Clear sensitive fields after saving
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
stripe_secret_key: '',
|
||||
@@ -91,7 +91,7 @@ const StripeSettingsPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-10 pb-8 animate-fade-in">
|
||||
{/* Header */}
|
||||
{}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-6 pb-6 border-b border-gray-200/60">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -117,7 +117,7 @@ const StripeSettingsPage: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info card */}
|
||||
{}
|
||||
<div className="enterprise-card flex gap-5 p-6 bg-gradient-to-br from-blue-50/50 to-indigo-50/30 border-blue-100/60">
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
<div className="p-2.5 rounded-lg bg-gradient-to-br from-blue-500/10 to-indigo-500/10 border border-blue-200/40">
|
||||
@@ -142,9 +142,9 @@ const StripeSettingsPage: React.FC = () => {
|
||||
<div className="bg-blue-50 border border-blue-200 rounded p-3 mt-2">
|
||||
<p className="font-semibold text-blue-900 mb-2">📝 How to Get Stripe Test Keys:</p>
|
||||
<ol className="list-decimal list-inside space-y-1 text-blue-800">
|
||||
<li>Go to <a href="https://dashboard.stripe.com/register" target="_blank" rel="noopener noreferrer" className="underline font-medium">Stripe Dashboard</a> (create account if needed)</li>
|
||||
<li>Go to <a href="https://dashboard.stripe.com/test/apikeys" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">Stripe Dashboard</a></li>
|
||||
<li>Make sure you're in <strong>Test mode</strong> (toggle in top right)</li>
|
||||
<li>Navigate to <a href="https://dashboard.stripe.com/test/apikeys" target="_blank" rel="noopener noreferrer" className="underline font-medium">API Keys</a></li>
|
||||
<li>Navigate to <a href="https://dashboard.stripe.com/test/apikeys" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">API Keys</a></li>
|
||||
<li>Copy your <strong>Publishable key</strong> (starts with <code className="bg-blue-100 px-1 rounded">pk_test_</code>)</li>
|
||||
<li>Click "Reveal test key" and copy your <strong>Secret key</strong> (starts with <code className="bg-blue-100 px-1 rounded">sk_test_</code>)</li>
|
||||
<li>Paste them in the fields below and click "Save Changes"</li>
|
||||
@@ -175,7 +175,7 @@ const StripeSettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stripe Settings Form */}
|
||||
{}
|
||||
<div className="enterprise-card p-6 space-y-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between pb-4 border-b border-gray-200/60">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -198,7 +198,7 @@ const StripeSettingsPage: React.FC = () => {
|
||||
</a>
|
||||
{' '}or{' '}
|
||||
<a
|
||||
href="https://dashboard.stripe.com/apikeys"
|
||||
href="https://dashboard.stripe.com/test/apikeys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-600 hover:text-indigo-700 underline font-medium"
|
||||
@@ -211,7 +211,7 @@ const StripeSettingsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Secret Key */}
|
||||
{}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-semibold text-gray-900 tracking-wide">
|
||||
<Lock className="w-4 h-4 inline mr-1" />
|
||||
@@ -255,7 +255,7 @@ const StripeSettingsPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Publishable Key */}
|
||||
{}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-semibold text-gray-900 tracking-wide">
|
||||
<Globe className="w-4 h-4 inline mr-1" />
|
||||
@@ -285,7 +285,7 @@ const StripeSettingsPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Webhook Secret */}
|
||||
{}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-semibold text-gray-900 tracking-wide">
|
||||
<Lock className="w-4 h-4 inline mr-1" />
|
||||
@@ -332,7 +332,7 @@ const StripeSettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Webhook URL Info */}
|
||||
{}
|
||||
<div className="mt-6 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm font-semibold text-yellow-900 mb-2">
|
||||
Webhook Endpoint URL
|
||||
|
||||
@@ -66,7 +66,7 @@ const UserManagementPage: React.FC = () => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingUser) {
|
||||
// When updating, only send password if changed
|
||||
|
||||
const updateData: any = {
|
||||
full_name: formData.full_name,
|
||||
email: formData.email,
|
||||
@@ -75,7 +75,7 @@ const UserManagementPage: React.FC = () => {
|
||||
status: formData.status,
|
||||
};
|
||||
|
||||
// Only add password if user entered a new one
|
||||
|
||||
if (formData.password && formData.password.trim() !== '') {
|
||||
updateData.password = formData.password;
|
||||
}
|
||||
@@ -85,7 +85,7 @@ const UserManagementPage: React.FC = () => {
|
||||
console.log('Update response:', response);
|
||||
toast.success('User updated successfully');
|
||||
} else {
|
||||
// When creating new, need complete information
|
||||
|
||||
if (!formData.password || formData.password.trim() === '') {
|
||||
toast.error('Please enter password');
|
||||
return;
|
||||
@@ -96,11 +96,11 @@ const UserManagementPage: React.FC = () => {
|
||||
toast.success('User added successfully');
|
||||
}
|
||||
|
||||
// Close modal and reset form first
|
||||
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
|
||||
// Reload users list after a bit to ensure DB is updated
|
||||
|
||||
setTimeout(() => {
|
||||
fetchUsers();
|
||||
}, 300);
|
||||
@@ -124,7 +124,7 @@ const UserManagementPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
// Prevent self-deletion
|
||||
|
||||
if (userInfo?.id === id) {
|
||||
toast.error('You cannot delete your own account');
|
||||
return;
|
||||
@@ -190,7 +190,7 @@ const UserManagementPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
|
||||
{/* Luxury Header */}
|
||||
{}
|
||||
<div className="flex justify-between items-center animate-fade-in">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
@@ -213,7 +213,7 @@ const UserManagementPage: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Luxury Filter Card */}
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
<div className="relative group">
|
||||
@@ -248,7 +248,7 @@ const UserManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Luxury Table Card */}
|
||||
{}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
@@ -331,11 +331,11 @@ const UserManagementPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Luxury Modal */}
|
||||
{}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4 animate-fade-in">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-hidden border border-slate-200 animate-scale-in">
|
||||
{/* Modal Header */}
|
||||
{}
|
||||
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-6 py-5 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
@@ -355,7 +355,7 @@ const UserManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
{}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-120px)]">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
|
||||
@@ -26,17 +26,17 @@ const ForgotPasswordPage: React.FC = () => {
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const [submittedEmail, setSubmittedEmail] = useState('');
|
||||
|
||||
// Get email and phone from centralized company settings
|
||||
|
||||
const supportEmail = settings.company_email || 'support@hotel.com';
|
||||
const supportPhone = settings.company_phone || '1900-xxxx';
|
||||
|
||||
// Update page title
|
||||
|
||||
useEffect(() => {
|
||||
const companyName = settings.company_name || 'Luxury Hotel';
|
||||
document.title = `Forgot Password - ${companyName}`;
|
||||
}, [settings.company_name]);
|
||||
|
||||
// React Hook Form setup
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -48,17 +48,17 @@ const ForgotPasswordPage: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
|
||||
const onSubmit = async (data: ForgotPasswordFormData) => {
|
||||
try {
|
||||
clearError();
|
||||
setSubmittedEmail(data.email);
|
||||
await forgotPassword({ email: data.email });
|
||||
|
||||
// Show success state
|
||||
|
||||
setIsSuccess(true);
|
||||
} catch (error) {
|
||||
// Error has been handled in store
|
||||
|
||||
console.error('Forgot password error:', error);
|
||||
}
|
||||
};
|
||||
@@ -70,15 +70,14 @@ const ForgotPasswordPage: React.FC = () => {
|
||||
justify-center py-4 sm:py-8 lg:py-12 px-3 sm:px-4 lg:px-8"
|
||||
>
|
||||
<div className="max-w-md w-full space-y-4 sm:space-y-6 lg:space-y-8">
|
||||
{/* Header */}
|
||||
{}
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-3 sm:mb-4">
|
||||
{settings.company_logo_url ? (
|
||||
<img
|
||||
src={settings.company_logo_url.startsWith('http')
|
||||
? settings.company_logo_url
|
||||
: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`
|
||||
}
|
||||
: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`}
|
||||
alt={settings.company_name || 'Logo'}
|
||||
className="h-14 sm:h-16 lg:h-20 w-auto max-w-[150px] sm:max-w-[180px] lg:max-w-[200px] object-contain"
|
||||
style={{ filter: 'drop-shadow(0 4px 6px rgba(0,0,0,0.1))' }}
|
||||
@@ -102,10 +101,10 @@ const ForgotPasswordPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form Container */}
|
||||
{}
|
||||
<div className="bg-white rounded-lg shadow-xl p-4 sm:p-6 lg:p-8">
|
||||
{isSuccess ? (
|
||||
// Success State
|
||||
|
||||
<div className="text-center space-y-4 sm:space-y-5 lg:space-y-6">
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
@@ -192,7 +191,7 @@ const ForgotPasswordPage: React.FC = () => {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Back to Home Button */}
|
||||
{}
|
||||
<div className="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-gray-200">
|
||||
<Link
|
||||
to="/"
|
||||
@@ -205,12 +204,12 @@ const ForgotPasswordPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Form State
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-4 sm:space-y-5 lg:space-y-6"
|
||||
>
|
||||
{/* Error Message */}
|
||||
{}
|
||||
{error && (
|
||||
<div
|
||||
className="bg-red-50 border
|
||||
@@ -221,7 +220,7 @@ const ForgotPasswordPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Field */}
|
||||
{}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
@@ -267,7 +266,7 @@ const ForgotPasswordPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
{}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
@@ -299,7 +298,7 @@ const ForgotPasswordPage: React.FC = () => {
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Back to Login Link */}
|
||||
{}
|
||||
<div className="text-center">
|
||||
<Link
|
||||
to="/login"
|
||||
@@ -314,7 +313,7 @@ const ForgotPasswordPage: React.FC = () => {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Back to Home Button */}
|
||||
{}
|
||||
<div className="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-gray-200">
|
||||
<Link
|
||||
to="/"
|
||||
@@ -329,7 +328,7 @@ const ForgotPasswordPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
{}
|
||||
{!isSuccess && (
|
||||
<div className="text-center text-xs sm:text-sm text-gray-500 px-2">
|
||||
<p>
|
||||
@@ -345,7 +344,7 @@ const ForgotPasswordPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Help Section */}
|
||||
{}
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-sm
|
||||
border border-gray-200 p-3 sm:p-4"
|
||||
|
||||
@@ -39,14 +39,14 @@ type MFATokenFormData = yup.InferType<typeof mfaTokenSchema>;
|
||||
const LoginPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { login, verifyMFA, isLoading, error, clearError, requiresMFA, clearMFA } =
|
||||
const { login, verifyMFA, isLoading, error, clearError, requiresMFA, clearMFA, userInfo, isAuthenticated } =
|
||||
useAuthStore();
|
||||
const { settings } = useCompanySettings();
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||
|
||||
// MFA form setup
|
||||
|
||||
const {
|
||||
register: registerMFA,
|
||||
handleSubmit: handleSubmitMFA,
|
||||
@@ -58,13 +58,26 @@ const LoginPage: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Update page title
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && isAuthenticated && !requiresMFA && userInfo) {
|
||||
if (userInfo.role === 'admin') {
|
||||
navigate('/admin/dashboard', { replace: true });
|
||||
} else if (userInfo.role === 'staff') {
|
||||
navigate('/staff/dashboard', { replace: true });
|
||||
} else {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}
|
||||
}, [isLoading, isAuthenticated, requiresMFA, userInfo, navigate]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const companyName = settings.company_name || 'Luxury Hotel';
|
||||
document.title = requiresMFA ? `Verify Identity - ${companyName}` : `Login - ${companyName}`;
|
||||
}, [settings.company_name, requiresMFA]);
|
||||
|
||||
// React Hook Form setup
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -78,12 +91,12 @@ const LoginPage: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
|
||||
const onSubmit = async (data: LoginFormData) => {
|
||||
try {
|
||||
clearError();
|
||||
|
||||
// Verify reCAPTCHA if enabled
|
||||
|
||||
if (recaptchaToken) {
|
||||
try {
|
||||
const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
|
||||
@@ -105,38 +118,60 @@ const LoginPage: React.FC = () => {
|
||||
rememberMe: data.rememberMe,
|
||||
});
|
||||
|
||||
// If MFA is required, don't redirect yet
|
||||
|
||||
if (!requiresMFA) {
|
||||
// Redirect to previous page or dashboard
|
||||
const from = location.state?.from?.pathname ||
|
||||
'/dashboard';
|
||||
navigate(from, { replace: true });
|
||||
|
||||
const getRedirectPath = () => {
|
||||
const from = location.state?.from?.pathname;
|
||||
if (from) return from;
|
||||
|
||||
const currentUserInfo = useAuthStore.getState().userInfo;
|
||||
if (currentUserInfo?.role === 'admin') {
|
||||
return '/admin/dashboard';
|
||||
} else if (currentUserInfo?.role === 'staff') {
|
||||
return '/staff/dashboard';
|
||||
}
|
||||
return '/dashboard';
|
||||
};
|
||||
|
||||
navigate(getRedirectPath(), { replace: true });
|
||||
}
|
||||
setRecaptchaToken(null);
|
||||
} catch (error) {
|
||||
// Error has been handled in store
|
||||
|
||||
console.error('Login error:', error);
|
||||
setRecaptchaToken(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle MFA verification
|
||||
|
||||
const onSubmitMFA = async (data: MFATokenFormData) => {
|
||||
try {
|
||||
clearError();
|
||||
await verifyMFA(data.mfaToken);
|
||||
|
||||
// Redirect to previous page or dashboard
|
||||
const from = location.state?.from?.pathname ||
|
||||
'/dashboard';
|
||||
navigate(from, { replace: true });
|
||||
|
||||
const getRedirectPath = () => {
|
||||
const from = location.state?.from?.pathname;
|
||||
if (from) return from;
|
||||
|
||||
const currentUserInfo = useAuthStore.getState().userInfo;
|
||||
if (currentUserInfo?.role === 'admin') {
|
||||
return '/admin/dashboard';
|
||||
} else if (currentUserInfo?.role === 'staff') {
|
||||
return '/staff/dashboard';
|
||||
}
|
||||
return '/dashboard';
|
||||
};
|
||||
|
||||
navigate(getRedirectPath(), { replace: true });
|
||||
} catch (error) {
|
||||
// Error has been handled in store
|
||||
|
||||
console.error('MFA verification error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle back to login
|
||||
|
||||
const handleBackToLogin = () => {
|
||||
clearMFA();
|
||||
clearError();
|
||||
@@ -147,21 +182,20 @@ const LoginPage: React.FC = () => {
|
||||
from-gray-50 via-gray-100 to-gray-50
|
||||
flex items-center justify-center py-4 sm:py-8 lg:py-12 px-3 sm:px-4 lg:px-8 relative overflow-hidden"
|
||||
>
|
||||
{/* Luxury background pattern */}
|
||||
{}
|
||||
<div className="absolute inset-0 opacity-5" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23000000' fill-opacity='1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9 0h2v20H9zM25 0h2v20h-2zM41 0h2v20h-2zM57 0h2v20h-2zM0 9h20v2H0zM0 25h20v2H0zM0 41h20v2H0zM0 57h20v2H0zM40 9h20v2H40zM40 25h20v2H40zM40 41h20v2H40zM40 57h20v2H40zM9 40h2v20H9zM25 40h2v20h-2zM41 40h2v20h-2zM57 40h2v20h-2z' fill='%23d4af37' opacity='0.05'/%3E%3C/svg%3E")`
|
||||
}}></div>
|
||||
|
||||
<div className="max-w-md w-full space-y-4 sm:space-y-6 lg:space-y-8 relative z-10">
|
||||
{/* Header */}
|
||||
{}
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-3 sm:mb-4">
|
||||
{settings.company_logo_url ? (
|
||||
<img
|
||||
src={settings.company_logo_url.startsWith('http')
|
||||
? settings.company_logo_url
|
||||
: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`
|
||||
}
|
||||
: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`}
|
||||
alt={settings.company_name || 'Logo'}
|
||||
className="h-14 sm:h-16 lg:h-20 w-auto max-w-[150px] sm:max-w-[180px] lg:max-w-[200px] object-contain"
|
||||
style={{ filter: 'drop-shadow(0 4px 6px rgba(0,0,0,0.1))' }}
|
||||
@@ -189,12 +223,12 @@ const LoginPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{requiresMFA ? (
|
||||
/* MFA Verification Form */
|
||||
|
||||
<div className="luxury-glass rounded-sm p-4 sm:p-6 lg:p-8 border border-[#d4af37]/20 shadow-2xl">
|
||||
<form onSubmit={handleSubmitMFA(onSubmitMFA)}
|
||||
className="space-y-4 sm:space-y-5 lg:space-y-6"
|
||||
>
|
||||
{/* Error Message */}
|
||||
{}
|
||||
{error && (
|
||||
<div className="bg-red-50/80 backdrop-blur-sm border border-red-200
|
||||
text-red-700 px-4 py-3 rounded-sm
|
||||
@@ -204,7 +238,7 @@ const LoginPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MFA Token Field */}
|
||||
{}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="mfaToken"
|
||||
@@ -243,7 +277,7 @@ const LoginPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
{}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
@@ -265,7 +299,7 @@ const LoginPage: React.FC = () => {
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Back to Login Link */}
|
||||
{}
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
@@ -283,13 +317,13 @@ const LoginPage: React.FC = () => {
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
/* Login Form */
|
||||
|
||||
<>
|
||||
<div className="luxury-glass rounded-sm p-4 sm:p-6 lg:p-8 border border-[#d4af37]/20 shadow-2xl">
|
||||
<form onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-4 sm:space-y-5 lg:space-y-6"
|
||||
>
|
||||
{/* Error Message */}
|
||||
{}
|
||||
{error && (
|
||||
<div className="bg-red-50/80 backdrop-blur-sm border border-red-200
|
||||
text-red-700 px-4 py-3 rounded-sm
|
||||
@@ -299,7 +333,7 @@ const LoginPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Field */}
|
||||
{}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
@@ -334,7 +368,7 @@ const LoginPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
{}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
@@ -384,7 +418,7 @@ const LoginPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remember Me & Forgot Password */}
|
||||
{}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 sm:gap-0">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
@@ -414,7 +448,7 @@ const LoginPage: React.FC = () => {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* reCAPTCHA */}
|
||||
{}
|
||||
<div className="flex justify-center">
|
||||
<Recaptcha
|
||||
onChange={(token) => setRecaptchaToken(token)}
|
||||
@@ -427,7 +461,7 @@ const LoginPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
{}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
@@ -449,7 +483,7 @@ const LoginPage: React.FC = () => {
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Back to Home Button */}
|
||||
{}
|
||||
<div className="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-gray-200">
|
||||
<Link
|
||||
to="/"
|
||||
@@ -462,7 +496,7 @@ const LoginPage: React.FC = () => {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Register Link */}
|
||||
{}
|
||||
<div className="mt-4 sm:mt-6 text-center">
|
||||
<p className="text-xs sm:text-sm text-gray-600 font-light tracking-wide">
|
||||
Don't have an account?{' '}
|
||||
@@ -477,7 +511,7 @@ const LoginPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
{}
|
||||
<div className="text-center text-xs sm:text-sm text-gray-500 font-light tracking-wide px-2">
|
||||
<p>
|
||||
By logging in, you agree to our{' '}
|
||||
|
||||
@@ -37,13 +37,13 @@ const RegisterPage: React.FC = () => {
|
||||
useState(false);
|
||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||
|
||||
// Update page title
|
||||
|
||||
useEffect(() => {
|
||||
const companyName = settings.company_name || 'Luxury Hotel';
|
||||
document.title = `Register - ${companyName}`;
|
||||
}, [settings.company_name]);
|
||||
|
||||
// React Hook Form setup
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -60,10 +60,10 @@ const RegisterPage: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Watch password to display password strength
|
||||
|
||||
const password = watch('password');
|
||||
|
||||
// Password strength checker
|
||||
|
||||
const getPasswordStrength = (pwd: string) => {
|
||||
if (!pwd) return { strength: 0, label: '', color: '' };
|
||||
|
||||
@@ -87,12 +87,12 @@ const RegisterPage: React.FC = () => {
|
||||
|
||||
const passwordStrength = getPasswordStrength(password || '');
|
||||
|
||||
// Handle form submission
|
||||
|
||||
const onSubmit = async (data: RegisterFormData) => {
|
||||
try {
|
||||
clearError();
|
||||
|
||||
// Verify reCAPTCHA if enabled
|
||||
|
||||
if (recaptchaToken) {
|
||||
try {
|
||||
const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
|
||||
@@ -115,11 +115,11 @@ const RegisterPage: React.FC = () => {
|
||||
phone: data.phone,
|
||||
});
|
||||
|
||||
// Redirect to login page
|
||||
|
||||
navigate('/login', { replace: true });
|
||||
setRecaptchaToken(null);
|
||||
} catch (error) {
|
||||
// Error has been handled in store
|
||||
|
||||
console.error('Register error:', error);
|
||||
setRecaptchaToken(null);
|
||||
}
|
||||
@@ -131,21 +131,20 @@ const RegisterPage: React.FC = () => {
|
||||
from-gray-50 via-gray-100 to-gray-50 flex items-center
|
||||
justify-center py-4 sm:py-8 lg:py-12 px-3 sm:px-4 lg:px-8 relative overflow-hidden"
|
||||
>
|
||||
{/* Luxury background pattern */}
|
||||
{}
|
||||
<div className="absolute inset-0 opacity-5" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23000000' fill-opacity='1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9 0h2v20H9zM25 0h2v20h-2zM41 0h2v20h-2zM57 0h2v20h-2zM0 9h20v2H0zM0 25h20v2H0zM0 41h20v2H0zM0 57h20v2H0zM40 9h20v2H40zM40 25h20v2H40zM40 41h20v2H40zM40 57h20v2H40zM9 40h2v20H9zM25 40h2v20h-2zM41 40h2v20h-2zM57 40h2v20h-2z' fill='%23d4af37' opacity='0.05'/%3E%3C/svg%3E")`
|
||||
}}></div>
|
||||
|
||||
<div className="max-w-md w-full space-y-4 sm:space-y-6 lg:space-y-8 relative z-10">
|
||||
{/* Header */}
|
||||
{}
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-3 sm:mb-4">
|
||||
{settings.company_logo_url ? (
|
||||
<img
|
||||
src={settings.company_logo_url.startsWith('http')
|
||||
? settings.company_logo_url
|
||||
: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`
|
||||
}
|
||||
: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`}
|
||||
alt={settings.company_name || 'Logo'}
|
||||
className="h-14 sm:h-16 lg:h-20 w-auto max-w-[150px] sm:max-w-[180px] lg:max-w-[200px] object-contain"
|
||||
style={{ filter: 'drop-shadow(0 4px 6px rgba(0,0,0,0.1))' }}
|
||||
@@ -170,13 +169,13 @@ const RegisterPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Register Form */}
|
||||
{}
|
||||
<div className="luxury-glass rounded-sm p-4 sm:p-6 lg:p-8 border border-[#d4af37]/20 shadow-2xl">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-4 sm:space-y-5"
|
||||
>
|
||||
{/* Error Message */}
|
||||
{}
|
||||
{error && (
|
||||
<div
|
||||
className="bg-red-50/80 backdrop-blur-sm border border-red-200
|
||||
@@ -187,7 +186,7 @@ const RegisterPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name Field */}
|
||||
{}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
@@ -224,7 +223,7 @@ const RegisterPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email Field */}
|
||||
{}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
@@ -261,7 +260,7 @@ const RegisterPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone Field */}
|
||||
{}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="phone"
|
||||
@@ -298,7 +297,7 @@ const RegisterPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
{}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
@@ -351,7 +350,7 @@ const RegisterPage: React.FC = () => {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Password Strength Indicator */}
|
||||
{}
|
||||
{password && password.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -381,7 +380,7 @@ const RegisterPage: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Password Requirements */}
|
||||
{}
|
||||
<div className="mt-2 space-y-1">
|
||||
<PasswordRequirement
|
||||
met={password.length >= 8}
|
||||
@@ -408,7 +407,7 @@ const RegisterPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
{}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
@@ -466,7 +465,7 @@ const RegisterPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* reCAPTCHA */}
|
||||
{}
|
||||
<div className="flex justify-center">
|
||||
<Recaptcha
|
||||
onChange={(token) => setRecaptchaToken(token)}
|
||||
@@ -479,7 +478,7 @@ const RegisterPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
{}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
@@ -502,7 +501,7 @@ const RegisterPage: React.FC = () => {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Login Link */}
|
||||
{}
|
||||
<div className="mt-4 sm:mt-6 text-center">
|
||||
<p className="text-xs sm:text-sm text-gray-600 font-light tracking-wide">
|
||||
Already have an account?{' '}
|
||||
@@ -516,7 +515,7 @@ const RegisterPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Back to Home Button */}
|
||||
{}
|
||||
<div className="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-gray-200">
|
||||
<Link
|
||||
to="/"
|
||||
@@ -529,7 +528,7 @@ const RegisterPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
{}
|
||||
<div className="text-center text-xs sm:text-sm text-gray-500 font-light tracking-wide px-2">
|
||||
<p>
|
||||
By registering, you agree to our{' '}
|
||||
@@ -553,7 +552,6 @@ const RegisterPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Helper component for password requirements
|
||||
const PasswordRequirement: React.FC<{
|
||||
met: boolean;
|
||||
text: string;
|
||||
|
||||
@@ -33,13 +33,13 @@ const ResetPasswordPage: React.FC = () => {
|
||||
useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
|
||||
// Update page title
|
||||
|
||||
useEffect(() => {
|
||||
const companyName = settings.company_name || 'Luxury Hotel';
|
||||
document.title = `Reset Password - ${companyName}`;
|
||||
}, [settings.company_name]);
|
||||
|
||||
// React Hook Form setup
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -53,17 +53,17 @@ const ResetPasswordPage: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Watch password to display password strength
|
||||
|
||||
const password = watch('password');
|
||||
|
||||
// Check if token exists
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
navigate('/forgot-password', { replace: true });
|
||||
}
|
||||
}, [token, navigate]);
|
||||
|
||||
// Password strength checker
|
||||
|
||||
const getPasswordStrength = (pwd: string) => {
|
||||
if (!pwd) return { strength: 0, label: '', color: '' };
|
||||
|
||||
@@ -87,7 +87,7 @@ const ResetPasswordPage: React.FC = () => {
|
||||
|
||||
const passwordStrength = getPasswordStrength(password || '');
|
||||
|
||||
// Handle form submission
|
||||
|
||||
const onSubmit = async (data: ResetPasswordFormData) => {
|
||||
if (!token) {
|
||||
return;
|
||||
@@ -101,24 +101,24 @@ const ResetPasswordPage: React.FC = () => {
|
||||
confirmPassword: data.confirmPassword,
|
||||
});
|
||||
|
||||
// Show success state
|
||||
|
||||
setIsSuccess(true);
|
||||
|
||||
// Redirect to login after 3 seconds
|
||||
|
||||
setTimeout(() => {
|
||||
navigate('/login', { replace: true });
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
// Error has been handled in store
|
||||
|
||||
console.error('Reset password error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Invalid token error check
|
||||
|
||||
const isTokenError =
|
||||
error?.includes('token') || error?.includes('expired');
|
||||
|
||||
// New password reuse error check
|
||||
|
||||
const isReuseError =
|
||||
error?.toLowerCase().includes('must be different') ||
|
||||
error?.toLowerCase().includes('different from old');
|
||||
@@ -130,15 +130,14 @@ const ResetPasswordPage: React.FC = () => {
|
||||
justify-center py-4 sm:py-8 lg:py-12 px-3 sm:px-4 lg:px-8"
|
||||
>
|
||||
<div className="max-w-md w-full space-y-4 sm:space-y-6 lg:space-y-8">
|
||||
{/* Header */}
|
||||
{}
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-3 sm:mb-4">
|
||||
{settings.company_logo_url ? (
|
||||
<img
|
||||
src={settings.company_logo_url.startsWith('http')
|
||||
? settings.company_logo_url
|
||||
: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`
|
||||
}
|
||||
: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`}
|
||||
alt={settings.company_name || 'Logo'}
|
||||
className="h-14 sm:h-16 lg:h-20 w-auto max-w-[150px] sm:max-w-[180px] lg:max-w-[200px] object-contain"
|
||||
style={{ filter: 'drop-shadow(0 4px 6px rgba(0,0,0,0.1))' }}
|
||||
@@ -164,10 +163,10 @@ const ResetPasswordPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form Container */}
|
||||
{}
|
||||
<div className="bg-white rounded-lg shadow-xl p-4 sm:p-6 lg:p-8">
|
||||
{isSuccess ? (
|
||||
// Success State
|
||||
|
||||
<div className="text-center space-y-4 sm:space-y-5 lg:space-y-6">
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
@@ -227,7 +226,7 @@ const ResetPasswordPage: React.FC = () => {
|
||||
Login Now
|
||||
</Link>
|
||||
|
||||
{/* Back to Home Button */}
|
||||
{}
|
||||
<div className="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-gray-200">
|
||||
<Link
|
||||
to="/"
|
||||
@@ -240,12 +239,12 @@ const ResetPasswordPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Form State
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-4 sm:space-y-5"
|
||||
>
|
||||
{/* Error Message */}
|
||||
{}
|
||||
{error && (
|
||||
<div
|
||||
className={`border px-3 sm:px-4 py-2.5 sm:py-3 rounded-lg
|
||||
@@ -279,7 +278,7 @@ const ResetPasswordPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password Field */}
|
||||
{}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
@@ -344,7 +343,7 @@ const ResetPasswordPage: React.FC = () => {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Password Strength Indicator */}
|
||||
{}
|
||||
{password && password.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -372,7 +371,7 @@ const ResetPasswordPage: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Password Requirements */}
|
||||
{}
|
||||
<div className="mt-2 space-y-1">
|
||||
<PasswordRequirement
|
||||
met={password.length >= 8}
|
||||
@@ -399,7 +398,7 @@ const ResetPasswordPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
{}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
@@ -468,7 +467,7 @@ const ResetPasswordPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
{}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
@@ -503,7 +502,7 @@ const ResetPasswordPage: React.FC = () => {
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Back to Login Link */}
|
||||
{}
|
||||
<div className="text-center">
|
||||
<Link
|
||||
to="/login"
|
||||
@@ -515,7 +514,7 @@ const ResetPasswordPage: React.FC = () => {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Back to Home Button */}
|
||||
{}
|
||||
<div className="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-gray-200">
|
||||
<Link
|
||||
to="/"
|
||||
@@ -530,7 +529,7 @@ const ResetPasswordPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Security Info */}
|
||||
{}
|
||||
{!isSuccess && (
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-sm
|
||||
@@ -561,7 +560,6 @@ const ResetPasswordPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Helper component for password requirements
|
||||
const PasswordRequirement: React.FC<{
|
||||
met: boolean;
|
||||
text: string;
|
||||
|
||||
@@ -49,7 +49,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
|
||||
// Redirect if not authenticated
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
toast.error(
|
||||
@@ -61,7 +61,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
}
|
||||
}, [isAuthenticated, navigate, id]);
|
||||
|
||||
// Fetch booking details
|
||||
|
||||
useEffect(() => {
|
||||
if (id && isAuthenticated) {
|
||||
fetchBookingDetails(Number(id));
|
||||
@@ -80,7 +80,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
response.data?.booking
|
||||
) {
|
||||
const bookingData = response.data.booking;
|
||||
// Debug: Log to see what we're receiving
|
||||
|
||||
console.log('Booking data:', bookingData);
|
||||
console.log('Service usages:', (bookingData as any).service_usages);
|
||||
console.log('Total price:', bookingData.total_price);
|
||||
@@ -112,7 +112,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
|
||||
console.log('Cancel booking clicked', { bookingId: booking.id, status: booking.status });
|
||||
|
||||
// Use a more user-friendly confirmation
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`⚠️ CANCEL BOOKING CONFIRMATION ⚠️\n\n` +
|
||||
`Booking Number: ${booking.booking_number}\n\n` +
|
||||
@@ -140,13 +140,13 @@ const BookingDetailPage: React.FC = () => {
|
||||
const response = await cancelBooking(booking.id);
|
||||
console.log('Cancel booking response:', response);
|
||||
|
||||
// Check both success and status fields
|
||||
if (response.success || response.status === 'success') {
|
||||
|
||||
if (response.success || (response as any).status === 'success') {
|
||||
toast.success(
|
||||
`✅ Booking ${booking.booking_number} cancelled successfully!`
|
||||
);
|
||||
|
||||
// Refresh booking details to get updated status
|
||||
|
||||
await fetchBookingDetails(booking.id);
|
||||
} else {
|
||||
throw new Error(
|
||||
@@ -168,7 +168,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
// Use parseDateLocal to handle date strings correctly
|
||||
|
||||
const date = parseDateLocal(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
@@ -180,7 +180,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
|
||||
const formatPrice = (price: number) => formatCurrency(price);
|
||||
|
||||
// Calculate number of nights
|
||||
|
||||
const calculateNights = () => {
|
||||
if (!booking) return 1;
|
||||
const checkIn = parseDateLocal(booking.check_in_date);
|
||||
@@ -189,10 +189,10 @@ const BookingDetailPage: React.FC = () => {
|
||||
return nights > 0 ? nights : 1;
|
||||
};
|
||||
|
||||
// Calculate services total
|
||||
|
||||
const calculateServicesTotal = () => {
|
||||
if (!booking) return 0;
|
||||
// Check both service_usages and services (for backwards compatibility)
|
||||
|
||||
const serviceUsages = (booking as any).service_usages || (booking as any).services || [];
|
||||
if (Array.isArray(serviceUsages) && serviceUsages.length > 0) {
|
||||
return serviceUsages.reduce((sum: number, su: any) => {
|
||||
@@ -202,23 +202,23 @@ const BookingDetailPage: React.FC = () => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Get service usages for display
|
||||
|
||||
const getServiceUsages = () => {
|
||||
if (!booking) return [];
|
||||
// Check both service_usages and services (for backwards compatibility)
|
||||
|
||||
const serviceUsages = (booking as any).service_usages || (booking as any).services || [];
|
||||
return Array.isArray(serviceUsages) ? serviceUsages : [];
|
||||
};
|
||||
|
||||
// Calculate the actual room price per night from the booking
|
||||
// This shows the price that was actually paid, not the current room price
|
||||
|
||||
|
||||
const calculateRoomPricePerNight = () => {
|
||||
if (!booking) return 0;
|
||||
|
||||
const nights = calculateNights();
|
||||
const servicesTotal = calculateServicesTotal();
|
||||
|
||||
// Debug logging
|
||||
|
||||
console.log('Calculating room price:', {
|
||||
total_price: booking.total_price,
|
||||
servicesTotal,
|
||||
@@ -226,14 +226,14 @@ const BookingDetailPage: React.FC = () => {
|
||||
roomTotal: booking.total_price - servicesTotal
|
||||
});
|
||||
|
||||
// Calculate room total by subtracting services from total_price
|
||||
|
||||
const roomTotal = booking.total_price - servicesTotal;
|
||||
|
||||
// Return room price per night (the actual price paid for the room)
|
||||
|
||||
return nights > 0 ? roomTotal / nights : roomTotal;
|
||||
};
|
||||
|
||||
// Calculate room total (price per night × nights)
|
||||
|
||||
const calculateRoomTotal = () => {
|
||||
return calculateRoomPricePerNight() * calculateNights();
|
||||
};
|
||||
@@ -280,7 +280,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const canCancelBooking = (booking: Booking) => {
|
||||
// Only allow cancellation of pending bookings
|
||||
|
||||
const canCancel = booking.status === 'pending';
|
||||
console.log('Can cancel booking?', {
|
||||
status: booking.status,
|
||||
@@ -332,7 +332,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
{/* Back Button */}
|
||||
{}
|
||||
<Link
|
||||
to="/bookings"
|
||||
className="inline-flex items-center gap-2
|
||||
@@ -343,7 +343,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
<span>Back to list</span>
|
||||
</Link>
|
||||
|
||||
{/* Page Title */}
|
||||
{}
|
||||
<div className="flex items-center justify-between
|
||||
mb-6"
|
||||
>
|
||||
@@ -351,7 +351,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
Booking Details
|
||||
</h1>
|
||||
|
||||
{/* Status Badge */}
|
||||
{}
|
||||
<div
|
||||
className={`flex items-center gap-2 px-4
|
||||
py-2 rounded-full font-medium
|
||||
@@ -362,7 +362,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Booking Number */}
|
||||
{}
|
||||
<div className="bg-indigo-50 border
|
||||
border-indigo-200 rounded-lg p-4 mb-6"
|
||||
>
|
||||
@@ -378,7 +378,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Room Information */}
|
||||
{}
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6 mb-6"
|
||||
>
|
||||
@@ -392,23 +392,23 @@ const BookingDetailPage: React.FC = () => {
|
||||
<div className="flex flex-col md:flex-row
|
||||
gap-6"
|
||||
>
|
||||
{/* Room Image */}
|
||||
{((room?.images && room.images.length > 0)
|
||||
? room.images[0]
|
||||
: roomType.images?.[0]) && (
|
||||
{}
|
||||
{((room?.room_type?.images && room.room_type.images.length > 0)
|
||||
? room.room_type.images[0]
|
||||
: roomType?.images?.[0]) && (
|
||||
<div className="md:w-64 flex-shrink-0">
|
||||
<img
|
||||
src={(room?.images && room.images.length > 0)
|
||||
? room.images[0]
|
||||
: (roomType.images?.[0] || '')}
|
||||
alt={roomType.name}
|
||||
src={(room?.room_type?.images && room.room_type.images.length > 0)
|
||||
? room.room_type.images[0]
|
||||
: (roomType?.images?.[0] || '')}
|
||||
alt={roomType?.name || 'Room'}
|
||||
className="w-full h-48 md:h-full
|
||||
object-cover rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Room Details */}
|
||||
{}
|
||||
<div className="flex-1">
|
||||
<h3 className="text-2xl font-bold
|
||||
text-gray-900 mb-2"
|
||||
@@ -427,7 +427,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
Capacity
|
||||
</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
Max {room?.capacity || roomType.capacity} guests
|
||||
Max {room?.room_type?.capacity || roomType?.capacity || 0} guests
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -444,7 +444,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Booking Details */}
|
||||
{}
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6 mb-6"
|
||||
>
|
||||
@@ -455,7 +455,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Dates */}
|
||||
{}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2
|
||||
gap-4"
|
||||
>
|
||||
@@ -479,7 +479,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guest Count */}
|
||||
{}
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
<Users className="w-4 h-4 inline mr-1" />
|
||||
@@ -490,7 +490,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{}
|
||||
{booking.notes && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
@@ -503,7 +503,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Method & Status */}
|
||||
{}
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
<CreditCard className="w-4 h-4 inline mr-1" />
|
||||
@@ -529,7 +529,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment History */}
|
||||
{}
|
||||
{booking.payments && booking.payments.length > 0 && (
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
@@ -593,14 +593,14 @@ const BookingDetailPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Price Breakdown */}
|
||||
{}
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Price Breakdown
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Room Price */}
|
||||
{}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
@@ -615,7 +615,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
{}
|
||||
{(() => {
|
||||
const services = getServiceUsages();
|
||||
console.log('Services to display:', services);
|
||||
@@ -643,9 +643,9 @@ const BookingDetailPage: React.FC = () => {
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* Payment Breakdown */}
|
||||
{}
|
||||
{(() => {
|
||||
// Calculate amount paid from completed payments
|
||||
|
||||
const completedPayments = booking.payments?.filter(
|
||||
(p) => p.payment_status === 'completed'
|
||||
) || [];
|
||||
@@ -682,7 +682,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Total */}
|
||||
{}
|
||||
<div className="border-t pt-3 mt-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-lg font-semibold text-gray-900">
|
||||
@@ -701,7 +701,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guest Information */}
|
||||
{}
|
||||
{booking.guest_info && (
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6 mb-6"
|
||||
@@ -743,7 +743,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stripe Payment Info - if needed */}
|
||||
{}
|
||||
{booking.payment_method === 'stripe' &&
|
||||
booking.payment_status === 'unpaid' && (
|
||||
<div
|
||||
@@ -777,7 +777,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Important Notes */}
|
||||
{}
|
||||
<div
|
||||
className="bg-yellow-50 border border-yellow-200
|
||||
rounded-lg p-4 mb-6"
|
||||
@@ -806,9 +806,9 @@ const BookingDetailPage: React.FC = () => {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
{}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Payment Button for unpaid stripe payment */}
|
||||
{}
|
||||
{booking.payment_method === 'stripe' &&
|
||||
booking.payment_status === 'unpaid' && (
|
||||
<Link
|
||||
@@ -869,7 +869,7 @@ const BookingDetailPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Debug info - remove in production */}
|
||||
{}
|
||||
{import.meta.env.DEV && (
|
||||
<div className="text-xs text-gray-400 mt-2 p-2 bg-gray-100 rounded">
|
||||
Debug: Status={booking.status}, CanCancel={canCancelBooking(booking) ? 'true' : 'false'}, Cancelling={cancelling ? 'true' : 'false'}
|
||||
|
||||
@@ -14,7 +14,7 @@ const BookingListPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Booking List */}
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((booking) => (
|
||||
<div key={booking}
|
||||
@@ -87,7 +87,7 @@ const BookingListPage: React.FC = () => {
|
||||
<span className="text-2xl font-bold
|
||||
text-gray-800"
|
||||
>
|
||||
{/* TODO: Replace with actual booking price using formatCurrency */}
|
||||
{}
|
||||
{booking * 150}
|
||||
</span>
|
||||
</div>
|
||||
@@ -104,20 +104,8 @@ const BookingListPage: React.FC = () => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{/* Uncomment when there are no bookings
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 text-lg">
|
||||
You don't have any bookings yet
|
||||
</p>
|
||||
<button className="mt-4 px-6 py-3
|
||||
bg-blue-600 text-white rounded-lg
|
||||
hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Book Now
|
||||
</button>
|
||||
</div>
|
||||
*/}
|
||||
{}
|
||||
{}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
Calendar,
|
||||
Users,
|
||||
CreditCard,
|
||||
Building2,
|
||||
FileText,
|
||||
ArrowLeft,
|
||||
AlertCircle,
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
CheckCircle,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Star,
|
||||
MapPin,
|
||||
Plus,
|
||||
Minus,
|
||||
@@ -67,7 +65,7 @@ const BookingPage: React.FC = () => {
|
||||
const [validatingPromotion, setValidatingPromotion] = useState(false);
|
||||
const [promotionError, setPromotionError] = useState<string | null>(null);
|
||||
|
||||
// Redirect if not authenticated
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
toast.error(
|
||||
@@ -76,10 +74,17 @@ const BookingPage: React.FC = () => {
|
||||
navigate('/login', {
|
||||
state: { from: `/booking/${id}` }
|
||||
});
|
||||
} else if (userInfo?.role === 'admin' || userInfo?.role === 'staff') {
|
||||
toast.error('Admin and staff users cannot make bookings');
|
||||
if (userInfo?.role === 'admin') {
|
||||
navigate('/admin/dashboard', { replace: true });
|
||||
} else {
|
||||
navigate('/staff/dashboard', { replace: true });
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, navigate, id]);
|
||||
}, [isAuthenticated, userInfo, navigate, id]);
|
||||
|
||||
// Fetch room details and services
|
||||
|
||||
useEffect(() => {
|
||||
if (id && isAuthenticated) {
|
||||
fetchRoomDetails(Number(id));
|
||||
@@ -91,15 +96,15 @@ const BookingPage: React.FC = () => {
|
||||
const fetchBookedDates = async (roomId: number) => {
|
||||
try {
|
||||
const response = await getRoomBookedDates(roomId);
|
||||
// Check for both 'success' boolean and 'status: "success"' string
|
||||
|
||||
const isSuccess = response.success === true || (response as any).status === 'success';
|
||||
|
||||
if (isSuccess && response.data?.booked_dates) {
|
||||
// Convert date strings to Date objects (normalized to midnight)
|
||||
|
||||
const dates = response.data.booked_dates.map((dateStr: string) => {
|
||||
const [year, month, day] = dateStr.split('-').map(Number);
|
||||
const date = new Date(year, month - 1, day);
|
||||
// Normalize to midnight to avoid timezone issues
|
||||
|
||||
date.setHours(0, 0, 0, 0);
|
||||
date.setMinutes(0, 0, 0);
|
||||
return date;
|
||||
@@ -108,11 +113,11 @@ const BookingPage: React.FC = () => {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching booked dates:', err);
|
||||
// Don't show error, just log it - booked dates are not critical
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to check if a date is booked
|
||||
|
||||
const isDateBooked = (date: Date): boolean => {
|
||||
if (!date || bookedDates.length === 0) return false;
|
||||
const normalizedDate = new Date(date);
|
||||
@@ -124,7 +129,7 @@ const BookingPage: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to check if a date range includes any booked dates
|
||||
|
||||
const doesRangeIncludeBookedDates = (startDate: Date, endDate: Date): boolean => {
|
||||
if (!startDate || !endDate || bookedDates.length === 0) return false;
|
||||
const start = new Date(startDate);
|
||||
@@ -151,7 +156,7 @@ const BookingPage: React.FC = () => {
|
||||
setServices(response.data.services || []);
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching services:', err);
|
||||
// Don't show error for services, just log it
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
@@ -182,7 +187,7 @@ const BookingPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Set up form with default values
|
||||
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
@@ -209,12 +214,12 @@ const BookingPage: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Watch form values for calculations
|
||||
|
||||
const checkInDate = watch('checkInDate');
|
||||
const checkOutDate = watch('checkOutDate');
|
||||
const paymentMethod = watch('paymentMethod');
|
||||
|
||||
// Calculate number of nights and total price
|
||||
|
||||
const numberOfNights =
|
||||
checkInDate && checkOutDate
|
||||
? Math.ceil(
|
||||
@@ -224,14 +229,14 @@ const BookingPage: React.FC = () => {
|
||||
)
|
||||
: 0;
|
||||
|
||||
// Prioritize room-specific price over room type base price
|
||||
|
||||
const roomPrice =
|
||||
(room?.price && room.price > 0)
|
||||
? room.price
|
||||
: (room?.room_type?.base_price || 0);
|
||||
const roomTotal = numberOfNights * roomPrice;
|
||||
|
||||
// Calculate services total
|
||||
|
||||
const servicesTotal = selectedServices.reduce((sum, item) => {
|
||||
return sum + (item.service.price * item.quantity);
|
||||
}, 0);
|
||||
@@ -239,10 +244,10 @@ const BookingPage: React.FC = () => {
|
||||
const subtotal = roomTotal + servicesTotal;
|
||||
const totalPrice = Math.max(0, subtotal - promotionDiscount);
|
||||
|
||||
// Format price using currency context
|
||||
|
||||
const formatPrice = (price: number) => formatCurrency(price);
|
||||
|
||||
// Handle promotion code validation
|
||||
|
||||
const handleValidatePromotion = async () => {
|
||||
if (!promotionCode.trim()) {
|
||||
setPromotionError('Please enter a promotion code');
|
||||
@@ -280,7 +285,7 @@ const BookingPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle removing promotion
|
||||
|
||||
const handleRemovePromotion = () => {
|
||||
setPromotionCode('');
|
||||
setSelectedPromotion(null);
|
||||
@@ -289,11 +294,11 @@ const BookingPage: React.FC = () => {
|
||||
toast.info('Promotion removed');
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
|
||||
const onSubmit = async (data: BookingFormData) => {
|
||||
if (!room) return;
|
||||
|
||||
// Verify reCAPTCHA if enabled
|
||||
|
||||
if (recaptchaToken) {
|
||||
try {
|
||||
const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken);
|
||||
@@ -312,25 +317,25 @@ const BookingPage: React.FC = () => {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
|
||||
// Format dates in local timezone to avoid timezone conversion issues
|
||||
|
||||
const checkInDateStr = formatDateLocal(data.checkInDate);
|
||||
const checkOutDateStr = formatDateLocal(data.checkOutDate);
|
||||
|
||||
// Validate that selected dates are not booked
|
||||
|
||||
const checkIn = new Date(data.checkInDate);
|
||||
checkIn.setHours(0, 0, 0, 0);
|
||||
const checkOut = new Date(data.checkOutDate);
|
||||
checkOut.setHours(0, 0, 0, 0);
|
||||
|
||||
// Check if check-in date is booked
|
||||
|
||||
if (isDateBooked(checkIn)) {
|
||||
toast.error('Check-in date is already booked. Please select another date.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if check-out date is booked (check-out date itself is not included in booking, but we should still validate)
|
||||
// Actually, check-out date is not part of the booking, so we don't need to check it
|
||||
// But we should check if any date in the range is booked
|
||||
|
||||
|
||||
|
||||
const selectedDates: Date[] = [];
|
||||
let currentDate = new Date(checkIn);
|
||||
while (currentDate < checkOut) {
|
||||
@@ -342,7 +347,7 @@ const BookingPage: React.FC = () => {
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
// Step 1: Check room availability
|
||||
|
||||
const availability = await checkRoomAvailability(
|
||||
room.id,
|
||||
checkInDateStr,
|
||||
@@ -357,7 +362,7 @@ const BookingPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Prepare booking data
|
||||
|
||||
const bookingData: BookingData & { invoice_info?: any } = {
|
||||
room_id: room.id,
|
||||
check_in_date: checkInDateStr,
|
||||
@@ -384,7 +389,7 @@ const BookingPage: React.FC = () => {
|
||||
} : undefined,
|
||||
};
|
||||
|
||||
// Step 3: Create booking
|
||||
|
||||
const response = await createBooking(bookingData);
|
||||
|
||||
if (
|
||||
@@ -393,10 +398,10 @@ const BookingPage: React.FC = () => {
|
||||
) {
|
||||
const bookingId = response.data.booking.id;
|
||||
|
||||
// If PayPal payment, redirect to PayPal payment flow
|
||||
|
||||
if (paymentMethod === 'paypal') {
|
||||
try {
|
||||
// Get current URL for return/cancel URLs
|
||||
|
||||
const currentUrl = window.location.origin;
|
||||
const returnUrl = `${currentUrl}/payment/paypal/return?bookingId=${bookingId}`;
|
||||
const cancelUrl = `${currentUrl}/payment/paypal/cancel?bookingId=${bookingId}`;
|
||||
@@ -404,15 +409,15 @@ const BookingPage: React.FC = () => {
|
||||
const paypalResponse = await createPayPalOrder(
|
||||
bookingId,
|
||||
totalPrice,
|
||||
currency || 'USD', // Use the platform currency from context
|
||||
currency || 'USD',
|
||||
returnUrl,
|
||||
cancelUrl
|
||||
);
|
||||
|
||||
if (paypalResponse.success && paypalResponse.data?.approval_url) {
|
||||
// Redirect to PayPal for payment
|
||||
|
||||
window.location.href = paypalResponse.data.approval_url;
|
||||
return; // Don't navigate to success page
|
||||
return;
|
||||
} else {
|
||||
throw new Error(paypalResponse.message || 'Failed to initialize PayPal payment');
|
||||
}
|
||||
@@ -423,30 +428,30 @@ const BookingPage: React.FC = () => {
|
||||
paypalError.message ||
|
||||
'Failed to initialize PayPal payment. Please try again or contact support.'
|
||||
);
|
||||
// Still navigate to booking success page so user can see their booking
|
||||
|
||||
navigate(`/booking-success/${bookingId}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If cash payment (pay on arrival), redirect to deposit payment page
|
||||
|
||||
if (paymentMethod === 'cash') {
|
||||
toast.success(
|
||||
'📋 Booking created! Please pay the 20% deposit to confirm your booking.',
|
||||
{ icon: <CheckCircle className="text-green-500" /> }
|
||||
);
|
||||
// Navigate to deposit payment page
|
||||
|
||||
navigate(`/payment/deposit/${bookingId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// For other payment methods, navigate to success page
|
||||
|
||||
toast.success(
|
||||
'🎉 Booking successful!',
|
||||
{ icon: <CheckCircle className="text-green-500" /> }
|
||||
);
|
||||
|
||||
// Navigate to success page
|
||||
|
||||
navigate(`/booking-success/${bookingId}`);
|
||||
} else {
|
||||
throw new Error(
|
||||
@@ -457,7 +462,7 @@ const BookingPage: React.FC = () => {
|
||||
} catch (err: any) {
|
||||
console.error('Error creating booking:', err);
|
||||
|
||||
// Handle specific error cases
|
||||
|
||||
if (err.response?.status === 409) {
|
||||
toast.error(
|
||||
'❌ Room is already booked during this time. ' +
|
||||
@@ -553,7 +558,7 @@ const BookingPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<div className="w-full px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4">
|
||||
{/* Back Button */}
|
||||
{}
|
||||
<Link
|
||||
to={`/rooms/${room.room_number}`}
|
||||
className="inline-flex items-center gap-1
|
||||
@@ -565,7 +570,7 @@ const BookingPage: React.FC = () => {
|
||||
<span>Back to room details</span>
|
||||
</Link>
|
||||
|
||||
{/* Page Title */}
|
||||
{}
|
||||
<div className="mb-4">
|
||||
<h1
|
||||
className="text-xl sm:text-2xl lg:text-3xl font-serif font-semibold
|
||||
@@ -581,7 +586,7 @@ const BookingPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 sm:gap-4 lg:gap-6">
|
||||
{/* Booking Form */}
|
||||
{}
|
||||
<div className="lg:col-span-2">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
@@ -590,7 +595,7 @@ const BookingPage: React.FC = () => {
|
||||
backdrop-blur-xl shadow-lg shadow-[#d4af37]/5
|
||||
p-3 sm:p-4 space-y-4"
|
||||
>
|
||||
{/* Guest Information */}
|
||||
{}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="p-1 bg-[#d4af37]/10 rounded-lg
|
||||
@@ -606,7 +611,7 @@ const BookingPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Full Name */}
|
||||
{}
|
||||
<div>
|
||||
<label
|
||||
className="block text-[10px] sm:text-xs font-light tracking-wide
|
||||
@@ -633,7 +638,7 @@ const BookingPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email & Phone */}
|
||||
{}
|
||||
<div className="grid grid-cols-1
|
||||
md:grid-cols-2 gap-3"
|
||||
>
|
||||
@@ -696,7 +701,7 @@ const BookingPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Booking Details */}
|
||||
{}
|
||||
<div className="border-t border-[#d4af37]/20 pt-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="p-1 bg-[#d4af37]/10 rounded-lg
|
||||
@@ -712,11 +717,11 @@ const BookingPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Date Range */}
|
||||
{}
|
||||
<div className="grid grid-cols-1
|
||||
md:grid-cols-2 gap-3"
|
||||
>
|
||||
{/* Check-in Date */}
|
||||
{}
|
||||
<div>
|
||||
<label
|
||||
className="block text-[10px] sm:text-xs font-light tracking-wide
|
||||
@@ -742,14 +747,14 @@ const BookingPage: React.FC = () => {
|
||||
placeholderText="Select check-in date"
|
||||
excludeDates={bookedDates}
|
||||
highlightDates={bookedDates.length > 0 ? bookedDates.map(date => {
|
||||
// Ensure date is properly formatted for react-datepicker
|
||||
|
||||
const normalized = new Date(date);
|
||||
normalized.setHours(0, 0, 0, 0);
|
||||
normalized.setMinutes(0, 0, 0);
|
||||
return normalized;
|
||||
}) : []}
|
||||
dayClassName={(date) => {
|
||||
// Add custom class for booked dates to ensure they're highlighted in red
|
||||
|
||||
if (!date) return '';
|
||||
const normalized = new Date(date);
|
||||
normalized.setHours(0, 0, 0, 0);
|
||||
@@ -760,12 +765,12 @@ const BookingPage: React.FC = () => {
|
||||
return '';
|
||||
}}
|
||||
filterDate={(date) => {
|
||||
// Prevent selection of booked dates
|
||||
|
||||
if (isDateBooked(date)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If check-out is already selected, prevent selecting check-in that would create invalid range
|
||||
|
||||
if (checkOutDate) {
|
||||
const testCheckIn = date;
|
||||
const testCheckOut = checkOutDate;
|
||||
@@ -777,13 +782,13 @@ const BookingPage: React.FC = () => {
|
||||
return true;
|
||||
}}
|
||||
onChange={(date) => {
|
||||
// Prevent setting booked dates
|
||||
|
||||
if (date && isDateBooked(date)) {
|
||||
toast.error('This date is already booked. Please select another date.');
|
||||
return;
|
||||
}
|
||||
|
||||
// If check-out date is already selected, validate the entire range
|
||||
|
||||
if (date && checkOutDate) {
|
||||
const newCheckIn = date;
|
||||
const newCheckOut = checkOutDate;
|
||||
@@ -817,7 +822,7 @@ const BookingPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Check-out Date */}
|
||||
{}
|
||||
<div>
|
||||
<label
|
||||
className="block text-[10px] sm:text-xs font-light tracking-wide
|
||||
@@ -845,14 +850,14 @@ const BookingPage: React.FC = () => {
|
||||
placeholderText="Select check-out date"
|
||||
excludeDates={bookedDates}
|
||||
highlightDates={bookedDates.length > 0 ? bookedDates.map(date => {
|
||||
// Ensure date is properly formatted for react-datepicker
|
||||
|
||||
const normalized = new Date(date);
|
||||
normalized.setHours(0, 0, 0, 0);
|
||||
normalized.setMinutes(0, 0, 0);
|
||||
return normalized;
|
||||
}) : []}
|
||||
dayClassName={(date) => {
|
||||
// Add custom class for booked dates to ensure they're highlighted in red
|
||||
|
||||
if (!date) return '';
|
||||
const normalized = new Date(date);
|
||||
normalized.setHours(0, 0, 0, 0);
|
||||
@@ -863,12 +868,12 @@ const BookingPage: React.FC = () => {
|
||||
return '';
|
||||
}}
|
||||
filterDate={(date) => {
|
||||
// Prevent selection of booked dates
|
||||
|
||||
if (isDateBooked(date)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If check-in is already selected, prevent selecting check-out that would create invalid range
|
||||
|
||||
if (checkInDate) {
|
||||
const testCheckIn = checkInDate;
|
||||
const testCheckOut = date;
|
||||
@@ -880,13 +885,13 @@ const BookingPage: React.FC = () => {
|
||||
return true;
|
||||
}}
|
||||
onChange={(date) => {
|
||||
// Prevent setting booked dates
|
||||
|
||||
if (date && isDateBooked(date)) {
|
||||
toast.error('This date is already booked. Please select another date.');
|
||||
return;
|
||||
}
|
||||
|
||||
// If check-in date is already selected, validate the entire range
|
||||
|
||||
if (date && checkInDate) {
|
||||
const newCheckIn = checkInDate;
|
||||
const newCheckOut = date;
|
||||
@@ -921,7 +926,7 @@ const BookingPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guest Count */}
|
||||
{}
|
||||
<div>
|
||||
<label
|
||||
className="block text-[10px] sm:text-xs font-light tracking-wide
|
||||
@@ -956,7 +961,7 @@ const BookingPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{}
|
||||
<div>
|
||||
<label
|
||||
className="block text-[10px] sm:text-xs font-light tracking-wide
|
||||
@@ -987,7 +992,7 @@ const BookingPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services Selection */}
|
||||
{}
|
||||
{services.length > 0 && (
|
||||
<div className="border-t border-[#d4af37]/20 pt-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
@@ -1118,7 +1123,7 @@ const BookingPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Promotion Code */}
|
||||
{}
|
||||
<div className="border-t border-[#d4af37]/20 pt-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="p-1 bg-[#d4af37]/10 rounded-lg border border-[#d4af37]/30">
|
||||
@@ -1225,7 +1230,7 @@ const BookingPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
{}
|
||||
<div className="border-t border-[#d4af37]/20 pt-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="p-1 bg-[#d4af37]/10 rounded-lg
|
||||
@@ -1241,7 +1246,7 @@ const BookingPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
{/* Cash */}
|
||||
{}
|
||||
<label
|
||||
className="flex items-start p-3
|
||||
bg-gradient-to-br from-[#0a0a0a] to-[#1a1a1a]
|
||||
@@ -1295,7 +1300,7 @@ const BookingPage: React.FC = () => {
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Stripe Payment */}
|
||||
{}
|
||||
<label
|
||||
className="flex items-start p-3
|
||||
bg-gradient-to-br from-[#0a0a0a] to-[#1a1a1a]
|
||||
@@ -1344,7 +1349,7 @@ const BookingPage: React.FC = () => {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* PayPal Payment */}
|
||||
{}
|
||||
<label
|
||||
className="flex items-start p-3
|
||||
bg-gradient-to-br from-[#0a0a0a] to-[#1a1a1a]
|
||||
@@ -1387,7 +1392,7 @@ const BookingPage: React.FC = () => {
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Stripe Payment Info */}
|
||||
{}
|
||||
{paymentMethod === 'stripe' && (
|
||||
<div
|
||||
className="bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/5
|
||||
@@ -1409,7 +1414,7 @@ const BookingPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PayPal Payment Info */}
|
||||
{}
|
||||
{paymentMethod === 'paypal' && (
|
||||
<div
|
||||
className="bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/5
|
||||
@@ -1432,7 +1437,7 @@ const BookingPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoice Information */}
|
||||
{}
|
||||
<div className="border-t border-[#d4af37]/20 pt-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="p-1 bg-[#d4af37]/10 rounded-lg
|
||||
@@ -1452,7 +1457,7 @@ const BookingPage: React.FC = () => {
|
||||
Fill in these details if you need an invoice for your booking
|
||||
</p>
|
||||
|
||||
{/* Company Name */}
|
||||
{}
|
||||
<div>
|
||||
<label className="block text-[10px] sm:text-xs font-light
|
||||
text-gray-300 mb-1 tracking-wide"
|
||||
@@ -1472,7 +1477,7 @@ const BookingPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Company Address */}
|
||||
{}
|
||||
<div>
|
||||
<label className="block text-[10px] sm:text-xs font-light
|
||||
text-gray-300 mb-1 tracking-wide"
|
||||
@@ -1492,7 +1497,7 @@ const BookingPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tax ID */}
|
||||
{}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[10px] sm:text-xs font-light
|
||||
@@ -1536,7 +1541,7 @@ const BookingPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* reCAPTCHA */}
|
||||
{}
|
||||
<div className="border-t border-[#d4af37]/20 pt-4">
|
||||
<div className="flex justify-center mb-4">
|
||||
<Recaptcha
|
||||
@@ -1551,7 +1556,7 @@ const BookingPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
{}
|
||||
<div className="border-t border-[#d4af37]/20 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -1589,7 +1594,7 @@ const BookingPage: React.FC = () => {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Booking Summary */}
|
||||
{}
|
||||
<div className="lg:col-span-1">
|
||||
<div
|
||||
className="bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a]
|
||||
@@ -1610,7 +1615,7 @@ const BookingPage: React.FC = () => {
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Room Info */}
|
||||
{}
|
||||
<div className="mb-4">
|
||||
{((room?.images && room.images.length > 0)
|
||||
? room.images[0]
|
||||
@@ -1649,7 +1654,7 @@ const BookingPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing Breakdown */}
|
||||
{}
|
||||
<div className="border-t border-[#d4af37]/20 pt-4 space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide">
|
||||
@@ -1751,7 +1756,7 @@ const BookingPage: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Deposit amount for cash payment */}
|
||||
{}
|
||||
{paymentMethod === 'cash' && numberOfNights > 0 && (
|
||||
<div className="bg-gradient-to-br from-orange-900/20 to-orange-800/10
|
||||
border border-orange-500/30 rounded-lg p-3 mt-3"
|
||||
@@ -1777,7 +1782,7 @@ const BookingPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
{}
|
||||
<div
|
||||
className={`border rounded-lg p-2.5 sm:p-3 mt-3 ${
|
||||
paymentMethod === 'cash'
|
||||
|
||||
@@ -75,7 +75,7 @@ const BookingSuccessPage: React.FC = () => {
|
||||
const bookingData = response.data.booking;
|
||||
setBooking(bookingData);
|
||||
|
||||
// Check for pending Stripe payment
|
||||
|
||||
if (bookingData.payment_method === 'stripe' && bookingData.payments) {
|
||||
const pendingStripePayment = bookingData.payments.find(
|
||||
(p: any) =>
|
||||
@@ -84,13 +84,13 @@ const BookingSuccessPage: React.FC = () => {
|
||||
);
|
||||
|
||||
if (pendingStripePayment) {
|
||||
// Redirect to payment page for Stripe payment
|
||||
|
||||
navigate(`/payment/${bookingId}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect to deposit payment page if required and not yet paid
|
||||
|
||||
if (
|
||||
bookingData.requires_deposit &&
|
||||
!bookingData.deposit_paid
|
||||
@@ -116,7 +116,7 @@ const BookingSuccessPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
// Use parseDateLocal to handle date strings correctly
|
||||
|
||||
const date = parseDateLocal(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
@@ -183,13 +183,13 @@ const BookingSuccessPage: React.FC = () => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('Image size must not exceed 5MB');
|
||||
return;
|
||||
@@ -197,7 +197,7 @@ const BookingSuccessPage: React.FC = () => {
|
||||
|
||||
setSelectedFile(file);
|
||||
|
||||
// Create preview
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setPreviewUrl(reader.result as string);
|
||||
@@ -211,7 +211,7 @@ const BookingSuccessPage: React.FC = () => {
|
||||
try {
|
||||
setUploadingReceipt(true);
|
||||
|
||||
// Generate transaction ID based on booking number
|
||||
|
||||
const transactionId =
|
||||
`TXN-${booking.booking_number}-${Date.now()}`;
|
||||
|
||||
@@ -228,7 +228,7 @@ const BookingSuccessPage: React.FC = () => {
|
||||
);
|
||||
setReceiptUploaded(true);
|
||||
|
||||
// Update booking payment status locally
|
||||
|
||||
setBooking((prev) =>
|
||||
prev
|
||||
? {
|
||||
@@ -304,7 +304,7 @@ const BookingSuccessPage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
{/* Success Header */}
|
||||
{}
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-md
|
||||
p-8 mb-6 text-center"
|
||||
@@ -328,7 +328,7 @@ const BookingSuccessPage: React.FC = () => {
|
||||
Thank you for booking with our hotel
|
||||
</p>
|
||||
|
||||
{/* Booking Number */}
|
||||
{}
|
||||
<div
|
||||
className="inline-flex items-center gap-2
|
||||
bg-indigo-50 px-6 py-3 rounded-lg"
|
||||
@@ -357,7 +357,7 @@ const BookingSuccessPage: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
{}
|
||||
<div className="mt-4">
|
||||
<span
|
||||
className={`inline-block px-4 py-2
|
||||
@@ -369,7 +369,7 @@ const BookingSuccessPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Booking Details */}
|
||||
{}
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6 mb-6"
|
||||
>
|
||||
@@ -380,18 +380,18 @@ const BookingSuccessPage: React.FC = () => {
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Room Information */}
|
||||
{}
|
||||
{roomType && (
|
||||
<div className="border-b pb-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{((room?.images && room.images.length > 0)
|
||||
? room.images[0]
|
||||
: roomType.images?.[0]) && (
|
||||
{((room?.room_type?.images && room.room_type.images.length > 0)
|
||||
? room.room_type.images[0]
|
||||
: roomType?.images?.[0]) && (
|
||||
<img
|
||||
src={(room?.images && room.images.length > 0)
|
||||
? room.images[0]
|
||||
: (roomType.images?.[0] || '')}
|
||||
alt={roomType.name}
|
||||
src={(room?.room_type?.images && room.room_type.images.length > 0)
|
||||
? room.room_type.images[0]
|
||||
: (roomType?.images?.[0] || '')}
|
||||
alt={roomType?.name || 'Room'}
|
||||
className="w-24 h-24 object-cover
|
||||
rounded-lg"
|
||||
/>
|
||||
@@ -414,14 +414,14 @@ const BookingSuccessPage: React.FC = () => {
|
||||
<p className="text-indigo-600
|
||||
font-semibold mt-1"
|
||||
>
|
||||
{formatPrice(room?.price || roomType.base_price)}/night
|
||||
{formatPrice(room?.room_type?.base_price || roomType?.base_price || 0)}/night
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dates */}
|
||||
{}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2
|
||||
gap-4"
|
||||
>
|
||||
@@ -445,7 +445,7 @@ const BookingSuccessPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guest Count */}
|
||||
{}
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
<Users className="w-4 h-4 inline mr-1" />
|
||||
@@ -456,7 +456,7 @@ const BookingSuccessPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{}
|
||||
{booking.notes && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
@@ -469,7 +469,7 @@ const BookingSuccessPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Method */}
|
||||
{}
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
<CreditCard className="w-4 h-4 inline mr-1" />
|
||||
@@ -482,7 +482,7 @@ const BookingSuccessPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Total Price */}
|
||||
{}
|
||||
<div className="border-t pt-4">
|
||||
{booking.original_price && booking.discount_amount && booking.discount_amount > 0 ? (
|
||||
<>
|
||||
@@ -515,7 +515,7 @@ const BookingSuccessPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guest Information */}
|
||||
{}
|
||||
{booking.guest_info && (
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6 mb-6"
|
||||
@@ -557,8 +557,8 @@ const BookingSuccessPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bank Transfer Instructions */}
|
||||
{booking.payment_method === 'bank_transfer' && (
|
||||
{}
|
||||
{(booking.payment_method === 'cash' || (booking as any).payment_method === 'bank_transfer') && (
|
||||
<div
|
||||
className="bg-blue-50 border border-blue-200
|
||||
rounded-lg p-6 mb-6"
|
||||
@@ -582,7 +582,7 @@ const BookingSuccessPage: React.FC = () => {
|
||||
<div className="grid grid-cols-1
|
||||
md:grid-cols-2 gap-4"
|
||||
>
|
||||
{/* Bank Info */}
|
||||
{}
|
||||
<div className="bg-white rounded-lg
|
||||
p-4 space-y-2"
|
||||
>
|
||||
@@ -616,7 +616,7 @@ const BookingSuccessPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* QR Code */}
|
||||
{}
|
||||
{qrCodeUrl && (
|
||||
<div className="bg-white rounded-lg
|
||||
p-4 flex flex-col items-center
|
||||
@@ -650,7 +650,7 @@ const BookingSuccessPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Receipt Section */}
|
||||
{}
|
||||
{!receiptUploaded ? (
|
||||
<div className="border-t border-blue-200
|
||||
pt-4"
|
||||
@@ -666,7 +666,7 @@ const BookingSuccessPage: React.FC = () => {
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* File Input */}
|
||||
{}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="receipt-upload"
|
||||
@@ -681,55 +681,7 @@ const BookingSuccessPage: React.FC = () => {
|
||||
<input
|
||||
id="receipt-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="flex flex-col
|
||||
items-center gap-2"
|
||||
>
|
||||
{previewUrl ? (
|
||||
<>
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
className="w-32 h-32
|
||||
object-cover rounded"
|
||||
/>
|
||||
<p className="text-sm
|
||||
text-blue-600 font-medium"
|
||||
>
|
||||
{selectedFile?.name}
|
||||
</p>
|
||||
<p className="text-xs
|
||||
text-gray-500"
|
||||
>
|
||||
Click to select another image
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText
|
||||
className="w-8 h-8
|
||||
text-blue-400"
|
||||
/>
|
||||
<p className="text-sm
|
||||
text-blue-600 font-medium"
|
||||
>
|
||||
Select receipt image
|
||||
</p>
|
||||
<p className="text-xs
|
||||
text-gray-500"
|
||||
>
|
||||
PNG, JPG, JPEG (Max 5MB)
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Upload Button */}
|
||||
accept="image}
|
||||
{selectedFile && (
|
||||
<button
|
||||
onClick={handleUploadReceipt}
|
||||
@@ -791,7 +743,7 @@ const BookingSuccessPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Important Notice */}
|
||||
{}
|
||||
<div
|
||||
className="bg-yellow-50 border border-yellow-200
|
||||
rounded-lg p-4 mb-6"
|
||||
@@ -813,7 +765,7 @@ const BookingSuccessPage: React.FC = () => {
|
||||
If you cancel the booking, 20% of
|
||||
the total order value will be charged
|
||||
</li>
|
||||
{booking.payment_method === 'bank_transfer' && (
|
||||
{(booking.payment_method === 'cash' || (booking as any).payment_method === 'bank_transfer') && (
|
||||
<li>
|
||||
Please transfer within 24 hours
|
||||
to secure your room
|
||||
@@ -822,7 +774,7 @@ const BookingSuccessPage: React.FC = () => {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
{}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Link
|
||||
to="/bookings"
|
||||
|
||||
@@ -6,11 +6,11 @@ import {
|
||||
Activity,
|
||||
TrendingDown,
|
||||
CreditCard,
|
||||
Receipt
|
||||
} from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dashboardService, { CustomerDashboardStats } from '../../services/api/dashboardService';
|
||||
import { paymentService, Payment } from '../../services/api';
|
||||
import { paymentService } from '../../services/api';
|
||||
import type { Payment } from '../../services/api/paymentService';
|
||||
import { toast } from 'react-toastify';
|
||||
import { formatDate, formatRelativeTime } from '../../utils/format';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
@@ -121,9 +121,9 @@ const DashboardPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
{}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-10">
|
||||
{/* Total Bookings */}
|
||||
{}
|
||||
<div className="enterprise-stat-card border-l-4 border-blue-500 animate-slide-up" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="p-3 bg-blue-100 rounded-lg">
|
||||
@@ -150,7 +150,7 @@ const DashboardPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Total Spending */}
|
||||
{}
|
||||
<div className="enterprise-stat-card border-l-4 border-green-500 animate-slide-up" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="p-3 bg-green-100 rounded-lg">
|
||||
@@ -177,7 +177,7 @@ const DashboardPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Currently Staying */}
|
||||
{}
|
||||
<div className="enterprise-stat-card border-l-4 border-purple-500 animate-slide-up" style={{ animationDelay: '0.3s' }}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="p-3 bg-purple-100 rounded-lg">
|
||||
@@ -197,7 +197,7 @@ const DashboardPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Upcoming Bookings Count */}
|
||||
{}
|
||||
<div className="enterprise-stat-card border-l-4 border-orange-500 animate-slide-up" style={{ animationDelay: '0.4s' }}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="p-3 bg-orange-100 rounded-lg">
|
||||
@@ -213,9 +213,9 @@ const DashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity, Upcoming Bookings & Payment History */}
|
||||
{}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Recent Activity */}
|
||||
{}
|
||||
<div className="enterprise-card p-6 animate-fade-in">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6 pb-3 border-b border-gray-200">
|
||||
Recent Activity
|
||||
@@ -257,7 +257,7 @@ const DashboardPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upcoming Bookings */}
|
||||
{}
|
||||
<div className="enterprise-card p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6 pb-3 border-b border-gray-200">
|
||||
Upcoming Bookings
|
||||
@@ -305,7 +305,7 @@ const DashboardPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment History */}
|
||||
{}
|
||||
<div className="enterprise-card p-6 animate-fade-in" style={{ animationDelay: '0.3s' }}>
|
||||
<div className="flex items-center justify-between mb-6 pb-3 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900">
|
||||
|
||||
@@ -43,7 +43,7 @@ const DepositPaymentPage: React.FC = () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fetch booking details
|
||||
|
||||
const bookingResponse = await getBookingById(id);
|
||||
if (!bookingResponse.success || !bookingResponse.data?.booking) {
|
||||
throw new Error('Booking not found');
|
||||
@@ -52,21 +52,21 @@ const DepositPaymentPage: React.FC = () => {
|
||||
const bookingData = bookingResponse.data.booking;
|
||||
setBooking(bookingData);
|
||||
|
||||
// Check if booking is already confirmed - redirect to booking details
|
||||
|
||||
if (bookingData.status === 'confirmed' || bookingData.status === 'checked_in') {
|
||||
toast.success('Booking is already confirmed!');
|
||||
navigate(`/bookings/${id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if booking requires deposit
|
||||
|
||||
if (!bookingData.requires_deposit) {
|
||||
toast.info('This booking does not require a deposit');
|
||||
navigate(`/bookings/${id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch payments
|
||||
|
||||
const paymentsResponse = await getPaymentsByBookingId(id);
|
||||
if (paymentsResponse.success) {
|
||||
const deposit = paymentsResponse.data.payments.find(
|
||||
@@ -108,7 +108,7 @@ const DepositPaymentPage: React.FC = () => {
|
||||
toast.success(
|
||||
`✅ Booking ${booking.booking_number} has been cancelled successfully!`
|
||||
);
|
||||
// Navigate to bookings list after cancellation
|
||||
|
||||
setTimeout(() => {
|
||||
navigate('/bookings');
|
||||
}, 1500);
|
||||
@@ -126,7 +126,6 @@ const DepositPaymentPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] py-8">
|
||||
@@ -188,7 +187,7 @@ const DepositPaymentPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<div className="w-full px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4">
|
||||
{/* Back Button and Cancel Button */}
|
||||
{}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 mb-3 sm:mb-4">
|
||||
<Link
|
||||
to={`/bookings/${bookingId}`}
|
||||
@@ -200,7 +199,7 @@ const DepositPaymentPage: React.FC = () => {
|
||||
<span>Back to booking details</span>
|
||||
</Link>
|
||||
|
||||
{/* Cancel Booking Button - Only show if deposit not paid */}
|
||||
{}
|
||||
{!isDepositPaid && booking && (
|
||||
<button
|
||||
onClick={handleCancelBooking}
|
||||
@@ -223,7 +222,7 @@ const DepositPaymentPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Success Header (if paid) */}
|
||||
{}
|
||||
{isDepositPaid && (
|
||||
<div
|
||||
className="bg-gradient-to-br from-green-900/20 to-green-800/10
|
||||
@@ -251,7 +250,7 @@ const DepositPaymentPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending Header */}
|
||||
{}
|
||||
{!isDepositPaid && (
|
||||
<div
|
||||
className="bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/5
|
||||
@@ -281,9 +280,9 @@ const DepositPaymentPage: React.FC = () => {
|
||||
|
||||
<div className="w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 sm:gap-4">
|
||||
{/* Payment Info */}
|
||||
{}
|
||||
<div className="lg:col-span-2 space-y-3">
|
||||
{/* Payment Summary */}
|
||||
{}
|
||||
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||
border border-gray-700/50 rounded-lg p-3 sm:p-4
|
||||
backdrop-blur-xl shadow-lg shadow-black/20">
|
||||
@@ -335,7 +334,7 @@ const DepositPaymentPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment Method Selection */}
|
||||
{}
|
||||
{!isDepositPaid && !selectedPaymentMethod && (
|
||||
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||
border border-gray-700/50 rounded-lg p-3 sm:p-4
|
||||
@@ -348,7 +347,7 @@ const DepositPaymentPage: React.FC = () => {
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 sm:gap-3">
|
||||
{/* Stripe Option */}
|
||||
{}
|
||||
<button
|
||||
onClick={() => setSelectedPaymentMethod('stripe')}
|
||||
className="bg-gradient-to-br from-gray-800/40 to-gray-700/20
|
||||
@@ -374,7 +373,7 @@ const DepositPaymentPage: React.FC = () => {
|
||||
</p>
|
||||
</button>
|
||||
|
||||
{/* PayPal Option */}
|
||||
{}
|
||||
<button
|
||||
onClick={() => setSelectedPaymentMethod('paypal')}
|
||||
className="bg-gradient-to-br from-gray-800/40 to-gray-700/20
|
||||
@@ -409,7 +408,7 @@ const DepositPaymentPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Method Selection Header (when method is selected) */}
|
||||
{}
|
||||
{!isDepositPaid && selectedPaymentMethod && (
|
||||
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||
border border-gray-700/50 rounded-lg p-3 sm:p-4 mb-3 sm:mb-4
|
||||
@@ -434,7 +433,7 @@ const DepositPaymentPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stripe Payment Panel */}
|
||||
{}
|
||||
{!isDepositPaid && booking && depositPayment && selectedPaymentMethod === 'stripe' && (
|
||||
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||
border border-gray-700/50 rounded-lg p-3 sm:p-4
|
||||
@@ -468,7 +467,7 @@ const DepositPaymentPage: React.FC = () => {
|
||||
onSuccess={() => {
|
||||
setPaymentSuccess(true);
|
||||
toast.success('✅ Payment successful! Your booking has been confirmed.');
|
||||
// Navigate to booking details after successful payment
|
||||
|
||||
setTimeout(() => {
|
||||
navigate(`/bookings/${booking.id}`);
|
||||
}, 2000);
|
||||
@@ -481,7 +480,7 @@ const DepositPaymentPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PayPal Payment Panel */}
|
||||
{}
|
||||
{!isDepositPaid && booking && depositPayment && selectedPaymentMethod === 'paypal' && (
|
||||
<div className="bg-gradient-to-br from-gray-900/40 to-gray-800/20
|
||||
border border-gray-700/50 rounded-lg p-3 sm:p-4
|
||||
|
||||
@@ -62,7 +62,7 @@ const FavoritesPage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
{}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
to="/"
|
||||
@@ -95,7 +95,7 @@ const FavoritesPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{}
|
||||
{isLoading && (
|
||||
<div
|
||||
className="grid grid-cols-1 md:grid-cols-2
|
||||
@@ -107,7 +107,7 @@ const FavoritesPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{}
|
||||
{error && !isLoading && (
|
||||
<div
|
||||
className="bg-red-50 border border-red-200
|
||||
@@ -131,7 +131,7 @@ const FavoritesPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{}
|
||||
{!isLoading &&
|
||||
!error &&
|
||||
favorites.length === 0 && (
|
||||
@@ -175,7 +175,7 @@ const FavoritesPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Favorites Grid */}
|
||||
{}
|
||||
{!isLoading &&
|
||||
!error &&
|
||||
favorites.length > 0 && (
|
||||
|
||||
@@ -40,7 +40,7 @@ const FullPaymentPage: React.FC = () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fetch booking details
|
||||
|
||||
const bookingResponse = await getBookingById(id);
|
||||
if (!bookingResponse.success || !bookingResponse.data?.booking) {
|
||||
throw new Error('Booking not found');
|
||||
@@ -49,21 +49,21 @@ const FullPaymentPage: React.FC = () => {
|
||||
const bookingData = bookingResponse.data.booking;
|
||||
setBooking(bookingData);
|
||||
|
||||
// Check if booking is already confirmed - redirect to booking details
|
||||
|
||||
if (bookingData.status === 'confirmed' || bookingData.status === 'checked_in') {
|
||||
toast.success('Booking is already confirmed!');
|
||||
navigate(`/bookings/${id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if booking uses Stripe payment method
|
||||
|
||||
if (bookingData.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);
|
||||
|
||||
@@ -71,7 +71,7 @@ const FullPaymentPage: React.FC = () => {
|
||||
const payments = paymentsResponse.data.payments;
|
||||
console.log('Payments found:', payments);
|
||||
|
||||
// Find pending Stripe payment (full payment)
|
||||
|
||||
const stripePaymentFound = payments.find(
|
||||
(p: Payment) =>
|
||||
(p.payment_method === 'stripe' || p.payment_method === 'credit_card') &&
|
||||
@@ -82,7 +82,7 @@ const FullPaymentPage: React.FC = () => {
|
||||
console.log('Found pending Stripe payment:', stripePaymentFound);
|
||||
setStripePayment(stripePaymentFound);
|
||||
} else {
|
||||
// Check if payment is already completed
|
||||
|
||||
const completedPayment = payments.find(
|
||||
(p: Payment) =>
|
||||
(p.payment_method === 'stripe' || p.payment_method === 'credit_card') &&
|
||||
@@ -93,8 +93,8 @@ const FullPaymentPage: React.FC = () => {
|
||||
console.log('Found completed Stripe payment:', completedPayment);
|
||||
setStripePayment(completedPayment);
|
||||
setPaymentSuccess(true);
|
||||
// If payment is completed and booking is confirmed, redirect
|
||||
if (bookingData.status === 'confirmed' || bookingData.status === 'checked_in') {
|
||||
|
||||
if ((bookingData.status as string) === 'confirmed' || (bookingData.status as string) === 'checked_in') {
|
||||
toast.info('Payment already completed. Booking is confirmed.');
|
||||
setTimeout(() => {
|
||||
navigate(`/bookings/${id}`);
|
||||
@@ -102,16 +102,16 @@ const FullPaymentPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// If no Stripe payment found, check if we can use booking data to create payment info
|
||||
|
||||
console.warn('No Stripe payment found in payments array:', payments);
|
||||
console.warn('Booking payment method:', bookingData.payment_method);
|
||||
|
||||
// If booking uses Stripe but no payment record exists, this is an error
|
||||
|
||||
throw new Error('No Stripe payment record found for this booking. The payment may not have been created properly.');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If payments endpoint fails or returns no payments, check booking payments array
|
||||
|
||||
console.warn('Payments response not successful or no payments data:', paymentsResponse);
|
||||
|
||||
if (bookingData.payments && bookingData.payments.length > 0) {
|
||||
@@ -123,12 +123,12 @@ const FullPaymentPage: React.FC = () => {
|
||||
);
|
||||
|
||||
if (stripePaymentFromBooking) {
|
||||
setStripePayment(stripePaymentFromBooking as Payment);
|
||||
setStripePayment(stripePaymentFromBooking as unknown as Payment);
|
||||
} else {
|
||||
throw new Error('No pending Stripe payment found for this booking');
|
||||
}
|
||||
} else {
|
||||
// If no payments found at all, this might be a timing issue - wait a moment and retry
|
||||
|
||||
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.');
|
||||
}
|
||||
@@ -181,15 +181,15 @@ const FullPaymentPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Get payment amount, but validate it's reasonable
|
||||
|
||||
let paymentAmount = parseFloat(stripePayment.amount.toString());
|
||||
const isPaymentCompleted = stripePayment.payment_status === 'completed';
|
||||
|
||||
// Log payment amount for debugging
|
||||
|
||||
console.log('Payment amount from payment record:', paymentAmount);
|
||||
console.log('Booking total price:', booking?.total_price);
|
||||
|
||||
// If payment amount seems incorrect (too large or doesn't match booking), use booking total
|
||||
|
||||
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) {
|
||||
@@ -198,7 +198,7 @@ const FullPaymentPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Final validation - ensure amount is reasonable for Stripe
|
||||
|
||||
if (paymentAmount > 999999.99) {
|
||||
const errorMsg = `Payment amount $${paymentAmount.toLocaleString()} exceeds Stripe's maximum. Please contact support.`;
|
||||
console.error(errorMsg);
|
||||
@@ -209,7 +209,7 @@ const FullPaymentPage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
{/* Back Button */}
|
||||
{}
|
||||
<Link
|
||||
to={`/bookings/${bookingId}`}
|
||||
className="inline-flex items-center gap-2
|
||||
@@ -221,7 +221,7 @@ const FullPaymentPage: React.FC = () => {
|
||||
<span>Back to booking details</span>
|
||||
</Link>
|
||||
|
||||
{/* Success Header (if paid) */}
|
||||
{}
|
||||
{isPaymentCompleted && (
|
||||
<div
|
||||
className="bg-gradient-to-br from-green-900/20 to-green-800/10
|
||||
@@ -247,7 +247,7 @@ const FullPaymentPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending Header */}
|
||||
{}
|
||||
{!isPaymentCompleted && (
|
||||
<div
|
||||
className="bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/5
|
||||
@@ -274,9 +274,9 @@ const FullPaymentPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Payment Info */}
|
||||
{}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Payment Summary */}
|
||||
{}
|
||||
<div
|
||||
className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
rounded-xl border border-[#d4af37]/20
|
||||
@@ -325,7 +325,7 @@ const FullPaymentPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stripe Payment Panel */}
|
||||
{}
|
||||
{!isPaymentCompleted && booking && stripePayment && (
|
||||
<div
|
||||
className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
@@ -365,7 +365,7 @@ const FullPaymentPage: React.FC = () => {
|
||||
onSuccess={() => {
|
||||
setPaymentSuccess(true);
|
||||
toast.success('✅ Payment successful! Your booking has been confirmed.');
|
||||
// Navigate to booking details after successful payment
|
||||
|
||||
setTimeout(() => {
|
||||
navigate(`/bookings/${booking.id}`);
|
||||
}, 2000);
|
||||
@@ -379,7 +379,7 @@ const FullPaymentPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Booking Summary Sidebar */}
|
||||
{}
|
||||
<div className="lg:col-span-1">
|
||||
<div
|
||||
className="bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
|
||||
@@ -100,7 +100,7 @@ const InvoicePage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8 print:bg-white">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
{/* Header Actions */}
|
||||
{}
|
||||
<div className="mb-6 flex items-center justify-between print:hidden">
|
||||
<Link
|
||||
to="/bookings"
|
||||
@@ -120,9 +120,9 @@ const InvoicePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoice Card */}
|
||||
{}
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 print:shadow-none">
|
||||
{/* Invoice Header */}
|
||||
{}
|
||||
<div className="flex justify-between items-start mb-8 pb-8 border-b-2 border-gray-200">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
@@ -143,9 +143,9 @@ const InvoicePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Company & Customer Info */}
|
||||
{}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
|
||||
{/* Company Info */}
|
||||
{}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-500 uppercase mb-3">From</h3>
|
||||
{invoice.company_name && (
|
||||
@@ -172,7 +172,7 @@ const InvoicePage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Customer Info */}
|
||||
{}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-500 uppercase mb-3">Bill To</h3>
|
||||
<div className="text-gray-900">
|
||||
@@ -195,7 +195,7 @@ const InvoicePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoice Details */}
|
||||
{}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8 p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">Issue Date</p>
|
||||
@@ -217,7 +217,7 @@ const InvoicePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoice Items */}
|
||||
{}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Items</h3>
|
||||
<div className="overflow-x-auto">
|
||||
@@ -252,7 +252,7 @@ const InvoicePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoice Totals */}
|
||||
{}
|
||||
<div className="flex justify-end mb-8">
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className="space-y-2">
|
||||
@@ -300,7 +300,7 @@ const InvoicePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes & Terms */}
|
||||
{}
|
||||
{(invoice.notes || invoice.terms_and_conditions || invoice.payment_instructions) && (
|
||||
<div className="mt-8 pt-8 border-t-2 border-gray-200 space-y-4">
|
||||
{invoice.notes && (
|
||||
@@ -324,7 +324,7 @@ const InvoicePage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
{}
|
||||
<div className="mt-8 pt-8 border-t border-gray-200 text-center text-sm text-gray-500">
|
||||
<p>Thank you for your business!</p>
|
||||
{invoice.company_name && (
|
||||
|
||||
@@ -44,7 +44,7 @@ const MyBookingsPage: React.FC = () => {
|
||||
const [statusFilter, setStatusFilter] =
|
||||
useState<string>('all');
|
||||
|
||||
// Redirect if not authenticated
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
toast.error('Please login to view your bookings');
|
||||
@@ -54,25 +54,25 @@ const MyBookingsPage: React.FC = () => {
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
// Fetch bookings
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
fetchBookings();
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Filter bookings
|
||||
|
||||
useEffect(() => {
|
||||
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(
|
||||
@@ -143,7 +143,7 @@ const MyBookingsPage: React.FC = () => {
|
||||
`✅ Successfully cancelled booking ${bookingNumber}!`
|
||||
);
|
||||
|
||||
// Update local state
|
||||
|
||||
setBookings((prev) =>
|
||||
prev.map((b) =>
|
||||
b.id === bookingId
|
||||
@@ -169,7 +169,7 @@ const MyBookingsPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
// Use parseDateLocal to handle date strings correctly
|
||||
|
||||
const date = parseDateLocal(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
@@ -199,7 +199,7 @@ const MyBookingsPage: React.FC = () => {
|
||||
return {
|
||||
icon: XCircle,
|
||||
color: 'bg-red-100 text-red-800',
|
||||
text: 'Cancelled',
|
||||
text: '❌ Canceled',
|
||||
};
|
||||
case 'checked_in':
|
||||
return {
|
||||
@@ -223,7 +223,7 @@ const MyBookingsPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const canCancelBooking = (booking: Booking) => {
|
||||
// Only allow cancellation of pending bookings
|
||||
|
||||
return booking.status === 'pending';
|
||||
};
|
||||
|
||||
@@ -234,7 +234,7 @@ const MyBookingsPage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
{/* Header */}
|
||||
{}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900
|
||||
mb-2"
|
||||
@@ -246,12 +246,12 @@ const MyBookingsPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{}
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-4 mb-6"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{/* Search */}
|
||||
{}
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search
|
||||
@@ -274,7 +274,7 @@ const MyBookingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
{}
|
||||
<div className="md:w-64">
|
||||
<div className="relative">
|
||||
<Filter
|
||||
@@ -308,14 +308,14 @@ const MyBookingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
{}
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Showing {filteredBookings.length} /
|
||||
{bookings.length} bookings
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{}
|
||||
{error && (
|
||||
<div
|
||||
className="bg-red-50 border border-red-200
|
||||
@@ -340,7 +340,7 @@ const MyBookingsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bookings List */}
|
||||
{}
|
||||
{filteredBookings.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Calendar}
|
||||
@@ -402,25 +402,25 @@ const MyBookingsPage: React.FC = () => {
|
||||
<div className="flex flex-col
|
||||
lg:flex-row gap-6"
|
||||
>
|
||||
{/* Room Image */}
|
||||
{((room?.images && room.images.length > 0)
|
||||
? room.images[0]
|
||||
{}
|
||||
{((room?.room_type?.images && room.room_type.images.length > 0)
|
||||
? room.room_type.images[0]
|
||||
: roomType?.images?.[0]) && (
|
||||
<div className="lg:w-48 flex-shrink-0">
|
||||
<img
|
||||
src={(room?.images && room.images.length > 0)
|
||||
? room.images[0]
|
||||
src={(room?.room_type?.images && room.room_type.images.length > 0)
|
||||
? room.room_type.images[0]
|
||||
: (roomType?.images?.[0] || '')}
|
||||
alt={roomType.name}
|
||||
alt={roomType?.name || 'Room'}
|
||||
className="w-full h-48 lg:h-full
|
||||
object-cover rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Booking Info */}
|
||||
{}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header */}
|
||||
{}
|
||||
<div className="flex items-start
|
||||
justify-between gap-4 mb-3"
|
||||
>
|
||||
@@ -442,7 +442,7 @@ const MyBookingsPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
{}
|
||||
<div
|
||||
className={`flex items-center
|
||||
gap-2 px-3 py-1.5 rounded-full
|
||||
@@ -456,11 +456,11 @@ const MyBookingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details Grid */}
|
||||
{}
|
||||
<div className="grid grid-cols-1
|
||||
sm:grid-cols-2 gap-3 mb-4"
|
||||
>
|
||||
{/* Booking Number */}
|
||||
{}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500
|
||||
mb-1"
|
||||
@@ -474,7 +474,7 @@ const MyBookingsPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Check-in */}
|
||||
{}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500
|
||||
mb-1"
|
||||
@@ -494,7 +494,7 @@ const MyBookingsPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Check-out */}
|
||||
{}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500
|
||||
mb-1"
|
||||
@@ -514,7 +514,7 @@ const MyBookingsPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Guest Count */}
|
||||
{}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500
|
||||
mb-1"
|
||||
@@ -532,7 +532,7 @@ const MyBookingsPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
{}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500
|
||||
mb-1"
|
||||
@@ -552,7 +552,7 @@ const MyBookingsPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Total Price */}
|
||||
{}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500
|
||||
mb-1"
|
||||
@@ -594,11 +594,11 @@ const MyBookingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{}
|
||||
<div className="flex flex-wrap gap-3
|
||||
pt-4 border-t"
|
||||
>
|
||||
{/* View Details */}
|
||||
{}
|
||||
<Link
|
||||
to={`/bookings/${booking.id}`}
|
||||
className="inline-flex items-center
|
||||
@@ -612,7 +612,7 @@ const MyBookingsPage: React.FC = () => {
|
||||
View details
|
||||
</Link>
|
||||
|
||||
{/* Cancel Booking */}
|
||||
{}
|
||||
{canCancelBooking(booking) && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -23,7 +23,7 @@ const PayPalCancelPage: React.FC = () => {
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error canceling payment:', err);
|
||||
// Don't show error toast - user already canceled, just log it
|
||||
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { capturePayPalPayment, cancelPayPalPayment } from '../../services/api/paymentService';
|
||||
import { toast } from 'react-toastify';
|
||||
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import Loading from '../../components/common/Loading';
|
||||
|
||||
const PayPalReturnPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -30,7 +29,7 @@ const PayPalReturnPage: React.FC = () => {
|
||||
if (response.success) {
|
||||
setSuccess(true);
|
||||
toast.success('Payment confirmed successfully!');
|
||||
// Redirect to booking details after a short delay
|
||||
|
||||
setTimeout(() => {
|
||||
navigate(`/bookings/${bookingId}`);
|
||||
}, 2000);
|
||||
@@ -38,7 +37,7 @@ const PayPalReturnPage: React.FC = () => {
|
||||
setError(response.message || 'Payment capture failed');
|
||||
toast.error(response.message || 'Payment capture failed');
|
||||
|
||||
// If payment capture fails, cancel the payment and booking
|
||||
|
||||
try {
|
||||
await cancelPayPalPayment(Number(bookingId));
|
||||
} catch (cancelErr) {
|
||||
|
||||
@@ -77,14 +77,14 @@ const PaymentConfirmationPage: React.FC = () => {
|
||||
) {
|
||||
const bookingData = response.data.booking;
|
||||
|
||||
// Check if already paid
|
||||
|
||||
if (bookingData.payment_status === 'paid') {
|
||||
toast.info('This booking has already been paid');
|
||||
navigate(`/bookings/${bookingId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if payment method is cash
|
||||
|
||||
if (bookingData.payment_method === 'cash') {
|
||||
toast.info(
|
||||
'This booking uses on-site payment method'
|
||||
@@ -176,7 +176,7 @@ const PaymentConfirmationPage: React.FC = () => {
|
||||
);
|
||||
setUploadSuccess(true);
|
||||
|
||||
// Redirect after 2 seconds
|
||||
|
||||
setTimeout(() => {
|
||||
navigate(`/bookings/${booking.id}`);
|
||||
}, 2000);
|
||||
@@ -238,7 +238,7 @@ const PaymentConfirmationPage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
{/* Back Button */}
|
||||
{}
|
||||
<Link
|
||||
to={`/bookings/${booking.id}`}
|
||||
className="inline-flex items-center gap-2
|
||||
@@ -249,7 +249,7 @@ const PaymentConfirmationPage: React.FC = () => {
|
||||
<span>Back to booking details</span>
|
||||
</Link>
|
||||
|
||||
{/* Page Title */}
|
||||
{}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900
|
||||
mb-2"
|
||||
@@ -261,7 +261,7 @@ const PaymentConfirmationPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Booking Info Card */}
|
||||
{}
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6 mb-6"
|
||||
>
|
||||
@@ -321,7 +321,7 @@ const PaymentConfirmationPage: React.FC = () => {
|
||||
|
||||
{!uploadSuccess ? (
|
||||
<>
|
||||
{/* Bank Transfer Instructions */}
|
||||
{}
|
||||
<div
|
||||
className="bg-blue-50 border border-blue-200
|
||||
rounded-lg p-6 mb-6"
|
||||
@@ -341,7 +341,7 @@ const PaymentConfirmationPage: React.FC = () => {
|
||||
<div className="grid grid-cols-1
|
||||
md:grid-cols-2 gap-4"
|
||||
>
|
||||
{/* Bank Info */}
|
||||
{}
|
||||
<div className="bg-white rounded-lg
|
||||
p-4 space-y-2 text-sm"
|
||||
>
|
||||
@@ -375,7 +375,7 @@ const PaymentConfirmationPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* QR Code */}
|
||||
{}
|
||||
<div className="bg-white rounded-lg
|
||||
p-4 flex flex-col items-center"
|
||||
>
|
||||
@@ -396,7 +396,7 @@ const PaymentConfirmationPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Receipt Section */}
|
||||
{}
|
||||
<div className="bg-white rounded-lg shadow-md
|
||||
p-6 mb-6"
|
||||
>
|
||||
@@ -413,7 +413,7 @@ const PaymentConfirmationPage: React.FC = () => {
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* File Input */}
|
||||
{}
|
||||
<label
|
||||
htmlFor="receipt-upload"
|
||||
className="block w-full px-4 py-6
|
||||
@@ -427,51 +427,7 @@ const PaymentConfirmationPage: React.FC = () => {
|
||||
<input
|
||||
id="receipt-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{previewUrl ? (
|
||||
<div className="flex flex-col
|
||||
items-center gap-3"
|
||||
>
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
className="w-48 h-48 object-cover
|
||||
rounded-lg border-2
|
||||
border-indigo-200"
|
||||
/>
|
||||
<p className="text-sm text-indigo-600
|
||||
font-medium"
|
||||
>
|
||||
{selectedFile?.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Click to select another image
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col
|
||||
items-center gap-2"
|
||||
>
|
||||
<FileText
|
||||
className="w-12 h-12 text-gray-400"
|
||||
/>
|
||||
<p className="text-sm text-gray-600
|
||||
font-medium"
|
||||
>
|
||||
Click to select receipt image
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
PNG, JPG, JPEG (Max 5MB)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* Upload Button */}
|
||||
accept="image}
|
||||
{selectedFile && (
|
||||
<button
|
||||
onClick={handleConfirmPayment}
|
||||
|
||||
@@ -16,7 +16,7 @@ const PaymentResultPage: React.FC = () => {
|
||||
const [countdown, setCountdown] = useState(10);
|
||||
const { settings } = useCompanySettings();
|
||||
|
||||
// Get email and phone from centralized company settings
|
||||
|
||||
const supportEmail = settings.company_email || 'support@hotel.com';
|
||||
const supportPhone = settings.company_phone || '1900 xxxx';
|
||||
|
||||
@@ -107,12 +107,12 @@ const PaymentResultPage: React.FC = () => {
|
||||
className={`${content.bgColor} border-2
|
||||
${content.borderColor} rounded-lg p-8`}
|
||||
>
|
||||
{/* Icon */}
|
||||
{}
|
||||
<div className="flex justify-center mb-6">
|
||||
{content.icon}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
{}
|
||||
<h1
|
||||
className={`text-3xl font-bold text-center
|
||||
mb-4 ${content.textColor}`}
|
||||
@@ -120,12 +120,12 @@ const PaymentResultPage: React.FC = () => {
|
||||
{content.title}
|
||||
</h1>
|
||||
|
||||
{/* Description */}
|
||||
{}
|
||||
<p className="text-center text-gray-700 mb-6">
|
||||
{content.description}
|
||||
</p>
|
||||
|
||||
{/* Transaction Details */}
|
||||
{}
|
||||
{status === 'success' && transactionId && (
|
||||
<div
|
||||
className="bg-white border border-gray-200
|
||||
@@ -154,7 +154,7 @@ const PaymentResultPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto redirect notice for success */}
|
||||
{}
|
||||
{status === 'success' && bookingId && countdown > 0 && (
|
||||
<div className="text-center mb-6">
|
||||
<div className="flex items-center justify-center gap-2
|
||||
@@ -168,7 +168,7 @@ const PaymentResultPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
{}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{status === 'success' && bookingId ? (
|
||||
<>
|
||||
@@ -230,7 +230,7 @@ const PaymentResultPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Support Notice */}
|
||||
{}
|
||||
<div className="mt-6 text-center text-sm text-gray-600">
|
||||
<p>
|
||||
If you have any issues, please contact{' '}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Mail,
|
||||
Phone,
|
||||
Save,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Lock,
|
||||
@@ -29,7 +28,6 @@ import { useAsync } from '../../hooks/useAsync';
|
||||
import { useGlobalLoading } from '../../contexts/GlobalLoadingContext';
|
||||
import { normalizeImageUrl } from '../../utils/imageUtils';
|
||||
|
||||
// Validation schema
|
||||
const profileValidationSchema = yup.object().shape({
|
||||
name: yup
|
||||
.string()
|
||||
@@ -81,7 +79,7 @@ const ProfilePage: React.FC = () => {
|
||||
confirm: false,
|
||||
});
|
||||
|
||||
// MFA state
|
||||
|
||||
const [mfaStatus, setMfaStatus] = useState<{mfa_enabled: boolean; backup_codes_count: number} | null>(null);
|
||||
const [mfaSecret, setMfaSecret] = useState<string | null>(null);
|
||||
const [mfaQrCode, setMfaQrCode] = useState<string | null>(null);
|
||||
@@ -89,13 +87,13 @@ const ProfilePage: React.FC = () => {
|
||||
const [showBackupCodes, setShowBackupCodes] = useState<string[] | null>(null);
|
||||
const [showMfaSecret, setShowMfaSecret] = useState<boolean>(false);
|
||||
|
||||
// Fetch profile data
|
||||
|
||||
const fetchProfile = async () => {
|
||||
const response = await authService.getProfile();
|
||||
if (response.status === 'success' || response.success) {
|
||||
const user = response.data?.user || response.data;
|
||||
if (user) {
|
||||
setUser(user);
|
||||
setUser(user as any);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -114,7 +112,7 @@ const ProfilePage: React.FC = () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Profile form
|
||||
|
||||
const {
|
||||
register: registerProfile,
|
||||
handleSubmit: handleSubmitProfile,
|
||||
@@ -123,13 +121,13 @@ const ProfilePage: React.FC = () => {
|
||||
} = useForm<ProfileFormData>({
|
||||
resolver: yupResolver(profileValidationSchema),
|
||||
defaultValues: {
|
||||
name: userInfo?.name || '',
|
||||
email: userInfo?.email || '',
|
||||
phone: userInfo?.phone || '',
|
||||
name: ((userInfo && 'user' in userInfo ? userInfo.user : userInfo) as any)?.name || '',
|
||||
email: ((userInfo && 'user' in userInfo ? userInfo.user : userInfo) as any)?.email || '',
|
||||
phone: ((userInfo && 'user' in userInfo ? userInfo.user : userInfo) as any)?.phone || '',
|
||||
},
|
||||
});
|
||||
|
||||
// Password form
|
||||
|
||||
const {
|
||||
register: registerPassword,
|
||||
handleSubmit: handleSubmitPassword,
|
||||
@@ -139,11 +137,11 @@ const ProfilePage: React.FC = () => {
|
||||
resolver: yupResolver(passwordValidationSchema),
|
||||
});
|
||||
|
||||
// Fetch MFA status
|
||||
|
||||
const fetchMFAStatus = async () => {
|
||||
try {
|
||||
const response = await authService.getMFAStatus();
|
||||
// Response is now directly the status data, not wrapped in data
|
||||
|
||||
if (response) {
|
||||
setMfaStatus({
|
||||
mfa_enabled: response.mfa_enabled || false,
|
||||
@@ -155,33 +153,33 @@ const ProfilePage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Update form when profile data loads
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData || userInfo) {
|
||||
const data = profileData || userInfo;
|
||||
const data = profileData || (userInfo && 'user' in userInfo ? userInfo.user : userInfo);
|
||||
resetProfile({
|
||||
name: data?.name || '',
|
||||
email: data?.email || '',
|
||||
phone: data?.phone || '',
|
||||
name: (data as any)?.name || '',
|
||||
email: (data as any)?.email || '',
|
||||
phone: (data as any)?.phone || '',
|
||||
});
|
||||
if (data?.avatar) {
|
||||
// Normalize avatar URL when loading
|
||||
setAvatarPreview(normalizeImageUrl(data.avatar));
|
||||
if ((data as any)?.avatar) {
|
||||
|
||||
setAvatarPreview(normalizeImageUrl((data as any).avatar));
|
||||
} else {
|
||||
setAvatarPreview(null);
|
||||
}
|
||||
}
|
||||
}, [profileData, userInfo, resetProfile]);
|
||||
|
||||
// Fetch MFA status when MFA tab is active
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'mfa') {
|
||||
fetchMFAStatus();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
}, [activeTab]);
|
||||
|
||||
// Handle profile update
|
||||
|
||||
const onSubmitProfile = async (data: ProfileFormData) => {
|
||||
try {
|
||||
setLoading(true, 'Updating profile...');
|
||||
@@ -203,21 +201,22 @@ const ProfilePage: React.FC = () => {
|
||||
}
|
||||
} else {
|
||||
const { updateUser } = await import('../../services/api/userService');
|
||||
const response = await updateUser(userInfo!.id, {
|
||||
const user = userInfo && 'user' in userInfo ? userInfo.user : userInfo;
|
||||
const response = await updateUser((user as any)?.id || (userInfo as any)?.id, {
|
||||
full_name: data.name,
|
||||
email: data.email,
|
||||
phone_number: data.phone,
|
||||
});
|
||||
|
||||
if (response.success || response.status === 'success') {
|
||||
if ((response as any).success || (response as any).status === 'success') {
|
||||
const updatedUser = response.data?.user || response.data;
|
||||
if (updatedUser) {
|
||||
setUser({
|
||||
id: updatedUser.id,
|
||||
name: updatedUser.full_name || updatedUser.name,
|
||||
name: (updatedUser as any).full_name || (updatedUser as any).name,
|
||||
email: updatedUser.email,
|
||||
phone: updatedUser.phone_number || updatedUser.phone,
|
||||
avatar: updatedUser.avatar,
|
||||
phone: (updatedUser as any).phone_number || (updatedUser as any).phone,
|
||||
avatar: (updatedUser as any).avatar,
|
||||
role: updatedUser.role,
|
||||
});
|
||||
toast.success('Profile updated successfully!');
|
||||
@@ -236,7 +235,7 @@ const ProfilePage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle password change
|
||||
|
||||
const onSubmitPassword = async (data: PasswordFormData) => {
|
||||
try {
|
||||
setLoading(true, 'Changing password...');
|
||||
@@ -253,11 +252,12 @@ const ProfilePage: React.FC = () => {
|
||||
}
|
||||
} else {
|
||||
const { updateUser } = await import('../../services/api/userService');
|
||||
const response = await updateUser(userInfo!.id, {
|
||||
const user = userInfo && 'user' in userInfo ? userInfo.user : userInfo;
|
||||
const response = await updateUser((user as any)?.id || (userInfo as any)?.id, {
|
||||
password: data.newPassword,
|
||||
});
|
||||
|
||||
if (response.success || response.status === 'success') {
|
||||
if ((response as any).success || (response as any).status === 'success') {
|
||||
toast.success('Password changed successfully!');
|
||||
resetPassword();
|
||||
}
|
||||
@@ -273,44 +273,44 @@ const ProfilePage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle avatar upload
|
||||
|
||||
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 2MB)
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error('Image size must be less than 2MB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setAvatarPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Upload to backend
|
||||
|
||||
try {
|
||||
setLoading(true, 'Uploading avatar...');
|
||||
const response = await authService.uploadAvatar(file);
|
||||
|
||||
if (response.status === 'success' && response.data?.user) {
|
||||
const updatedUser = response.data.user;
|
||||
// Use full_url if available, otherwise normalize the avatar URL
|
||||
const avatarUrl = response.data.full_url || normalizeImageUrl(updatedUser.avatar);
|
||||
|
||||
const avatarUrl = (response.data as any).full_url || normalizeImageUrl((updatedUser as any).avatar);
|
||||
setUser({
|
||||
id: updatedUser.id,
|
||||
name: updatedUser.name,
|
||||
name: (updatedUser as any).name || (updatedUser as any).full_name,
|
||||
email: updatedUser.email,
|
||||
phone: updatedUser.phone,
|
||||
phone: (updatedUser as any).phone || (updatedUser as any).phone_number,
|
||||
avatar: avatarUrl,
|
||||
role: updatedUser.role,
|
||||
});
|
||||
@@ -324,11 +324,11 @@ const ProfilePage: React.FC = () => {
|
||||
error.message ||
|
||||
'Failed to upload avatar';
|
||||
toast.error(errorMessage);
|
||||
// Reset preview on error
|
||||
|
||||
setAvatarPreview(userInfo?.avatar ? normalizeImageUrl(userInfo.avatar) : null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
// Reset file input
|
||||
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
@@ -355,7 +355,7 @@ 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">
|
||||
{/* Header */}
|
||||
{}
|
||||
<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
|
||||
@@ -365,7 +365,7 @@ const ProfilePage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
{}
|
||||
<div className="mb-4 sm:mb-6 border-b border-gray-200 overflow-x-auto">
|
||||
<div className="flex space-x-4 sm:space-x-8 min-w-max">
|
||||
<button
|
||||
@@ -404,11 +404,11 @@ const ProfilePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile Tab */}
|
||||
{}
|
||||
{activeTab === 'profile' && (
|
||||
<div className="luxury-glass rounded-sm p-4 sm:p-6 lg:p-8 border border-[#d4af37]/20 shadow-2xl animate-slide-up">
|
||||
<form onSubmit={handleSubmitProfile(onSubmitProfile)} className="space-y-5 sm:space-y-6">
|
||||
{/* Avatar Section */}
|
||||
{}
|
||||
<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 ? (
|
||||
@@ -431,23 +431,13 @@ const ProfilePage: React.FC = () => {
|
||||
id="avatar-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleAvatarChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="text-center sm:text-left flex-1">
|
||||
<h3 className="text-lg sm:text-xl font-semibold text-gray-900 mb-1">
|
||||
{userInfo?.name || 'User'}
|
||||
</h3>
|
||||
<p className="text-sm sm:text-base text-gray-500 mb-1">{userInfo?.email}</p>
|
||||
<p className="text-xs sm:text-sm text-[#d4af37] font-medium tracking-wide uppercase">
|
||||
{userInfo?.role?.charAt(0).toUpperCase() + userInfo?.role?.slice(1)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full Name */}
|
||||
<div>
|
||||
<label className="block text-sm sm:text-base font-medium text-gray-700 mb-2">
|
||||
<User className="w-4 h-4 sm:w-5 sm:h-5 inline mr-2 text-[#d4af37]" />
|
||||
@@ -470,7 +460,7 @@ const ProfilePage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
{}
|
||||
<div>
|
||||
<label className="block text-sm sm:text-base font-medium text-gray-700 mb-2">
|
||||
<Mail className="w-4 h-4 sm:w-5 sm:h-5 inline mr-2 text-[#d4af37]" />
|
||||
@@ -493,7 +483,7 @@ const ProfilePage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
{}
|
||||
<div>
|
||||
<label className="block text-sm sm:text-base font-medium text-gray-700 mb-2">
|
||||
<Phone className="w-4 h-4 sm:w-5 sm:h-5 inline mr-2 text-[#d4af37]" />
|
||||
@@ -516,7 +506,7 @@ const ProfilePage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
{}
|
||||
<div className="flex justify-end pt-4 sm:pt-5 border-t border-gray-200">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -531,11 +521,11 @@ const ProfilePage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password Tab */}
|
||||
{}
|
||||
{activeTab === 'password' && (
|
||||
<div className="luxury-glass rounded-sm p-4 sm:p-6 lg:p-8 border border-[#d4af37]/20 shadow-2xl animate-slide-up">
|
||||
<form onSubmit={handleSubmitPassword(onSubmitPassword)} className="space-y-5 sm:space-y-6">
|
||||
{/* Info Banner */}
|
||||
{}
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200/60 rounded-sm p-4 sm:p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-1.5 sm:p-2 bg-blue-100 rounded-full flex-shrink-0">
|
||||
@@ -559,7 +549,7 @@ const ProfilePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Password */}
|
||||
{}
|
||||
<div>
|
||||
<label className="block text-sm sm:text-base font-medium text-gray-700 mb-2">
|
||||
<Lock className="w-4 h-4 sm:w-5 sm:h-5 inline mr-2 text-[#d4af37]" />
|
||||
@@ -595,7 +585,7 @@ const ProfilePage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Password */}
|
||||
{}
|
||||
<div>
|
||||
<label className="block text-sm sm:text-base font-medium text-gray-700 mb-2">
|
||||
<Lock className="w-4 h-4 sm:w-5 sm:h-5 inline mr-2 text-[#d4af37]" />
|
||||
@@ -631,7 +621,7 @@ const ProfilePage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
{}
|
||||
<div>
|
||||
<label className="block text-sm sm:text-base font-medium text-gray-700 mb-2">
|
||||
<Lock className="w-4 h-4 sm:w-5 sm:h-5 inline mr-2 text-[#d4af37]" />
|
||||
@@ -667,7 +657,7 @@ const ProfilePage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
{}
|
||||
<div className="flex justify-end pt-4 sm:pt-5 border-t border-gray-200">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -682,10 +672,10 @@ const ProfilePage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MFA Tab */}
|
||||
{}
|
||||
{activeTab === 'mfa' && (
|
||||
<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">
|
||||
{/* Header */}
|
||||
{}
|
||||
<div>
|
||||
<h2 className="text-xl sm:text-2xl font-serif font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 sm:w-6 sm:h-6 text-[#d4af37]" />
|
||||
@@ -697,9 +687,9 @@ const ProfilePage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{mfaStatus?.mfa_enabled ? (
|
||||
/* MFA Enabled State */
|
||||
|
||||
<div className="space-y-5 sm:space-y-6">
|
||||
{/* Status Card */}
|
||||
{}
|
||||
<div className="bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200/60 rounded-sm p-4 sm:p-5 shadow-lg">
|
||||
<div className="flex items-start sm:items-center gap-3 sm:gap-4">
|
||||
<div className="p-2 sm:p-2.5 bg-green-100 rounded-full flex-shrink-0">
|
||||
@@ -717,7 +707,7 @@ const ProfilePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backup Codes */}
|
||||
{}
|
||||
{showBackupCodes && (
|
||||
<div className="bg-gradient-to-r from-yellow-50 to-amber-50 border border-yellow-200/60 rounded-sm p-4 sm:p-5 shadow-lg">
|
||||
<div className="mb-4">
|
||||
@@ -757,7 +747,7 @@ const ProfilePage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Regenerate Backup Codes */}
|
||||
{}
|
||||
<div className="bg-white border border-gray-200 rounded-sm p-4 sm:p-5">
|
||||
<h3 className="text-sm sm:text-base font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
||||
<RefreshCw className="w-4 h-4 sm:w-5 sm:h-5 text-[#d4af37]" />
|
||||
@@ -789,7 +779,7 @@ const ProfilePage: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Disable MFA */}
|
||||
{}
|
||||
<div className="border-t border-gray-200 pt-5 sm:pt-6">
|
||||
<h3 className="text-sm sm:text-base font-semibold text-red-900 mb-2 flex items-center gap-2">
|
||||
<ShieldOff className="w-4 h-4 sm:w-5 sm:h-5 text-red-600" />
|
||||
@@ -824,10 +814,10 @@ const ProfilePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* MFA Setup Flow */
|
||||
|
||||
<div className="space-y-5 sm:space-y-6">
|
||||
{!mfaSecret ? (
|
||||
/* Step 1: Initialize MFA */
|
||||
|
||||
<div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
@@ -852,9 +842,9 @@ const ProfilePage: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Step 2: Verify and Enable */
|
||||
|
||||
<div className="space-y-5 sm:space-y-6">
|
||||
{/* QR Code Section */}
|
||||
{}
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200/60 rounded-sm p-4 sm:p-5 shadow-lg">
|
||||
<h3 className="font-semibold text-blue-900 mb-2 text-sm sm:text-base flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
@@ -903,7 +893,7 @@ const ProfilePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verification Section */}
|
||||
{}
|
||||
<div className="bg-white border border-gray-200 rounded-sm p-4 sm:p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-2 text-sm sm:text-base flex items-center gap-2">
|
||||
<KeyRound className="w-4 h-4 sm:w-5 sm:h-5 text-[#d4af37]" />
|
||||
|
||||
@@ -15,14 +15,15 @@ import { getRoomByNumber, type Room } from
|
||||
import RoomGallery from '../../components/rooms/RoomGallery';
|
||||
import RoomAmenities from '../../components/rooms/RoomAmenities';
|
||||
import ReviewSection from '../../components/rooms/ReviewSection';
|
||||
import RatingStars from '../../components/rooms/RatingStars';
|
||||
import CurrencyIcon from '../../components/common/CurrencyIcon';
|
||||
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
|
||||
const RoomDetailPage: React.FC = () => {
|
||||
const { room_number } = useParams<{ room_number: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const { userInfo } = useAuthStore();
|
||||
const [room, setRoom] = useState<Room | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -39,12 +40,12 @@ const RoomDetailPage: React.FC = () => {
|
||||
setError(null);
|
||||
const response = await getRoomByNumber(roomNumber);
|
||||
|
||||
// backend uses `status: 'success'` (not `success`), accept both
|
||||
|
||||
if ((response as any).success || (response as any).status === 'success') {
|
||||
if (response.data && response.data.room) {
|
||||
const fetchedRoom = response.data.room;
|
||||
|
||||
// Verify the room number matches what we requested
|
||||
|
||||
if (fetchedRoom.room_number !== roomNumber) {
|
||||
console.error(`Room number mismatch: requested ${roomNumber}, got ${fetchedRoom.room_number}`);
|
||||
throw new Error(`Room data mismatch: expected room number ${roomNumber} but got ${fetchedRoom.room_number}`);
|
||||
@@ -147,7 +148,7 @@ const RoomDetailPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<div className="w-full px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4">
|
||||
{/* Back Button */}
|
||||
{}
|
||||
<Link
|
||||
to="/rooms"
|
||||
className="inline-flex items-center gap-1
|
||||
@@ -159,7 +160,7 @@ const RoomDetailPage: React.FC = () => {
|
||||
<span>Back to room list</span>
|
||||
</Link>
|
||||
|
||||
{/* Image Gallery */}
|
||||
{}
|
||||
<div className="mb-4">
|
||||
<RoomGallery
|
||||
images={(room.images && room.images.length > 0)
|
||||
@@ -169,13 +170,13 @@ const RoomDetailPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Room Information */}
|
||||
{}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-3 sm:gap-4 lg:gap-6 mb-4 sm:mb-5">
|
||||
{/* Main Info */}
|
||||
{}
|
||||
<div className="lg:col-span-8 space-y-3 sm:space-y-4">
|
||||
{/* Title & Basic Info */}
|
||||
{}
|
||||
<div className="space-y-3">
|
||||
{/* Room Name with Luxury Badge */}
|
||||
{}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
@@ -220,7 +221,7 @@ const RoomDetailPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Info Grid */}
|
||||
{}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 sm:gap-3 mb-3">
|
||||
<div className="flex items-center gap-2
|
||||
p-2 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
@@ -287,7 +288,7 @@ const RoomDetailPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description - Show room-specific description first, fallback to room type */}
|
||||
{}
|
||||
{(room?.description || roomType?.description) && (
|
||||
<div className="p-3 sm:p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
rounded-lg border border-[#d4af37]/20
|
||||
@@ -312,7 +313,7 @@ const RoomDetailPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Amenities */}
|
||||
{}
|
||||
<div className="p-3 sm:p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
rounded-lg border border-[#d4af37]/20
|
||||
backdrop-blur-xl shadow-lg shadow-[#d4af37]/5"
|
||||
@@ -338,14 +339,14 @@ const RoomDetailPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Booking Card */}
|
||||
{}
|
||||
<aside className="lg:col-span-4">
|
||||
<div className="bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a]
|
||||
rounded-lg border border-[#d4af37]/30
|
||||
backdrop-blur-xl shadow-lg shadow-[#d4af37]/20
|
||||
p-3 sm:p-4 sticky top-4"
|
||||
>
|
||||
{/* Price Section */}
|
||||
{}
|
||||
<div className="mb-4 pb-4 border-b border-[#d4af37]/20">
|
||||
<p className="text-[10px] sm:text-xs text-gray-400 font-light tracking-wide mb-1">
|
||||
Starting from
|
||||
@@ -366,44 +367,48 @@ const RoomDetailPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Booking Button */}
|
||||
<div className="mb-3">
|
||||
<Link
|
||||
to={`/booking/${room.id}`}
|
||||
className={`block w-full py-2 text-center
|
||||
font-medium rounded-sm transition-all duration-300
|
||||
tracking-wide relative overflow-hidden group text-xs sm:text-sm
|
||||
${
|
||||
room.status === 'available'
|
||||
? 'bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] hover:from-[#f5d76e] hover:to-[#d4af37] shadow-sm shadow-[#d4af37]/30 hover:shadow-[#d4af37]/50'
|
||||
: 'bg-gray-800 text-gray-500 cursor-not-allowed border border-gray-700'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (room.status !== 'available') e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<span className="relative z-10 flex items-center justify-center gap-1.5">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
{room.status === 'available' ? 'Book Now' : 'Not Available'}
|
||||
</span>
|
||||
{room.status === 'available' && (
|
||||
<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>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
{}
|
||||
{userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<Link
|
||||
to={`/booking/${room.id}`}
|
||||
className={`block w-full py-2 text-center
|
||||
font-medium rounded-sm transition-all duration-300
|
||||
tracking-wide relative overflow-hidden group text-xs sm:text-sm
|
||||
${
|
||||
room.status === 'available'
|
||||
? 'bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] hover:from-[#f5d76e] hover:to-[#d4af37] shadow-sm shadow-[#d4af37]/30 hover:shadow-[#d4af37]/50'
|
||||
: 'bg-gray-800 text-gray-500 cursor-not-allowed border border-gray-700'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (room.status !== 'available') e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<span className="relative z-10 flex items-center justify-center gap-1.5">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
{room.status === 'available' ? 'Book Now' : 'Not Available'}
|
||||
</span>
|
||||
{room.status === 'available' && (
|
||||
<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>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{room.status === 'available' && (
|
||||
<div className="flex items-start gap-2 p-2 bg-[#d4af37]/5
|
||||
rounded-lg border border-[#d4af37]/20 mb-3"
|
||||
>
|
||||
<Shield className="w-3.5 h-3.5 text-[#d4af37] mt-0.5 flex-shrink-0" />
|
||||
<p className="text-[10px] sm:text-xs text-gray-300 font-light tracking-wide leading-relaxed">
|
||||
No immediate charge — secure your booking now and pay at the hotel
|
||||
</p>
|
||||
</div>
|
||||
{room.status === 'available' && (
|
||||
<div className="flex items-start gap-2 p-2 bg-[#d4af37]/5
|
||||
rounded-lg border border-[#d4af37]/20 mb-3"
|
||||
>
|
||||
<Shield className="w-3.5 h-3.5 text-[#d4af37] mt-0.5 flex-shrink-0" />
|
||||
<p className="text-[10px] sm:text-xs text-gray-300 font-light tracking-wide leading-relaxed">
|
||||
No immediate charge — secure your booking now and pay at the hotel
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Room Details */}
|
||||
{}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between
|
||||
py-1.5 border-b border-[#d4af37]/10"
|
||||
@@ -436,7 +441,7 @@ const RoomDetailPage: React.FC = () => {
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Reviews Section */}
|
||||
{}
|
||||
<div className="mb-4 p-3 sm:p-4 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a]
|
||||
rounded-lg border border-[#d4af37]/20
|
||||
backdrop-blur-xl shadow-lg shadow-[#d4af37]/5"
|
||||
|
||||
@@ -22,7 +22,7 @@ const RoomListPage: React.FC = () => {
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
// Scroll to filter when opened on mobile
|
||||
|
||||
useEffect(() => {
|
||||
if (isFilterOpen && filterRef.current && window.innerWidth < 1280) {
|
||||
setTimeout(() => {
|
||||
@@ -31,7 +31,7 @@ const RoomListPage: React.FC = () => {
|
||||
}
|
||||
}, [isFilterOpen]);
|
||||
|
||||
// Fetch rooms based on URL params
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRooms = async () => {
|
||||
setLoading(true);
|
||||
@@ -78,10 +78,10 @@ const RoomListPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-full" style={{ width: '100vw', position: 'relative', left: '50%', right: '50%', marginLeft: '-50vw', marginRight: '-50vw', marginTop: '-1.5rem', marginBottom: '-1.5rem' }}>
|
||||
{/* Full-width hero section */}
|
||||
{}
|
||||
<div className="w-full bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a] border-b border-[#d4af37]/10 pt-6 sm:pt-7 md:pt-8">
|
||||
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20 py-6 sm:py-7 md:py-8 lg:py-10">
|
||||
{/* Back Button */}
|
||||
{}
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2
|
||||
@@ -98,7 +98,7 @@ const RoomListPage: React.FC = () => {
|
||||
<span>Back to home</span>
|
||||
</Link>
|
||||
|
||||
{/* Page Header */}
|
||||
{}
|
||||
<div className="text-center max-w-3xl mx-auto px-2">
|
||||
<div className="inline-flex items-center justify-center gap-2 mb-3 sm:mb-4">
|
||||
<div className="p-2 sm:p-2.5 bg-[#d4af37]/10 rounded-lg border border-[#d4af37]/30 backdrop-blur-sm">
|
||||
@@ -120,11 +120,11 @@ const RoomListPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full-width content area */}
|
||||
{}
|
||||
<div className="w-full py-4 sm:py-5 md:py-6 lg:py-8">
|
||||
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-12 gap-3 sm:gap-4 md:gap-5 lg:gap-6 xl:gap-7">
|
||||
{/* Mobile Filter Toggle Button */}
|
||||
{}
|
||||
<div className="xl:hidden order-1 mb-4">
|
||||
<button
|
||||
onClick={() => setIsFilterOpen(!isFilterOpen)}
|
||||
@@ -151,7 +151,7 @@ const RoomListPage: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter Sidebar - Collapsible on mobile, sidebar on desktop */}
|
||||
{}
|
||||
<aside
|
||||
ref={filterRef}
|
||||
className={`xl:col-span-3 order-2 xl:order-1 mb-4 sm:mb-5 md:mb-6 xl:mb-0 transition-all duration-300 ${
|
||||
@@ -163,7 +163,7 @@ const RoomListPage: React.FC = () => {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content - Full width on mobile, 9 columns on desktop */}
|
||||
{}
|
||||
<main className="xl:col-span-9 order-3 xl:order-2">
|
||||
{loading && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3
|
||||
@@ -245,7 +245,7 @@ const RoomListPage: React.FC = () => {
|
||||
|
||||
{!loading && !error && rooms.length > 0 && (
|
||||
<>
|
||||
{/* Results Count */}
|
||||
{}
|
||||
<div className="mb-3 sm:mb-4 md:mb-5 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 sm:gap-3">
|
||||
<p className="text-gray-400 font-light tracking-wide text-xs sm:text-sm md:text-base">
|
||||
Showing <span className="text-[#d4af37] font-medium">{rooms.length}</span> of{' '}
|
||||
@@ -253,7 +253,7 @@ const RoomListPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Responsive grid: 1 col mobile, 2 cols tablet, 3 cols desktop */}
|
||||
{}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3
|
||||
gap-3 sm:gap-4 md:gap-5 lg:gap-6 mb-4 sm:mb-5 md:mb-6"
|
||||
>
|
||||
|
||||
@@ -37,7 +37,7 @@ const SearchResultsPage: React.FC = () => {
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
// Get search params
|
||||
|
||||
const from = searchParams.get('from') || '';
|
||||
const to = searchParams.get('to') || '';
|
||||
const type = searchParams.get('type') || '';
|
||||
@@ -47,7 +47,7 @@ const SearchResultsPage: React.FC = () => {
|
||||
const page = pageParam ? Number(pageParam) : 1;
|
||||
|
||||
useEffect(() => {
|
||||
// Validate required params
|
||||
|
||||
if (!from || !to) {
|
||||
toast.error(
|
||||
'Missing search information. ' +
|
||||
@@ -82,7 +82,7 @@ const SearchResultsPage: React.FC = () => {
|
||||
if (response.data.pagination) {
|
||||
setPagination(response.data.pagination);
|
||||
} else {
|
||||
// Fallback compute
|
||||
|
||||
const total = response.data.rooms
|
||||
? response.data.rooms.length
|
||||
: 0;
|
||||
@@ -121,7 +121,7 @@ const SearchResultsPage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
{/* Back Button */}
|
||||
{}
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2 bg-indigo-600
|
||||
@@ -132,7 +132,7 @@ const SearchResultsPage: React.FC = () => {
|
||||
<span>Back to home</span>
|
||||
</Link>
|
||||
|
||||
{/* Search Info Header */}
|
||||
{}
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-sm
|
||||
p-6 mb-8"
|
||||
@@ -214,7 +214,7 @@ const SearchResultsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{}
|
||||
{loading && (
|
||||
<div>
|
||||
<p
|
||||
@@ -234,7 +234,7 @@ const SearchResultsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{}
|
||||
{error && !loading && (
|
||||
<div
|
||||
className="bg-red-50 border border-red-200
|
||||
@@ -258,7 +258,7 @@ const SearchResultsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{}
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{rooms.length > 0 ? (
|
||||
@@ -285,7 +285,7 @@ const SearchResultsPage: React.FC = () => {
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
// Empty State
|
||||
|
||||
<div
|
||||
className="bg-white rounded-lg
|
||||
shadow-sm p-12 text-center"
|
||||
|
||||
537
Frontend/src/pages/staff/ChatManagementPage.tsx
Normal file
537
Frontend/src/pages/staff/ChatManagementPage.tsx
Normal file
@@ -0,0 +1,537 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { MessageCircle, CheckCircle, XCircle, Send, Clock, User, X } from 'lucide-react';
|
||||
import { chatService, type Chat, type ChatMessage } from '../../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Loading, EmptyState } from '../../components/common';
|
||||
import { formatDate } from '../../utils/format';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import { useChatNotifications } from '../../contexts/ChatNotificationContext';
|
||||
|
||||
const ChatManagementPage: React.FC = () => {
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const [selectedChat, setSelectedChat] = useState<Chat | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||
const [notificationWs, setNotificationWs] = useState<WebSocket | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const authStore = useAuthStore();
|
||||
const token = authStore.token || localStorage.getItem('token');
|
||||
const { refreshCount } = useChatNotifications();
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchChats();
|
||||
connectNotificationWebSocket();
|
||||
if (refreshCount) {
|
||||
refreshCount();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (ws) ws.close();
|
||||
if (notificationWs) notificationWs.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedChat) {
|
||||
fetchMessages(selectedChat.id);
|
||||
connectChatWebSocket(selectedChat.id);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
setWs(null);
|
||||
}
|
||||
};
|
||||
}, [selectedChat]);
|
||||
|
||||
const connectNotificationWebSocket = () => {
|
||||
if (!token) return;
|
||||
|
||||
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
const normalizedBase = baseUrl.replace(/\/$/, '');
|
||||
const wsProtocol = normalizedBase.startsWith('https') ? 'wss' : 'ws';
|
||||
const wsBase = normalizedBase.replace(/^https?/, wsProtocol);
|
||||
const wsUrl = `${wsBase}/api/chat/ws/staff/notifications?token=${encodeURIComponent(token)}`;
|
||||
|
||||
const websocket = new WebSocket(wsUrl);
|
||||
|
||||
websocket.onopen = () => {
|
||||
console.log('Notification WebSocket connected');
|
||||
};
|
||||
|
||||
websocket.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'new_chat') {
|
||||
toast.info(`New chat from ${data.data.visitor_name}`, {
|
||||
onClick: () => {
|
||||
fetchChats().then(() => {
|
||||
const newChat = chats.find(c => c.id === data.data.id);
|
||||
if (newChat) setSelectedChat(newChat);
|
||||
});
|
||||
}
|
||||
});
|
||||
fetchChats();
|
||||
} else if (data.type === 'new_message_notification') {
|
||||
const chatData = data.data.chat;
|
||||
const messageData = data.data.message;
|
||||
toast.info(`New message from ${chatData.visitor_name}: ${messageData.message.substring(0, 50)}${messageData.message.length > 50 ? '...' : ''}`, {
|
||||
onClick: () => {
|
||||
fetchChats().then(() => {
|
||||
const chat = chats.find(c => c.id === chatData.id);
|
||||
if (chat) {
|
||||
setSelectedChat(chat);
|
||||
fetchMessages(chat.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
autoClose: 10000
|
||||
});
|
||||
fetchChats();
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
console.error('Notification WebSocket error:', error);
|
||||
};
|
||||
|
||||
websocket.onclose = () => {
|
||||
console.log('Notification WebSocket disconnected');
|
||||
setTimeout(() => {
|
||||
connectNotificationWebSocket();
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
setNotificationWs(websocket);
|
||||
};
|
||||
|
||||
const connectChatWebSocket = (chatId: number) => {
|
||||
if (!token) return;
|
||||
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
|
||||
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
const normalizedBase = baseUrl.replace(/\/$/, '');
|
||||
const wsProtocol = normalizedBase.startsWith('https') ? 'wss' : 'ws';
|
||||
const wsBase = normalizedBase.replace(/^https?/, wsProtocol);
|
||||
const wsUrl = `${wsBase}/api/chat/ws/${chatId}?user_type=staff&token=${encodeURIComponent(token)}`;
|
||||
|
||||
const websocket = new WebSocket(wsUrl);
|
||||
|
||||
websocket.onopen = () => {
|
||||
console.log('Chat WebSocket connected for chat', chatId);
|
||||
};
|
||||
|
||||
websocket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'new_message') {
|
||||
setMessages(prev => {
|
||||
|
||||
const exists = prev.find(m => m.id === data.data.id);
|
||||
if (exists) return prev;
|
||||
return [...prev, data.data];
|
||||
});
|
||||
} else if (data.type === 'chat_accepted') {
|
||||
|
||||
if (selectedChat && selectedChat.id === chatId) {
|
||||
setSelectedChat({ ...selectedChat, status: 'active' });
|
||||
}
|
||||
fetchChats();
|
||||
} else if (data.type === 'chat_closed') {
|
||||
toast.info('Chat has been closed');
|
||||
if (selectedChat) {
|
||||
setSelectedChat({ ...selectedChat, status: 'closed' });
|
||||
}
|
||||
fetchChats();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
console.error('Chat WebSocket error:', error);
|
||||
};
|
||||
|
||||
websocket.onclose = (event) => {
|
||||
console.log('Chat WebSocket disconnected', event.code, event.reason);
|
||||
|
||||
if (event.code !== 1000 && selectedChat && selectedChat.id === chatId) {
|
||||
console.log('Attempting to reconnect chat WebSocket...');
|
||||
setTimeout(() => {
|
||||
if (selectedChat && selectedChat.id === chatId) {
|
||||
connectChatWebSocket(chatId);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
setWs(websocket);
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedChat) return;
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const response = await chatService.getMessages(selectedChat.id);
|
||||
if (response.success) {
|
||||
setMessages(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling messages:', error);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(pollInterval);
|
||||
}, [selectedChat]);
|
||||
|
||||
const fetchChats = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await chatService.listChats();
|
||||
if (response.success) {
|
||||
setChats(response.data);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to load chats');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMessages = async (chatId: number) => {
|
||||
try {
|
||||
setLoadingMessages(true);
|
||||
const response = await chatService.getMessages(chatId);
|
||||
if (response.success) {
|
||||
setMessages(response.data);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to load messages');
|
||||
} finally {
|
||||
setLoadingMessages(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAcceptChat = async (chatId: number) => {
|
||||
try {
|
||||
const response = await chatService.acceptChat(chatId);
|
||||
if (response.success) {
|
||||
toast.success('Chat accepted');
|
||||
fetchChats();
|
||||
if (selectedChat?.id === chatId) {
|
||||
setSelectedChat(response.data);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to accept chat');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!newMessage.trim() || !selectedChat) return;
|
||||
|
||||
const messageText = newMessage.trim();
|
||||
setNewMessage('');
|
||||
|
||||
|
||||
const tempMessage: ChatMessage = {
|
||||
id: Date.now(),
|
||||
chat_id: selectedChat.id,
|
||||
sender_type: 'staff',
|
||||
sender_name: 'You',
|
||||
message: messageText,
|
||||
is_read: false,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
setMessages(prev => [...prev, tempMessage]);
|
||||
|
||||
try {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message',
|
||||
message: messageText
|
||||
}));
|
||||
} else {
|
||||
|
||||
await chatService.sendMessage(selectedChat.id, messageText);
|
||||
|
||||
const messagesResponse = await chatService.getMessages(selectedChat.id);
|
||||
setMessages(messagesResponse.data);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error('Failed to send message');
|
||||
|
||||
setMessages(prev => prev.filter(m => m.id !== tempMessage.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseChat = async (chatId: number) => {
|
||||
try {
|
||||
const response = await chatService.closeChat(chatId);
|
||||
if (response.success) {
|
||||
toast.success('Chat closed');
|
||||
fetchChats();
|
||||
if (selectedChat?.id === chatId) {
|
||||
setSelectedChat({ ...selectedChat, status: 'closed' });
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to close chat');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return (
|
||||
<span className="px-2 py-1 text-xs font-semibold rounded-full bg-amber-100 text-amber-800 border border-amber-200">
|
||||
<Clock className="w-3 h-3 inline mr-1" />
|
||||
Pending
|
||||
</span>
|
||||
);
|
||||
case 'active':
|
||||
return (
|
||||
<span className="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800 border border-green-200">
|
||||
<CheckCircle className="w-3 h-3 inline mr-1" />
|
||||
Active
|
||||
</span>
|
||||
);
|
||||
case 'closed':
|
||||
return (
|
||||
<span className="px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-800 border border-gray-200">
|
||||
<XCircle className="w-3 h-3 inline mr-1" />
|
||||
Closed
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && chats.length === 0) {
|
||||
return <Loading fullScreen text="Loading chats..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-900">Chat Management</h1>
|
||||
<p className="text-slate-600 mt-2">Manage customer support chats</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchChats}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 h-[calc(100vh-200px)]">
|
||||
{}
|
||||
<div className="bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-gray-200 bg-gradient-to-r from-blue-50 to-blue-100">
|
||||
<h2 className="font-semibold text-slate-900">Chats ({chats.length})</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{chats.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No chats"
|
||||
description="No chat requests at the moment"
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{chats.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
onClick={() => setSelectedChat(chat)}
|
||||
className={`p-4 cursor-pointer hover:bg-gray-50 transition-colors ${
|
||||
selectedChat?.id === chat.id ? 'bg-blue-50 border-l-4 border-blue-600' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-gray-500" />
|
||||
<span className="font-semibold text-slate-900">
|
||||
{chat.visitor_name || 'Guest'}
|
||||
</span>
|
||||
</div>
|
||||
{getStatusBadge(chat.status)}
|
||||
</div>
|
||||
{chat.visitor_email && (
|
||||
<p className="text-sm text-gray-600 mb-2">{chat.visitor_email}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatDate(chat.created_at)}
|
||||
</p>
|
||||
{chat.status === 'pending' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAcceptChat(chat.id);
|
||||
}}
|
||||
className="mt-2 w-full px-3 py-1.5 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Accept Chat
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="lg:col-span-2 bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden flex flex-col">
|
||||
{selectedChat ? (
|
||||
<>
|
||||
<div className="p-4 border-b border-gray-200 bg-gradient-to-r from-blue-50 to-blue-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">
|
||||
{selectedChat.visitor_name || 'Guest'}
|
||||
</h2>
|
||||
{selectedChat.visitor_email && (
|
||||
<p className="text-sm text-gray-600">{selectedChat.visitor_email}</p>
|
||||
)}
|
||||
{selectedChat.staff_name && (
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
Accepted by: {selectedChat.staff_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusBadge(selectedChat.status)}
|
||||
{selectedChat.status !== 'closed' && (
|
||||
<button
|
||||
onClick={() => handleCloseChat(selectedChat.id)}
|
||||
className="p-2 text-gray-600 hover:text-red-600 transition-colors"
|
||||
title="Close chat"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50">
|
||||
{loadingMessages ? (
|
||||
<Loading text="Loading messages..." />
|
||||
) : messages.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No messages"
|
||||
description="Start the conversation"
|
||||
/>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${
|
||||
message.sender_type === 'staff' ? 'justify-end' : 'justify-start'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg p-3 ${
|
||||
message.sender_type === 'staff'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-gray-800 border border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{message.sender_type === 'visitor' && (
|
||||
<div className="text-xs font-semibold mb-1 text-blue-600">
|
||||
{message.sender_name || 'Visitor'}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm whitespace-pre-wrap break-words">
|
||||
{message.message}
|
||||
</p>
|
||||
<p
|
||||
className={`text-xs mt-1 ${
|
||||
message.sender_type === 'staff'
|
||||
? 'text-blue-100'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{new Date(message.created_at).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{selectedChat.status !== 'closed' && (
|
||||
<div className="p-4 border-t border-gray-200 bg-white">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Type your message..."
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={selectedChat.status === 'pending'}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!newMessage.trim() || selectedChat.status === 'pending'}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{selectedChat.status === 'pending' && (
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Accept the chat to start messaging
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<EmptyState
|
||||
title="No chat selected"
|
||||
description="Select a chat from the list to view messages"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatManagementPage;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user