This commit is contained in:
Iliyan Angelov
2025-12-05 22:12:32 +02:00
parent 13c91f95f4
commit 7667eb5eda
53 changed files with 3065 additions and 9257 deletions

View File

@@ -127,6 +127,7 @@ const LuxuryBookingModal: React.FC<LuxuryBookingModalProps> = ({
const checkInDate = watch('checkInDate');
const checkOutDate = watch('checkOutDate');
const guestCount = watch('guestCount');
const paymentMethodForm = watch('paymentMethod');
useEffect(() => {
@@ -134,6 +135,26 @@ const LuxuryBookingModal: React.FC<LuxuryBookingModalProps> = ({
fetchRoomDetails(roomId);
fetchServices();
fetchBookedDates(roomId);
// Auto-fill promotion code from sessionStorage or URL
try {
const storedPromotion = sessionStorage.getItem('activePromotion');
if (storedPromotion) {
const promo = JSON.parse(storedPromotion);
if (promo.code && !promotionCode) {
setPromotionCode(promo.code.toUpperCase());
}
}
} catch (error) {
console.warn('Failed to read promotion from sessionStorage:', error);
}
// Check URL params as fallback
const urlParams = new URLSearchParams(window.location.search);
const urlPromoCode = urlParams.get('promo');
if (urlPromoCode && !promotionCode) {
setPromotionCode(urlPromoCode.toUpperCase());
}
}
}, [isOpen, roomId]);
@@ -251,16 +272,29 @@ const LuxuryBookingModal: React.FC<LuxuryBookingModalProps> = ({
setPromotionError('Please enter a promotion code');
return;
}
if (subtotal === 0) {
if (subtotal === 0 || !checkInDate || !checkOutDate) {
setPromotionError('Please select dates first');
return;
}
try {
setValidatingPromotion(true);
setPromotionError(null);
// Format dates as YYYY-MM-DD for backend
const checkInDateStr = formatDateLocal(checkInDate);
const checkOutDateStr = formatDateLocal(checkOutDate);
// Get room type ID from room object
const roomTypeId = room?.room_type_id || room?.room_type?.id || undefined;
const response = await promotionService.validatePromotion(
promotionCode.toUpperCase().trim(),
subtotal
subtotal,
checkInDateStr,
checkOutDateStr,
roomTypeId,
guestCount || undefined,
undefined // is_first_time_customer - will be determined by backend based on user bookings
);
if (response.success && response.data) {
setSelectedPromotion(response.data.promotion);

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
ArrowRight,
AlertCircle,
@@ -8,6 +8,7 @@ import {
ChevronRight,
ZoomIn,
} from 'lucide-react';
import { toast } from 'react-toastify';
import * as LucideIcons from 'lucide-react';
import {
BannerCarousel,
@@ -29,6 +30,7 @@ import type { PageContent } from '../services/pageContentService';
import type { Service } from '../../hotel_services/services/serviceService';
const HomePage: React.FC = () => {
const navigate = useNavigate();
const { formatCurrency } = useFormatCurrency();
const [banners, setBanners] = useState<Banner[]>([]);
const [featuredRooms, setFeaturedRooms] = useState<Room[]>([]);
@@ -41,7 +43,7 @@ const HomePage: React.FC = () => {
const [isLoadingRooms, setIsLoadingRooms] = useState(true);
const [isLoadingNewest, setIsLoadingNewest] = useState(true);
const [, setIsLoadingContent] = useState(true);
const [isLoadingServices, setIsLoadingServices] = useState(true);
const [, setIsLoadingServices] = useState(true);
const [isLoadingBlog, setIsLoadingBlog] = useState(false);
const [error, setError] = useState<string | null>(null);
const [apiError, setApiError] = useState(false);
@@ -49,6 +51,7 @@ const HomePage: React.FC = () => {
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const [lightboxImages, setLightboxImages] = useState<string[]>([]);
const [clickedPromotion, setClickedPromotion] = useState<number | null>(null);
// Prevent body scroll when API error modal is shown
useEffect(() => {
@@ -104,6 +107,100 @@ const HomePage: React.FC = () => {
return Array.from(roomMap.values());
}, [featuredRooms, newestRooms]);
// Enterprise-grade promotion click handler
const handlePromotionClick = useCallback((promo: any, index: number, e?: React.MouseEvent) => {
// Prevent default if event is provided (for button clicks)
if (e) {
e.preventDefault();
e.stopPropagation();
}
try {
// Validate promotion data
if (!promo) {
toast.error('Invalid promotion. Please try again.');
return;
}
// Check if promotion is still valid
if (promo.valid_until) {
const validUntil = new Date(promo.valid_until);
const now = new Date();
if (validUntil < now) {
toast.warning('This promotion has expired.');
return;
}
}
// Set loading state for this specific promotion
setClickedPromotion(index);
// Store promotion data in sessionStorage for booking flow
const promotionData = {
id: promo.code || `promo-${index}`,
title: promo.title,
description: promo.description,
discount: promo.discount,
code: promo.code || promo.title?.toUpperCase().replace(/\s+/g, '') || '',
valid_until: promo.valid_until,
link: promo.link,
clicked_at: new Date().toISOString(),
};
try {
sessionStorage.setItem('activePromotion', JSON.stringify(promotionData));
} catch (storageError) {
console.warn('Failed to store promotion data:', storageError);
// Continue even if storage fails
}
// Analytics tracking (enterprise-grade)
try {
if (window.gtag) {
window.gtag('event', 'promotion_click', {
promotion_title: promo.title,
promotion_code: promotionData.code,
promotion_discount: promo.discount,
});
}
} catch (analyticsError) {
console.warn('Analytics tracking failed:', analyticsError);
}
// Determine navigation target
let targetPath = promo.link || '/rooms';
// Add promotion code to URL for visibility
if (targetPath.startsWith('/rooms') || (!targetPath.startsWith('http') && targetPath.startsWith('/'))) {
const url = new URL(targetPath, window.location.origin);
if (promotionData.code) {
url.searchParams.set('promo', promotionData.code);
}
targetPath = url.pathname + url.search;
}
// Show success message
toast.success(`🎉 Promotion "${promo.title}" activated! The code will be applied when you book.`, {
autoClose: 4000,
});
// Navigate after a brief delay for UX (allows loading state to show)
setTimeout(() => {
navigate(targetPath);
// Reset loading state after navigation
setTimeout(() => {
setClickedPromotion(null);
}, 500);
}, 150);
} catch (error: any) {
console.error('Error handling promotion click:', error);
toast.error('An error occurred. Please try again.');
setClickedPromotion(null);
}
}, [navigate]);
useEffect(() => {
fetchServices();
@@ -1337,62 +1434,6 @@ const HomePage: React.FC = () => {
</section>
)}
{/* Trust Badges Section */}
{(pageContent?.sections_enabled?.trust_badges !== false) && pageContent?.trust_badges_enabled && pageContent?.trust_badges && Array.isArray(pageContent.trust_badges) && pageContent.trust_badges.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">
<div className="inline-block mb-3">
<div className="h-0.5 w-16 md:w-20 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto rounded-full"></div>
</div>
{pageContent.trust_badges_section_title && (
<h2 className="text-2xl sm:text-3xl md:text-4xl font-serif font-bold text-gray-900 tracking-tight mb-3 md:mb-4 px-4">
{pageContent.trust_badges_section_title}
</h2>
)}
{pageContent.trust_badges_section_subtitle && (
<p className="text-sm sm:text-base md:text-lg text-gray-600 font-light tracking-wide max-w-2xl mx-auto mt-3 md:mt-4 px-4 leading-relaxed">
{pageContent.trust_badges_section_subtitle}
</p>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6 md:gap-8 px-4">
{pageContent.trust_badges.map((badge, index) => (
<div key={index} className="flex flex-col items-center text-center group">
{badge.link ? (
<a href={badge.link} target="_blank" rel="noopener noreferrer" className="w-full">
<div className="w-24 h-24 md:w-32 md:h-32 mx-auto mb-4 rounded-lg overflow-hidden bg-white p-4 shadow-lg hover:shadow-xl transition-all duration-300 border border-gray-200 hover:border-[#d4af37]/50 group-hover:scale-105">
{badge.logo && (
<img src={badge.logo} alt={badge.name} className="w-full h-full object-contain" />
)}
</div>
{badge.name && (
<h4 className="text-sm md:text-base font-semibold text-gray-900 mt-2">{badge.name}</h4>
)}
{badge.description && (
<p className="text-xs md:text-sm text-gray-600 mt-1">{badge.description}</p>
)}
</a>
) : (
<>
<div className="w-24 h-24 md:w-32 md:h-32 mx-auto mb-4 rounded-lg overflow-hidden bg-white p-4 shadow-lg border border-gray-200">
{badge.logo && (
<img src={badge.logo} alt={badge.name} className="w-full h-full object-contain" />
)}
</div>
{badge.name && (
<h4 className="text-sm md:text-base font-semibold text-gray-900 mt-2">{badge.name}</h4>
)}
{badge.description && (
<p className="text-xs md:text-sm text-gray-600 mt-1">{badge.description}</p>
)}
</>
)}
</div>
))}
</div>
</section>
)}
{/* Promotions Section */}
{(pageContent?.sections_enabled?.promotions !== false) && pageContent?.promotions_enabled && pageContent?.promotions && Array.isArray(pageContent.promotions) && pageContent.promotions.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">
@@ -1412,38 +1453,131 @@ const HomePage: React.FC = () => {
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8 px-4">
{pageContent.promotions.map((promo, index) => (
<div key={index} className="relative bg-white rounded-xl shadow-xl overflow-hidden border border-gray-200 hover:shadow-2xl transition-all duration-300 group">
{promo.image && (
<div className="relative h-48 overflow-hidden">
<img src={promo.image} alt={promo.title} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
{promo.discount && (
<div className="absolute top-4 right-4 bg-red-600 text-white px-4 py-2 rounded-full font-bold text-lg shadow-lg">
{promo.discount}
</div>
{pageContent.promotions.map((promo, index) => {
const isClicked = clickedPromotion === index;
const now = new Date();
now.setHours(0, 0, 0, 0);
const validUntilDate = promo.valid_until ? new Date(promo.valid_until) : null;
if (validUntilDate) {
validUntilDate.setHours(23, 59, 59, 999);
}
const isValid = validUntilDate ? validUntilDate >= now : true;
const handleCardClick = () => {
if (!isValid) {
toast.warning('This promotion has expired.');
return;
}
if (!isClicked) {
handlePromotionClick(promo, index);
}
};
return (
<div
key={index}
onClick={handleCardClick}
className={`relative bg-white rounded-xl shadow-xl overflow-hidden border transition-all duration-300 group ${
isValid
? 'border-gray-200 hover:shadow-2xl hover:-translate-y-1 cursor-pointer'
: 'border-gray-300 opacity-75 cursor-not-allowed'
} ${
isClicked ? 'opacity-75 pointer-events-none' : ''
}`}
role="button"
tabIndex={isValid ? 0 : -1}
onKeyDown={(e) => {
if (isValid && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
handleCardClick();
}
}}
aria-label={`${isValid ? 'View' : 'Expired'} promotion: ${promo.title}`}
aria-disabled={!isValid}
>
{promo.image && (
<div className="relative h-48 overflow-hidden">
<img
src={promo.image}
alt={promo.title || 'Promotion'}
className={`w-full h-full object-cover transition-transform duration-500 ${
isValid ? 'group-hover:scale-110' : 'grayscale'
}`}
loading="lazy"
/>
{promo.discount && (
<div className={`absolute top-4 right-4 px-4 py-2 rounded-full font-bold text-lg shadow-lg z-10 ${
isValid ? 'bg-red-600 text-white' : 'bg-gray-500 text-white'
}`}>
{promo.discount}
</div>
)}
{!isValid && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center z-20">
<span className="bg-red-600 text-white px-6 py-3 rounded-lg font-bold text-base shadow-xl">
EXPIRED
</span>
</div>
)}
</div>
)}
<div className="p-6">
<div className="flex items-start justify-between mb-2">
<h3 className={`text-xl font-bold mb-2 transition-colors ${
isValid ? 'text-gray-900 group-hover:text-[#d4af37]' : 'text-gray-500'
}`}>
{promo.title}
</h3>
{!isValid && (
<span className="ml-2 px-2 py-1 bg-gray-200 text-gray-600 text-xs font-semibold rounded">
Expired
</span>
)}
</div>
{promo.description && (
<p className={`mb-4 line-clamp-3 ${isValid ? 'text-gray-600' : 'text-gray-400'}`}>
{promo.description}
</p>
)}
</div>
)}
<div className="p-6">
<h3 className="text-xl font-bold text-gray-900 mb-2">{promo.title}</h3>
{promo.description && (
<p className="text-gray-600 mb-4">{promo.description}</p>
)}
{promo.valid_until && (
<p className="text-sm text-gray-500 mb-4">Valid until: {new Date(promo.valid_until).toLocaleDateString()}</p>
)}
{promo.link && (
<Link
to={promo.link}
className="inline-flex items-center gap-2 px-6 py-2 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] rounded-lg font-semibold hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300"
{promo.valid_until && (
<p className={`text-sm mb-4 ${
isValid ? 'text-gray-500' : 'text-red-500 font-medium'
}`}>
{isValid ? 'Valid until:' : 'Expired on:'} {new Date(promo.valid_until).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</p>
)}
<div
onClick={(e) => {
e.stopPropagation();
if (!isValid) {
toast.warning('This promotion has expired.');
return;
}
handlePromotionClick(promo, index, e);
}}
className={`inline-flex items-center gap-2 px-6 py-2 rounded-lg font-semibold transition-all duration-300 ${
isValid
? 'bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] hover:from-[#f5d76e] hover:to-[#d4af37] cursor-pointer group-hover:shadow-lg'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
role="button"
tabIndex={-1}
aria-disabled={!isValid}
>
{promo.button_text || 'Learn More'}
<ArrowRight className="w-4 h-4" />
</Link>
)}
<span>{isClicked ? 'Loading...' : (promo.button_text || (isValid ? 'View Offer' : 'Expired'))}</span>
{!isClicked && isValid && <ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />}
{isClicked && (
<div className="w-4 h-4 border-2 border-[#0f0f0f] border-t-transparent rounded-full animate-spin" />
)}
</div>
</div>
</div>
</div>
))}
);
})}
</div>
</section>
)}

View File

@@ -9,6 +9,19 @@ export interface Promotion {
discount_value: number;
min_booking_amount?: number;
max_discount_amount?: number;
min_stay_days?: number;
max_stay_days?: number;
advance_booking_days?: number;
max_advance_booking_days?: number;
allowed_check_in_days?: number[];
allowed_check_out_days?: number[];
allowed_room_type_ids?: number[];
excluded_room_type_ids?: number[];
min_guests?: number;
max_guests?: number;
first_time_customer_only?: boolean;
repeat_customer_only?: boolean;
blackout_dates?: string[];
start_date: string;
end_date: string;
usage_limit?: number;
@@ -41,6 +54,19 @@ export interface CreatePromotionData {
discount_value: number;
min_booking_amount?: number;
max_discount_amount?: number;
min_stay_days?: number;
max_stay_days?: number;
advance_booking_days?: number;
max_advance_booking_days?: number;
allowed_check_in_days?: number[];
allowed_check_out_days?: number[];
allowed_room_type_ids?: number[];
excluded_room_type_ids?: number[];
min_guests?: number;
max_guests?: number;
first_time_customer_only?: boolean;
repeat_customer_only?: boolean;
blackout_dates?: string[];
start_date: string;
end_date: string;
usage_limit?: number;
@@ -55,6 +81,19 @@ export interface UpdatePromotionData {
discount_value?: number;
min_booking_amount?: number;
max_discount_amount?: number;
min_stay_days?: number;
max_stay_days?: number;
advance_booking_days?: number;
max_advance_booking_days?: number;
allowed_check_in_days?: number[];
allowed_check_out_days?: number[];
allowed_room_type_ids?: number[];
excluded_room_type_ids?: number[];
min_guests?: number;
max_guests?: number;
first_time_customer_only?: boolean;
repeat_customer_only?: boolean;
blackout_dates?: string[];
start_date?: string;
end_date?: string;
usage_limit?: number;
@@ -106,11 +145,21 @@ export const deletePromotion = async (
export const validatePromotion = async (
code: string,
bookingValue: number
bookingValue: number,
checkInDate?: string,
checkOutDate?: string,
roomTypeId?: number,
guestCount?: number,
isFirstTimeCustomer?: boolean
): Promise<{ success: boolean; data: { promotion: Promotion; discount: number }; message: string }> => {
const response = await apiClient.post('/promotions/validate', {
code,
booking_value: bookingValue,
check_in_date: checkInDate,
check_out_date: checkOutDate,
room_type_id: roomTypeId,
guest_count: guestCount,
is_first_time_customer: isFirstTimeCustomer,
});
return response.data;
};

View File

@@ -22,10 +22,13 @@ import { useAsync } from '../../shared/hooks/useAsync';
import { useNavigate } from 'react-router-dom';
import { logger } from '../../shared/utils/logger';
import { getPaymentStatusColor } from '../../shared/utils/paymentUtils';
import MfaRequiredBanner from '../../shared/components/MfaRequiredBanner';
import { useMfaStatus } from '../../shared/hooks/useMfaStatus';
const AccountantDashboardPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const navigate = useNavigate();
const { requiresMfaButNotEnabled } = useMfaStatus();
const [dateRange, setDateRange] = useState({
from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
to: new Date().toISOString().split('T')[0],
@@ -235,6 +238,11 @@ const AccountantDashboardPage: React.FC = () => {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
{/* MFA Required Banner */}
{requiresMfaButNotEnabled && (
<MfaRequiredBanner securityPagePath="/accountant/security" />
)}
{/* Header */}
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 sm:gap-6 mb-6 sm:mb-8 md:mb-10 animate-fade-in">
<div className="w-full lg:w-auto">

View File

@@ -1,13 +1,16 @@
import React, { useState, useEffect } from 'react';
import { Shield, Activity, LogOut, AlertTriangle, CheckCircle2, Clock } from 'lucide-react';
import { toast } from 'react-toastify';
import { useSearchParams } from 'react-router-dom';
import accountantSecurityService, { AccountantSession, AccountantActivityLog, MFAStatus } from '../../features/security/services/accountantSecurityService';
import Loading from '../../shared/components/Loading';
import EmptyState from '../../shared/components/EmptyState';
import { formatDate } from '../../shared/utils/format';
const SecurityManagementPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'sessions' | 'activity' | 'mfa'>('sessions');
const [searchParams] = useSearchParams();
const setupMfa = searchParams.get('setup_mfa') === 'true';
const [activeTab, setActiveTab] = useState<'sessions' | 'activity' | 'mfa'>(setupMfa ? 'mfa' : 'sessions');
const [loading, setLoading] = useState(true);
const [sessions, setSessions] = useState<AccountantSession[]>([]);
const [activityLogs, setActivityLogs] = useState<AccountantActivityLog[]>([]);
@@ -20,6 +23,19 @@ const SecurityManagementPage: React.FC = () => {
else if (activeTab === 'mfa') fetchMFAStatus();
}, [activeTab]);
// Auto-switch to MFA tab if setup_mfa query param is present
useEffect(() => {
if (setupMfa && activeTab !== 'mfa') {
setActiveTab('mfa');
fetchMFAStatus();
// Show toast notification
toast.info('Please enable Multi-Factor Authentication to access financial features.', {
autoClose: 5000,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setupMfa]);
const fetchSessions = async () => {
try {
setLoading(true);

View File

@@ -24,8 +24,14 @@ import invoiceService, { Invoice } from '../../features/payments/services/invoic
import paymentService from '../../features/payments/services/paymentService';
import type { Payment } from '../../features/payments/services/paymentService';
import promotionService, { Promotion } from '../../features/loyalty/services/promotionService';
import { getRoomTypes } from '../../features/rooms/services/roomService';
import { formatDate } from '../../shared/utils/format';
interface RoomType {
id: number;
name: string;
}
type BusinessTab = 'overview' | 'invoices' | 'payments' | 'promotions';
const BusinessDashboardPage: React.FC = () => {
@@ -83,11 +89,26 @@ const BusinessDashboardPage: React.FC = () => {
discount_value: 0,
min_booking_amount: 0,
max_discount_amount: 0,
min_stay_days: 0,
max_stay_days: 0,
advance_booking_days: 0,
max_advance_booking_days: 0,
allowed_check_in_days: [] as number[],
allowed_check_out_days: [] as number[],
allowed_room_type_ids: [] as number[],
excluded_room_type_ids: [] as number[],
min_guests: 0,
max_guests: 0,
first_time_customer_only: false,
repeat_customer_only: false,
blackout_dates: [] as string[],
start_date: '',
end_date: '',
usage_limit: 0,
status: 'active' as 'active' | 'inactive' | 'expired',
});
const [roomTypes, setRoomTypes] = useState<RoomType[]>([]);
useEffect(() => {
if (activeTab === 'invoices') {
@@ -99,6 +120,21 @@ const BusinessDashboardPage: React.FC = () => {
}
}, [activeTab, invoiceFilters, invoicesCurrentPage, paymentFilters, paymentsCurrentPage, promotionFilters, promotionsCurrentPage]);
useEffect(() => {
fetchRoomTypes();
}, []);
const fetchRoomTypes = async () => {
try {
const response = await getRoomTypes();
if (response.success && response.data.room_types) {
setRoomTypes(response.data.room_types);
}
} catch (error: any) {
console.error('Failed to fetch room types:', error);
}
};
useEffect(() => {
if (activeTab === 'invoices') {
setInvoicesCurrentPage(1);
@@ -280,11 +316,30 @@ const BusinessDashboardPage: React.FC = () => {
const handlePromotionSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
// Prepare data, converting empty arrays to undefined and 0 values to undefined for optional fields
const submitData: any = {
...promotionFormData,
min_stay_days: promotionFormData.min_stay_days || undefined,
max_stay_days: promotionFormData.max_stay_days || undefined,
advance_booking_days: promotionFormData.advance_booking_days || undefined,
max_advance_booking_days: promotionFormData.max_advance_booking_days || undefined,
min_guests: promotionFormData.min_guests || undefined,
max_guests: promotionFormData.max_guests || undefined,
allowed_check_in_days: promotionFormData.allowed_check_in_days?.length ? promotionFormData.allowed_check_in_days : undefined,
allowed_check_out_days: promotionFormData.allowed_check_out_days?.length ? promotionFormData.allowed_check_out_days : undefined,
allowed_room_type_ids: promotionFormData.allowed_room_type_ids?.length ? promotionFormData.allowed_room_type_ids : undefined,
excluded_room_type_ids: promotionFormData.excluded_room_type_ids?.length ? promotionFormData.excluded_room_type_ids : undefined,
blackout_dates: promotionFormData.blackout_dates?.length ? promotionFormData.blackout_dates : undefined,
min_booking_amount: promotionFormData.min_booking_amount || undefined,
max_discount_amount: promotionFormData.max_discount_amount || undefined,
usage_limit: promotionFormData.usage_limit || undefined,
};
if (editingPromotion) {
await promotionService.updatePromotion(editingPromotion.id, promotionFormData);
await promotionService.updatePromotion(editingPromotion.id, submitData);
toast.success('Promotion updated successfully');
} else {
await promotionService.createPromotion(promotionFormData);
await promotionService.createPromotion(submitData);
toast.success('Promotion added successfully');
}
setShowPromotionModal(false);
@@ -305,6 +360,19 @@ const BusinessDashboardPage: React.FC = () => {
discount_value: promotion.discount_value,
min_booking_amount: promotion.min_booking_amount || 0,
max_discount_amount: promotion.max_discount_amount || 0,
min_stay_days: promotion.min_stay_days || 0,
max_stay_days: promotion.max_stay_days || 0,
advance_booking_days: promotion.advance_booking_days || 0,
max_advance_booking_days: promotion.max_advance_booking_days || 0,
allowed_check_in_days: promotion.allowed_check_in_days || [],
allowed_check_out_days: promotion.allowed_check_out_days || [],
allowed_room_type_ids: promotion.allowed_room_type_ids || [],
excluded_room_type_ids: promotion.excluded_room_type_ids || [],
min_guests: promotion.min_guests || 0,
max_guests: promotion.max_guests || 0,
first_time_customer_only: promotion.first_time_customer_only || false,
repeat_customer_only: promotion.repeat_customer_only || false,
blackout_dates: promotion.blackout_dates || [],
start_date: promotion.start_date?.split('T')[0] || '',
end_date: promotion.end_date?.split('T')[0] || '',
usage_limit: promotion.usage_limit || 0,
@@ -335,6 +403,19 @@ const BusinessDashboardPage: React.FC = () => {
discount_value: 0,
min_booking_amount: 0,
max_discount_amount: 0,
min_stay_days: 0,
max_stay_days: 0,
advance_booking_days: 0,
max_advance_booking_days: 0,
allowed_check_in_days: [],
allowed_check_out_days: [],
allowed_room_type_ids: [],
excluded_room_type_ids: [],
min_guests: 0,
max_guests: 0,
first_time_customer_only: false,
repeat_customer_only: false,
blackout_dates: [],
start_date: '',
end_date: '',
usage_limit: 0,
@@ -1041,36 +1122,37 @@ const BusinessDashboardPage: React.FC = () => {
/>
</div>
)}
</div>
)}
</div>
{}
{showPromotionModal && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-4">
<div className="min-h-full flex items-start justify-center py-8">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-3xl my-8 max-h-[calc(100vh-4rem)] overflow-hidden border border-gray-200">
{}
<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>
<h2 className="text-3xl font-bold text-purple-100 mb-1">
{editingPromotion ? 'Update Promotion' : 'Add New Promotion'}
</h2>
<p className="text-purple-200/80 text-sm font-light">
{editingPromotion ? 'Modify promotion details' : 'Create a new promotion program'}
</p>
</div>
<button
onClick={() => setShowPromotionModal(false)}
className="w-10 h-10 flex items-center justify-center rounded-xl text-purple-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-purple-400"
>
<X className="w-6 h-6" />
</button>
</div>
{}
{showPromotionModal && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-3 sm:p-4">
<div className="min-h-full flex items-center justify-center py-4">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-5xl w-full my-4 flex flex-col border border-gray-200" style={{ maxHeight: 'calc(100vh - 2rem)' }}>
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 md:px-8 py-4 sm:py-5 md:py-6 border-b border-slate-700 flex-shrink-0">
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-purple-100 mb-1">
{editingPromotion ? 'Update Promotion' : 'Add New Promotion'}
</h2>
<p className="text-purple-200/80 text-xs sm:text-sm font-light">
{editingPromotion ? 'Modify promotion details' : 'Create a new promotion program'}
</p>
</div>
{}
<div className="p-8 overflow-y-auto max-h-[calc(100vh-12rem)] custom-scrollbar">
<form onSubmit={handlePromotionSubmit} className="space-y-6">
<div className="grid grid-cols-2 gap-6">
<button
onClick={() => setShowPromotionModal(false)}
className="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl text-purple-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-purple-400"
>
<X className="w-5 h-5 sm:w-6 sm:h-6" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
<form onSubmit={handlePromotionSubmit} className="p-4 sm:p-6 md:p-8 space-y-4 sm:space-y-5 md:space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
Code <span className="text-red-500">*</span>
@@ -1169,6 +1251,258 @@ const BusinessDashboardPage: React.FC = () => {
</div>
</div>
{/* Enterprise Booking Conditions Section */}
<div className="border-t-2 border-purple-200 pt-6 mt-6">
<h3 className="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
<div className="h-1 w-8 bg-gradient-to-r from-purple-400 to-purple-600 rounded-full"></div>
Enterprise Booking Conditions
</h3>
<p className="text-sm text-gray-600 mb-6">Configure advanced conditions for when this promotion applies</p>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
Minimum Stay (nights)
</label>
<input
type="number"
value={promotionFormData.min_stay_days || ''}
onChange={(e) => setPromotionFormData({ ...promotionFormData, min_stay_days: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
min="0"
placeholder="0 = no minimum"
/>
<p className="text-xs text-gray-500 mt-1">Minimum number of nights required for booking</p>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
Advance Booking (days)
</label>
<input
type="number"
value={promotionFormData.advance_booking_days || ''}
onChange={(e) => setPromotionFormData({ ...promotionFormData, advance_booking_days: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
min="0"
placeholder="0 = no requirement"
/>
<p className="text-xs text-gray-500 mt-1">Minimum days in advance the booking must be made</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
Maximum Stay (nights)
</label>
<input
type="number"
value={promotionFormData.max_stay_days || ''}
onChange={(e) => setPromotionFormData({ ...promotionFormData, max_stay_days: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
min="0"
placeholder="0 = no maximum"
/>
<p className="text-xs text-gray-500 mt-1">Maximum number of nights allowed for booking</p>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
Max Advance Booking (days)
</label>
<input
type="number"
value={promotionFormData.max_advance_booking_days || ''}
onChange={(e) => setPromotionFormData({ ...promotionFormData, max_advance_booking_days: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
min="0"
placeholder="0 = no maximum"
/>
<p className="text-xs text-gray-500 mt-1">Maximum days in advance the booking can be made</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
Allowed Check-in Days
</label>
<div className="grid grid-cols-7 gap-2">
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day, index) => (
<label key={day} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={promotionFormData.allowed_check_in_days?.includes(index) || false}
onChange={(e) => {
const current = promotionFormData.allowed_check_in_days || [];
if (e.target.checked) {
setPromotionFormData({ ...promotionFormData, allowed_check_in_days: [...current, index] });
} else {
setPromotionFormData({ ...promotionFormData, allowed_check_in_days: current.filter(d => d !== index) });
}
}}
className="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span className="text-xs text-gray-700">{day}</span>
</label>
))}
</div>
<p className="text-xs text-gray-500 mt-1">Leave empty to allow all days</p>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
Allowed Check-out Days
</label>
<div className="grid grid-cols-7 gap-2">
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day, index) => (
<label key={day} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={promotionFormData.allowed_check_out_days?.includes(index) || false}
onChange={(e) => {
const current = promotionFormData.allowed_check_out_days || [];
if (e.target.checked) {
setPromotionFormData({ ...promotionFormData, allowed_check_out_days: [...current, index] });
} else {
setPromotionFormData({ ...promotionFormData, allowed_check_out_days: current.filter(d => d !== index) });
}
}}
className="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span className="text-xs text-gray-700">{day}</span>
</label>
))}
</div>
<p className="text-xs text-gray-500 mt-1">Leave empty to allow all days</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
Allowed Room Types
</label>
<select
multiple
value={promotionFormData.allowed_room_type_ids?.map(String) || []}
onChange={(e) => {
const selected = Array.from(e.target.selectedOptions, option => parseInt(option.value));
setPromotionFormData({ ...promotionFormData, allowed_room_type_ids: selected });
}}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
size={4}
>
{roomTypes.map(rt => (
<option key={rt.id} value={rt.id}>{rt.name}</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">Hold Ctrl/Cmd to select multiple. Leave empty to allow all room types.</p>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
Excluded Room Types
</label>
<select
multiple
value={promotionFormData.excluded_room_type_ids?.map(String) || []}
onChange={(e) => {
const selected = Array.from(e.target.selectedOptions, option => parseInt(option.value));
setPromotionFormData({ ...promotionFormData, excluded_room_type_ids: selected });
}}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
size={4}
>
{roomTypes.map(rt => (
<option key={rt.id} value={rt.id}>{rt.name}</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">Hold Ctrl/Cmd to select multiple. These room types cannot use this promotion.</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
Minimum Guests
</label>
<input
type="number"
value={promotionFormData.min_guests || ''}
onChange={(e) => setPromotionFormData({ ...promotionFormData, min_guests: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
min="1"
placeholder="0 = no minimum"
/>
<p className="text-xs text-gray-500 mt-1">Minimum number of guests required</p>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
Maximum Guests
</label>
<input
type="number"
value={promotionFormData.max_guests || ''}
onChange={(e) => setPromotionFormData({ ...promotionFormData, max_guests: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
min="1"
placeholder="0 = no maximum"
/>
<p className="text-xs text-gray-500 mt-1">Maximum number of guests allowed</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={promotionFormData.first_time_customer_only || false}
onChange={(e) => setPromotionFormData({ ...promotionFormData, first_time_customer_only: e.target.checked, repeat_customer_only: e.target.checked ? false : promotionFormData.repeat_customer_only })}
className="w-5 h-5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span className="text-sm font-semibold text-gray-700">First-Time Customer Only</span>
</label>
<p className="text-xs text-gray-500 mt-1 ml-8">Only available to first-time customers</p>
</div>
<div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={promotionFormData.repeat_customer_only || false}
onChange={(e) => setPromotionFormData({ ...promotionFormData, repeat_customer_only: e.target.checked, first_time_customer_only: e.target.checked ? false : promotionFormData.first_time_customer_only })}
className="w-5 h-5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span className="text-sm font-semibold text-gray-700">Repeat Customer Only</span>
</label>
<p className="text-xs text-gray-500 mt-1 ml-8">Only available to returning customers</p>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
Blackout Dates
</label>
<textarea
value={promotionFormData.blackout_dates?.join('\n') || ''}
onChange={(e) => {
const dates = e.target.value.split('\n').filter(d => d.trim()).map(d => d.trim());
setPromotionFormData({ ...promotionFormData, blackout_dates: dates });
}}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm"
rows={3}
placeholder="Enter dates (one per line) in YYYY-MM-DD format&#10;Example:&#10;2024-12-25&#10;2024-12-31&#10;2025-01-01"
/>
<p className="text-xs text-gray-500 mt-1">Dates when promotion doesn't apply. One date per line (YYYY-MM-DD format).</p>
</div>
{/* Dates & Status Section */}
<div className="border-t-2 border-purple-200 pt-6 mt-6">
<h3 className="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
<div className="h-1 w-8 bg-gradient-to-r from-purple-400 to-purple-600 rounded-full"></div>
Promotion Period & Status
</h3>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
@@ -1224,7 +1558,7 @@ const BusinessDashboardPage: React.FC = () => {
</div>
</div>
<div className="flex justify-end gap-3 pt-6 border-t border-gray-200">
<div className="sticky bottom-0 bg-white border-t border-gray-200 mt-8 -mx-4 sm:-mx-6 md:-mx-8 px-4 sm:px-6 md:px-8 py-4 flex justify-end gap-3">
<button
type="button"
onClick={() => setShowPromotionModal(false)}
@@ -1239,15 +1573,12 @@ const BusinessDashboardPage: React.FC = () => {
{editingPromotion ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
)}
</form>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -2895,167 +2895,6 @@ const PageContentDashboard: React.FC = () => {
</div>
</div>
{/* Trust Badges Section */}
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-extrabold text-gray-900">Trust Badges Section</h2>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={homeData.trust_badges_enabled ?? false}
onChange={(e) => setHomeData({ ...homeData, trust_badges_enabled: e.target.checked })}
className="w-5 h-5 text-purple-600 rounded focus:ring-purple-500"
/>
<span className="text-sm font-semibold text-gray-700">Enable Trust Badges</span>
</label>
</div>
{homeData.trust_badges_enabled && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Title</label>
<input
type="text"
value={homeData.trust_badges_section_title || ''}
onChange={(e) => setHomeData({ ...homeData, trust_badges_section_title: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Trusted & Certified"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Section Subtitle</label>
<input
type="text"
value={homeData.trust_badges_section_subtitle || ''}
onChange={(e) => setHomeData({ ...homeData, trust_badges_section_subtitle: e.target.value })}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl"
placeholder="Recognized for excellence"
/>
</div>
</div>
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Trust Badges</h3>
<button
type="button"
onClick={() => {
const current = Array.isArray(homeData.trust_badges) ? homeData.trust_badges : [];
setHomeData({
...homeData,
trust_badges: [...current, { name: '', logo: '', description: '', link: '' }]
});
}}
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-green-500 to-green-600 text-white rounded-lg font-semibold hover:from-green-600 hover:to-green-700 transition-all"
>
<Plus className="w-4 h-4" />
Add Badge
</button>
</div>
<div className="space-y-4">
{Array.isArray(homeData.trust_badges) && homeData.trust_badges.map((badge, index) => (
<div key={`badge-${index}`} className="p-6 border-2 border-gray-200 rounded-xl space-y-4">
<div className="flex justify-between items-center">
<h4 className="font-semibold text-gray-900">Badge {index + 1}</h4>
<button
type="button"
onClick={() => {
const current = Array.isArray(homeData.trust_badges) ? homeData.trust_badges : [];
const updated = current.filter((_, i) => i !== index);
setHomeData({ ...homeData, trust_badges: updated });
}}
className="text-red-600 hover:text-red-700 p-1"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Name</label>
<input
type="text"
value={badge?.name || ''}
onChange={(e) => {
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
current[index] = { ...current[index], name: e.target.value };
setHomeData({ ...homeData, trust_badges: current });
}}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="Certification Name"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Link (Optional)</label>
<input
type="url"
value={badge?.link || ''}
onChange={(e) => {
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
current[index] = { ...current[index], link: e.target.value };
setHomeData({ ...homeData, trust_badges: current });
}}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="https://example.com"
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Description (Optional)</label>
<textarea
value={badge?.description || ''}
onChange={(e) => {
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
current[index] = { ...current[index], description: e.target.value };
setHomeData({ ...homeData, trust_badges: current });
}}
rows={2}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="Brief description"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Logo</label>
<div className="flex gap-2">
<input
type="url"
value={badge?.logo || ''}
onChange={(e) => {
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
current[index] = { ...current[index], logo: e.target.value };
setHomeData({ ...homeData, trust_badges: current });
}}
className="flex-1 px-4 py-2 border-2 border-gray-200 rounded-lg"
placeholder="Logo URL or upload"
/>
<label className="px-5 py-2 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-lg font-bold hover:from-green-700 hover:to-green-800 transition-all cursor-pointer flex items-center gap-2">
<Upload className="w-4 h-4" />
Upload
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
await handlePageContentImageUpload(file, (imageUrl) => {
const current = Array.isArray(homeData.trust_badges) ? [...homeData.trust_badges] : [];
current[index] = { ...current[index], logo: imageUrl };
setHomeData({ ...homeData, trust_badges: current });
});
}
}}
className="hidden"
/>
</label>
</div>
</div>
</div>
))}
{(!homeData.trust_badges || homeData.trust_badges.length === 0) && (
<p className="text-gray-500 text-center py-8">No trust badges added yet. Click "Add Badge" to get started.</p>
)}
</div>
</div>
)}
</div>
{/* Promotions Section */}
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<div className="flex items-center justify-between mb-6">

View File

@@ -1,12 +1,18 @@
import React, { useEffect, useState } from 'react';
import { Plus, Search, Edit, Trash2, X, Tag } from 'lucide-react';
import promotionService, { Promotion } from '../../features/loyalty/services/promotionService';
import { getRoomTypes } from '../../features/rooms/services/roomService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import Pagination from '../../shared/components/Pagination';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { useCurrency } from '../../features/payments/contexts/CurrencyContext';
interface RoomType {
id: number;
name: string;
}
const PromotionManagementPage: React.FC = () => {
const { currency } = useCurrency();
const { formatCurrency } = useFormatCurrency();
@@ -32,11 +38,27 @@ const PromotionManagementPage: React.FC = () => {
discount_value: 0,
min_booking_amount: 0,
max_discount_amount: 0,
min_stay_days: 0,
max_stay_days: 0,
advance_booking_days: 0,
max_advance_booking_days: 0,
allowed_check_in_days: [] as number[],
allowed_check_out_days: [] as number[],
allowed_room_type_ids: [] as number[],
excluded_room_type_ids: [] as number[],
min_guests: 0,
max_guests: 0,
first_time_customer_only: false,
repeat_customer_only: false,
blackout_dates: [] as string[],
start_date: '',
end_date: '',
usage_limit: 0,
status: 'active' as 'active' | 'inactive' | 'expired',
});
const [roomTypes, setRoomTypes] = useState<RoomType[]>([]);
const [loadingRoomTypes, setLoadingRoomTypes] = useState(false);
useEffect(() => {
setCurrentPage(1);
@@ -46,6 +68,24 @@ const PromotionManagementPage: React.FC = () => {
fetchPromotions();
}, [filters, currentPage]);
useEffect(() => {
fetchRoomTypes();
}, []);
const fetchRoomTypes = async () => {
try {
setLoadingRoomTypes(true);
const response = await getRoomTypes();
if (response.success && response.data.room_types) {
setRoomTypes(response.data.room_types);
}
} catch (error: any) {
console.error('Failed to fetch room types:', error);
} finally {
setLoadingRoomTypes(false);
}
};
const fetchPromotions = async () => {
try {
setLoading(true);
@@ -69,11 +109,30 @@ const PromotionManagementPage: React.FC = () => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
// Prepare data, converting empty arrays to undefined and 0 values to undefined for optional fields
const submitData: any = {
...formData,
min_stay_days: formData.min_stay_days || undefined,
max_stay_days: formData.max_stay_days || undefined,
advance_booking_days: formData.advance_booking_days || undefined,
max_advance_booking_days: formData.max_advance_booking_days || undefined,
min_guests: formData.min_guests || undefined,
max_guests: formData.max_guests || undefined,
allowed_check_in_days: formData.allowed_check_in_days?.length ? formData.allowed_check_in_days : undefined,
allowed_check_out_days: formData.allowed_check_out_days?.length ? formData.allowed_check_out_days : undefined,
allowed_room_type_ids: formData.allowed_room_type_ids?.length ? formData.allowed_room_type_ids : undefined,
excluded_room_type_ids: formData.excluded_room_type_ids?.length ? formData.excluded_room_type_ids : undefined,
blackout_dates: formData.blackout_dates?.length ? formData.blackout_dates : undefined,
min_booking_amount: formData.min_booking_amount || undefined,
max_discount_amount: formData.max_discount_amount || undefined,
usage_limit: formData.usage_limit || undefined,
};
if (editingPromotion) {
await promotionService.updatePromotion(editingPromotion.id, formData);
await promotionService.updatePromotion(editingPromotion.id, submitData);
toast.success('Promotion updated successfully');
} else {
await promotionService.createPromotion(formData);
await promotionService.createPromotion(submitData);
toast.success('Promotion added successfully');
}
setShowModal(false);
@@ -94,6 +153,19 @@ const PromotionManagementPage: React.FC = () => {
discount_value: promotion.discount_value,
min_booking_amount: promotion.min_booking_amount || 0,
max_discount_amount: promotion.max_discount_amount || 0,
min_stay_days: promotion.min_stay_days || 0,
max_stay_days: promotion.max_stay_days || 0,
advance_booking_days: promotion.advance_booking_days || 0,
max_advance_booking_days: promotion.max_advance_booking_days || 0,
allowed_check_in_days: promotion.allowed_check_in_days || [],
allowed_check_out_days: promotion.allowed_check_out_days || [],
allowed_room_type_ids: promotion.allowed_room_type_ids || [],
excluded_room_type_ids: promotion.excluded_room_type_ids || [],
min_guests: promotion.min_guests || 0,
max_guests: promotion.max_guests || 0,
first_time_customer_only: promotion.first_time_customer_only || false,
repeat_customer_only: promotion.repeat_customer_only || false,
blackout_dates: promotion.blackout_dates || [],
start_date: promotion.start_date?.split('T')[0] || '',
end_date: promotion.end_date?.split('T')[0] || '',
usage_limit: promotion.usage_limit || 0,
@@ -124,6 +196,19 @@ const PromotionManagementPage: React.FC = () => {
discount_value: 0,
min_booking_amount: 0,
max_discount_amount: 0,
min_stay_days: 0,
max_stay_days: 0,
advance_booking_days: 0,
max_advance_booking_days: 0,
allowed_check_in_days: [],
allowed_check_out_days: [],
allowed_room_type_ids: [],
excluded_room_type_ids: [],
min_guests: 0,
max_guests: 0,
first_time_customer_only: false,
repeat_customer_only: false,
blackout_dates: [],
start_date: '',
end_date: '',
usage_limit: 0,
@@ -315,32 +400,30 @@ const PromotionManagementPage: React.FC = () => {
{}
{showModal && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-4 animate-fade-in">
<div className="min-h-full flex items-start justify-center py-8">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-3xl my-8 max-h-[calc(100vh-4rem)] overflow-hidden border border-slate-200 animate-scale-in">
{}
<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>
<h2 className="text-3xl font-bold text-amber-100 mb-1">
{editingPromotion ? 'Update Promotion' : 'Add New Promotion'}
</h2>
<p className="text-amber-200/80 text-sm font-light">
{editingPromotion ? 'Modify promotion details' : 'Create a new promotion program'}
</p>
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-3 sm:p-4 animate-fade-in" onClick={(e) => e.target === e.currentTarget && setShowModal(false)}>
<div className="min-h-full flex items-center justify-center py-4 sm:py-8">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-5xl w-full my-8 flex flex-col border border-slate-200 animate-scale-in" style={{ maxHeight: 'calc(100vh - 2rem)' }} onClick={(e) => e.stopPropagation()}>
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 md:px-8 py-4 sm:py-5 md:py-6 border-b border-slate-700 flex-shrink-0">
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-amber-100 mb-1">
{editingPromotion ? 'Update Promotion' : 'Add New Promotion'}
</h2>
<p className="text-amber-200/80 text-xs sm:text-sm font-light">
{editingPromotion ? 'Modify promotion details' : 'Create a new promotion program'}
</p>
</div>
<button
onClick={() => setShowModal(false)}
className="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400"
>
<X className="w-5 h-5 sm:w-6 sm:h-6" />
</button>
</div>
<button
onClick={() => setShowModal(false)}
className="w-10 h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400"
>
<X className="w-6 h-6" />
</button>
</div>
</div>
{}
<div className="p-8 overflow-y-auto max-h-[calc(100vh-12rem)] custom-scrollbar">
<form onSubmit={handleSubmit} className="space-y-6">
<div className="flex-1 overflow-y-auto" style={{ minHeight: 0, maxHeight: 'calc(100vh - 180px)' }}>
<form onSubmit={handleSubmit} className="p-4 sm:p-6 md:p-8 space-y-4 sm:space-y-5 md:space-y-6 pb-8">
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
@@ -440,6 +523,258 @@ const PromotionManagementPage: React.FC = () => {
</div>
</div>
{/* Enterprise Booking Conditions Section */}
<div className="border-t-2 border-amber-200 pt-6 mt-6">
<h3 className="text-lg font-bold text-slate-900 mb-4 flex items-center gap-2">
<div className="h-1 w-8 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
Enterprise Booking Conditions
</h3>
<p className="text-sm text-slate-600 mb-6">Configure advanced conditions for when this promotion applies</p>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Minimum Stay (nights)
</label>
<input
type="number"
value={formData.min_stay_days || ''}
onChange={(e) => setFormData({ ...formData, min_stay_days: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
min="0"
placeholder="0 = no minimum"
/>
<p className="text-xs text-slate-500 mt-1">Minimum number of nights required for booking</p>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Advance Booking (days)
</label>
<input
type="number"
value={formData.advance_booking_days || ''}
onChange={(e) => setFormData({ ...formData, advance_booking_days: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
min="0"
placeholder="0 = no requirement"
/>
<p className="text-xs text-slate-500 mt-1">Minimum days in advance the booking must be made</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Maximum Stay (nights)
</label>
<input
type="number"
value={formData.max_stay_days || ''}
onChange={(e) => setFormData({ ...formData, max_stay_days: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
min="0"
placeholder="0 = no maximum"
/>
<p className="text-xs text-slate-500 mt-1">Maximum number of nights allowed for booking</p>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Max Advance Booking (days)
</label>
<input
type="number"
value={formData.max_advance_booking_days || ''}
onChange={(e) => setFormData({ ...formData, max_advance_booking_days: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
min="0"
placeholder="0 = no maximum"
/>
<p className="text-xs text-slate-500 mt-1">Maximum days in advance the booking can be made</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Allowed Check-in Days
</label>
<div className="grid grid-cols-7 gap-2">
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day, index) => (
<label key={day} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.allowed_check_in_days?.includes(index) || false}
onChange={(e) => {
const current = formData.allowed_check_in_days || [];
if (e.target.checked) {
setFormData({ ...formData, allowed_check_in_days: [...current, index] });
} else {
setFormData({ ...formData, allowed_check_in_days: current.filter(d => d !== index) });
}
}}
className="w-4 h-4 text-amber-600 border-slate-300 rounded focus:ring-amber-500"
/>
<span className="text-xs text-slate-700">{day}</span>
</label>
))}
</div>
<p className="text-xs text-slate-500 mt-1">Leave empty to allow all days</p>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Allowed Check-out Days
</label>
<div className="grid grid-cols-7 gap-2">
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day, index) => (
<label key={day} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.allowed_check_out_days?.includes(index) || false}
onChange={(e) => {
const current = formData.allowed_check_out_days || [];
if (e.target.checked) {
setFormData({ ...formData, allowed_check_out_days: [...current, index] });
} else {
setFormData({ ...formData, allowed_check_out_days: current.filter(d => d !== index) });
}
}}
className="w-4 h-4 text-amber-600 border-slate-300 rounded focus:ring-amber-500"
/>
<span className="text-xs text-slate-700">{day}</span>
</label>
))}
</div>
<p className="text-xs text-slate-500 mt-1">Leave empty to allow all days</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Allowed Room Types
</label>
<select
multiple
value={formData.allowed_room_type_ids?.map(String) || []}
onChange={(e) => {
const selected = Array.from(e.target.selectedOptions, option => parseInt(option.value));
setFormData({ ...formData, allowed_room_type_ids: selected });
}}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
size={4}
>
{roomTypes.map(rt => (
<option key={rt.id} value={rt.id}>{rt.name}</option>
))}
</select>
<p className="text-xs text-slate-500 mt-1">Hold Ctrl/Cmd to select multiple. Leave empty to allow all room types.</p>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Excluded Room Types
</label>
<select
multiple
value={formData.excluded_room_type_ids?.map(String) || []}
onChange={(e) => {
const selected = Array.from(e.target.selectedOptions, option => parseInt(option.value));
setFormData({ ...formData, excluded_room_type_ids: selected });
}}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
size={4}
>
{roomTypes.map(rt => (
<option key={rt.id} value={rt.id}>{rt.name}</option>
))}
</select>
<p className="text-xs text-slate-500 mt-1">Hold Ctrl/Cmd to select multiple. These room types cannot use this promotion.</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Minimum Guests
</label>
<input
type="number"
value={formData.min_guests || ''}
onChange={(e) => setFormData({ ...formData, min_guests: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
min="1"
placeholder="0 = no minimum"
/>
<p className="text-xs text-slate-500 mt-1">Minimum number of guests required</p>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Maximum Guests
</label>
<input
type="number"
value={formData.max_guests || ''}
onChange={(e) => setFormData({ ...formData, max_guests: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
min="1"
placeholder="0 = no maximum"
/>
<p className="text-xs text-slate-500 mt-1">Maximum number of guests allowed</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.first_time_customer_only || false}
onChange={(e) => setFormData({ ...formData, first_time_customer_only: e.target.checked, repeat_customer_only: e.target.checked ? false : formData.repeat_customer_only })}
className="w-5 h-5 text-amber-600 border-slate-300 rounded focus:ring-amber-500"
/>
<span className="text-sm font-semibold text-slate-700">First-Time Customer Only</span>
</label>
<p className="text-xs text-slate-500 mt-1 ml-8">Only available to first-time customers</p>
</div>
<div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.repeat_customer_only || false}
onChange={(e) => setFormData({ ...formData, repeat_customer_only: e.target.checked, first_time_customer_only: e.target.checked ? false : formData.first_time_customer_only })}
className="w-5 h-5 text-amber-600 border-slate-300 rounded focus:ring-amber-500"
/>
<span className="text-sm font-semibold text-slate-700">Repeat Customer Only</span>
</label>
<p className="text-xs text-slate-500 mt-1 ml-8">Only available to returning customers</p>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
Blackout Dates
</label>
<textarea
value={formData.blackout_dates?.join('\n') || ''}
onChange={(e) => {
const dates = e.target.value.split('\n').filter(d => d.trim()).map(d => d.trim());
setFormData({ ...formData, blackout_dates: dates });
}}
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
rows={3}
placeholder="Enter dates (one per line) in YYYY-MM-DD format&#10;Example:&#10;2024-12-25&#10;2024-12-31&#10;2025-01-01"
/>
<p className="text-xs text-slate-500 mt-1">Dates when promotion doesn't apply. One date per line (YYYY-MM-DD format).</p>
</div>
{/* Dates & Status Section */}
<div className="border-t-2 border-amber-200 pt-6 mt-6">
<h3 className="text-lg font-bold text-slate-900 mb-4 flex items-center gap-2">
<div className="h-1 w-8 bg-gradient-to-r from-amber-400 to-amber-600 rounded-full"></div>
Promotion Period & Status
</h3>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
@@ -495,7 +830,7 @@ const PromotionManagementPage: React.FC = () => {
</div>
</div>
<div className="flex justify-end gap-3 pt-6 border-t border-slate-200">
<div className="sticky bottom-0 bg-white border-t border-slate-200 mt-8 -mx-4 sm:-mx-6 md:-mx-8 px-4 sm:px-6 md:px-8 py-4 flex justify-end gap-3">
<button
type="button"
onClick={() => setShowModal(false)}
@@ -514,7 +849,6 @@ const PromotionManagementPage: React.FC = () => {
</div>
</div>
</div>
</div>
)}
</div>
);

View File

@@ -6,8 +6,9 @@ import RoomFilter from '../../features/rooms/components/RoomFilter';
import RoomCard from '../../features/rooms/components/RoomCard';
import RoomCardSkeleton from '../../features/rooms/components/RoomCardSkeleton';
import Pagination from '../../shared/components/Pagination';
import { ArrowLeft, Hotel, Filter, ChevronDown, ChevronUp } from 'lucide-react';
import { ArrowLeft, Hotel, Filter, ChevronDown, ChevronUp, Tag, X, CheckCircle } from 'lucide-react';
import { logger } from '../../shared/utils/logger';
import { toast } from 'react-toastify';
const RoomListPage: React.FC = () => {
const [searchParams] = useSearchParams();
@@ -23,6 +24,45 @@ const RoomListPage: React.FC = () => {
totalPages: 1,
});
const abortControllerRef = useRef<AbortController | null>(null);
const [activePromotion, setActivePromotion] = useState<any>(null);
const [showPromotionBanner, setShowPromotionBanner] = useState(false);
// Check for active promotion from URL or sessionStorage
useEffect(() => {
const promoCode = searchParams.get('promo');
// Check sessionStorage first (from homepage promotion click)
try {
const storedPromotion = sessionStorage.getItem('activePromotion');
if (storedPromotion) {
const promo = JSON.parse(storedPromotion);
setActivePromotion(promo);
setShowPromotionBanner(true);
return;
}
} catch (error) {
console.warn('Failed to read promotion from sessionStorage:', error);
}
// Check URL params (fallback)
if (promoCode) {
setActivePromotion({
code: promoCode,
title: 'Special Offer',
discount: searchParams.get('discount') || '',
});
setShowPromotionBanner(true);
}
}, [searchParams]);
const handleDismissPromotion = () => {
setShowPromotionBanner(false);
try {
sessionStorage.removeItem('activePromotion');
} catch (error) {
console.warn('Failed to remove promotion from sessionStorage:', error);
}
};
useEffect(() => {
@@ -108,6 +148,44 @@ 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' }}>
{}
{/* Promotion Banner */}
{showPromotionBanner && activePromotion && (
<div className="w-full bg-gradient-to-r from-[#d4af37]/20 via-[#f5d76e]/15 to-[#d4af37]/20 border-b border-[#d4af37]/30">
<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-4">
<div className="flex items-center justify-between gap-4 bg-gradient-to-r from-[#1a1a1a] to-[#0f0f0f] border border-[#d4af37]/40 rounded-lg p-4 backdrop-blur-xl shadow-lg">
<div className="flex items-center gap-3 flex-1">
<div className="p-2 bg-[#d4af37]/20 rounded-lg border border-[#d4af37]/40">
<Tag className="w-5 h-5 text-[#d4af37]" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<CheckCircle className="w-4 h-4 text-green-400" />
<span className="text-sm font-semibold text-[#d4af37]">
Active Promotion: {activePromotion.code || activePromotion.title}
</span>
</div>
{activePromotion.discount && (
<p className="text-xs text-gray-300">
{activePromotion.discount} - {activePromotion.description || 'Valid on bookings'}
</p>
)}
<p className="text-xs text-gray-400 mt-1">
The promotion code will be automatically applied when you book a room
</p>
</div>
</div>
<button
onClick={handleDismissPromotion}
className="p-2 hover:bg-[#d4af37]/10 rounded-lg transition-colors"
aria-label="Dismiss promotion"
>
<X className="w-5 h-5 text-gray-400 hover:text-white" />
</button>
</div>
</div>
</div>
)}
<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">
{}

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { AlertTriangle, Shield, ArrowRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import useAuthStore from '../../store/useAuthStore';
interface MfaRequiredBannerProps {
/**
* Role-specific security page path
* e.g., '/accountant/security' or '/admin/security'
*/
securityPagePath: string;
/**
* Optional custom message
*/
message?: string;
/**
* Optional callback when banner is dismissed
*/
onDismiss?: () => void;
}
/**
* MFA Required Banner Component
*
* Displays a warning banner when MFA is required but not enabled.
* Used on dashboards for roles that require MFA for financial access.
*/
const MfaRequiredBanner: React.FC<MfaRequiredBannerProps> = ({
securityPagePath,
message,
onDismiss,
}) => {
const navigate = useNavigate();
const { userInfo } = useAuthStore();
const [dismissed, setDismissed] = React.useState(false);
// Check if MFA is required for this role
const isFinancialRole = userInfo?.role === 'accountant' || userInfo?.role === 'admin';
if (!isFinancialRole || dismissed) {
return null;
}
const handleSetupMfa = () => {
navigate(`${securityPagePath}?setup_mfa=true`);
};
const handleDismiss = () => {
setDismissed(true);
if (onDismiss) {
onDismiss();
}
};
return (
<div className="bg-gradient-to-r from-amber-50 via-yellow-50 to-amber-50 border-l-4 border-amber-500 rounded-lg shadow-md mb-6 p-4 sm:p-5">
<div className="flex items-start gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-full bg-amber-100 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 sm:w-6 sm:h-6 text-amber-600" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h3 className="text-base sm:text-lg font-semibold text-amber-900 mb-1 flex items-center gap-2">
<Shield className="w-4 h-4 sm:w-5 sm:h-5" />
Multi-Factor Authentication Required
</h3>
<p className="text-sm sm:text-base text-amber-800 mb-3">
{message ||
'Multi-factor authentication is required for financial access. Please enable MFA to continue using financial features.'}
</p>
<button
onClick={handleSetupMfa}
className="inline-flex items-center gap-2 px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white font-medium rounded-lg transition-colors duration-200 shadow-sm hover:shadow-md"
>
<Shield className="w-4 h-4" />
Enable MFA Now
<ArrowRight className="w-4 h-4" />
</button>
</div>
<button
onClick={handleDismiss}
className="flex-shrink-0 text-amber-600 hover:text-amber-800 transition-colors p-1"
aria-label="Dismiss banner"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
</div>
);
};
export default MfaRequiredBanner;

View File

@@ -0,0 +1,52 @@
import { useState, useEffect } from 'react';
import accountantSecurityService, { MFAStatus } from '../../features/security/services/accountantSecurityService';
import useAuthStore from '../../store/useAuthStore';
/**
* Hook to fetch and check MFA status for accountant/admin users
*/
export const useMfaStatus = () => {
const { userInfo } = useAuthStore();
const [mfaStatus, setMfaStatus] = useState<MFAStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isFinancialRole = userInfo?.role === 'accountant' || userInfo?.role === 'admin';
useEffect(() => {
if (!isFinancialRole) {
setLoading(false);
return;
}
const fetchMfaStatus = async () => {
try {
setLoading(true);
setError(null);
const response = await accountantSecurityService.getMFAStatus();
setMfaStatus(response.data);
} catch (err: any) {
// Silently fail - MFA status check shouldn't block dashboard
setError(err.response?.data?.detail || 'Failed to load MFA status');
setMfaStatus(null);
} finally {
setLoading(false);
}
};
fetchMfaStatus();
}, [isFinancialRole, userInfo?.role]);
const requiresMfaButNotEnabled = mfaStatus
? (mfaStatus.requires_mfa || mfaStatus.is_enforced) && !mfaStatus.mfa_enabled
: false;
return {
mfaStatus,
loading,
error,
requiresMfaButNotEnabled,
isFinancialRole,
};
};

View File

@@ -0,0 +1,235 @@
import { useMemo } from 'react';
import useAuthStore from '../../store/useAuthStore';
/**
* Permission constants matching backend ROLE_PERMISSIONS
* These are used for frontend UI/UX (showing/hiding elements)
* Backend enforces actual permissions for API access
*/
const ROLE_PERMISSIONS: Record<string, Set<string>> = {
admin: new Set([
'financial.view_reports',
'financial.manage_invoices',
'financial.manage_payments',
'financial.manage_settings',
'financial.high_risk_approve',
'users.manage',
'housekeeping.view_tasks',
'housekeeping.manage_tasks',
'housekeeping.view_rooms',
'bookings.manage',
'rooms.manage',
]),
accountant: new Set([
'financial.view_reports',
'financial.manage_invoices',
'financial.manage_payments',
'financial.manage_settings',
'financial.high_risk_approve',
]),
accountant_readonly: new Set([
'financial.view_reports',
'financial.view_invoices',
'financial.view_payments',
'financial.view_audit_trail',
]),
accountant_operator: new Set([
'financial.view_reports',
'financial.view_invoices',
'financial.view_payments',
'financial.manage_invoices',
'financial.manage_payments',
]),
accountant_approver: new Set([
'financial.view_reports',
'financial.view_invoices',
'financial.view_payments',
'financial.high_risk_approve',
]),
staff: new Set([
'financial.view_invoices',
'financial.manage_invoices',
'financial.view_payments',
'housekeeping.view_tasks',
'housekeeping.manage_tasks',
'housekeeping.view_rooms',
'bookings.manage',
'rooms.manage',
]),
housekeeping: new Set([
'housekeeping.view_tasks',
'housekeeping.manage_tasks',
'housekeeping.create_tasks',
'housekeeping.view_rooms',
'housekeeping.upload_photos',
'housekeeping.report_issues',
'housekeeping.view_inspections',
'housekeeping.manage_inspections',
]),
customer: new Set([
'bookings.view_own',
'bookings.create',
'rooms.view',
'profile.manage',
]),
};
/**
* Get permissions for a role
*/
const getRolePermissions = (roleName: string | undefined | null): Set<string> => {
if (!roleName) return new Set();
return ROLE_PERMISSIONS[roleName] || new Set();
};
/**
* Check if user has a specific permission
*/
const hasPermission = (roleName: string | undefined | null, permission: string): boolean => {
const permissions = getRolePermissions(roleName);
return permissions.has(permission);
};
/**
* Check if user has any of the given permissions
*/
const hasAnyPermission = (
roleName: string | undefined | null,
permissions: string[]
): boolean => {
if (!permissions.length) return true;
const rolePerms = getRolePermissions(roleName);
return permissions.some(perm => rolePerms.has(perm));
};
/**
* Check if user has all of the given permissions
*/
const hasAllPermissions = (
roleName: string | undefined | null,
permissions: string[]
): boolean => {
if (!permissions.length) return true;
const rolePerms = getRolePermissions(roleName);
return permissions.every(perm => rolePerms.has(perm));
};
/**
* Main hook to check permissions
*/
export const usePermissions = () => {
const { userInfo } = useAuthStore();
const roleName = userInfo?.role;
const permissions = useMemo(() => {
return getRolePermissions(roleName);
}, [roleName]);
return {
roleName,
permissions,
hasPermission: (permission: string) => hasPermission(roleName, permission),
hasAnyPermission: (permissions: string[]) => hasAnyPermission(roleName, permissions),
hasAllPermissions: (permissions: string[]) => hasAllPermissions(roleName, permissions),
};
};
/**
* Hook to check if user can create invoices
*/
export const useCanCreateInvoices = (): boolean => {
const { roleName, hasPermission } = usePermissions();
return useMemo(() => {
if (!roleName) return false;
// Admin, staff, accountant, accountant_operator can create invoices
return ['admin', 'staff', 'accountant', 'accountant_operator'].includes(roleName);
}, [roleName]);
};
/**
* Hook to check if user can manage users
*/
export const useCanManageUsers = (): boolean => {
const { roleName } = usePermissions();
return roleName === 'admin';
};
/**
* Hook to check if user can manage housekeeping tasks
*/
export const useCanManageHousekeepingTasks = (): boolean => {
const { hasPermission } = usePermissions();
return hasPermission('housekeeping.manage_tasks');
};
/**
* Hook to check if user can view financial reports
*/
export const useCanViewFinancialReports = (): boolean => {
const { hasPermission } = usePermissions();
return hasPermission('financial.view_reports');
};
/**
* Hook to check if user can manage invoices
*/
export const useCanManageInvoices = (): boolean => {
const { hasPermission } = usePermissions();
return hasPermission('financial.manage_invoices');
};
/**
* Hook to check if user can manage payments
*/
export const useCanManagePayments = (): boolean => {
const { hasPermission } = usePermissions();
return hasPermission('financial.manage_payments');
};
/**
* Hook to check if user can access financial features
*/
export const useCanAccessFinancial = (): boolean => {
const { roleName, hasPermission } = usePermissions();
return useMemo(() => {
if (!roleName) return false;
// Any permission starting with 'financial.' indicates financial access
return ['admin', 'staff', 'accountant', 'accountant_readonly', 'accountant_operator', 'accountant_approver']
.includes(roleName) || hasPermission('financial.view_reports');
}, [roleName, hasPermission]);
};
/**
* Hook to check if user can manage bookings
*/
export const useCanManageBookings = (): boolean => {
const { roleName, hasPermission } = usePermissions();
return useMemo(() => {
if (!roleName) return false;
return ['admin', 'staff'].includes(roleName) || hasPermission('bookings.manage');
}, [roleName, hasPermission]);
};
/**
* Hook to check if user can manage rooms
*/
export const useCanManageRooms = (): boolean => {
const { roleName, hasPermission } = usePermissions();
return useMemo(() => {
if (!roleName) return false;
return ['admin', 'staff'].includes(roleName) || hasPermission('rooms.manage');
}, [roleName, hasPermission]);
};
/**
* Hook to check if user can view all housekeeping tasks (not just assigned)
*/
export const useCanViewAllHousekeepingTasks = (): boolean => {
const { roleName } = usePermissions();
return roleName === 'admin' || roleName === 'staff';
};

View File

@@ -1,4 +1,5 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { toast } from 'react-toastify';
import useAuthStore from '../../store/useAuthStore';
import { logSecurityWarning, logDebug, logWarn } from '../utils/errorReporter';
@@ -257,7 +258,61 @@ apiClient.interceptors.response.use(
let errorMessage = 'You do not have permission to access this resource.';
let shouldRetry = false;
if (rawMessage.includes('CSRF token') || rawMessage.toLowerCase().includes('csrf')) {
// Check for MFA requirement error
const isMfaRequired =
errorData?.error === 'mfa_required' ||
errorData?.requires_mfa_setup === true ||
(typeof errorData === 'object' && errorData?.detail &&
(typeof errorData.detail === 'string' && errorData.detail.includes('Multi-factor authentication is required')) ||
(typeof errorData.detail === 'object' && errorData.detail?.error === 'mfa_required'));
if (isMfaRequired) {
// Get user info to determine redirect path
try {
const userInfoStr = localStorage.getItem('userInfo');
if (userInfoStr) {
const userInfo = JSON.parse(userInfoStr);
const userRole = userInfo?.role;
// Redirect to appropriate security page based on role
if (userRole === 'accountant') {
errorMessage = 'Multi-factor authentication is required for financial access. Redirecting to setup...';
toast.error('Multi-factor authentication is required for financial access. Please enable MFA to continue.');
// Use setTimeout to allow error to be logged before redirect
setTimeout(() => {
window.location.href = '/accountant/security?setup_mfa=true&redirect=' + encodeURIComponent(window.location.pathname);
}, 1000);
return Promise.reject({
...error,
message: errorMessage,
requiresMfaSetup: true,
redirectPath: '/accountant/security?setup_mfa=true',
});
} else if (userRole === 'admin') {
errorMessage = 'Multi-factor authentication is required for financial access. Redirecting to setup...';
toast.error('Multi-factor authentication is required for financial access. Please enable MFA to continue.');
setTimeout(() => {
window.location.href = '/admin/security?setup_mfa=true&redirect=' + encodeURIComponent(window.location.pathname);
}, 1000);
return Promise.reject({
...error,
message: errorMessage,
requiresMfaSetup: true,
redirectPath: '/admin/security?setup_mfa=true',
});
}
}
} catch (e) {
logDebug('Error parsing userInfo for MFA redirect', { error: e instanceof Error ? e.message : String(e) });
}
// Fallback message if we can't determine role
errorMessage = 'Multi-factor authentication is required. Please enable MFA in your security settings.';
} else if (rawMessage.includes('CSRF token') || rawMessage.toLowerCase().includes('csrf')) {
errorMessage = 'Security validation failed. Please refresh the page and try again.';
shouldRetry = true;
} else if (rawMessage.toLowerCase().includes('forbidden') || rawMessage.toLowerCase().includes('permission')) {
@@ -271,6 +326,7 @@ apiClient.interceptors.response.use(
url: originalRequest?.url,
method: originalRequest?.method,
rawMessage: rawMessage.substring(0, 100), // Limit length
isMfaRequired,
});
}

View File

@@ -0,0 +1,176 @@
/**
* Centralized error handling utilities
* Provides consistent error handling and user feedback across the application
*/
import { toast } from 'react-toastify';
import { AxiosError } from 'axios';
import { logger } from './logger';
export interface ErrorDetails {
message: string;
errors?: Array<{ field?: string; message: string }>;
statusCode?: number;
requestId?: string;
}
/**
* Extract error message from various error types
*/
export const extractErrorMessage = (error: any): string => {
// Handle Axios errors
if (error?.response?.data) {
const data = error.response.data;
// Check for standard error response format
if (data.message) {
return data.message;
}
// Check for detail field
if (data.detail) {
return typeof data.detail === 'string'
? data.detail
: data.detail?.message || 'An error occurred';
}
// Check for errors array
if (data.errors && Array.isArray(data.errors) && data.errors.length > 0) {
return data.errors[0].message || data.errors[0].detail || 'Validation error';
}
}
// Handle standard Error objects
if (error instanceof Error) {
return error.message;
}
// Handle string errors
if (typeof error === 'string') {
return error;
}
// Fallback
return 'An unexpected error occurred. Please try again.';
};
/**
* Extract error details for logging/debugging
*/
export const extractErrorDetails = (error: any): ErrorDetails => {
const message = extractErrorMessage(error);
const statusCode = error?.response?.status;
const requestId = error?.response?.headers?.['x-request-id'] || error?.requestId;
let errors: Array<{ field?: string; message: string }> | undefined;
if (error?.response?.data?.errors && Array.isArray(error.response.data.errors)) {
errors = error.response.data.errors.map((err: any) => ({
field: err.field || err.path,
message: err.message || 'Invalid value',
}));
}
return {
message,
errors,
statusCode,
requestId,
};
};
/**
* Handle error with user feedback
* Shows toast notification and optionally logs error
*/
export const handleError = (
error: any,
options?: {
defaultMessage?: string;
showToast?: boolean;
logError?: boolean;
toastOptions?: any;
}
): ErrorDetails => {
const {
defaultMessage = 'An error occurred. Please try again.',
showToast = true,
logError = true,
toastOptions = {},
} = options || {};
const errorDetails = extractErrorDetails(error);
const displayMessage = errorDetails.message || defaultMessage;
// Log error for debugging (only in development or if explicitly enabled)
if (logError && (import.meta.env.DEV || import.meta.env.MODE === 'development')) {
logger.error('Error occurred', {
message: displayMessage,
statusCode: errorDetails.statusCode,
requestId: errorDetails.requestId,
error: error instanceof Error ? error.stack : String(error),
});
}
// Show user-friendly toast notification
if (showToast) {
toast.error(displayMessage, {
autoClose: 5000,
...toastOptions,
});
}
return errorDetails;
};
/**
* Handle error silently (no toast, only logging)
*/
export const handleErrorSilently = (error: any): ErrorDetails => {
return handleError(error, { showToast: false, logError: true });
};
/**
* Handle error with custom message
*/
export const handleErrorWithMessage = (
error: any,
customMessage: string
): ErrorDetails => {
const errorDetails = extractErrorDetails(error);
toast.error(customMessage, {
autoClose: 5000,
});
if (import.meta.env.DEV) {
logger.error('Error occurred', {
customMessage,
originalError: errorDetails.message,
statusCode: errorDetails.statusCode,
requestId: errorDetails.requestId,
});
}
return errorDetails;
};
/**
* Handle validation errors specifically
*/
export const handleValidationError = (error: any): ErrorDetails => {
const errorDetails = extractErrorDetails(error);
if (errorDetails.errors && errorDetails.errors.length > 0) {
// Show first validation error
toast.error(errorDetails.errors[0].message || 'Please check your input and try again.', {
autoClose: 5000,
});
} else {
toast.error(errorDetails.message || 'Please check your input and try again.', {
autoClose: 5000,
});
}
return errorDetails;
};

View File

@@ -0,0 +1,144 @@
/**
* Permission helper utilities
* Mirror backend permission structure for frontend UI/UX
*
* NOTE: These are for UI purposes only. Backend enforces actual permissions.
*/
export type Permission =
// Financial permissions
| 'financial.view_reports'
| 'financial.manage_invoices'
| 'financial.manage_payments'
| 'financial.manage_settings'
| 'financial.high_risk_approve'
| 'financial.view_invoices'
| 'financial.view_payments'
| 'financial.view_audit_trail'
// User management permissions
| 'users.manage'
// Housekeeping permissions
| 'housekeeping.view_tasks'
| 'housekeeping.manage_tasks'
| 'housekeeping.create_tasks'
| 'housekeeping.view_rooms'
| 'housekeeping.upload_photos'
| 'housekeeping.report_issues'
| 'housekeeping.view_inspections'
| 'housekeeping.manage_inspections'
// Booking permissions
| 'bookings.manage'
| 'bookings.view_own'
| 'bookings.create'
// Room permissions
| 'rooms.manage'
| 'rooms.view'
// Profile permissions
| 'profile.manage';
/**
* Check if a role has a specific permission
* This is a pure function that can be used outside of React components
*/
export const roleHasPermission = (
roleName: string | undefined | null,
permission: Permission
): boolean => {
if (!roleName) return false;
// Import permissions from hook (circular dependency issue if we import directly)
// Instead, we'll define a simplified check here
const rolePermissions: Record<string, Permission[]> = {
admin: [
'financial.view_reports',
'financial.manage_invoices',
'financial.manage_payments',
'financial.manage_settings',
'financial.high_risk_approve',
'users.manage',
'housekeeping.view_tasks',
'housekeeping.manage_tasks',
'housekeeping.view_rooms',
'bookings.manage',
'rooms.manage',
],
accountant: [
'financial.view_reports',
'financial.manage_invoices',
'financial.manage_payments',
'financial.manage_settings',
'financial.high_risk_approve',
],
accountant_readonly: [
'financial.view_reports',
'financial.view_invoices',
'financial.view_payments',
'financial.view_audit_trail',
],
accountant_operator: [
'financial.view_reports',
'financial.view_invoices',
'financial.view_payments',
'financial.manage_invoices',
'financial.manage_payments',
],
accountant_approver: [
'financial.view_reports',
'financial.view_invoices',
'financial.view_payments',
'financial.high_risk_approve',
],
staff: [
'financial.view_invoices',
'financial.manage_invoices',
'financial.view_payments',
'housekeeping.view_tasks',
'housekeeping.manage_tasks',
'housekeeping.view_rooms',
'bookings.manage',
'rooms.manage',
],
housekeeping: [
'housekeeping.view_tasks',
'housekeeping.manage_tasks',
'housekeeping.create_tasks',
'housekeeping.view_rooms',
'housekeeping.upload_photos',
'housekeeping.report_issues',
'housekeeping.view_inspections',
'housekeeping.manage_inspections',
],
customer: [
'bookings.view_own',
'bookings.create',
'rooms.view',
'profile.manage',
],
};
const permissions = rolePermissions[roleName] || [];
return permissions.includes(permission);
};
/**
* Check if role is in accountant family
*/
export const isAccountantRole = (roleName: string | undefined | null): boolean => {
if (!roleName) return false;
return roleName === 'accountant' || roleName.startsWith('accountant_');
};
/**
* Check if role can access financial features
*/
export const canAccessFinancial = (roleName: string | undefined | null): boolean => {
if (!roleName) return false;
return ['admin', 'staff', 'accountant', 'accountant_readonly', 'accountant_operator', 'accountant_approver']
.includes(roleName);
};

View File

@@ -0,0 +1,131 @@
/**
* Input sanitization utilities using DOMPurify
* Prevents XSS attacks by sanitizing user-generated content
*/
// DOMPurify needs to run in browser environment
let DOMPurify: any = null;
if (typeof window !== 'undefined') {
DOMPurify = require('dompurify');
}
/**
* Configuration for DOMPurify
*/
const PURIFY_CONFIG = {
ALLOWED_TAGS: [
'p', 'br', 'strong', 'em', 'u', 'b', 'i', 's', 'strike',
'a', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'blockquote', 'pre', 'code', 'hr', 'div', 'span',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
],
ALLOWED_ATTR: [
'href', 'title', 'target', 'rel',
'class',
'colspan', 'rowspan',
],
ALLOW_DATA_ATTR: false,
};
/**
* Sanitize HTML content to prevent XSS attacks
*
* @param content - HTML content to sanitize
* @param strip - If true, remove disallowed tags instead of escaping them
* @returns Sanitized HTML string
*/
export const sanitizeHTML = (content: string | null | undefined, strip: boolean = false): string => {
if (!content) return '';
if (typeof content !== 'string') {
content = String(content);
}
// Skip sanitization in SSR or if DOMPurify not available
if (!DOMPurify || typeof window === 'undefined') {
// Fallback: basic HTML entity escaping
return content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
// Sanitize HTML
return DOMPurify.sanitize(content, {
...PURIFY_CONFIG,
KEEP_CONTENT: !strip,
});
};
/**
* Strip all HTML tags from content, leaving only plain text
*
* @param content - Content to strip HTML from
* @returns Plain text string
*/
export const sanitizeText = (content: string | null | undefined): string => {
if (!content) return '';
if (typeof content !== 'string') {
content = String(content);
}
// Skip sanitization in SSR or if DOMPurify not available
if (!DOMPurify || typeof window === 'undefined') {
// Fallback: basic HTML entity escaping
return content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
// Strip all HTML tags
return DOMPurify.sanitize(content, {
ALLOWED_TAGS: [],
KEEP_CONTENT: true,
});
};
/**
* Escape HTML entities for safe display
*
* @param text - Text to escape
* @returns Escaped text safe for HTML display
*/
export const escapeHTML = (text: string | null | undefined): string => {
if (!text) return '';
if (typeof text !== 'string') {
text = String(text);
}
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
/**
* Sanitize user input for display in React components
* This removes any potentially dangerous content
*
* @param input - User input to sanitize
* @returns Sanitized string safe for display
*/
export const sanitizeUserInput = (input: string | null | undefined): string => {
return sanitizeText(input);
};
/**
* Sanitize rich text content (allows safe HTML)
*
* @param html - HTML content to sanitize
* @returns Sanitized HTML safe for rendering
*/
export const sanitizeRichText = (html: string | null | undefined): string => {
return sanitizeHTML(html, false);
};