This commit is contained in:
Iliyan Angelov
2025-11-16 20:05:08 +02:00
parent 98ccd5b6ff
commit 48353cde9c
118 changed files with 9488 additions and 1336 deletions

View File

@@ -1,35 +1,34 @@
# Environment
NODE_ENV=development
# Hotel Booking API - Environment Variables
# Copy this file to .env and fill in your actual values
# Server
PORT=3000
HOST=localhost
# ============================================
# Email/SMTP Configuration
# ============================================
# SMTP Server Settings
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-specific-password
# Database
# Email Sender Information
SMTP_FROM_EMAIL=noreply@yourdomain.com
SMTP_FROM_NAME=Hotel Booking
# Alternative: Legacy environment variable names (for backward compatibility)
# MAIL_HOST=smtp.gmail.com
# MAIL_PORT=587
# MAIL_USER=your-email@gmail.com
# MAIL_PASS=your-app-specific-password
# MAIL_FROM=noreply@yourdomain.com
# MAIL_SECURE=false
# ============================================
# Other Required Variables
# ============================================
CLIENT_URL=http://localhost:5173
DB_USER=root
DB_PASS=your_database_password
DB_NAME=hotel_db
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASS=
DB_NAME=hotel_booking_dev
# JWT
JWT_SECRET=your_super_secret_jwt_key_change_this_in_production
JWT_EXPIRES_IN=1h
JWT_REFRESH_SECRET=your_super_secret_refresh_key_change_this_in_production
JWT_REFRESH_EXPIRES_IN=7d
# Client URL
CLIENT_URL=http://localhost:5173
# Upload
MAX_FILE_SIZE=5242880
ALLOWED_FILE_TYPES=image/jpeg,image/png,image/jpg,image/webp
# Pagination
DEFAULT_PAGE_SIZE=10
MAX_PAGE_SIZE=100
# Rate Limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
JWT_SECRET=your-super-secret-jwt-key-change-in-production

Binary file not shown.

View File

@@ -5,12 +5,17 @@ from alembic import context
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
# Import models and Base
from src.config.database import Base
from src.config.settings import settings
from src.models import * # Import all models
# this is the Alembic Config object
@@ -20,16 +25,8 @@ config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Get database URL from environment
database_url = os.getenv("DATABASE_URL")
if not database_url:
db_user = os.getenv("DB_USER", "root")
db_pass = os.getenv("DB_PASS", "")
db_name = os.getenv("DB_NAME", "hotel_db")
db_host = os.getenv("DB_HOST", "localhost")
db_port = os.getenv("DB_PORT", "3306")
database_url = f"mysql+pymysql://{db_user}:{db_pass}@{db_host}:{db_port}/{db_name}"
# Get database URL from settings
database_url = settings.database_url
config.set_main_option("sqlalchemy.url", database_url)
# add your model's MetaData object here

View File

@@ -0,0 +1,285 @@
"""Initial migration: create all tables with indexes
Revision ID: 59baf2338f8a
Revises:
Create Date: 2025-11-16 16:03:26.313117
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '59baf2338f8a'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('audit_logs',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('action', sa.String(length=100), nullable=False),
sa.Column('resource_type', sa.String(length=50), nullable=False),
sa.Column('resource_id', sa.Integer(), nullable=True),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.Column('user_agent', sa.String(length=255), nullable=True),
sa.Column('request_id', sa.String(length=36), nullable=True),
sa.Column('details', sa.JSON(), nullable=True),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_audit_logs_action'), 'audit_logs', ['action'], unique=False)
op.create_index(op.f('ix_audit_logs_created_at'), 'audit_logs', ['created_at'], unique=False)
op.create_index(op.f('ix_audit_logs_id'), 'audit_logs', ['id'], unique=False)
op.create_index(op.f('ix_audit_logs_request_id'), 'audit_logs', ['request_id'], unique=False)
op.create_index(op.f('ix_audit_logs_resource_id'), 'audit_logs', ['resource_id'], unique=False)
op.create_index(op.f('ix_audit_logs_resource_type'), 'audit_logs', ['resource_type'], unique=False)
op.create_index(op.f('ix_audit_logs_user_id'), 'audit_logs', ['user_id'], unique=False)
op.drop_index('name', table_name='SequelizeMeta')
op.drop_table('SequelizeMeta')
op.drop_index('banners_is_active', table_name='banners')
op.drop_index('banners_position', table_name='banners')
op.create_index(op.f('ix_banners_id'), 'banners', ['id'], unique=False)
# Drop foreign keys first, then indexes
op.drop_constraint('bookings_ibfk_2', 'bookings', type_='foreignkey')
op.drop_constraint('bookings_ibfk_1', 'bookings', type_='foreignkey')
op.drop_index('booking_number', table_name='bookings')
op.drop_index('bookings_booking_number', table_name='bookings')
op.drop_index('bookings_check_in_date', table_name='bookings')
op.drop_index('bookings_check_out_date', table_name='bookings')
op.drop_index('bookings_room_id', table_name='bookings')
op.drop_index('bookings_status', table_name='bookings')
op.drop_index('bookings_user_id', table_name='bookings')
op.create_index(op.f('ix_bookings_booking_number'), 'bookings', ['booking_number'], unique=True)
op.create_index(op.f('ix_bookings_id'), 'bookings', ['id'], unique=False)
op.create_foreign_key(None, 'bookings', 'users', ['user_id'], ['id'])
op.create_foreign_key(None, 'bookings', 'rooms', ['room_id'], ['id'])
# Drop foreign keys first, then indexes
op.drop_constraint('checkin_checkout_ibfk_1', 'checkin_checkout', type_='foreignkey')
op.drop_constraint('checkin_checkout_ibfk_2', 'checkin_checkout', type_='foreignkey')
op.drop_constraint('checkin_checkout_ibfk_3', 'checkin_checkout', type_='foreignkey')
op.drop_index('checkin_checkout_booking_id', table_name='checkin_checkout')
op.create_index(op.f('ix_checkin_checkout_id'), 'checkin_checkout', ['id'], unique=False)
op.create_unique_constraint(None, 'checkin_checkout', ['booking_id'])
op.create_foreign_key(None, 'checkin_checkout', 'bookings', ['booking_id'], ['id'])
op.create_foreign_key(None, 'checkin_checkout', 'users', ['checkout_by'], ['id'])
op.create_foreign_key(None, 'checkin_checkout', 'users', ['checkin_by'], ['id'])
# Drop foreign keys first, then indexes
op.drop_constraint('favorites_ibfk_2', 'favorites', type_='foreignkey')
op.drop_constraint('favorites_ibfk_1', 'favorites', type_='foreignkey')
op.drop_index('favorites_room_id', table_name='favorites')
op.drop_index('favorites_user_id', table_name='favorites')
op.drop_index('unique_user_room_favorite', table_name='favorites')
op.create_index(op.f('ix_favorites_id'), 'favorites', ['id'], unique=False)
op.create_foreign_key(None, 'favorites', 'users', ['user_id'], ['id'])
op.create_foreign_key(None, 'favorites', 'rooms', ['room_id'], ['id'])
op.alter_column('password_reset_tokens', 'used',
existing_type=mysql.TINYINT(display_width=1),
nullable=False,
existing_server_default=sa.text("'0'"))
# Drop foreign key first, then indexes
op.drop_constraint('password_reset_tokens_ibfk_1', 'password_reset_tokens', type_='foreignkey')
op.drop_index('password_reset_tokens_token', table_name='password_reset_tokens')
op.drop_index('password_reset_tokens_user_id', table_name='password_reset_tokens')
op.drop_index('token', table_name='password_reset_tokens')
op.create_index(op.f('ix_password_reset_tokens_id'), 'password_reset_tokens', ['id'], unique=False)
op.create_index(op.f('ix_password_reset_tokens_token'), 'password_reset_tokens', ['token'], unique=True)
op.create_foreign_key(None, 'password_reset_tokens', 'users', ['user_id'], ['id'])
op.alter_column('payments', 'deposit_percentage',
existing_type=mysql.INTEGER(),
comment=None,
existing_comment='Percentage of deposit (e.g., 20, 30, 50)',
existing_nullable=True)
# Drop foreign keys first, then indexes
op.drop_constraint('payments_related_payment_id_foreign_idx', 'payments', type_='foreignkey')
op.drop_constraint('payments_ibfk_1', 'payments', type_='foreignkey')
op.drop_index('payments_booking_id', table_name='payments')
op.drop_index('payments_payment_status', table_name='payments')
op.create_index(op.f('ix_payments_id'), 'payments', ['id'], unique=False)
op.create_foreign_key(None, 'payments', 'bookings', ['booking_id'], ['id'])
op.create_foreign_key(None, 'payments', 'payments', ['related_payment_id'], ['id'])
op.drop_index('code', table_name='promotions')
op.drop_index('promotions_code', table_name='promotions')
op.drop_index('promotions_is_active', table_name='promotions')
op.create_index(op.f('ix_promotions_code'), 'promotions', ['code'], unique=True)
op.create_index(op.f('ix_promotions_id'), 'promotions', ['id'], unique=False)
# Drop foreign key first, then indexes
op.drop_constraint('refresh_tokens_ibfk_1', 'refresh_tokens', type_='foreignkey')
op.drop_index('refresh_tokens_token', table_name='refresh_tokens')
op.drop_index('refresh_tokens_user_id', table_name='refresh_tokens')
op.drop_index('token', table_name='refresh_tokens')
op.create_index(op.f('ix_refresh_tokens_id'), 'refresh_tokens', ['id'], unique=False)
op.create_index(op.f('ix_refresh_tokens_token'), 'refresh_tokens', ['token'], unique=True)
op.create_foreign_key(None, 'refresh_tokens', 'users', ['user_id'], ['id'])
# Drop foreign keys first, then indexes
op.drop_constraint('reviews_ibfk_2', 'reviews', type_='foreignkey')
op.drop_constraint('reviews_ibfk_1', 'reviews', type_='foreignkey')
op.drop_index('reviews_room_id', table_name='reviews')
op.drop_index('reviews_status', table_name='reviews')
op.drop_index('reviews_user_id', table_name='reviews')
op.create_index(op.f('ix_reviews_id'), 'reviews', ['id'], unique=False)
op.create_foreign_key(None, 'reviews', 'rooms', ['room_id'], ['id'])
op.create_foreign_key(None, 'reviews', 'users', ['user_id'], ['id'])
op.drop_index('name', table_name='roles')
op.create_index(op.f('ix_roles_id'), 'roles', ['id'], unique=False)
op.create_index(op.f('ix_roles_name'), 'roles', ['name'], unique=True)
op.create_index(op.f('ix_room_types_id'), 'room_types', ['id'], unique=False)
# Drop foreign key first, then indexes
op.drop_constraint('rooms_ibfk_1', 'rooms', type_='foreignkey')
op.drop_index('room_number', table_name='rooms')
op.drop_index('rooms_featured', table_name='rooms')
op.drop_index('rooms_room_type_id', table_name='rooms')
op.drop_index('rooms_status', table_name='rooms')
op.create_index(op.f('ix_rooms_id'), 'rooms', ['id'], unique=False)
op.create_index(op.f('ix_rooms_room_number'), 'rooms', ['room_number'], unique=True)
op.create_foreign_key(None, 'rooms', 'room_types', ['room_type_id'], ['id'])
# Drop foreign keys first, then indexes
op.drop_constraint('service_usages_ibfk_1', 'service_usages', type_='foreignkey')
op.drop_constraint('service_usages_ibfk_2', 'service_usages', type_='foreignkey')
op.drop_index('service_usages_booking_id', table_name='service_usages')
op.drop_index('service_usages_service_id', table_name='service_usages')
op.create_index(op.f('ix_service_usages_id'), 'service_usages', ['id'], unique=False)
op.create_foreign_key(None, 'service_usages', 'bookings', ['booking_id'], ['id'])
op.create_foreign_key(None, 'service_usages', 'services', ['service_id'], ['id'])
op.drop_index('services_category', table_name='services')
op.create_index(op.f('ix_services_id'), 'services', ['id'], unique=False)
# Drop foreign key first, then indexes
op.drop_constraint('users_ibfk_1', 'users', type_='foreignkey')
op.drop_index('email', table_name='users')
op.drop_index('users_email', table_name='users')
op.drop_index('users_role_id', table_name='users')
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_foreign_key(None, 'users', 'roles', ['role_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'users', type_='foreignkey')
op.create_foreign_key('users_ibfk_1', 'users', 'roles', ['role_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.create_index('users_role_id', 'users', ['role_id'], unique=False)
op.create_index('users_email', 'users', ['email'], unique=False)
op.create_index('email', 'users', ['email'], unique=False)
op.drop_index(op.f('ix_services_id'), table_name='services')
op.create_index('services_category', 'services', ['category'], unique=False)
op.drop_constraint(None, 'service_usages', type_='foreignkey')
op.drop_constraint(None, 'service_usages', type_='foreignkey')
op.create_foreign_key('service_usages_ibfk_2', 'service_usages', 'services', ['service_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT')
op.create_foreign_key('service_usages_ibfk_1', 'service_usages', 'bookings', ['booking_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT')
op.drop_index(op.f('ix_service_usages_id'), table_name='service_usages')
op.create_index('service_usages_service_id', 'service_usages', ['service_id'], unique=False)
op.create_index('service_usages_booking_id', 'service_usages', ['booking_id'], unique=False)
op.drop_constraint(None, 'rooms', type_='foreignkey')
op.create_foreign_key('rooms_ibfk_1', 'rooms', 'room_types', ['room_type_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT')
op.drop_index(op.f('ix_rooms_room_number'), table_name='rooms')
op.drop_index(op.f('ix_rooms_id'), table_name='rooms')
op.create_index('rooms_status', 'rooms', ['status'], unique=False)
op.create_index('rooms_room_type_id', 'rooms', ['room_type_id'], unique=False)
op.create_index('rooms_featured', 'rooms', ['featured'], unique=False)
op.create_index('room_number', 'rooms', ['room_number'], unique=False)
op.drop_index(op.f('ix_room_types_id'), table_name='room_types')
op.drop_index(op.f('ix_roles_name'), table_name='roles')
op.drop_index(op.f('ix_roles_id'), table_name='roles')
op.create_index('name', 'roles', ['name'], unique=False)
op.drop_constraint(None, 'reviews', type_='foreignkey')
op.drop_constraint(None, 'reviews', type_='foreignkey')
op.create_foreign_key('reviews_ibfk_1', 'reviews', 'users', ['user_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE')
op.create_foreign_key('reviews_ibfk_2', 'reviews', 'rooms', ['room_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE')
op.drop_index(op.f('ix_reviews_id'), table_name='reviews')
op.create_index('reviews_user_id', 'reviews', ['user_id'], unique=False)
op.create_index('reviews_status', 'reviews', ['status'], unique=False)
op.create_index('reviews_room_id', 'reviews', ['room_id'], unique=False)
op.drop_constraint(None, 'refresh_tokens', type_='foreignkey')
op.create_foreign_key('refresh_tokens_ibfk_1', 'refresh_tokens', 'users', ['user_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE')
op.drop_index(op.f('ix_refresh_tokens_token'), table_name='refresh_tokens')
op.drop_index(op.f('ix_refresh_tokens_id'), table_name='refresh_tokens')
op.create_index('token', 'refresh_tokens', ['token'], unique=False)
op.create_index('refresh_tokens_user_id', 'refresh_tokens', ['user_id'], unique=False)
op.create_index('refresh_tokens_token', 'refresh_tokens', ['token'], unique=False)
op.drop_index(op.f('ix_promotions_id'), table_name='promotions')
op.drop_index(op.f('ix_promotions_code'), table_name='promotions')
op.create_index('promotions_is_active', 'promotions', ['is_active'], unique=False)
op.create_index('promotions_code', 'promotions', ['code'], unique=False)
op.create_index('code', 'promotions', ['code'], unique=False)
op.drop_constraint(None, 'payments', type_='foreignkey')
op.drop_constraint(None, 'payments', type_='foreignkey')
op.create_foreign_key('payments_ibfk_1', 'payments', 'bookings', ['booking_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT')
op.create_foreign_key('payments_related_payment_id_foreign_idx', 'payments', 'payments', ['related_payment_id'], ['id'], onupdate='CASCADE', ondelete='SET NULL')
op.drop_index(op.f('ix_payments_id'), table_name='payments')
op.create_index('payments_payment_status', 'payments', ['payment_status'], unique=False)
op.create_index('payments_booking_id', 'payments', ['booking_id'], unique=False)
op.alter_column('payments', 'deposit_percentage',
existing_type=mysql.INTEGER(),
comment='Percentage of deposit (e.g., 20, 30, 50)',
existing_nullable=True)
op.drop_constraint(None, 'password_reset_tokens', type_='foreignkey')
op.create_foreign_key('password_reset_tokens_ibfk_1', 'password_reset_tokens', 'users', ['user_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE')
op.drop_index(op.f('ix_password_reset_tokens_token'), table_name='password_reset_tokens')
op.drop_index(op.f('ix_password_reset_tokens_id'), table_name='password_reset_tokens')
op.create_index('token', 'password_reset_tokens', ['token'], unique=False)
op.create_index('password_reset_tokens_user_id', 'password_reset_tokens', ['user_id'], unique=False)
op.create_index('password_reset_tokens_token', 'password_reset_tokens', ['token'], unique=False)
op.alter_column('password_reset_tokens', 'used',
existing_type=mysql.TINYINT(display_width=1),
nullable=True,
existing_server_default=sa.text("'0'"))
op.drop_constraint(None, 'favorites', type_='foreignkey')
op.drop_constraint(None, 'favorites', type_='foreignkey')
op.create_foreign_key('favorites_ibfk_1', 'favorites', 'users', ['user_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE')
op.create_foreign_key('favorites_ibfk_2', 'favorites', 'rooms', ['room_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE')
op.drop_index(op.f('ix_favorites_id'), table_name='favorites')
op.create_index('unique_user_room_favorite', 'favorites', ['user_id', 'room_id'], unique=False)
op.create_index('favorites_user_id', 'favorites', ['user_id'], unique=False)
op.create_index('favorites_room_id', 'favorites', ['room_id'], unique=False)
op.drop_constraint(None, 'checkin_checkout', type_='foreignkey')
op.drop_constraint(None, 'checkin_checkout', type_='foreignkey')
op.drop_constraint(None, 'checkin_checkout', type_='foreignkey')
op.create_foreign_key('checkin_checkout_ibfk_3', 'checkin_checkout', 'users', ['checkout_by'], ['id'], onupdate='CASCADE', ondelete='SET NULL')
op.create_foreign_key('checkin_checkout_ibfk_2', 'checkin_checkout', 'users', ['checkin_by'], ['id'], onupdate='CASCADE', ondelete='SET NULL')
op.create_foreign_key('checkin_checkout_ibfk_1', 'checkin_checkout', 'bookings', ['booking_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT')
op.drop_constraint(None, 'checkin_checkout', type_='unique')
op.drop_index(op.f('ix_checkin_checkout_id'), table_name='checkin_checkout')
op.create_index('checkin_checkout_booking_id', 'checkin_checkout', ['booking_id'], unique=False)
op.drop_constraint(None, 'bookings', type_='foreignkey')
op.drop_constraint(None, 'bookings', type_='foreignkey')
op.create_foreign_key('bookings_ibfk_1', 'bookings', 'users', ['user_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT')
op.create_foreign_key('bookings_ibfk_2', 'bookings', 'rooms', ['room_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT')
op.drop_index(op.f('ix_bookings_id'), table_name='bookings')
op.drop_index(op.f('ix_bookings_booking_number'), table_name='bookings')
op.create_index('bookings_user_id', 'bookings', ['user_id'], unique=False)
op.create_index('bookings_status', 'bookings', ['status'], unique=False)
op.create_index('bookings_room_id', 'bookings', ['room_id'], unique=False)
op.create_index('bookings_check_out_date', 'bookings', ['check_out_date'], unique=False)
op.create_index('bookings_check_in_date', 'bookings', ['check_in_date'], unique=False)
op.create_index('bookings_booking_number', 'bookings', ['booking_number'], unique=False)
op.create_index('booking_number', 'bookings', ['booking_number'], unique=False)
op.drop_index(op.f('ix_banners_id'), table_name='banners')
op.create_index('banners_position', 'banners', ['position'], unique=False)
op.create_index('banners_is_active', 'banners', ['is_active'], unique=False)
op.create_table('SequelizeMeta',
sa.Column('name', mysql.VARCHAR(collation='utf8mb3_unicode_ci', length=255), nullable=False),
sa.PrimaryKeyConstraint('name'),
mysql_collate='utf8mb3_unicode_ci',
mysql_default_charset='utf8mb3',
mysql_engine='InnoDB'
)
op.create_index('name', 'SequelizeMeta', ['name'], unique=False)
op.drop_index(op.f('ix_audit_logs_user_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_resource_type'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_resource_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_request_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_created_at'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_action'), table_name='audit_logs')
op.drop_table('audit_logs')
# ### end Alembic commands ###

View File

@@ -17,3 +17,8 @@ aiosmtplib==3.0.1
jinja2==3.1.2
alembic==1.12.1
# Enterprise features (optional but recommended)
# redis==5.0.1 # Uncomment if using Redis caching
# prometheus-client==0.19.0 # Uncomment for Prometheus metrics
# sentry-sdk==1.38.0 # Uncomment for Sentry error tracking

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""
Script to reset passwords for test users
"""
import sys
import os
import bcrypt
# Add the src directory to the path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
from sqlalchemy.orm import Session
from src.config.database import SessionLocal
from src.models.user import User
from src.config.logging_config import setup_logging
logger = setup_logging()
def hash_password(password: str) -> str:
"""Hash password using bcrypt"""
password_bytes = password.encode('utf-8')
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password_bytes, salt)
return hashed.decode('utf-8')
def reset_password(db: Session, email: str, new_password: str) -> bool:
"""Reset password for a user"""
user = db.query(User).filter(User.email == email).first()
if not user:
print(f"❌ User with email '{email}' not found")
return False
# Hash new password
hashed_password = hash_password(new_password)
# Update password
user.password = hashed_password
db.commit()
db.refresh(user)
print(f"✅ Password reset for {email}")
print(f" New password: {new_password}")
print(f" Hash length: {len(user.password)} characters")
print()
return True
def main():
"""Reset passwords for all test users"""
db = SessionLocal()
try:
print("="*80)
print("RESETTING TEST USER PASSWORDS")
print("="*80)
print()
test_users = [
{"email": "admin@hotel.com", "password": "admin123"},
{"email": "staff@hotel.com", "password": "staff123"},
{"email": "customer@hotel.com", "password": "customer123"},
]
for user_data in test_users:
reset_password(db, user_data["email"], user_data["password"])
print("="*80)
print("SUMMARY")
print("="*80)
print("All test user passwords have been reset.")
print("\nYou can now login with:")
for user_data in test_users:
print(f" {user_data['email']:<25} Password: {user_data['password']}")
print()
except Exception as e:
logger.error(f"Error: {e}", exc_info=True)
print(f"\n❌ Error: {e}")
db.rollback()
finally:
db.close()
if __name__ == "__main__":
main()

View File

@@ -3,21 +3,46 @@
Main entry point for the FastAPI server
"""
import uvicorn
import os
from dotenv import load_dotenv
from src.config.settings import settings
from src.config.logging_config import setup_logging, get_logger
load_dotenv()
# Setup logging
setup_logging()
logger = get_logger(__name__)
if __name__ == "__main__":
port = int(os.getenv("PORT", 8000))
host = os.getenv("HOST", "0.0.0.0")
reload = os.getenv("NODE_ENV") == "development"
logger.info(f"Starting {settings.APP_NAME} on {settings.HOST}:{settings.PORT}")
import os
from pathlib import Path
# Only watch the src directory to avoid watching logs, uploads, etc.
base_dir = Path(__file__).parent
src_dir = str(base_dir / "src")
# Temporarily disable reload to stop constant "1 change detected" messages
# The file watcher is detecting changes that cause a loop
# TODO: Investigate what's causing constant file changes
use_reload = False # Disabled until we identify the source of constant changes
uvicorn.run(
"src.main:app",
host=host,
port=port,
reload=reload,
log_level="info"
host=settings.HOST,
port=8000,
reload=use_reload,
log_level=settings.LOG_LEVEL.lower(),
reload_dirs=[src_dir] if use_reload else None,
reload_excludes=[
"*.log",
"*.pyc",
"*.pyo",
"*.pyd",
"__pycache__",
"**/__pycache__/**",
"*.db",
"*.sqlite",
"*.sqlite3"
],
reload_delay=1.0 # Increase delay to reduce false positives
)

View File

@@ -1,38 +1,63 @@
from sqlalchemy import create_engine
from sqlalchemy import create_engine, event
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
import os
from dotenv import load_dotenv
from sqlalchemy.pool import QueuePool
from .settings import settings
from .logging_config import get_logger
load_dotenv()
logger = get_logger(__name__)
# Database configuration
DB_USER = os.getenv("DB_USER", "root")
DB_PASS = os.getenv("DB_PASS", "")
DB_NAME = os.getenv("DB_NAME", "hotel_db")
DB_HOST = os.getenv("DB_HOST", "localhost")
DB_PORT = os.getenv("DB_PORT", "3306")
DATABASE_URL = f"mysql+pymysql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
# Database configuration using settings
DATABASE_URL = settings.database_url
# Enhanced engine configuration for enterprise use
engine = create_engine(
DATABASE_URL,
pool_pre_ping=True,
pool_recycle=300,
pool_size=5,
max_overflow=10,
echo=os.getenv("NODE_ENV") == "development"
poolclass=QueuePool,
pool_pre_ping=True, # Verify connections before using
pool_recycle=3600, # Recycle connections after 1 hour
pool_size=10, # Number of connections to maintain
max_overflow=20, # Additional connections beyond pool_size
echo=settings.is_development, # Log SQL queries in development
future=True, # Use SQLAlchemy 2.0 style
connect_args={
"charset": "utf8mb4",
"connect_timeout": 10
}
)
# Event listeners for connection pool monitoring
@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_conn, connection_record):
"""Set connection-level settings"""
logger.debug("New database connection established")
@event.listens_for(engine, "checkout")
def receive_checkout(dbapi_conn, connection_record, connection_proxy):
"""Log connection checkout"""
logger.debug("Connection checked out from pool")
@event.listens_for(engine, "checkin")
def receive_checkin(dbapi_conn, connection_record):
"""Log connection checkin"""
logger.debug("Connection returned to pool")
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
# Dependency to get DB session
def get_db():
"""
Dependency for getting database session.
Automatically handles session lifecycle.
"""
db = SessionLocal()
try:
yield db
except Exception:
db.rollback()
raise
finally:
db.close()

View File

@@ -0,0 +1,96 @@
"""
Enterprise-grade structured logging configuration
"""
import logging
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Optional
from .settings import settings
def setup_logging(
log_level: Optional[str] = None,
log_file: Optional[str] = None,
enable_file_logging: bool = True
) -> logging.Logger:
"""
Setup structured logging with file and console handlers
Args:
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
log_file: Path to log file
enable_file_logging: Whether to enable file logging
Returns:
Configured root logger
"""
# Get configuration from settings
level = log_level or settings.LOG_LEVEL
log_file_path = log_file or settings.LOG_FILE
# Convert string level to logging constant
numeric_level = getattr(logging, level.upper(), logging.INFO)
# Create logs directory if it doesn't exist
if enable_file_logging and log_file_path:
log_path = Path(log_file_path)
log_path.parent.mkdir(parents=True, exist_ok=True)
# Create formatter with structured format
detailed_formatter = logging.Formatter(
fmt='%(asctime)s | %(levelname)-8s | %(name)s | %(funcName)s:%(lineno)d | %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
simple_formatter = logging.Formatter(
fmt='%(asctime)s | %(levelname)-8s | %(message)s',
datefmt='%H:%M:%S'
)
# Configure root logger
root_logger = logging.getLogger()
root_logger.setLevel(numeric_level)
# Remove existing handlers
root_logger.handlers.clear()
# Console handler (always enabled)
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(numeric_level)
console_handler.setFormatter(simple_formatter if settings.is_development else detailed_formatter)
root_logger.addHandler(console_handler)
# File handler (rotating) - Disabled in development to avoid file watcher issues
if enable_file_logging and log_file_path and not settings.is_development:
file_handler = RotatingFileHandler(
log_file_path,
maxBytes=settings.LOG_MAX_BYTES,
backupCount=settings.LOG_BACKUP_COUNT,
encoding='utf-8'
)
file_handler.setLevel(numeric_level)
file_handler.setFormatter(detailed_formatter)
root_logger.addHandler(file_handler)
# Set levels for third-party loggers
logging.getLogger("uvicorn").setLevel(logging.INFO)
logging.getLogger("uvicorn.access").setLevel(logging.INFO if settings.is_development else logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
logging.getLogger("slowapi").setLevel(logging.WARNING)
return root_logger
def get_logger(name: str) -> logging.Logger:
"""
Get a logger instance with the given name
Args:
name: Logger name (typically __name__)
Returns:
Logger instance
"""
return logging.getLogger(name)

View File

@@ -0,0 +1,119 @@
"""
Enterprise-grade configuration management using Pydantic Settings
"""
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
from typing import List
import os
class Settings(BaseSettings):
"""Application settings with environment variable support"""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore"
)
# Application
APP_NAME: str = Field(default="Hotel Booking API", description="Application name")
APP_VERSION: str = Field(default="1.0.0", description="Application version")
ENVIRONMENT: str = Field(default="development", description="Environment: development, staging, production")
DEBUG: bool = Field(default=False, description="Debug mode")
API_V1_PREFIX: str = Field(default="/api/v1", description="API v1 prefix")
# Server
HOST: str = Field(default="0.0.0.0", description="Server host")
PORT: int = Field(default=8000, description="Server port")
# Database
DB_USER: str = Field(default="root", description="Database user")
DB_PASS: str = Field(default="", description="Database password")
DB_NAME: str = Field(default="hotel_db", description="Database name")
DB_HOST: str = Field(default="localhost", description="Database host")
DB_PORT: str = Field(default="3306", description="Database port")
# Security
JWT_SECRET: str = Field(default="dev-secret-key-change-in-production-12345", description="JWT secret key")
JWT_ALGORITHM: str = Field(default="HS256", description="JWT algorithm")
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30, description="JWT access token expiration in minutes")
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7, description="JWT refresh token expiration in days")
# CORS
CLIENT_URL: str = Field(default="http://localhost:5173", description="Frontend client URL")
CORS_ORIGINS: List[str] = Field(
default_factory=lambda: [
"http://localhost:5173",
"http://localhost:3000",
"http://127.0.0.1:5173"
],
description="Allowed CORS origins"
)
# Rate Limiting
RATE_LIMIT_ENABLED: bool = Field(default=True, description="Enable rate limiting")
RATE_LIMIT_PER_MINUTE: int = Field(default=60, description="Requests per minute per IP")
# Logging
LOG_LEVEL: str = Field(default="INFO", description="Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL")
LOG_FILE: str = Field(default="logs/app.log", description="Log file path")
LOG_MAX_BYTES: int = Field(default=10485760, description="Max log file size (10MB)")
LOG_BACKUP_COUNT: int = Field(default=5, description="Number of backup log files")
# Email
SMTP_HOST: str = Field(default="smtp.gmail.com", description="SMTP host")
SMTP_PORT: int = Field(default=587, description="SMTP port")
SMTP_USER: str = Field(default="", description="SMTP username")
SMTP_PASSWORD: str = Field(default="", description="SMTP password")
SMTP_FROM_EMAIL: str = Field(default="", description="From email address")
SMTP_FROM_NAME: str = Field(default="Hotel Booking", description="From name")
# File Upload
UPLOAD_DIR: str = Field(default="uploads", description="Upload directory")
MAX_UPLOAD_SIZE: int = Field(default=5242880, description="Max upload size in bytes (5MB)")
ALLOWED_EXTENSIONS: List[str] = Field(
default_factory=lambda: ["jpg", "jpeg", "png", "gif", "webp"],
description="Allowed file extensions"
)
# Redis (for caching)
REDIS_ENABLED: bool = Field(default=False, description="Enable Redis caching")
REDIS_HOST: str = Field(default="localhost", description="Redis host")
REDIS_PORT: int = Field(default=6379, description="Redis port")
REDIS_DB: int = Field(default=0, description="Redis database number")
REDIS_PASSWORD: str = Field(default="", description="Redis password")
# Request Timeout
REQUEST_TIMEOUT: int = Field(default=30, description="Request timeout in seconds")
# Health Check
HEALTH_CHECK_INTERVAL: int = Field(default=30, description="Health check interval in seconds")
@property
def database_url(self) -> str:
"""Construct database URL"""
return f"mysql+pymysql://{self.DB_USER}:{self.DB_PASS}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
@property
def is_production(self) -> bool:
"""Check if running in production"""
return self.ENVIRONMENT.lower() == "production"
@property
def is_development(self) -> bool:
"""Check if running in development"""
return self.ENVIRONMENT.lower() == "development"
@property
def redis_url(self) -> str:
"""Construct Redis URL"""
if self.REDIS_PASSWORD:
return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
# Global settings instance
settings = Settings()

View File

@@ -1,17 +1,30 @@
from fastapi import FastAPI, Request, HTTPException
from fastapi import FastAPI, Request, HTTPException, Depends, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from sqlalchemy.exc import IntegrityError
from sqlalchemy.exc import IntegrityError, OperationalError
from jose.exceptions import JWTError
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
import os
from pathlib import Path
from datetime import datetime
import sys
from .config.database import engine, Base
# Import configuration and logging FIRST
from .config.settings import settings
from .config.logging_config import setup_logging, get_logger
from .config.database import engine, Base, get_db
from . import models # noqa: F401 - ensure models are imported so tables are created
from sqlalchemy.orm import Session
# Setup logging before anything else
logger = setup_logging()
logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION} in {settings.ENVIRONMENT} mode")
# Import middleware
from .middleware.error_handler import (
validation_exception_handler,
integrity_error_handler,
@@ -19,38 +32,65 @@ from .middleware.error_handler import (
http_exception_handler,
general_exception_handler
)
# Create database tables
Base.metadata.create_all(bind=engine)
from .middleware.request_id import RequestIDMiddleware
from .middleware.security import SecurityHeadersMiddleware
from .middleware.timeout import TimeoutMiddleware
from .middleware.cookie_consent import CookieConsentMiddleware
# Create database tables (for development, migrations should be used in production)
if settings.is_development:
logger.info("Creating database tables (development mode)")
Base.metadata.create_all(bind=engine)
else:
# Ensure new cookie-related tables exist even if full migrations haven't been run yet.
try:
from .models.cookie_policy import CookiePolicy
from .models.cookie_integration_config import CookieIntegrationConfig
logger.info("Ensuring cookie-related tables exist")
CookiePolicy.__table__.create(bind=engine, checkfirst=True)
CookieIntegrationConfig.__table__.create(bind=engine, checkfirst=True)
except Exception as e:
logger.error(f"Failed to ensure cookie tables exist: {e}")
from .routes import auth_routes
from .routes import privacy_routes
# Initialize FastAPI app
app = FastAPI(
title="Hotel Booking API",
description="Hotel booking backend API",
version="1.0.0"
title=settings.APP_NAME,
description="Enterprise-grade Hotel Booking API",
version=settings.APP_VERSION,
docs_url="/api/docs" if not settings.is_production else None,
redoc_url="/api/redoc" if not settings.is_production else None,
openapi_url="/api/openapi.json" if not settings.is_production else None
)
# Add middleware in order (order matters!)
# 1. Request ID middleware (first to add request ID)
app.add_middleware(RequestIDMiddleware)
# 2. Cookie consent middleware (makes consent available on request.state)
app.add_middleware(CookieConsentMiddleware)
# 3. Timeout middleware
if settings.REQUEST_TIMEOUT > 0:
app.add_middleware(TimeoutMiddleware)
# 4. Security headers middleware
app.add_middleware(SecurityHeadersMiddleware)
# Rate limiting
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
if settings.RATE_LIMIT_ENABLED:
limiter = Limiter(
key_func=get_remote_address,
default_limits=[f"{settings.RATE_LIMIT_PER_MINUTE}/minute"]
)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
logger.info(f"Rate limiting enabled: {settings.RATE_LIMIT_PER_MINUTE} requests/minute")
# CORS configuration
# Allow multiple origins for development
client_url = os.getenv("CLIENT_URL", "http://localhost:5173")
allowed_origins = [
client_url,
"http://localhost:5173", # Vite default
"http://localhost:3000", # Alternative port
"http://localhost:5174", # Vite alternative
"http://127.0.0.1:5173",
"http://127.0.0.1:3000",
"http://127.0.0.1:5174",
]
# In development, allow all localhost origins using regex
if os.getenv("ENVIRONMENT", "development") == "development":
if settings.is_development:
# For development, use regex to allow any localhost port
app.add_middleware(
CORSMiddleware,
@@ -59,18 +99,20 @@ if os.getenv("ENVIRONMENT", "development") == "development":
allow_methods=["*"],
allow_headers=["*"],
)
logger.info("CORS configured for development (allowing localhost)")
else:
# Production: use specific origins
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allow_headers=["*"],
)
logger.info(f"CORS configured for production with {len(settings.CORS_ORIGINS)} allowed origins")
# Serve static files (uploads)
uploads_dir = Path(__file__).parent.parent / "uploads"
uploads_dir = Path(__file__).parent.parent / settings.UPLOAD_DIR
uploads_dir.mkdir(exist_ok=True)
app.mount("/uploads", StaticFiles(directory=str(uploads_dir)), name="uploads")
@@ -81,25 +123,82 @@ app.add_exception_handler(IntegrityError, integrity_error_handler)
app.add_exception_handler(JWTError, jwt_error_handler)
app.add_exception_handler(Exception, general_exception_handler)
# Health check
@app.get("/health")
async def health_check():
return {
"status": "success",
"message": "Server is running",
"timestamp": __import__("datetime").datetime.utcnow().isoformat()
# Enhanced Health check with database connectivity
@app.get("/health", tags=["health"])
async def health_check(db: Session = Depends(get_db)):
"""
Enhanced health check endpoint with database connectivity test
"""
health_status = {
"status": "healthy",
"timestamp": datetime.utcnow().isoformat(),
"service": settings.APP_NAME,
"version": settings.APP_VERSION,
"environment": settings.ENVIRONMENT,
"checks": {
"api": "ok",
"database": "unknown"
}
}
# API Routes
# Check database connectivity
try:
from sqlalchemy import text
db.execute(text("SELECT 1"))
health_status["checks"]["database"] = "ok"
except OperationalError as e:
health_status["status"] = "unhealthy"
health_status["checks"]["database"] = "error"
health_status["error"] = str(e)
logger.error(f"Database health check failed: {str(e)}")
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content=health_status
)
except Exception as e:
health_status["status"] = "unhealthy"
health_status["checks"]["database"] = "error"
health_status["error"] = str(e)
logger.error(f"Health check failed: {str(e)}")
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content=health_status
)
return health_status
# Metrics endpoint (basic)
@app.get("/metrics", tags=["monitoring"])
async def metrics():
"""
Basic metrics endpoint (can be extended with Prometheus or similar)
"""
return {
"status": "success",
"service": settings.APP_NAME,
"version": settings.APP_VERSION,
"environment": settings.ENVIRONMENT,
"timestamp": datetime.utcnow().isoformat()
}
# API Routes with versioning
# Legacy routes (maintain backward compatibility)
app.include_router(auth_routes.router, prefix="/api")
app.include_router(privacy_routes.router, prefix="/api")
# Versioned API routes (v1)
app.include_router(auth_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(privacy_routes.router, prefix=settings.API_V1_PREFIX)
# Import and include other routes
from .routes import (
room_routes, booking_routes, payment_routes, banner_routes,
favorite_routes, service_routes, promotion_routes, report_routes,
review_routes, user_routes
review_routes, user_routes, audit_routes, admin_privacy_routes
)
# Legacy routes (maintain backward compatibility)
app.include_router(room_routes.router, prefix="/api")
app.include_router(booking_routes.router, prefix="/api")
app.include_router(payment_routes.router, prefix="/api")
@@ -110,12 +209,66 @@ app.include_router(promotion_routes.router, prefix="/api")
app.include_router(report_routes.router, prefix="/api")
app.include_router(review_routes.router, prefix="/api")
app.include_router(user_routes.router, prefix="/api")
app.include_router(audit_routes.router, prefix="/api")
app.include_router(admin_privacy_routes.router, prefix="/api")
# Note: FastAPI automatically handles 404s for unmatched routes
# This handler is kept for custom 404 responses but may not be needed
# Versioned routes (v1)
app.include_router(room_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(booking_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(payment_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(banner_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(favorite_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(service_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(promotion_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(report_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(review_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(user_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(audit_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(admin_privacy_routes.router, prefix=settings.API_V1_PREFIX)
logger.info("All routes registered successfully")
# Startup event
@app.on_event("startup")
async def startup_event():
"""Run on application startup"""
logger.info(f"{settings.APP_NAME} started successfully")
logger.info(f"Environment: {settings.ENVIRONMENT}")
logger.info(f"Debug mode: {settings.DEBUG}")
logger.info(f"API version: {settings.API_V1_PREFIX}")
# Shutdown event
@app.on_event("shutdown")
async def shutdown_event():
"""Run on application shutdown"""
logger.info(f"{settings.APP_NAME} shutting down gracefully")
if __name__ == "__main__":
import uvicorn
port = int(os.getenv("PORT", 3000))
uvicorn.run("main:app", host="0.0.0.0", port=port, reload=True)
from pathlib import Path
# Only watch the src directory to avoid watching logs, uploads, etc.
base_dir = Path(__file__).parent.parent
src_dir = str(base_dir / "src")
uvicorn.run(
"src.main:app",
host=settings.HOST,
port=settings.PORT,
reload=settings.is_development,
log_level=settings.LOG_LEVEL.lower(),
reload_dirs=[src_dir] if settings.is_development else None,
reload_excludes=[
"*.log",
"*.pyc",
"*.pyo",
"*.pyd",
"__pycache__",
"**/__pycache__/**",
"*.db",
"*.sqlite",
"*.sqlite3"
],
reload_delay=0.5 # Increase delay to reduce false positives
)

View File

@@ -6,6 +6,7 @@ from typing import Optional
import os
from ..config.database import get_db
from ..config.settings import settings
from ..models.user import User
security = HTTPBearer()
@@ -26,7 +27,8 @@ def get_current_user(
)
try:
payload = jwt.decode(token, os.getenv("JWT_SECRET"), algorithms=["HS256"])
jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv("JWT_SECRET", "dev-secret-key-change-in-production-12345")
payload = jwt.decode(token, jwt_secret, algorithms=["HS256"])
user_id: int = payload.get("userId")
if user_id is None:
raise credentials_exception

View File

@@ -0,0 +1,89 @@
import json
from typing import Callable, Awaitable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from ..schemas.privacy import CookieConsent, CookieCategoryPreferences
from ..config.settings import settings
from ..config.logging_config import get_logger
logger = get_logger(__name__)
COOKIE_CONSENT_COOKIE_NAME = "cookieConsent"
def _parse_consent_cookie(raw_value: str | None) -> CookieConsent:
if not raw_value:
return CookieConsent() # Defaults: only necessary = True
try:
data = json.loads(raw_value)
# Pydantic will validate and coerce as needed
return CookieConsent(**data)
except Exception as exc: # pragma: no cover - defensive
logger.warning(f"Failed to parse cookie consent cookie: {exc}")
return CookieConsent()
class CookieConsentMiddleware(BaseHTTPMiddleware):
"""
Middleware that parses the cookie consent cookie (if present) and attaches it
to `request.state.cookie_consent` for downstream handlers.
"""
async def dispatch(
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
raw_cookie = request.cookies.get(COOKIE_CONSENT_COOKIE_NAME)
consent = _parse_consent_cookie(raw_cookie)
# Ensure 'necessary' is always true regardless of stored value
consent.categories.necessary = True
request.state.cookie_consent = consent
response = await call_next(request)
# If there's no cookie yet, set a minimal default consent cookie
# so that the banner can be rendered based on server-side knowledge.
if COOKIE_CONSENT_COOKIE_NAME not in request.cookies:
try:
response.set_cookie(
key=COOKIE_CONSENT_COOKIE_NAME,
value=consent.model_dump_json(),
httponly=True,
secure=settings.is_production,
samesite="lax",
max_age=365 * 24 * 60 * 60, # 1 year
path="/",
)
except Exception as exc: # pragma: no cover - defensive
logger.warning(f"Failed to set default cookie consent cookie: {exc}")
return response
def is_analytics_allowed(request: Request) -> bool:
consent: CookieConsent | None = getattr(request.state, "cookie_consent", None)
if not consent:
return False
return consent.categories.analytics
def is_marketing_allowed(request: Request) -> bool:
consent: CookieConsent | None = getattr(request.state, "cookie_consent", None)
if not consent:
return False
return consent.categories.marketing
def is_preferences_allowed(request: Request) -> bool:
consent: CookieConsent | None = getattr(request.state, "cookie_consent", None)
if not consent:
return False
return consent.categories.preferences

View File

@@ -3,7 +3,6 @@ from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from sqlalchemy.exc import IntegrityError
from jose.exceptions import JWTError
import os
import traceback
@@ -96,10 +95,23 @@ async def general_exception_handler(request: Request, exc: Exception):
"""
Handle all other exceptions
"""
# Log error
print(f"Error: {exc}")
if os.getenv("NODE_ENV") == "development":
traceback.print_exc()
from ..config.logging_config import get_logger
from ..config.settings import settings
logger = get_logger(__name__)
request_id = getattr(request.state, "request_id", None)
# Log error with context
logger.error(
f"Unhandled exception: {type(exc).__name__}: {str(exc)}",
extra={
"request_id": request_id,
"path": request.url.path,
"method": request.method,
"exception_type": type(exc).__name__
},
exc_info=True
)
# Handle HTTPException with dict detail
if isinstance(exc, Exception) and hasattr(exc, "status_code"):
@@ -116,12 +128,17 @@ async def general_exception_handler(request: Request, exc: Exception):
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
message = str(exc) if str(exc) else "Internal server error"
response_content = {
"status": "error",
"message": message
}
# Add stack trace in development
if settings.is_development:
response_content["stack"] = traceback.format_exc()
return JSONResponse(
status_code=status_code,
content={
"status": "error",
"message": message,
**({"stack": traceback.format_exc()} if os.getenv("NODE_ENV") == "development" else {})
}
content=response_content
)

View File

@@ -0,0 +1,65 @@
"""
Request ID middleware for tracking requests across services
"""
import uuid
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
from ..config.logging_config import get_logger
logger = get_logger(__name__)
class RequestIDMiddleware(BaseHTTPMiddleware):
"""Add unique request ID to each request for tracing"""
async def dispatch(self, request: Request, call_next):
# Generate or get request ID
request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
# Add request ID to request state
request.state.request_id = request_id
# Log request
logger.info(
f"Request started: {request.method} {request.url.path}",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"client_ip": request.client.host if request.client else None
}
)
# Process request
try:
response = await call_next(request)
# Add request ID to response headers
response.headers["X-Request-ID"] = request_id
# Log response
logger.info(
f"Request completed: {request.method} {request.url.path} - {response.status_code}",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"status_code": response.status_code
}
)
return response
except Exception as e:
logger.error(
f"Request failed: {request.method} {request.url.path} - {str(e)}",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"error": str(e)
},
exc_info=True
)
raise

View File

@@ -0,0 +1,57 @@
"""
Security middleware for adding security headers
"""
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
from ..config.logging_config import get_logger
from ..config.settings import settings
logger = get_logger(__name__)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Add security headers to all responses"""
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
# Security headers
security_headers = {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "1; mode=block",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "geolocation=(), microphone=(), camera=()",
}
# Allow resources (like banner images) to be loaded cross-origin by the frontend.
# This helps avoid Firefox's OpaqueResponseBlocking when the frontend runs
# on a different origin (e.g. Vite dev server on :5173) and loads images
# from the API origin (e.g. :8000).
#
# In production you may want a stricter policy (e.g. "same-site") depending
# on your deployment topology.
security_headers.setdefault("Cross-Origin-Resource-Policy", "cross-origin")
# Add Content-Security-Policy
if settings.is_production:
security_headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: https:; "
"font-src 'self' data:; "
"connect-src 'self'"
)
# Add Strict-Transport-Security in production with HTTPS
if settings.is_production:
security_headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
# Apply headers
for header, value in security_headers.items():
response.headers[header] = value
return response

View File

@@ -0,0 +1,41 @@
"""
Request timeout middleware
"""
import asyncio
from fastapi import Request, HTTPException, status
from starlette.middleware.base import BaseHTTPMiddleware
from ..config.logging_config import get_logger
from ..config.settings import settings
logger = get_logger(__name__)
class TimeoutMiddleware(BaseHTTPMiddleware):
"""Add timeout to requests"""
async def dispatch(self, request: Request, call_next):
try:
# Use asyncio.wait_for to add timeout
response = await asyncio.wait_for(
call_next(request),
timeout=settings.REQUEST_TIMEOUT
)
return response
except asyncio.TimeoutError:
logger.warning(
f"Request timeout: {request.method} {request.url.path}",
extra={
"request_id": getattr(request.state, "request_id", None),
"method": request.method,
"path": request.url.path,
"timeout": settings.REQUEST_TIMEOUT
}
)
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail={
"status": "error",
"message": "Request timeout. Please try again."
}
)

View File

@@ -13,6 +13,9 @@ from .checkin_checkout import CheckInCheckOut
from .banner import Banner
from .review import Review
from .favorite import Favorite
from .audit_log import AuditLog
from .cookie_policy import CookiePolicy
from .cookie_integration_config import CookieIntegrationConfig
__all__ = [
"Role",
@@ -30,5 +33,8 @@ __all__ = [
"Banner",
"Review",
"Favorite",
"AuditLog",
"CookiePolicy",
"CookieIntegrationConfig",
]

View File

@@ -0,0 +1,28 @@
"""
Audit log model for tracking important actions
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON
from sqlalchemy.orm import relationship
from datetime import datetime
from ..config.database import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
action = Column(String(100), nullable=False, index=True) # e.g., "user.created", "booking.cancelled"
resource_type = Column(String(50), nullable=False, index=True) # e.g., "user", "booking"
resource_id = Column(Integer, nullable=True, index=True)
ip_address = Column(String(45), nullable=True) # IPv6 compatible
user_agent = Column(String(255), nullable=True)
request_id = Column(String(36), nullable=True, index=True) # UUID
details = Column(JSON, nullable=True) # Additional context
status = Column(String(20), nullable=False, default="success") # success, failed, error
error_message = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
# Relationships
user = relationship("User", foreign_keys=[user_id])

View File

@@ -0,0 +1,30 @@
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from ..config.database import Base
class CookieIntegrationConfig(Base):
"""
Stores IDs for well-known integrations (e.g., Google Analytics, Meta Pixel).
Does NOT allow arbitrary script injection from the dashboard.
"""
__tablename__ = "cookie_integration_configs"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
ga_measurement_id = Column(String(64), nullable=True) # e.g. G-XXXXXXXXXX
fb_pixel_id = Column(String(64), nullable=True) # e.g. 1234567890
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
updated_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
updated_by = relationship("User", lazy="joined")

View File

@@ -0,0 +1,31 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer
from sqlalchemy.orm import relationship
from ..config.database import Base
class CookiePolicy(Base):
"""
Global cookie policy controlled by administrators.
This does NOT store per-user consent; it controls which cookie categories
are available to be requested from users (e.g., disable analytics entirely).
"""
__tablename__ = "cookie_policies"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
analytics_enabled = Column(Boolean, default=True, nullable=False)
marketing_enabled = Column(Boolean, default=True, nullable=False)
preferences_enabled = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
updated_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
updated_by = relationship("User", lazy="joined")

View File

@@ -0,0 +1,120 @@
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from ..config.database import get_db
from ..middleware.auth import authorize_roles
from ..models.user import User
from ..schemas.admin_privacy import (
CookieIntegrationSettings,
CookieIntegrationSettingsResponse,
CookiePolicySettings,
CookiePolicySettingsResponse,
)
from ..services.privacy_admin_service import privacy_admin_service
router = APIRouter(prefix="/admin/privacy", tags=["admin-privacy"])
@router.get(
"/cookie-policy",
response_model=CookiePolicySettingsResponse,
status_code=status.HTTP_200_OK,
)
def get_cookie_policy(
db: Session = Depends(get_db),
_: User = Depends(authorize_roles("admin")),
) -> CookiePolicySettingsResponse:
"""
Get global cookie policy configuration (admin only).
"""
settings = privacy_admin_service.get_policy_settings(db)
policy = privacy_admin_service.get_or_create_policy(db)
updated_by_name = (
policy.updated_by.full_name if getattr(policy, "updated_by", None) else None
)
return CookiePolicySettingsResponse(
data=settings,
updated_at=policy.updated_at,
updated_by=updated_by_name,
)
@router.put(
"/cookie-policy",
response_model=CookiePolicySettingsResponse,
status_code=status.HTTP_200_OK,
)
def update_cookie_policy(
payload: CookiePolicySettings,
db: Session = Depends(get_db),
current_user: User = Depends(authorize_roles("admin")),
) -> CookiePolicySettingsResponse:
"""
Update global cookie policy configuration (admin only).
"""
policy = privacy_admin_service.update_policy(db, payload, current_user)
settings = privacy_admin_service.get_policy_settings(db)
updated_by_name = (
policy.updated_by.full_name if getattr(policy, "updated_by", None) else None
)
return CookiePolicySettingsResponse(
data=settings,
updated_at=policy.updated_at,
updated_by=updated_by_name,
)
@router.get(
"/integrations",
response_model=CookieIntegrationSettingsResponse,
status_code=status.HTTP_200_OK,
)
def get_cookie_integrations(
db: Session = Depends(get_db),
_: User = Depends(authorize_roles("admin")),
) -> CookieIntegrationSettingsResponse:
"""
Get IDs for third-party integrations (admin only).
"""
settings = privacy_admin_service.get_integration_settings(db)
cfg = privacy_admin_service.get_or_create_integrations(db)
updated_by_name = (
cfg.updated_by.full_name if getattr(cfg, "updated_by", None) else None
)
return CookieIntegrationSettingsResponse(
data=settings,
updated_at=cfg.updated_at,
updated_by=updated_by_name,
)
@router.put(
"/integrations",
response_model=CookieIntegrationSettingsResponse,
status_code=status.HTTP_200_OK,
)
def update_cookie_integrations(
payload: CookieIntegrationSettings,
db: Session = Depends(get_db),
current_user: User = Depends(authorize_roles("admin")),
) -> CookieIntegrationSettingsResponse:
"""
Update IDs for third-party integrations (admin only).
"""
cfg = privacy_admin_service.update_integrations(db, payload, current_user)
settings = privacy_admin_service.get_integration_settings(db)
updated_by_name = (
cfg.updated_by.full_name if getattr(cfg, "updated_by", None) else None
)
return CookieIntegrationSettingsResponse(
data=settings,
updated_at=cfg.updated_at,
updated_by=updated_by_name,
)

View File

@@ -0,0 +1,239 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from sqlalchemy import desc, or_, func
from typing import Optional
from datetime import datetime
from ..config.database import get_db
from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User
from ..models.audit_log import AuditLog
router = APIRouter(prefix="/audit-logs", tags=["audit-logs"])
@router.get("/")
async def get_audit_logs(
action: Optional[str] = Query(None, description="Filter by action"),
resource_type: Optional[str] = Query(None, description="Filter by resource type"),
user_id: Optional[int] = Query(None, description="Filter by user ID"),
status_filter: Optional[str] = Query(None, alias="status", description="Filter by status"),
search: Optional[str] = Query(None, description="Search in action, resource_type, or details"),
start_date: Optional[str] = Query(None, description="Start date (YYYY-MM-DD)"),
end_date: Optional[str] = Query(None, description="End date (YYYY-MM-DD)"),
page: int = Query(1, ge=1, description="Page number"),
limit: int = Query(20, ge=1, le=100, description="Items per page"),
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Get audit logs (Admin only)"""
try:
query = db.query(AuditLog)
# Apply filters
if action:
query = query.filter(AuditLog.action.like(f"%{action}%"))
if resource_type:
query = query.filter(AuditLog.resource_type == resource_type)
if user_id:
query = query.filter(AuditLog.user_id == user_id)
if status_filter:
query = query.filter(AuditLog.status == status_filter)
if search:
search_filter = or_(
AuditLog.action.like(f"%{search}%"),
AuditLog.resource_type.like(f"%{search}%"),
AuditLog.ip_address.like(f"%{search}%")
)
query = query.filter(search_filter)
# Date range filter
if start_date:
try:
start = datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(AuditLog.created_at >= start)
except ValueError:
pass
if end_date:
try:
end = datetime.strptime(end_date, "%Y-%m-%d")
# Set to end of day
end = end.replace(hour=23, minute=59, second=59)
query = query.filter(AuditLog.created_at <= end)
except ValueError:
pass
# Get total count
total = query.count()
# Apply pagination and ordering
offset = (page - 1) * limit
logs = query.order_by(desc(AuditLog.created_at)).offset(offset).limit(limit).all()
# Format response
result = []
for log in logs:
log_dict = {
"id": log.id,
"user_id": log.user_id,
"action": log.action,
"resource_type": log.resource_type,
"resource_id": log.resource_id,
"ip_address": log.ip_address,
"user_agent": log.user_agent,
"request_id": log.request_id,
"details": log.details,
"status": log.status,
"error_message": log.error_message,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
# Add user info if available
if log.user:
log_dict["user"] = {
"id": log.user.id,
"full_name": log.user.full_name,
"email": log.user.email,
}
result.append(log_dict)
return {
"status": "success",
"data": {
"logs": result,
"pagination": {
"total": total,
"page": page,
"limit": limit,
"totalPages": (total + limit - 1) // limit,
},
},
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/stats")
async def get_audit_stats(
start_date: Optional[str] = Query(None, description="Start date (YYYY-MM-DD)"),
end_date: Optional[str] = Query(None, description="End date (YYYY-MM-DD)"),
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Get audit log statistics (Admin only)"""
try:
query = db.query(AuditLog)
# Date range filter
if start_date:
try:
start = datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(AuditLog.created_at >= start)
except ValueError:
pass
if end_date:
try:
end = datetime.strptime(end_date, "%Y-%m-%d")
end = end.replace(hour=23, minute=59, second=59)
query = query.filter(AuditLog.created_at <= end)
except ValueError:
pass
# Get statistics
total_logs = query.count()
success_count = query.filter(AuditLog.status == "success").count()
failed_count = query.filter(AuditLog.status == "failed").count()
error_count = query.filter(AuditLog.status == "error").count()
# Get top actions
top_actions = (
db.query(
AuditLog.action,
func.count(AuditLog.id).label("count")
)
.group_by(AuditLog.action)
.order_by(desc("count"))
.limit(10)
.all()
)
# Get top resource types
top_resource_types = (
db.query(
AuditLog.resource_type,
func.count(AuditLog.id).label("count")
)
.group_by(AuditLog.resource_type)
.order_by(desc("count"))
.limit(10)
.all()
)
return {
"status": "success",
"data": {
"total": total_logs,
"by_status": {
"success": success_count,
"failed": failed_count,
"error": error_count,
},
"top_actions": [{"action": action, "count": count} for action, count in top_actions],
"top_resource_types": [{"resource_type": rt, "count": count} for rt, count in top_resource_types],
},
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{id}")
async def get_audit_log_by_id(
id: int,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Get audit log by ID (Admin only)"""
try:
log = db.query(AuditLog).filter(AuditLog.id == id).first()
if not log:
raise HTTPException(status_code=404, detail="Audit log not found")
log_dict = {
"id": log.id,
"user_id": log.user_id,
"action": log.action,
"resource_type": log.resource_type,
"resource_id": log.resource_id,
"ip_address": log.ip_address,
"user_agent": log.user_agent,
"request_id": log.request_id,
"details": log.details,
"status": log.status,
"error_message": log.error_message,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
if log.user:
log_dict["user"] = {
"id": log.user.id,
"full_name": log.user.full_name,
"email": log.user.email,
}
return {
"status": "success",
"data": {"log": log_dict}
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -163,7 +163,12 @@ async def get_profile(
"""Get current user profile"""
try:
user = await auth_service.get_profile(db, current_user.id)
return user
return {
"status": "success",
"data": {
"user": user
}
}
except ValueError as e:
if "User not found" in str(e):
raise HTTPException(
@@ -176,6 +181,46 @@ async def get_profile(
)
@router.put("/profile")
async def update_profile(
profile_data: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Update current user profile"""
try:
user = await auth_service.update_profile(
db=db,
user_id=current_user.id,
full_name=profile_data.get("full_name"),
email=profile_data.get("email"),
phone_number=profile_data.get("phone_number"),
password=profile_data.get("password"),
current_password=profile_data.get("currentPassword")
)
return {
"status": "success",
"message": "Profile updated successfully",
"data": {
"user": user
}
}
except ValueError as e:
error_message = str(e)
status_code = status.HTTP_400_BAD_REQUEST
if "not found" in error_message.lower():
status_code = status.HTTP_404_NOT_FOUND
raise HTTPException(
status_code=status_code,
detail=error_message
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"An error occurred: {str(e)}"
)
@router.post("/forgot-password", response_model=MessageResponse)
async def forgot_password(
request: ForgotPasswordRequest,

View File

@@ -1,9 +1,12 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, UploadFile, File
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from typing import Optional
from datetime import datetime
from pathlib import Path
import os
import aiofiles
import uuid
from ..config.database import get_db
from ..middleware.auth import get_current_user, authorize_roles
@@ -215,6 +218,12 @@ async def delete_banner(
if not banner:
raise HTTPException(status_code=404, detail="Banner not found")
# Delete image file if it exists and is a local upload
if banner.image_url and banner.image_url.startswith('/uploads/banners/'):
file_path = Path(__file__).parent.parent.parent / "uploads" / "banners" / Path(banner.image_url).name
if file_path.exists():
file_path.unlink()
db.delete(banner)
db.commit()
@@ -227,3 +236,51 @@ async def delete_banner(
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/upload", dependencies=[Depends(authorize_roles("admin"))])
async def upload_banner_image(
request: Request,
image: UploadFile = File(...),
current_user: User = Depends(authorize_roles("admin")),
):
"""Upload banner image (Admin only)"""
try:
# Validate file type
if not image.content_type or not image.content_type.startswith('image/'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must be an image"
)
# Create uploads directory
upload_dir = Path(__file__).parent.parent.parent / "uploads" / "banners"
upload_dir.mkdir(parents=True, exist_ok=True)
# Generate filename
ext = Path(image.filename).suffix
filename = f"banner-{uuid.uuid4()}{ext}"
file_path = upload_dir / filename
# Save file
async with aiofiles.open(file_path, 'wb') as f:
content = await image.read()
await f.write(content)
# Return the image URL
image_url = f"/uploads/banners/{filename}"
base_url = get_base_url(request)
full_url = normalize_image_url(image_url, base_url)
return {
"status": "success",
"message": "Image uploaded successfully",
"data": {
"image_url": image_url,
"full_url": full_url
}
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -4,14 +4,21 @@ from sqlalchemy import and_, or_
from typing import Optional
from datetime import datetime
import random
import os
from ..config.database import get_db
from ..config.settings import settings
from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User
from ..models.booking import Booking, BookingStatus
from ..models.room import Room
from ..models.room_type import RoomType
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
from ..utils.mailer import send_email
from ..utils.email_templates import (
booking_confirmation_email_template,
booking_status_changed_email_template
)
router = APIRouter(prefix="/bookings", tags=["bookings"])
@@ -255,6 +262,33 @@ async def create_booking(
# Fetch with relations
booking = db.query(Booking).filter(Booking.id == booking.id).first()
# Send booking confirmation email (non-blocking)
try:
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
room = db.query(Room).filter(Room.id == room_id).first()
room_type_name = room.room_type.name if room and room.room_type else "Room"
email_html = booking_confirmation_email_template(
booking_number=booking.booking_number,
guest_name=current_user.full_name,
room_number=room.room_number if room else "N/A",
room_type=room_type_name,
check_in=check_in.strftime("%B %d, %Y"),
check_out=check_out.strftime("%B %d, %Y"),
num_guests=guest_count,
total_price=float(total_price),
requires_deposit=requires_deposit,
deposit_amount=deposit_amount if requires_deposit else None,
client_url=client_url
)
await send_email(
to=current_user.email,
subject=f"Booking Confirmation - {booking.booking_number}",
html=email_html
)
except Exception as e:
print(f"Failed to send booking confirmation email: {e}")
return {
"success": True,
"data": {"booking": booking},
@@ -354,6 +388,23 @@ async def cancel_booking(
booking.status = BookingStatus.cancelled
db.commit()
# Send cancellation email (non-blocking)
try:
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
email_html = booking_status_changed_email_template(
booking_number=booking.booking_number,
guest_name=booking.user.full_name if booking.user else "Guest",
status="cancelled",
client_url=client_url
)
await send_email(
to=booking.user.email if booking.user else None,
subject=f"Booking Cancelled - {booking.booking_number}",
html=email_html
)
except Exception as e:
print(f"Failed to send cancellation email: {e}")
return {
"success": True,
"data": {"booking": booking}
@@ -378,6 +429,7 @@ async def update_booking(
if not booking:
raise HTTPException(status_code=404, detail="Booking not found")
old_status = booking.status
status_value = booking_data.get("status")
if status_value:
try:
@@ -388,6 +440,24 @@ async def update_booking(
db.commit()
db.refresh(booking)
# Send status change email if status changed (non-blocking)
if status_value and old_status != booking.status:
try:
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
email_html = booking_status_changed_email_template(
booking_number=booking.booking_number,
guest_name=booking.user.full_name if booking.user else "Guest",
status=booking.status.value,
client_url=client_url
)
await send_email(
to=booking.user.email if booking.user else None,
subject=f"Booking Status Updated - {booking.booking_number}",
html=email_html
)
except Exception as e:
print(f"Failed to send status change email: {e}")
return {
"status": "success",
"message": "Booking updated successfully",

View File

@@ -2,12 +2,16 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime
import os
from ..config.database import get_db
from ..config.settings import settings
from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
from ..models.booking import Booking
from ..models.booking import Booking, BookingStatus
from ..utils.mailer import send_email
from ..utils.email_templates import payment_confirmation_email_template
router = APIRouter(prefix="/payments", tags=["payments"])
@@ -85,6 +89,63 @@ async def get_payments(
raise HTTPException(status_code=500, detail=str(e))
@router.get("/booking/{booking_id}")
async def get_payments_by_booking_id(
booking_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get all payments for a specific booking"""
try:
# Check if booking exists and user has access
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise HTTPException(status_code=404, detail="Booking not found")
# Check access - users can only see their own bookings unless admin
if current_user.role_id != 1 and booking.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
# Get all payments for this booking
payments = db.query(Payment).filter(Payment.booking_id == booking_id).order_by(Payment.created_at.desc()).all()
result = []
for payment in payments:
payment_dict = {
"id": payment.id,
"booking_id": payment.booking_id,
"amount": float(payment.amount) if payment.amount else 0.0,
"payment_method": payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else payment.payment_method,
"payment_type": payment.payment_type.value if isinstance(payment.payment_type, PaymentType) else payment.payment_type,
"deposit_percentage": payment.deposit_percentage,
"related_payment_id": payment.related_payment_id,
"payment_status": payment.payment_status.value if isinstance(payment.payment_status, PaymentStatus) else payment.payment_status,
"transaction_id": payment.transaction_id,
"payment_date": payment.payment_date.isoformat() if payment.payment_date else None,
"notes": payment.notes,
"created_at": payment.created_at.isoformat() if payment.created_at else None,
}
if payment.booking:
payment_dict["booking"] = {
"id": payment.booking.id,
"booking_number": payment.booking.booking_number,
}
result.append(payment_dict)
return {
"status": "success",
"data": {
"payments": result
}
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{id}")
async def get_payment_by_id(
id: int,
@@ -169,11 +230,32 @@ async def create_payment(
# If marked as paid, update status
if payment_data.get("mark_as_paid"):
payment.payment_status = PaymentStatus.completed
payment.payment_date = datetime.utcnow()
db.add(payment)
db.commit()
db.refresh(payment)
# Send payment confirmation email if payment was marked as paid (non-blocking)
if payment.payment_status == PaymentStatus.completed and booking.user:
try:
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
email_html = payment_confirmation_email_template(
booking_number=booking.booking_number,
guest_name=booking.user.full_name,
amount=float(payment.amount),
payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method),
transaction_id=payment.transaction_id,
client_url=client_url
)
await send_email(
to=booking.user.email,
subject=f"Payment Confirmed - {booking.booking_number}",
html=email_html
)
except Exception as e:
print(f"Failed to send payment confirmation email: {e}")
return {
"status": "success",
"message": "Payment created successfully",
@@ -209,6 +291,7 @@ async def update_payment_status(
if status_data.get("transaction_id"):
payment.transaction_id = status_data["transaction_id"]
old_status = payment.payment_status
if status_data.get("mark_as_paid"):
payment.payment_status = PaymentStatus.completed
payment.payment_date = datetime.utcnow()
@@ -216,6 +299,37 @@ async def update_payment_status(
db.commit()
db.refresh(payment)
# Send payment confirmation email if payment was just completed (non-blocking)
if payment.payment_status == PaymentStatus.completed and old_status != PaymentStatus.completed:
try:
# Refresh booking relationship
payment = db.query(Payment).filter(Payment.id == id).first()
if payment.booking and payment.booking.user:
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
email_html = payment_confirmation_email_template(
booking_number=payment.booking.booking_number,
guest_name=payment.booking.user.full_name,
amount=float(payment.amount),
payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method),
transaction_id=payment.transaction_id,
client_url=client_url
)
await send_email(
to=payment.booking.user.email,
subject=f"Payment Confirmed - {payment.booking.booking_number}",
html=email_html
)
# If this is a deposit payment, update booking deposit_paid status
if payment.payment_type == PaymentType.deposit and payment.booking:
payment.booking.deposit_paid = True
# Optionally auto-confirm booking if deposit is paid
if payment.booking.status == BookingStatus.pending:
payment.booking.status = BookingStatus.confirmed
db.commit()
except Exception as e:
print(f"Failed to send payment confirmation email: {e}")
return {
"status": "success",
"message": "Payment status updated successfully",

View File

@@ -0,0 +1,111 @@
from fastapi import APIRouter, Depends, Request, Response, status
from sqlalchemy.orm import Session
from ..config.database import get_db
from ..config.logging_config import get_logger
from ..config.settings import settings
from ..middleware.cookie_consent import COOKIE_CONSENT_COOKIE_NAME, _parse_consent_cookie
from ..schemas.admin_privacy import PublicPrivacyConfigResponse
from ..schemas.privacy import (
CookieCategoryPreferences,
CookieConsent,
CookieConsentResponse,
UpdateCookieConsentRequest,
)
from ..services.privacy_admin_service import privacy_admin_service
logger = get_logger(__name__)
router = APIRouter(prefix="/privacy", tags=["privacy"])
@router.get(
"/cookie-consent",
response_model=CookieConsentResponse,
status_code=status.HTTP_200_OK,
)
async def get_cookie_consent(request: Request) -> CookieConsentResponse:
"""
Return the current cookie consent preferences.
Reads from the cookie (if present) or returns default (necessary only).
"""
raw_cookie = request.cookies.get(COOKIE_CONSENT_COOKIE_NAME)
consent = _parse_consent_cookie(raw_cookie)
# Ensure necessary is always true
consent.categories.necessary = True
return CookieConsentResponse(data=consent)
@router.post(
"/cookie-consent",
response_model=CookieConsentResponse,
status_code=status.HTTP_200_OK,
)
async def update_cookie_consent(
request: UpdateCookieConsentRequest, response: Response
) -> CookieConsentResponse:
"""
Update cookie consent preferences.
The 'necessary' category is controlled by the server and always true.
"""
# Build categories from existing cookie (if any) so partial updates work
existing_raw = response.headers.get("cookie") # usually empty here
# We can't reliably read cookies from the response; rely on defaults.
# For the purposes of this API, we always start from defaults and then
# override with the request payload.
categories = CookieCategoryPreferences()
if request.analytics is not None:
categories.analytics = request.analytics
if request.marketing is not None:
categories.marketing = request.marketing
if request.preferences is not None:
categories.preferences = request.preferences
# 'necessary' enforced server-side
categories.necessary = True
consent = CookieConsent(categories=categories, has_decided=True)
# Persist consent as a secure, HttpOnly cookie
response.set_cookie(
key=COOKIE_CONSENT_COOKIE_NAME,
value=consent.model_dump_json(),
httponly=True,
secure=settings.is_production,
samesite="lax",
max_age=365 * 24 * 60 * 60, # 1 year
path="/",
)
logger.info(
"Cookie consent updated: analytics=%s, marketing=%s, preferences=%s",
consent.categories.analytics,
consent.categories.marketing,
consent.categories.preferences,
)
return CookieConsentResponse(data=consent)
@router.get(
"/config",
response_model=PublicPrivacyConfigResponse,
status_code=status.HTTP_200_OK,
)
async def get_public_privacy_config(
db: Session = Depends(get_db),
) -> PublicPrivacyConfigResponse:
"""
Public privacy configuration for the frontend:
- Global policy flags
- Public integration IDs (e.g. GA measurement ID)
"""
config = privacy_admin_service.get_public_privacy_config(db)
return PublicPrivacyConfigResponse(data=config)

View File

@@ -10,6 +10,8 @@ from ..models.user import User
from ..models.booking import Booking, BookingStatus
from ..models.payment import Payment, PaymentStatus
from ..models.room import Room
from ..models.service_usage import ServiceUsage
from ..models.service import Service
router = APIRouter(prefix="/reports", tags=["reports"])
@@ -140,6 +142,33 @@ async def get_reports(
for room_id, room_number, bookings, revenue in top_rooms_data
]
# Service usage statistics
service_usage_query = db.query(
Service.id,
Service.name,
func.count(ServiceUsage.id).label('usage_count'),
func.sum(ServiceUsage.total_price).label('total_revenue')
).join(ServiceUsage, Service.id == ServiceUsage.service_id)
if start_date:
service_usage_query = service_usage_query.filter(ServiceUsage.usage_date >= start_date)
if end_date:
service_usage_query = service_usage_query.filter(ServiceUsage.usage_date <= end_date)
service_usage_data = service_usage_query.group_by(Service.id, Service.name).order_by(
func.sum(ServiceUsage.total_price).desc()
).limit(10).all()
service_usage = [
{
"service_id": service_id,
"service_name": service_name,
"usage_count": int(usage_count or 0),
"total_revenue": float(total_revenue or 0)
}
for service_id, service_name, usage_count, total_revenue in service_usage_data
]
return {
"status": "success",
"success": True,
@@ -152,6 +181,7 @@ async def get_reports(
"revenue_by_date": revenue_by_date if revenue_by_date else None,
"bookings_by_status": bookings_by_status,
"top_rooms": top_rooms if top_rooms else None,
"service_usage": service_usage if service_usage else None,
}
}
except Exception as e:
@@ -221,6 +251,171 @@ async def get_dashboard_stats(
raise HTTPException(status_code=500, detail=str(e))
@router.get("/customer/dashboard")
async def get_customer_dashboard_stats(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get customer dashboard statistics"""
try:
from datetime import datetime, timedelta
# Total bookings count for user
total_bookings = db.query(Booking).filter(
Booking.user_id == current_user.id
).count()
# Total spending (sum of completed payments from user's bookings)
user_bookings = db.query(Booking.id).filter(
Booking.user_id == current_user.id
).subquery()
total_spending = db.query(func.sum(Payment.amount)).filter(
and_(
Payment.booking_id.in_(db.query(user_bookings.c.id)),
Payment.payment_status == PaymentStatus.completed
)
).scalar() or 0.0
# Currently staying (checked_in bookings)
now = datetime.utcnow()
currently_staying = db.query(Booking).filter(
and_(
Booking.user_id == current_user.id,
Booking.status == BookingStatus.checked_in,
Booking.check_in_date <= now,
Booking.check_out_date >= now
)
).count()
# Upcoming bookings (confirmed/pending with check_in_date in future)
upcoming_bookings_query = db.query(Booking).filter(
and_(
Booking.user_id == current_user.id,
Booking.status.in_([BookingStatus.confirmed, BookingStatus.pending]),
Booking.check_in_date > now
)
).order_by(Booking.check_in_date.asc()).limit(5).all()
upcoming_bookings = []
for booking in upcoming_bookings_query:
booking_dict = {
"id": booking.id,
"booking_number": booking.booking_number,
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None,
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None,
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
"total_price": float(booking.total_price) if booking.total_price else 0.0,
}
if booking.room:
booking_dict["room"] = {
"id": booking.room.id,
"room_number": booking.room.room_number,
"room_type": {
"name": booking.room.room_type.name if booking.room.room_type else None
}
}
upcoming_bookings.append(booking_dict)
# Recent activity (last 5 bookings ordered by created_at)
recent_bookings_query = db.query(Booking).filter(
Booking.user_id == current_user.id
).order_by(Booking.created_at.desc()).limit(5).all()
recent_activity = []
for booking in recent_bookings_query:
activity_type = None
if booking.status == BookingStatus.checked_out:
activity_type = "Check-out"
elif booking.status == BookingStatus.checked_in:
activity_type = "Check-in"
elif booking.status == BookingStatus.confirmed:
activity_type = "Booking Confirmed"
elif booking.status == BookingStatus.pending:
activity_type = "Booking"
else:
activity_type = "Booking"
activity_dict = {
"action": activity_type,
"booking_id": booking.id,
"booking_number": booking.booking_number,
"created_at": booking.created_at.isoformat() if booking.created_at else None,
}
if booking.room:
activity_dict["room"] = {
"room_number": booking.room.room_number,
}
recent_activity.append(activity_dict)
# Calculate percentage change (placeholder - can be enhanced)
# For now, compare last month vs this month
last_month_start = (now - timedelta(days=30)).replace(day=1, hour=0, minute=0, second=0)
last_month_end = now.replace(day=1, hour=0, minute=0, second=0) - timedelta(seconds=1)
last_month_bookings = db.query(Booking).filter(
and_(
Booking.user_id == current_user.id,
Booking.created_at >= last_month_start,
Booking.created_at <= last_month_end
)
).count()
this_month_bookings = db.query(Booking).filter(
and_(
Booking.user_id == current_user.id,
Booking.created_at >= now.replace(day=1, hour=0, minute=0, second=0),
Booking.created_at <= now
)
).count()
booking_change_percentage = 0
if last_month_bookings > 0:
booking_change_percentage = ((this_month_bookings - last_month_bookings) / last_month_bookings) * 100
last_month_spending = db.query(func.sum(Payment.amount)).filter(
and_(
Payment.booking_id.in_(db.query(user_bookings.c.id)),
Payment.payment_status == PaymentStatus.completed,
Payment.payment_date >= last_month_start,
Payment.payment_date <= last_month_end
)
).scalar() or 0.0
this_month_spending = db.query(func.sum(Payment.amount)).filter(
and_(
Payment.booking_id.in_(db.query(user_bookings.c.id)),
Payment.payment_status == PaymentStatus.completed,
Payment.payment_date >= now.replace(day=1, hour=0, minute=0, second=0),
Payment.payment_date <= now
)
).scalar() or 0.0
spending_change_percentage = 0
if last_month_spending > 0:
spending_change_percentage = ((this_month_spending - last_month_spending) / last_month_spending) * 100
return {
"status": "success",
"success": True,
"data": {
"total_bookings": total_bookings,
"total_spending": float(total_spending),
"currently_staying": currently_staying,
"upcoming_bookings": upcoming_bookings,
"recent_activity": recent_activity,
"booking_change_percentage": round(booking_change_percentage, 1),
"spending_change_percentage": round(spending_change_percentage, 1),
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/revenue")
async def get_revenue_report(
start_date: Optional[str] = Query(None),

View File

@@ -0,0 +1,68 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class CookiePolicySettings(BaseModel):
"""
Admin-configurable global cookie policy.
Controls which categories can be used in the application.
"""
analytics_enabled: bool = Field(
default=True,
description="If false, analytics cookies/scripts should not be used at all.",
)
marketing_enabled: bool = Field(
default=True,
description="If false, marketing cookies/scripts should not be used at all.",
)
preferences_enabled: bool = Field(
default=True,
description="If false, preference cookies should not be used at all.",
)
class CookiePolicySettingsResponse(BaseModel):
status: str = Field(default="success")
data: CookiePolicySettings
updated_at: Optional[datetime] = None
updated_by: Optional[str] = None
class CookieIntegrationSettings(BaseModel):
"""
IDs for well-known third-party integrations, configured by admin.
"""
ga_measurement_id: Optional[str] = Field(
default=None, description="Google Analytics 4 measurement ID (e.g. G-XXXXXXX)."
)
fb_pixel_id: Optional[str] = Field(
default=None, description="Meta (Facebook) Pixel ID."
)
class CookieIntegrationSettingsResponse(BaseModel):
status: str = Field(default="success")
data: CookieIntegrationSettings
updated_at: Optional[datetime] = None
updated_by: Optional[str] = None
class PublicPrivacyConfig(BaseModel):
"""
Publicly consumable privacy configuration for the frontend.
Does not expose any secrets, only IDs and flags.
"""
policy: CookiePolicySettings
integrations: CookieIntegrationSettings
class PublicPrivacyConfigResponse(BaseModel):
status: str = Field(default="success")
data: PublicPrivacyConfig

View File

@@ -0,0 +1,70 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class CookieCategoryPreferences(BaseModel):
"""
Granular consent for different cookie categories.
- necessary: required for the site to function (always true, not revocable)
- analytics: usage analytics, performance tracking
- marketing: advertising, remarketing cookies
- preferences: UI / language / personalization preferences
"""
necessary: bool = Field(
default=True,
description="Strictly necessary cookies (always enabled as they are required for core functionality).",
)
analytics: bool = Field(
default=False, description="Allow anonymous analytics and performance cookies."
)
marketing: bool = Field(
default=False, description="Allow marketing and advertising cookies."
)
preferences: bool = Field(
default=False,
description="Allow preference cookies (e.g. language, layout settings).",
)
class CookieConsent(BaseModel):
"""
Persisted cookie consent state.
Stored in an HttpOnly cookie and exposed via the API.
"""
version: int = Field(
default=1, description="Consent schema version for future migrations."
)
updated_at: datetime = Field(
default_factory=datetime.utcnow, description="Last time consent was updated."
)
has_decided: bool = Field(
default=False,
description="Whether the user has actively made a consent choice.",
)
categories: CookieCategoryPreferences = Field(
default_factory=CookieCategoryPreferences,
description="Granular per-category consent.",
)
class CookieConsentResponse(BaseModel):
status: str = Field(default="success")
data: CookieConsent
class UpdateCookieConsentRequest(BaseModel):
"""
Request body for updating cookie consent.
'necessary' is ignored on write and always treated as True by the server.
"""
analytics: Optional[bool] = None
marketing: Optional[bool] = None
preferences: Optional[bool] = None

View File

@@ -0,0 +1,82 @@
"""
Audit logging service for tracking important actions
"""
from sqlalchemy.orm import Session
from typing import Optional, Dict, Any
from datetime import datetime
from ..models.audit_log import AuditLog
from ..config.logging_config import get_logger
logger = get_logger(__name__)
class AuditService:
"""Service for creating audit log entries"""
@staticmethod
async def log_action(
db: Session,
action: str,
resource_type: str,
user_id: Optional[int] = None,
resource_id: Optional[int] = None,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None,
request_id: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
status: str = "success",
error_message: Optional[str] = None
):
"""
Create an audit log entry
Args:
db: Database session
action: Action performed (e.g., "user.created", "booking.cancelled")
resource_type: Type of resource (e.g., "user", "booking")
user_id: ID of user who performed the action
resource_id: ID of the resource affected
ip_address: IP address of the request
user_agent: User agent string
request_id: Request ID for tracing
details: Additional context as dictionary
status: Status of the action (success, failed, error)
error_message: Error message if action failed
"""
try:
audit_log = AuditLog(
user_id=user_id,
action=action,
resource_type=resource_type,
resource_id=resource_id,
ip_address=ip_address,
user_agent=user_agent,
request_id=request_id,
details=details,
status=status,
error_message=error_message
)
db.add(audit_log)
db.commit()
logger.info(
f"Audit log created: {action} on {resource_type}",
extra={
"action": action,
"resource_type": resource_type,
"resource_id": resource_id,
"user_id": user_id,
"status": status,
"request_id": request_id
}
)
except Exception as e:
logger.error(f"Failed to create audit log: {str(e)}", exc_info=True)
db.rollback()
# Don't raise exception - audit logging failures shouldn't break the app
# Global audit service instance
audit_service = AuditService()

View File

@@ -5,19 +5,29 @@ import secrets
import hashlib
from sqlalchemy.orm import Session
from typing import Optional
import logging
from ..models.user import User
from ..models.refresh_token import RefreshToken
from ..models.password_reset_token import PasswordResetToken
from ..models.role import Role
from ..utils.mailer import send_email
from ..utils.email_templates import (
welcome_email_template,
password_reset_email_template,
password_changed_email_template
)
from ..config.settings import settings
import os
logger = logging.getLogger(__name__)
class AuthService:
def __init__(self):
self.jwt_secret = os.getenv("JWT_SECRET")
self.jwt_refresh_secret = os.getenv("JWT_REFRESH_SECRET")
# Use settings, fallback to env vars, then to defaults for development
self.jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv("JWT_SECRET", "dev-secret-key-change-in-production-12345")
self.jwt_refresh_secret = os.getenv("JWT_REFRESH_SECRET") or (self.jwt_secret + "-refresh")
self.jwt_expires_in = os.getenv("JWT_EXPIRES_IN", "1h")
self.jwt_refresh_expires_in = os.getenv("JWT_REFRESH_EXPIRES_IN", "7d")
@@ -70,6 +80,7 @@ class AuthService:
"name": user.full_name,
"email": user.email,
"phone": user.phone,
"avatar": user.avatar,
"role": user.role.name if user.role else "customer",
"createdAt": user.created_at.isoformat() if user.created_at else None,
"updatedAt": user.updated_at.isoformat() if user.updated_at else None,
@@ -115,33 +126,16 @@ class AuthService:
# Send welcome email (non-blocking)
try:
client_url = os.getenv("CLIENT_URL", "http://localhost:5173")
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
email_html = welcome_email_template(user.full_name, user.email, client_url)
await send_email(
to=user.email,
subject="Welcome to Hotel Booking",
html=f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #4F46E5;">Welcome {user.full_name}!</h2>
<p>Thank you for registering an account at <strong>Hotel Booking</strong>.</p>
<p>Your account has been successfully created with email: <strong>{user.email}</strong></p>
<div style="background-color: #F3F4F6; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0;"><strong>You can:</strong></p>
<ul style="margin-top: 10px;">
<li>Search and book hotel rooms</li>
<li>Manage your bookings</li>
<li>Update your personal information</li>
</ul>
</div>
<p>
<a href="{client_url}/login" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
Login Now
</a>
</p>
</div>
"""
html=email_html
)
logger.info(f"Welcome email sent successfully to {user.email}")
except Exception as e:
print(f"Failed to send welcome email: {e}")
logger.error(f"Failed to send welcome email to {user.email}: {type(e).__name__}: {str(e)}", exc_info=True)
return {
"user": self.format_user_response(user),
@@ -170,7 +164,15 @@ class AuthService:
expiry_days = 7 if remember_me else 1
expires_at = datetime.utcnow() + timedelta(days=expiry_days)
# Save refresh token
# Delete old/expired refresh tokens for this user to prevent duplicates
# This ensures we don't have multiple active tokens and prevents unique constraint violations
try:
db.query(RefreshToken).filter(
RefreshToken.user_id == user.id
).delete()
db.flush() # Flush to ensure deletion happens before insert
# Save new refresh token
refresh_token = RefreshToken(
user_id=user.id,
token=tokens["refreshToken"],
@@ -178,6 +180,26 @@ class AuthService:
)
db.add(refresh_token)
db.commit()
except Exception as e:
db.rollback()
logger.error(f"Error saving refresh token for user {user.id}: {str(e)}", exc_info=True)
# If there's still a duplicate, try to delete and retry once
try:
db.query(RefreshToken).filter(
RefreshToken.token == tokens["refreshToken"]
).delete()
db.flush()
refresh_token = RefreshToken(
user_id=user.id,
token=tokens["refreshToken"],
expires_at=expires_at
)
db.add(refresh_token)
db.commit()
except Exception as retry_error:
db.rollback()
logger.error(f"Retry failed for refresh token: {str(retry_error)}", exc_info=True)
raise ValueError("Failed to create session. Please try again.")
return {
"user": self.format_user_response(user),
@@ -235,6 +257,53 @@ class AuthService:
return self.format_user_response(user)
async def update_profile(
self,
db: Session,
user_id: int,
full_name: Optional[str] = None,
email: Optional[str] = None,
phone_number: Optional[str] = None,
password: Optional[str] = None,
current_password: Optional[str] = None
) -> dict:
"""Update user profile"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise ValueError("User not found")
# If password is being changed, verify current password
if password:
if not current_password:
raise ValueError("Current password is required to change password")
if not self.verify_password(current_password, user.password):
raise ValueError("Current password is incorrect")
# Hash new password
user.password = self.hash_password(password)
# Update other fields
if full_name is not None:
user.full_name = full_name
if email is not None:
# Check if email is already taken by another user
existing_user = db.query(User).filter(
User.email == email,
User.id != user_id
).first()
if existing_user:
raise ValueError("Email already registered")
user.email = email
if phone_number is not None:
user.phone = phone_number
db.commit()
db.refresh(user)
# Load role
user.role = db.query(Role).filter(Role.id == user.role_id).first()
return self.format_user_response(user)
def generate_reset_token(self) -> tuple:
"""Generate reset token"""
reset_token = secrets.token_hex(32)
@@ -270,22 +339,41 @@ class AuthService:
db.commit()
# Build reset URL
client_url = os.getenv("CLIENT_URL", "http://localhost:5173")
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
reset_url = f"{client_url}/reset-password/{reset_token}"
# Try to send email
try:
logger.info(f"Attempting to send password reset email to {user.email}")
logger.info(f"Reset URL: {reset_url}")
email_html = password_reset_email_template(reset_url)
# Create plain text version for better email deliverability
plain_text = f"""
Password Reset Request
You (or someone) has requested to reset your password for your Hotel Booking account.
Click the link below to reset your password. This link will expire in 1 hour:
{reset_url}
If you did not request this, please ignore this email.
Best regards,
Hotel Booking Team
""".strip()
await send_email(
to=user.email,
subject="Reset password - Hotel Booking",
html=f"""
<p>You (or someone) has requested to reset your password.</p>
<p>Click the link below to reset your password (expires in 1 hour):</p>
<p><a href="{reset_url}">{reset_url}</a></p>
"""
html=email_html,
text=plain_text
)
logger.info(f"Password reset email sent successfully to {user.email} with reset URL: {reset_url}")
except Exception as e:
print(f"Failed to send reset email: {e}")
logger.error(f"Failed to send password reset email to {user.email}: {type(e).__name__}: {str(e)}", exc_info=True)
# Still return success to prevent email enumeration, but log the error
return {
"success": True,
@@ -332,13 +420,16 @@ class AuthService:
# Send confirmation email (non-blocking)
try:
logger.info(f"Attempting to send password changed confirmation email to {user.email}")
email_html = password_changed_email_template(user.email)
await send_email(
to=user.email,
subject="Password Changed",
html=f"<p>The password for account {user.email} has been changed successfully.</p>"
html=email_html
)
logger.info(f"Password changed confirmation email sent successfully to {user.email}")
except Exception as e:
print(f"Failed to send confirmation email: {e}")
logger.error(f"Failed to send password changed confirmation email to {user.email}: {type(e).__name__}: {str(e)}", exc_info=True)
return {
"success": True,

View File

@@ -0,0 +1,98 @@
from sqlalchemy.orm import Session
from ..models.cookie_policy import CookiePolicy
from ..models.cookie_integration_config import CookieIntegrationConfig
from ..models.user import User
from ..schemas.admin_privacy import (
CookieIntegrationSettings,
CookiePolicySettings,
PublicPrivacyConfig,
)
class PrivacyAdminService:
"""
Service layer for admin-controlled cookie policy and integrations.
"""
# Policy
@staticmethod
def get_or_create_policy(db: Session) -> CookiePolicy:
policy = db.query(CookiePolicy).first()
if policy:
return policy
policy = CookiePolicy()
db.add(policy)
db.commit()
db.refresh(policy)
return policy
@staticmethod
def get_policy_settings(db: Session) -> CookiePolicySettings:
policy = PrivacyAdminService.get_or_create_policy(db)
return CookiePolicySettings(
analytics_enabled=policy.analytics_enabled,
marketing_enabled=policy.marketing_enabled,
preferences_enabled=policy.preferences_enabled,
)
@staticmethod
def update_policy(
db: Session, settings: CookiePolicySettings, updated_by: User | None
) -> CookiePolicy:
policy = PrivacyAdminService.get_or_create_policy(db)
policy.analytics_enabled = settings.analytics_enabled
policy.marketing_enabled = settings.marketing_enabled
policy.preferences_enabled = settings.preferences_enabled
if updated_by:
policy.updated_by_id = updated_by.id
db.add(policy)
db.commit()
db.refresh(policy)
return policy
# Integrations
@staticmethod
def get_or_create_integrations(db: Session) -> CookieIntegrationConfig:
config = db.query(CookieIntegrationConfig).first()
if config:
return config
config = CookieIntegrationConfig()
db.add(config)
db.commit()
db.refresh(config)
return config
@staticmethod
def get_integration_settings(db: Session) -> CookieIntegrationSettings:
cfg = PrivacyAdminService.get_or_create_integrations(db)
return CookieIntegrationSettings(
ga_measurement_id=cfg.ga_measurement_id,
fb_pixel_id=cfg.fb_pixel_id,
)
@staticmethod
def update_integrations(
db: Session, settings: CookieIntegrationSettings, updated_by: User | None
) -> CookieIntegrationConfig:
cfg = PrivacyAdminService.get_or_create_integrations(db)
cfg.ga_measurement_id = settings.ga_measurement_id
cfg.fb_pixel_id = settings.fb_pixel_id
if updated_by:
cfg.updated_by_id = updated_by.id
db.add(cfg)
db.commit()
db.refresh(cfg)
return cfg
@staticmethod
def get_public_privacy_config(db: Session) -> PublicPrivacyConfig:
policy = PrivacyAdminService.get_policy_settings(db)
integrations = PrivacyAdminService.get_integration_settings(db)
return PublicPrivacyConfig(policy=policy, integrations=integrations)
privacy_admin_service = PrivacyAdminService()

View File

@@ -0,0 +1,261 @@
"""
Email templates for various notifications
"""
from datetime import datetime
from typing import Optional
def get_base_template(content: str, title: str = "Hotel Booking") -> str:
"""Base HTML email template"""
return f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 20px 0; text-align: center; background-color: #4F46E5;">
<h1 style="color: #ffffff; margin: 0;">Hotel Booking</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 20px; background-color: #ffffff;">
<table role="presentation" style="width: 100%; max-width: 600px; margin: 0 auto;">
<tr>
<td>
{content}
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding: 20px; text-align: center; background-color: #f4f4f4; color: #666666; font-size: 12px;">
<p style="margin: 0;">This is an automated email. Please do not reply.</p>
<p style="margin: 5px 0 0 0;"{datetime.now().year} Hotel Booking. All rights reserved.</p>
</td>
</tr>
</table>
</body>
</html>
"""
def welcome_email_template(name: str, email: str, client_url: str) -> str:
"""Welcome email template for new registrations"""
content = f"""
<h2 style="color: #4F46E5; margin-top: 0;">Welcome {name}!</h2>
<p>Thank you for registering an account at <strong>Hotel Booking</strong>.</p>
<p>Your account has been successfully created with email: <strong>{email}</strong></p>
<div style="background-color: #F3F4F6; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0;"><strong>You can:</strong></p>
<ul style="margin-top: 10px;">
<li>Search and book hotel rooms</li>
<li>Manage your bookings</li>
<li>Update your personal information</li>
</ul>
</div>
<p style="text-align: center; margin-top: 30px;">
<a href="{client_url}/login" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
Login Now
</a>
</p>
"""
return get_base_template(content, "Welcome to Hotel Booking")
def password_reset_email_template(reset_url: str) -> str:
"""Password reset email template"""
content = f"""
<h2 style="color: #4F46E5; margin-top: 0;">Password Reset Request</h2>
<p>You (or someone) has requested to reset your password.</p>
<p>Click the link below to reset your password. This link will expire in 1 hour:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{reset_url}" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
Reset Password
</a>
</p>
<p style="color: #666666; font-size: 14px;">If you did not request this, please ignore this email.</p>
"""
return get_base_template(content, "Password Reset")
def password_changed_email_template(email: str) -> str:
"""Password changed confirmation email template"""
content = f"""
<h2 style="color: #4F46E5; margin-top: 0;">Password Changed Successfully</h2>
<p>The password for account <strong>{email}</strong> has been changed successfully.</p>
<p>If you did not make this change, please contact our support team immediately.</p>
"""
return get_base_template(content, "Password Changed")
def booking_confirmation_email_template(
booking_number: str,
guest_name: str,
room_number: str,
room_type: str,
check_in: str,
check_out: str,
num_guests: int,
total_price: float,
requires_deposit: bool,
deposit_amount: Optional[float] = None,
client_url: str = "http://localhost:5173"
) -> str:
"""Booking confirmation email template"""
deposit_info = ""
if requires_deposit and deposit_amount:
deposit_info = f"""
<div style="background-color: #FEF3C7; border-left: 4px solid #F59E0B; padding: 15px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: bold; color: #92400E;">⚠️ Deposit Required</p>
<p style="margin: 5px 0 0 0; color: #78350F;">Please pay a deposit of <strong>€{deposit_amount:.2f}</strong> to confirm your booking.</p>
<p style="margin: 5px 0 0 0; color: #78350F;">Your booking will be confirmed once the deposit is received.</p>
</div>
"""
content = f"""
<h2 style="color: #4F46E5; margin-top: 0;">Booking Confirmation</h2>
<p>Dear {guest_name},</p>
<p>Thank you for your booking! We have received your reservation request.</p>
<div style="background-color: #F3F4F6; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0; color: #1F2937;">Booking Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #6B7280; width: 40%;">Booking Number:</td>
<td style="padding: 8px 0; font-weight: bold; color: #1F2937;">{booking_number}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Room:</td>
<td style="padding: 8px 0; color: #1F2937;">{room_type} - Room {room_number}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Check-in:</td>
<td style="padding: 8px 0; color: #1F2937;">{check_in}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Check-out:</td>
<td style="padding: 8px 0; color: #1F2937;">{check_out}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Guests:</td>
<td style="padding: 8px 0; color: #1F2937;">{num_guests}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Total Price:</td>
<td style="padding: 8px 0; font-weight: bold; color: #1F2937;">€{total_price:.2f}</td>
</tr>
</table>
</div>
{deposit_info}
<p style="text-align: center; margin-top: 30px;">
<a href="{client_url}/bookings/{booking_number}" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
View Booking Details
</a>
</p>
"""
return get_base_template(content, "Booking Confirmation")
def payment_confirmation_email_template(
booking_number: str,
guest_name: str,
amount: float,
payment_method: str,
transaction_id: Optional[str] = None,
client_url: str = "http://localhost:5173"
) -> str:
"""Payment confirmation email template"""
transaction_info = ""
if transaction_id:
transaction_info = f"""
<tr>
<td style="padding: 8px 0; color: #6B7280;">Transaction ID:</td>
<td style="padding: 8px 0; color: #1F2937; font-family: monospace;">{transaction_id}</td>
</tr>
"""
content = f"""
<h2 style="color: #10B981; margin-top: 0;">Payment Received</h2>
<p>Dear {guest_name},</p>
<p>We have successfully received your payment for booking <strong>{booking_number}</strong>.</p>
<div style="background-color: #ECFDF5; border-left: 4px solid #10B981; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0; color: #065F46;">Payment Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #6B7280; width: 40%;">Booking Number:</td>
<td style="padding: 8px 0; font-weight: bold; color: #1F2937;">{booking_number}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Amount:</td>
<td style="padding: 8px 0; font-weight: bold; color: #065F46; font-size: 18px;">€{amount:.2f}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Payment Method:</td>
<td style="padding: 8px 0; color: #1F2937;">{payment_method}</td>
</tr>
{transaction_info}
</table>
</div>
<p>Your booking is now confirmed. We look forward to hosting you!</p>
<p style="text-align: center; margin-top: 30px;">
<a href="{client_url}/bookings/{booking_number}" style="background-color: #10B981; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
View Booking
</a>
</p>
"""
return get_base_template(content, "Payment Confirmation")
def booking_status_changed_email_template(
booking_number: str,
guest_name: str,
status: str,
client_url: str = "http://localhost:5173"
) -> str:
"""Booking status change email template"""
status_colors = {
"confirmed": ("#10B981", "Confirmed"),
"cancelled": ("#EF4444", "Cancelled"),
"checked_in": ("#3B82F6", "Checked In"),
"checked_out": ("#8B5CF6", "Checked Out"),
}
color, status_text = status_colors.get(status.lower(), ("#6B7280", status.title()))
content = f"""
<h2 style="color: {color}; margin-top: 0;">Booking Status Updated</h2>
<p>Dear {guest_name},</p>
<p>Your booking status has been updated.</p>
<div style="background-color: #F3F4F6; padding: 20px; border-radius: 8px; margin: 20px 0;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #6B7280; width: 40%;">Booking Number:</td>
<td style="padding: 8px 0; font-weight: bold; color: #1F2937;">{booking_number}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">New Status:</td>
<td style="padding: 8px 0; font-weight: bold; color: {color}; font-size: 18px;">{status_text}</td>
</tr>
</table>
</div>
<p style="text-align: center; margin-top: 30px;">
<a href="{client_url}/bookings/{booking_number}" style="background-color: {color}; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
View Booking
</a>
</p>
"""
return get_base_template(content, f"Booking {status_text}")

View File

@@ -2,31 +2,45 @@ import aiosmtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import os
import logging
from ..config.settings import settings
logger = logging.getLogger(__name__)
async def send_email(to: str, subject: str, html: str = None, text: str = None):
"""
Send email using SMTP
Requires MAIL_HOST, MAIL_USER and MAIL_PASS to be set in env.
Uses settings from config/settings.py with fallback to environment variables
"""
# Require SMTP credentials to be present
mail_host = os.getenv("MAIL_HOST")
mail_user = os.getenv("MAIL_USER")
mail_pass = os.getenv("MAIL_PASS")
try:
# Get SMTP settings from settings.py, fallback to env vars
mail_host = settings.SMTP_HOST or os.getenv("MAIL_HOST")
mail_user = settings.SMTP_USER or os.getenv("MAIL_USER")
mail_pass = settings.SMTP_PASSWORD or os.getenv("MAIL_PASS")
mail_port = settings.SMTP_PORT or int(os.getenv("MAIL_PORT", "587"))
mail_secure = os.getenv("MAIL_SECURE", "false").lower() == "true"
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
# Get from address - prefer settings, then env, then generate from client_url
from_address = settings.SMTP_FROM_EMAIL or os.getenv("MAIL_FROM")
if not from_address:
# Generate from client_url if not set
domain = client_url.replace('https://', '').replace('http://', '').split('/')[0]
from_address = f"no-reply@{domain}"
# Use from name if available
from_name = settings.SMTP_FROM_NAME or "Hotel Booking"
from_header = f"{from_name} <{from_address}>"
if not (mail_host and mail_user and mail_pass):
raise ValueError(
"SMTP mailer not configured. Set MAIL_HOST, MAIL_USER and MAIL_PASS in env."
)
mail_port = int(os.getenv("MAIL_PORT", "587"))
mail_secure = os.getenv("MAIL_SECURE", "false").lower() == "true"
client_url = os.getenv("CLIENT_URL", "example.com")
from_address = os.getenv("MAIL_FROM", f"no-reply@{client_url.replace('https://', '').replace('http://', '')}")
error_msg = "SMTP mailer not configured. Set SMTP_HOST, SMTP_USER and SMTP_PASSWORD in .env file."
logger.error(error_msg)
raise ValueError(error_msg)
# Create message
message = MIMEMultipart("alternative")
message["From"] = from_address
message["From"] = from_header
message["To"] = to
message["Subject"] = subject
@@ -35,14 +49,49 @@ async def send_email(to: str, subject: str, html: str = None, text: str = None):
if html:
message.attach(MIMEText(html, "html"))
# Send email
await aiosmtplib.send(
message,
# If no content provided, add a default text
if not text and not html:
message.attach(MIMEText("", "plain"))
# Determine TLS/SSL settings
# For port 587: use STARTTLS (use_tls=False, start_tls=True)
# For port 465: use SSL/TLS (use_tls=True, start_tls=False)
# For port 25: plain (usually not used for authenticated sending)
if mail_port == 465 or mail_secure:
# SSL/TLS connection (port 465)
use_tls = True
start_tls = False
elif mail_port == 587:
# STARTTLS connection (port 587)
use_tls = False
start_tls = True
else:
# Plain connection (port 25 or other)
use_tls = False
start_tls = False
logger.info(f"Attempting to send email to {to} via {mail_host}:{mail_port} (use_tls: {use_tls}, start_tls: {start_tls})")
# Send email using SMTP client
smtp_client = aiosmtplib.SMTP(
hostname=mail_host,
port=mail_port,
use_tls=not mail_secure and mail_port == 587,
start_tls=not mail_secure and mail_port == 587,
use_tls=use_tls,
start_tls=start_tls,
username=mail_user,
password=mail_pass,
)
try:
await smtp_client.connect()
# Authentication happens automatically if username/password are provided in constructor
await smtp_client.send_message(message)
logger.info(f"Email sent successfully to {to}")
finally:
await smtp_client.quit()
except Exception as e:
error_msg = f"Failed to send email to {to}: {type(e).__name__}: {str(e)}"
logger.error(error_msg, exc_info=True)
raise

View File

@@ -4,7 +4,10 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hotel Booking - Management System</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />
<title>Luxury Hotel - Excellence Redefined</title>
</head>
<body>
<div id="root"></div>

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { useEffect, lazy, Suspense } from 'react';
import {
BrowserRouter,
Routes,
@@ -7,6 +7,12 @@ import {
} from 'react-router-dom';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { GlobalLoadingProvider } from './contexts/GlobalLoadingContext';
import { CookieConsentProvider } from './contexts/CookieConsentContext';
import OfflineIndicator from './components/common/OfflineIndicator';
import CookieConsentBanner from './components/common/CookieConsentBanner';
import AnalyticsLoader from './components/common/AnalyticsLoader';
import Loading from './components/common/Loading';
// Store
import useAuthStore from './store/useAuthStore';
@@ -22,52 +28,42 @@ import {
AdminRoute
} from './components/auth';
// Pages
import HomePage from './pages/HomePage';
import DashboardPage from
'./pages/customer/DashboardPage';
import RoomListPage from
'./pages/customer/RoomListPage';
import RoomDetailPage from
'./pages/customer/RoomDetailPage';
import SearchResultsPage from
'./pages/customer/SearchResultsPage';
import FavoritesPage from
'./pages/customer/FavoritesPage';
import MyBookingsPage from
'./pages/customer/MyBookingsPage';
import BookingPage from
'./pages/customer/BookingPage';
import BookingSuccessPage from
'./pages/customer/BookingSuccessPage';
import BookingDetailPage from
'./pages/customer/BookingDetailPage';
import DepositPaymentPage from
'./pages/customer/DepositPaymentPage';
import PaymentConfirmationPage from
'./pages/customer/PaymentConfirmationPage';
import PaymentResultPage from
'./pages/customer/PaymentResultPage';
import {
LoginPage,
RegisterPage,
ForgotPasswordPage,
ResetPasswordPage
} from './pages/auth';
// Lazy load pages for code splitting
const HomePage = lazy(() => import('./pages/HomePage'));
const DashboardPage = lazy(() => import('./pages/customer/DashboardPage'));
const RoomListPage = lazy(() => import('./pages/customer/RoomListPage'));
const RoomDetailPage = lazy(() => import('./pages/customer/RoomDetailPage'));
const SearchResultsPage = lazy(() => import('./pages/customer/SearchResultsPage'));
const FavoritesPage = lazy(() => import('./pages/customer/FavoritesPage'));
const MyBookingsPage = lazy(() => import('./pages/customer/MyBookingsPage'));
const BookingPage = lazy(() => import('./pages/customer/BookingPage'));
const BookingSuccessPage = lazy(() => import('./pages/customer/BookingSuccessPage'));
const BookingDetailPage = lazy(() => import('./pages/customer/BookingDetailPage'));
const DepositPaymentPage = lazy(() => import('./pages/customer/DepositPaymentPage'));
const PaymentConfirmationPage = lazy(() => import('./pages/customer/PaymentConfirmationPage'));
const PaymentResultPage = lazy(() => import('./pages/customer/PaymentResultPage'));
const ProfilePage = lazy(() => import('./pages/customer/ProfilePage'));
const AboutPage = lazy(() => import('./pages/AboutPage'));
const LoginPage = lazy(() => import('./pages/auth/LoginPage'));
const RegisterPage = lazy(() => import('./pages/auth/RegisterPage'));
const ForgotPasswordPage = lazy(() => import('./pages/auth/ForgotPasswordPage'));
const ResetPasswordPage = lazy(() => import('./pages/auth/ResetPasswordPage'));
// Admin Pages
import {
DashboardPage as AdminDashboardPage,
RoomManagementPage,
UserManagementPage,
BookingManagementPage,
PaymentManagementPage,
ServiceManagementPage,
ReviewManagementPage,
PromotionManagementPage,
CheckInPage,
CheckOutPage,
} from './pages/admin';
// Lazy load admin pages
const AdminDashboardPage = lazy(() => import('./pages/admin/DashboardPage'));
const RoomManagementPage = lazy(() => import('./pages/admin/RoomManagementPage'));
const UserManagementPage = lazy(() => import('./pages/admin/UserManagementPage'));
const BookingManagementPage = lazy(() => import('./pages/admin/BookingManagementPage'));
const PaymentManagementPage = lazy(() => import('./pages/admin/PaymentManagementPage'));
const ServiceManagementPage = lazy(() => import('./pages/admin/ServiceManagementPage'));
const ReviewManagementPage = lazy(() => import('./pages/admin/ReviewManagementPage'));
const PromotionManagementPage = lazy(() => import('./pages/admin/PromotionManagementPage'));
const BannerManagementPage = lazy(() => import('./pages/admin/BannerManagementPage'));
const ReportsPage = lazy(() => import('./pages/admin/ReportsPage'));
const CookieSettingsPage = lazy(() => import('./pages/admin/CookieSettingsPage'));
const AuditLogsPage = lazy(() => import('./pages/admin/AuditLogsPage'));
const CheckInPage = lazy(() => import('./pages/admin/CheckInPage'));
const CheckOutPage = lazy(() => import('./pages/admin/CheckOutPage'));
// Demo component for pages not yet created
const DemoPage: React.FC<{ title: string }> = ({ title }) => (
@@ -125,7 +121,15 @@ function App() {
};
return (
<BrowserRouter>
<GlobalLoadingProvider>
<CookieConsentProvider>
<BrowserRouter
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<Suspense fallback={<Loading fullScreen text="Loading page..." />}>
<Routes>
{/* Public Routes with Main Layout */}
<Route
@@ -161,7 +165,7 @@ function App() {
/>
<Route
path="about"
element={<DemoPage title="About" />}
element={<AboutPage />}
/>
{/* Protected Routes - Requires login */}
@@ -225,7 +229,7 @@ function App() {
path="profile"
element={
<ProtectedRoute>
<DemoPage title="Profile" />
<ProfilePage />
</ProtectedRoute>
}
/>
@@ -301,15 +305,19 @@ function App() {
/>
<Route
path="banners"
element={<DemoPage title="Banner Management" />}
element={<BannerManagementPage />}
/>
<Route
path="reports"
element={<DemoPage title="Reports" />}
element={<ReportsPage />}
/>
<Route
path="audit-logs"
element={<AuditLogsPage />}
/>
<Route
path="settings"
element={<DemoPage title="Settings" />}
element={<CookieSettingsPage />}
/>
</Route>
@@ -330,8 +338,17 @@ function App() {
pauseOnFocusLoss
draggable
pauseOnHover
theme="light"
toastClassName="rounded-lg shadow-lg"
bodyClassName="text-sm font-medium"
/>
<OfflineIndicator />
<CookieConsentBanner />
<AnalyticsLoader />
</Suspense>
</BrowserRouter>
</CookieConsentProvider>
</GlobalLoadingProvider>
);
}

View File

@@ -0,0 +1,117 @@
import React, { useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import privacyService, {
PublicPrivacyConfig,
} from '../../services/api/privacyService';
import { useCookieConsent } from '../../contexts/CookieConsentContext';
declare global {
interface Window {
dataLayer: any[];
gtag: (...args: any[]) => void;
fbq: (...args: any[]) => void;
}
}
const AnalyticsLoader: React.FC = () => {
const location = useLocation();
const { consent } = useCookieConsent();
const [config, setConfig] = useState<PublicPrivacyConfig | null>(null);
const gaLoadedRef = useRef(false);
const fbLoadedRef = useRef(false);
// Load public privacy config once
useEffect(() => {
let mounted = true;
const loadConfig = async () => {
try {
const cfg = await privacyService.getPublicConfig();
if (!mounted) return;
setConfig(cfg);
} catch {
// Fail silently in production; analytics are non-critical
}
};
void loadConfig();
return () => {
mounted = false;
};
}, []);
// Load Google Analytics when allowed
useEffect(() => {
if (!config || !consent) return;
const measurementId = config.integrations.ga_measurement_id;
const analyticsAllowed =
config.policy.analytics_enabled && consent.categories.analytics;
if (!measurementId || !analyticsAllowed || gaLoadedRef.current) return;
// Inject GA script
const script = document.createElement('script');
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(
measurementId
)}`;
document.head.appendChild(script);
window.dataLayer = window.dataLayer || [];
function gtag(...args: any[]) {
window.dataLayer.push(args);
}
window.gtag = gtag;
gtag('js', new Date());
gtag('config', measurementId, { anonymize_ip: true });
gaLoadedRef.current = true;
return () => {
// We don't remove GA script on unmount; typical SPA behaviour is to keep it.
};
}, [config, consent]);
// Track GA page views on route change
useEffect(() => {
if (!gaLoadedRef.current || !config?.integrations.ga_measurement_id) return;
if (typeof window.gtag !== 'function') return;
window.gtag('config', config.integrations.ga_measurement_id, {
page_path: location.pathname + location.search,
});
}, [location, config]);
// Load Meta Pixel when allowed
useEffect(() => {
if (!config || !consent) return;
const pixelId = config.integrations.fb_pixel_id;
const marketingAllowed =
config.policy.marketing_enabled && consent.categories.marketing;
if (!pixelId || !marketingAllowed || fbLoadedRef.current) return;
// Meta Pixel base code
!(function (f: any, b, e, v, n?, t?, s?) {
if (f.fbq) return;
n = f.fbq = function () {
(n.callMethod ? n.callMethod : n.queue.push).apply(n, arguments);
};
if (!f._fbq) f._fbq = n;
(n as any).push = n;
(n as any).loaded = true;
(n as any).version = '2.0';
(n as any).queue = [];
t = b.createElement(e);
t.async = true;
t.src = 'https://connect.facebook.net/en_US/fbevents.js';
s = b.getElementsByTagName(e)[0];
s.parentNode?.insertBefore(t, s);
})(window, document, 'script');
window.fbq('init', pixelId);
window.fbq('track', 'PageView');
fbLoadedRef.current = true;
}, [config, consent]);
return null;
};
export default AnalyticsLoader;

View File

@@ -0,0 +1,164 @@
import React from 'react';
import { AlertTriangle, X } from 'lucide-react';
interface ConfirmationDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'warning' | 'info';
isLoading?: boolean;
}
const ConfirmationDialog: React.FC<ConfirmationDialogProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
variant = 'info',
isLoading = false,
}) => {
if (!isOpen) return null;
const variantStyles = {
danger: {
icon: 'text-red-600',
button: 'bg-red-600 hover:bg-red-700 focus:ring-red-500',
},
warning: {
icon: 'text-yellow-600',
button: 'bg-yellow-600 hover:bg-yellow-700 focus:ring-yellow-500',
},
info: {
icon: 'text-blue-600',
button: 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500',
},
};
const styles = variantStyles[variant];
return (
<div
className="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
onClick={onClose}
aria-hidden="true"
/>
{/* Dialog */}
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div className="relative transform overflow-hidden rounded-sm bg-gradient-to-b from-white to-gray-50 text-left shadow-2xl border border-[#d4af37]/20 transition-all sm:my-8 sm:w-full sm:max-w-lg animate-fade-in">
{/* Close button */}
<button
onClick={onClose}
className="absolute right-4 top-4 text-gray-400 hover:text-[#d4af37] focus:outline-none focus:ring-2 focus:ring-[#d4af37]/50 rounded-sm p-1 transition-colors"
aria-label="Close"
>
<X className="w-5 h-5" />
</button>
<div className="bg-transparent px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div
className={`mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full ${
variant === 'danger'
? 'bg-red-100 border-2 border-red-200'
: variant === 'warning'
? 'bg-[#d4af37]/20 border-2 border-[#d4af37]/40'
: 'bg-[#d4af37]/20 border-2 border-[#d4af37]/40'
} sm:mx-0 sm:h-10 sm:w-10`}
>
<AlertTriangle
className={`h-6 w-6 ${
variant === 'danger'
? 'text-red-600'
: variant === 'warning'
? 'text-[#d4af37]'
: 'text-[#d4af37]'
}`}
aria-hidden="true"
/>
</div>
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
<h3
className="text-lg font-serif font-semibold leading-6 text-gray-900 tracking-tight"
id="modal-title"
>
{title}
</h3>
<div className="mt-2">
<p className="text-sm text-gray-600 font-light leading-relaxed">{message}</p>
</div>
</div>
</div>
</div>
<div className="bg-gray-50/50 backdrop-blur-sm px-4 py-4 sm:flex sm:flex-row-reverse sm:px-6 gap-3 border-t border-gray-200">
<button
type="button"
onClick={onConfirm}
disabled={isLoading}
className={`inline-flex w-full justify-center rounded-sm px-4 py-2.5 text-sm font-medium tracking-wide text-white shadow-lg sm:ml-3 sm:w-auto focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all ${
variant === 'danger'
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
: variant === 'warning'
? 'bg-gradient-to-r from-[#d4af37] to-[#c9a227] hover:from-[#f5d76e] hover:to-[#d4af37] focus:ring-[#d4af37]'
: 'btn-luxury-primary focus:ring-[#d4af37]'
}`}
>
{isLoading ? (
<span className="flex items-center gap-2">
<svg
className="animate-spin h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Processing...
</span>
) : (
confirmText
)}
</button>
<button
type="button"
onClick={onClose}
disabled={isLoading}
className="mt-3 inline-flex w-full justify-center rounded-sm bg-white/80 backdrop-blur-sm px-4 py-2.5 text-sm font-medium tracking-wide text-gray-700 shadow-sm border border-gray-300 hover:bg-white hover:border-[#d4af37]/30 hover:text-[#d4af37] sm:mt-0 sm:w-auto disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
{cancelText}
</button>
</div>
</div>
</div>
</div>
);
};
export default ConfirmationDialog;

View File

@@ -0,0 +1,200 @@
import React, { useEffect, useState } from 'react';
import { useCookieConsent } from '../../contexts/CookieConsentContext';
const CookieConsentBanner: React.FC = () => {
const { consent, isLoading, hasDecided, updateConsent } = useCookieConsent();
const [showDetails, setShowDetails] = useState(false);
const [analyticsChecked, setAnalyticsChecked] = useState(false);
const [marketingChecked, setMarketingChecked] = useState(false);
const [preferencesChecked, setPreferencesChecked] = useState(false);
useEffect(() => {
if (consent) {
setAnalyticsChecked(consent.categories.analytics);
setMarketingChecked(consent.categories.marketing);
setPreferencesChecked(consent.categories.preferences);
}
}, [consent]);
useEffect(() => {
const handleOpenPreferences = () => {
setShowDetails(true);
};
window.addEventListener('open-cookie-preferences', handleOpenPreferences);
return () => {
window.removeEventListener(
'open-cookie-preferences',
handleOpenPreferences
);
};
}, []);
if (isLoading || hasDecided) {
return null;
}
const handleAcceptAll = async () => {
await updateConsent({
analytics: true,
marketing: true,
preferences: true,
});
};
const handleRejectNonEssential = async () => {
await updateConsent({
analytics: false,
marketing: false,
preferences: false,
});
};
const handleSaveSelection = async () => {
await updateConsent({
analytics: analyticsChecked,
marketing: marketingChecked,
preferences: preferencesChecked,
});
};
return (
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-40 flex justify-center px-4 pb-4 sm:px-6 sm:pb-6">
<div className="pointer-events-auto relative w-full max-w-4xl overflow-hidden rounded-2xl bg-gradient-to-r from-black/85 via-zinc-900/90 to-black/85 p-[1px] shadow-[0_24px_60px_rgba(0,0,0,0.8)]">
{/* Gold inner border */}
<div className="absolute inset-0 rounded-2xl border border-[#d4af37]/40" />
{/* Subtle glow */}
<div className="pointer-events-none absolute -inset-8 bg-[radial-gradient(circle_at_top,_rgba(212,175,55,0.18),_transparent_55%),radial-gradient(circle_at_bottom,_rgba(0,0,0,0.8),_transparent_60%)] opacity-80" />
<div className="relative flex flex-col gap-4 bg-gradient-to-br from-zinc-950/80 via-zinc-900/90 to-black/90 px-4 py-4 sm:px-6 sm:py-5 lg:px-8 lg:py-6 sm:flex-row sm:items-start sm:justify-between">
{/* Left: copy + details */}
<div className="space-y-3 sm:max-w-xl">
<div className="inline-flex items-center gap-2 rounded-full bg-black/60 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.16em] text-[#d4af37]/90 ring-1 ring-[#d4af37]/30">
<span className="h-1.5 w-1.5 rounded-full bg-[#d4af37]" />
Privacy Suite
</div>
<div className="space-y-1.5">
<h2 className="text-lg font-semibold tracking-wide text-white sm:text-xl">
A tailored privacy experience
</h2>
<p className="text-xs leading-relaxed text-zinc-300 sm:text-sm">
We use cookies to ensure a seamless booking journey, enhance performance,
and offer curated experiences. Choose a level of personalization that
matches your comfort.
</p>
</div>
<button
type="button"
className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[#d4af37] underline underline-offset-4 hover:text-[#f6e7b4]"
onClick={() => setShowDetails((prev) => !prev)}
>
{showDetails ? 'Hide detailed preferences' : 'Fine-tune preferences'}
</button>
{showDetails && (
<div className="mt-1.5 space-y-3 rounded-xl bg-black/40 p-3 ring-1 ring-zinc-800/80 backdrop-blur-md sm:p-4">
<div className="flex flex-col gap-2 text-xs text-zinc-200 sm:text-[13px]">
<div className="flex items-start gap-3">
<div className="mt-0.5 h-4 w-4 rounded border border-[#d4af37]/50 bg-[#d4af37]/20" />
<div>
<p className="font-semibold text-zinc-50">Strictly necessary</p>
<p className="text-[11px] text-zinc-400">
Essential for security, authentication, and core booking flows.
These are always enabled.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<input
id="cookie-analytics"
type="checkbox"
className="mt-0.5 h-4 w-4 rounded border-zinc-500 bg-black/40 text-[#d4af37] focus:ring-[#d4af37]"
checked={analyticsChecked}
onChange={(e) => setAnalyticsChecked(e.target.checked)}
/>
<label htmlFor="cookie-analytics" className="cursor-pointer">
<p className="font-semibold text-zinc-50">Analytics</p>
<p className="text-[11px] text-zinc-400">
Anonymous insights that help us refine performance and guest
experience throughout the site.
</p>
</label>
</div>
<div className="flex items-start gap-3">
<input
id="cookie-marketing"
type="checkbox"
className="mt-0.5 h-4 w-4 rounded border-zinc-500 bg-black/40 text-[#d4af37] focus:ring-[#d4af37]"
checked={marketingChecked}
onChange={(e) => setMarketingChecked(e.target.checked)}
/>
<label htmlFor="cookie-marketing" className="cursor-pointer">
<p className="font-semibold text-zinc-50">Tailored offers</p>
<p className="text-[11px] text-zinc-400">
Allow us to present bespoke promotions and experiences aligned
with your interests.
</p>
</label>
</div>
<div className="flex items-start gap-3">
<input
id="cookie-preferences"
type="checkbox"
className="mt-0.5 h-4 w-4 rounded border-zinc-500 bg-black/40 text-[#d4af37] focus:ring-[#d4af37]"
checked={preferencesChecked}
onChange={(e) => setPreferencesChecked(e.target.checked)}
/>
<label htmlFor="cookie-preferences" className="cursor-pointer">
<p className="font-semibold text-zinc-50">Comfort preferences</p>
<p className="text-[11px] text-zinc-400">
Remember your choices such as language, currency, and layout for
a smoother return visit.
</p>
</label>
</div>
</div>
</div>
)}
</div>
{/* Right: actions */}
<div className="mt-2 flex flex-col gap-2 sm:mt-0 sm:w-56">
<button
type="button"
className="inline-flex w-full items-center justify-center rounded-full bg-gradient-to-r from-[#d4af37] via-[#f2cf74] to-[#d4af37] px-4 py-2.5 text-xs font-semibold uppercase tracking-[0.16em] text-black shadow-[0_10px_30px_rgba(0,0,0,0.6)] transition hover:from-[#f8e4a6] hover:via-[#ffe6a3] hover:to-[#f2cf74] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#d4af37]/70"
onClick={handleAcceptAll}
>
Accept all & continue
</button>
<button
type="button"
className="inline-flex w-full items-center justify-center rounded-full border border-zinc-600/80 bg-black/40 px-4 py-2.5 text-xs font-semibold uppercase tracking-[0.16em] text-zinc-100 shadow-[0_10px_25px_rgba(0,0,0,0.65)] transition hover:border-zinc-400 hover:bg-black/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500/70"
onClick={handleRejectNonEssential}
>
Essential only
</button>
{showDetails && (
<button
type="button"
className="inline-flex w-full items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/80 px-4 py-2.5 text-xs font-semibold uppercase tracking-[0.16em] text-zinc-100 shadow-[0_8px_22px_rgba(0,0,0,0.6)] transition hover:border-[#d4af37]/60 hover:text-[#f5e9c6] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#d4af37]/70"
onClick={handleSaveSelection}
>
Save my selection
</button>
)}
</div>
</div>
</div>
</div>
);
};
export default CookieConsentBanner;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { useCookieConsent } from '../../contexts/CookieConsentContext';
const CookiePreferencesLink: React.FC = () => {
const { hasDecided } = useCookieConsent();
if (!hasDecided) {
return null;
}
const handleClick = () => {
// Dispatch a custom event listened by the banner to reopen details.
window.dispatchEvent(new CustomEvent('open-cookie-preferences'));
};
return (
<button
type="button"
onClick={handleClick}
className="text-xs font-medium text-gray-500 underline hover:text-gray-700"
>
Cookie preferences
</button>
);
};
export default CookiePreferencesLink;

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
interface GlobalLoadingProps {
isLoading: boolean;
text?: string;
}
const GlobalLoading: React.FC<GlobalLoadingProps> = ({
isLoading,
text = 'Loading...',
}) => {
if (!isLoading) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-white bg-opacity-75 backdrop-blur-sm"
role="status"
aria-label="Loading"
>
<div className="flex flex-col items-center gap-4">
<Loader2 className="w-8 h-8 text-indigo-600 animate-spin" />
<p className="text-sm font-medium text-gray-700">{text}</p>
</div>
</div>
);
};
export default GlobalLoading;

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { WifiOff } from 'lucide-react';
import { useOffline } from '../../hooks/useOffline';
const OfflineIndicator: React.FC = () => {
const isOffline = useOffline();
if (!isOffline) return null;
return (
<div
className="fixed bottom-0 left-0 right-0 z-50 bg-yellow-500 text-white px-4 py-3 shadow-lg flex items-center justify-center gap-2 animate-slide-up"
role="alert"
aria-live="polite"
>
<WifiOff className="w-5 h-5" />
<span className="text-sm font-medium">
You're currently offline. Some features may be unavailable.
</span>
</div>
);
};
export default OfflineIndicator;

View File

@@ -0,0 +1,47 @@
import React from 'react';
interface SkeletonProps {
width?: string | number;
height?: string | number;
className?: string;
variant?: 'text' | 'circular' | 'rectangular';
animation?: 'pulse' | 'wave' | 'none';
}
const Skeleton: React.FC<SkeletonProps> = ({
width,
height,
className = '',
variant = 'rectangular',
animation = 'pulse',
}) => {
const baseClasses = 'bg-gray-200';
const variantClasses = {
text: 'h-4 rounded',
circular: 'rounded-full',
rectangular: 'rounded',
};
const animationClasses = {
pulse: 'animate-pulse',
wave: 'animate-shimmer',
none: '',
};
const style: React.CSSProperties = {};
if (width) style.width = typeof width === 'number' ? `${width}px` : width;
if (height) style.height = typeof height === 'number' ? `${height}px` : height;
return (
<div
className={`${baseClasses} ${variantClasses[variant]} ${animationClasses[animation]} ${className}`}
style={style}
aria-busy="true"
aria-label="Loading"
/>
);
};
export default Skeleton;

View File

@@ -0,0 +1,12 @@
export { default as EmptyState } from './EmptyState';
export { default as ErrorBoundary } from './ErrorBoundary';
export { default as Loading } from './Loading';
export { default as OptimizedImage } from './OptimizedImage';
export { default as Pagination } from './Pagination';
export { default as PaymentMethodSelector } from './PaymentMethodSelector';
export { default as PaymentStatusBadge } from './PaymentStatusBadge';
export { default as ConfirmationDialog } from './ConfirmationDialog';
export { default as GlobalLoading } from './GlobalLoading';
export { default as OfflineIndicator } from './OfflineIndicator';
export { default as Skeleton } from './Skeleton';

View File

@@ -7,189 +7,277 @@ import {
Instagram,
Mail,
Phone,
MapPin
MapPin,
Linkedin,
Youtube,
Award,
Shield,
Star
} from 'lucide-react';
import CookiePreferencesLink from '../common/CookiePreferencesLink';
const Footer: React.FC = () => {
return (
<footer className="bg-gray-900 text-gray-300">
<div className="container mx-auto px-4 py-12">
<div className="grid grid-cols-1 md:grid-cols-2
lg:grid-cols-4 gap-8">
{/* Company Info */}
<div>
<div className="flex items-center space-x-2 mb-4">
<Hotel className="w-8 h-8 text-blue-500" />
<span className="text-xl font-bold text-white">
Hotel Booking
</span>
<footer className="relative bg-gradient-to-b from-[#1a1a1a] via-[#0f0f0f] to-black text-gray-300 overflow-hidden">
{/* Elegant top border with gradient */}
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-[#d4af37]/50 to-transparent"></div>
{/* Subtle background pattern */}
<div className="absolute inset-0 opacity-5" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`
}}></div>
<div className="relative container mx-auto px-6 lg:px-8 py-16 lg:py-20">
{/* Main Footer Content */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-12 lg:gap-16 mb-16">
{/* Company Info - Enhanced */}
<div className="lg:col-span-2">
<div className="flex items-center space-x-3 mb-6">
<div className="relative">
<Hotel className="w-10 h-10 text-[#d4af37]" />
<div className="absolute inset-0 bg-[#d4af37]/20 blur-xl"></div>
</div>
<p className="text-sm text-gray-400 mb-4">
Leading online hotel management and
booking system.
<div>
<span className="text-2xl font-serif font-semibold text-white tracking-wide">
Luxury Hotel
</span>
<p className="text-xs text-[#d4af37]/80 font-light tracking-widest uppercase mt-0.5">
Excellence Redefined
</p>
<div className="flex space-x-4">
</div>
</div>
<p className="text-sm text-gray-400 mb-6 leading-relaxed max-w-md">
Experience unparalleled luxury and world-class hospitality.
Your journey to exceptional comfort begins here.
</p>
{/* Premium Certifications */}
<div className="flex items-center space-x-6 mb-8">
<div className="flex items-center space-x-2 text-[#d4af37]/90">
<Award className="w-5 h-5" />
<span className="text-xs font-medium tracking-wide">5-Star Rated</span>
</div>
<div className="flex items-center space-x-2 text-[#d4af37]/90">
<Shield className="w-5 h-5" />
<span className="text-xs font-medium tracking-wide">Award Winning</span>
</div>
</div>
{/* Social Media - Premium Style */}
<div className="flex items-center space-x-3">
<a
href="#"
className="hover:text-blue-500
transition-colors"
className="group relative w-11 h-11 flex items-center justify-center rounded-full bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/50 transition-all duration-300 hover:bg-[#d4af37]/10"
aria-label="Facebook"
>
<Facebook className="w-5 h-5" />
<Facebook className="w-5 h-5 text-gray-400 group-hover:text-[#d4af37] transition-colors" />
<div className="absolute inset-0 rounded-full bg-[#d4af37]/0 group-hover:bg-[#d4af37]/20 blur-lg transition-all duration-300"></div>
</a>
<a
href="#"
className="hover:text-blue-500
transition-colors"
className="group relative w-11 h-11 flex items-center justify-center rounded-full bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/50 transition-all duration-300 hover:bg-[#d4af37]/10"
aria-label="Twitter"
>
<Twitter className="w-5 h-5" />
<Twitter className="w-5 h-5 text-gray-400 group-hover:text-[#d4af37] transition-colors" />
<div className="absolute inset-0 rounded-full bg-[#d4af37]/0 group-hover:bg-[#d4af37]/20 blur-lg transition-all duration-300"></div>
</a>
<a
href="#"
className="hover:text-blue-500
transition-colors"
className="group relative w-11 h-11 flex items-center justify-center rounded-full bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/50 transition-all duration-300 hover:bg-[#d4af37]/10"
aria-label="Instagram"
>
<Instagram className="w-5 h-5" />
<Instagram className="w-5 h-5 text-gray-400 group-hover:text-[#d4af37] transition-colors" />
<div className="absolute inset-0 rounded-full bg-[#d4af37]/0 group-hover:bg-[#d4af37]/20 blur-lg transition-all duration-300"></div>
</a>
<a
href="#"
className="group relative w-11 h-11 flex items-center justify-center rounded-full bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/50 transition-all duration-300 hover:bg-[#d4af37]/10"
aria-label="LinkedIn"
>
<Linkedin className="w-5 h-5 text-gray-400 group-hover:text-[#d4af37] transition-colors" />
<div className="absolute inset-0 rounded-full bg-[#d4af37]/0 group-hover:bg-[#d4af37]/20 blur-lg transition-all duration-300"></div>
</a>
<a
href="#"
className="group relative w-11 h-11 flex items-center justify-center rounded-full bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/50 transition-all duration-300 hover:bg-[#d4af37]/10"
aria-label="YouTube"
>
<Youtube className="w-5 h-5 text-gray-400 group-hover:text-[#d4af37] transition-colors" />
<div className="absolute inset-0 rounded-full bg-[#d4af37]/0 group-hover:bg-[#d4af37]/20 blur-lg transition-all duration-300"></div>
</a>
</div>
</div>
{/* Quick Links */}
{/* Quick Links - Enhanced */}
<div>
<h3 className="text-white font-semibold mb-4">
Quick Links
<h3 className="text-white font-semibold text-lg mb-6 relative inline-block tracking-wide">
<span className="relative z-10">Quick Links</span>
<span className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-[#d4af37]/50 to-transparent"></span>
</h3>
<ul className="space-y-2">
<ul className="space-y-3">
<li>
<Link
to="/"
className="hover:text-blue-500
transition-colors text-sm"
className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
>
Home
<span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span>
<span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">Home</span>
</Link>
</li>
<li>
<Link
to="/rooms"
className="hover:text-blue-500
transition-colors text-sm"
className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
>
Rooms
<span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span>
<span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">Rooms & Suites</span>
</Link>
</li>
<li>
<Link
to="/bookings"
className="hover:text-blue-500
transition-colors text-sm"
className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
>
Bookings
<span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span>
<span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">My Bookings</span>
</Link>
</li>
<li>
<Link
to="/about"
className="hover:text-blue-500
transition-colors text-sm"
className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
>
About
<span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span>
<span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">About Us</span>
</Link>
</li>
</ul>
</div>
{/* Support */}
{/* Support - Enhanced */}
<div>
<h3 className="text-white font-semibold mb-4">
Support
<h3 className="text-white font-semibold text-lg mb-6 relative inline-block tracking-wide">
<span className="relative z-10">Guest Services</span>
<span className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-[#d4af37]/50 to-transparent"></span>
</h3>
<ul className="space-y-2">
<ul className="space-y-3">
<li>
<Link
to="/faq"
className="hover:text-blue-500
transition-colors text-sm"
className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
>
FAQ
<span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span>
<span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">FAQ</span>
</Link>
</li>
<li>
<Link
to="/terms"
className="hover:text-blue-500
transition-colors text-sm"
className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
>
Terms of Service
<span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span>
<span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">Terms of Service</span>
</Link>
</li>
<li>
<Link
to="/privacy"
className="hover:text-blue-500
transition-colors text-sm"
className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
>
Privacy Policy
<span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span>
<span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">Privacy Policy</span>
</Link>
</li>
<li>
<Link
to="/contact"
className="hover:text-blue-500
transition-colors text-sm"
className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
>
Contact
<span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span>
<span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">Contact Us</span>
</Link>
</li>
</ul>
</div>
{/* Contact Info */}
{/* Contact Info - Premium Style */}
<div>
<h3 className="text-white font-semibold mb-4">
Contact
<h3 className="text-white font-semibold text-lg mb-6 relative inline-block tracking-wide">
<span className="relative z-10">Contact</span>
<span className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-[#d4af37]/50 to-transparent"></span>
</h3>
<ul className="space-y-3">
<li className="flex items-start space-x-3">
<MapPin className="w-5 h-5 text-blue-500
flex-shrink-0 mt-0.5"
/>
<span className="text-sm">
123 ABC Street, District 1, Ho Chi Minh City
<ul className="space-y-4">
<li className="flex items-start space-x-4 group">
<div className="relative mt-0.5">
<MapPin className="w-5 h-5 text-[#d4af37]/80 group-hover:text-[#d4af37] transition-colors flex-shrink-0" />
<div className="absolute inset-0 bg-[#d4af37]/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity"></div>
</div>
<span className="text-sm text-gray-400 group-hover:text-gray-300 transition-colors leading-relaxed font-light">
123 ABC Street, District 1<br />
Ho Chi Minh City, Vietnam
</span>
</li>
<li className="flex items-center space-x-3">
<Phone className="w-5 h-5 text-blue-500
flex-shrink-0"
/>
<span className="text-sm">
<li className="flex items-center space-x-4 group">
<div className="relative">
<Phone className="w-5 h-5 text-[#d4af37]/80 group-hover:text-[#d4af37] transition-colors flex-shrink-0" />
<div className="absolute inset-0 bg-[#d4af37]/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity"></div>
</div>
<a href="tel:+842812345678" className="text-sm text-gray-400 group-hover:text-[#d4af37] transition-colors font-light tracking-wide">
(028) 1234 5678
</span>
</a>
</li>
<li className="flex items-center space-x-3">
<Mail className="w-5 h-5 text-blue-500
flex-shrink-0"
/>
<span className="text-sm">
info@hotelbooking.com
</span>
<li className="flex items-center space-x-4 group">
<div className="relative">
<Mail className="w-5 h-5 text-[#d4af37]/80 group-hover:text-[#d4af37] transition-colors flex-shrink-0" />
<div className="absolute inset-0 bg-[#d4af37]/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity"></div>
</div>
<a href="mailto:info@luxuryhotel.com" className="text-sm text-gray-400 group-hover:text-[#d4af37] transition-colors font-light tracking-wide">
info@luxuryhotel.com
</a>
</li>
</ul>
{/* Star Rating Display */}
<div className="mt-6 pt-6 border-t border-gray-800/50">
<div className="flex items-center space-x-1 mb-2">
{[...Array(5)].map((_, i) => (
<Star key={i} className="w-4 h-4 fill-[#d4af37] text-[#d4af37]" />
))}
</div>
<p className="text-xs text-gray-500 font-light">Rated 5.0 by 10,000+ guests</p>
</div>
</div>
</div>
{/* Copyright */}
<div className="border-t border-gray-800 mt-8
pt-4 -mb-8 text-center"
>
<p className="text-sm text-gray-400">
&copy; {new Date().getFullYear()} Hotel Booking.
All rights reserved.
</p>
{/* Divider with Elegance */}
<div className="relative my-12">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-800"></div>
</div>
<div className="relative flex justify-center">
<div className="bg-gray-900 px-4">
<div className="w-16 h-px bg-gradient-to-r from-transparent via-[#d4af37]/30 to-transparent"></div>
</div>
</div>
</div>
{/* Copyright - Enhanced */}
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
<div className="text-sm text-gray-500 font-light tracking-wide">
&copy; {new Date().getFullYear()} Luxury Hotel. All rights reserved.
</div>
<div className="flex items-center space-x-6 text-xs text-gray-600">
<span className="hover:text-[#d4af37]/80 transition-colors cursor-pointer font-light tracking-wide">Privacy</span>
<span className="text-gray-700"></span>
<span className="hover:text-[#d4af37]/80 transition-colors cursor-pointer font-light tracking-wide">Terms</span>
<span className="text-gray-700"></span>
<CookiePreferencesLink />
</div>
</div>
</div>
{/* Elegant bottom accent */}
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-[#d4af37]/30 to-transparent"></div>
</footer>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import { Link } from 'react-router-dom';
import {
Hotel,
@@ -9,7 +9,10 @@ import {
LogIn,
UserPlus,
Heart,
Phone,
Mail,
} from 'lucide-react';
import { useClickOutside } from '../../hooks/useClickOutside';
interface HeaderProps {
isAuthenticated?: boolean;
@@ -30,6 +33,14 @@ const Header: React.FC<HeaderProps> = ({
const [isMobileMenuOpen, setIsMobileMenuOpen] =
useState(false);
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const userMenuRef = useRef<HTMLDivElement>(null);
// Close user menu when clicking outside
useClickOutside(userMenuRef, () => {
if (isUserMenuOpen) {
setIsUserMenuOpen(false);
}
});
const toggleMobileMenu = () => {
setIsMobileMenuOpen(!isMobileMenuOpen);
@@ -48,112 +59,151 @@ const Header: React.FC<HeaderProps> = ({
};
return (
<header className="bg-white shadow-md sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
<header className="bg-gradient-to-b from-[#1a1a1a] to-[#0f0f0f] sticky top-0 z-50 border-b border-[#d4af37]/20 shadow-2xl">
{/* Top Bar - Contact Info */}
<div className="hidden lg:block bg-[#0a0a0a]/50 border-b border-[#d4af37]/10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2">
<div className="flex items-center justify-end space-x-6 text-sm">
<a href="tel:+1234567890" className="flex items-center space-x-2 text-[#d4af37] hover:text-[#f5d76e] transition-colors duration-300 font-light">
<Phone className="w-3.5 h-3.5" />
<span className="tracking-wide">+1 (234) 567-890</span>
</a>
<a href="mailto:info@luxuryhotel.com" className="flex items-center space-x-2 text-[#d4af37] hover:text-[#f5d76e] transition-colors duration-300 font-light">
<Mail className="w-3.5 h-3.5" />
<span className="tracking-wide">info@luxuryhotel.com</span>
</a>
</div>
</div>
</div>
{/* Main Header */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
{/* Logo */}
<Link
to="/"
className="flex items-center space-x-2
hover:opacity-80 transition-opacity"
className="flex items-center space-x-3
group transition-all duration-300 hover:opacity-90"
>
<Hotel className="w-8 h-8 text-blue-600" />
<span className="text-2xl font-bold text-gray-800">
Hotel Booking
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37] to-[#f5d76e] rounded-full blur-md opacity-30 group-hover:opacity-50 transition-opacity duration-300"></div>
<Hotel className="relative w-10 h-10 text-[#d4af37] drop-shadow-lg" />
</div>
<div className="flex flex-col">
<span className="text-2xl font-serif font-semibold text-white tracking-tight leading-tight">
Luxury Hotel
</span>
<span className="text-[10px] text-[#d4af37] tracking-[0.2em] uppercase font-light">
Excellence Redefined
</span>
</div>
</Link>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center
space-x-6"
space-x-1"
>
<Link
to="/"
className="text-gray-700 hover:text-blue-600
transition-colors font-medium"
className="text-white/90 hover:text-[#d4af37]
transition-all duration-300 font-light px-4 py-2
relative group tracking-wide"
>
Home
<span className="relative z-10">Home</span>
<span className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/0 via-[#d4af37]/10 to-[#d4af37]/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
</Link>
<Link
to="/rooms"
className="text-gray-700 hover:text-blue-600
transition-colors font-medium"
className="text-white/90 hover:text-[#d4af37]
transition-all duration-300 font-light px-4 py-2
relative group tracking-wide"
>
Rooms
<span className="relative z-10">Rooms</span>
<span className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/0 via-[#d4af37]/10 to-[#d4af37]/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
</Link>
<Link
to="/bookings"
className="text-gray-700 hover:text-blue-600
transition-colors font-medium"
className="text-white/90 hover:text-[#d4af37]
transition-all duration-300 font-light px-4 py-2
relative group tracking-wide"
>
Bookings
<span className="relative z-10">Bookings</span>
<span className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/0 via-[#d4af37]/10 to-[#d4af37]/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
</Link>
<Link
to="/favorites"
className="text-gray-700 hover:text-blue-600
transition-colors font-medium flex
items-center gap-1"
className="text-white/90 hover:text-[#d4af37]
transition-all duration-300 font-light px-4 py-2
relative group tracking-wide flex items-center gap-2"
>
<Heart className="w-4 h-4" />
Favorites
<Heart className="w-4 h-4 relative z-10" />
<span className="relative z-10">Favorites</span>
<span className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/0 via-[#d4af37]/10 to-[#d4af37]/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
</Link>
<Link
to="/about"
className="text-gray-700 hover:text-blue-600
transition-colors font-medium"
className="text-white/90 hover:text-[#d4af37]
transition-all duration-300 font-light px-4 py-2
relative group tracking-wide"
>
About
<span className="relative z-10">About</span>
<span className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/0 via-[#d4af37]/10 to-[#d4af37]/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
</Link>
</nav>
{/* Desktop Auth Section */}
<div className="hidden md:flex items-center
space-x-4"
space-x-3"
>
{!isAuthenticated ? (
<>
<Link
to="/login"
className="flex items-center space-x-2
px-4 py-2 text-blue-600
hover:text-blue-700 transition-colors
font-medium"
px-5 py-2 text-white/90
hover:text-[#d4af37] transition-all duration-300
font-light tracking-wide relative group"
>
<LogIn className="w-4 h-4" />
<span>Login</span>
<LogIn className="w-4 h-4 relative z-10" />
<span className="relative z-10">Login</span>
<span className="absolute inset-0 border border-[#d4af37]/30 rounded-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
</Link>
<Link
to="/register"
className="flex items-center space-x-2
px-4 py-2 bg-blue-600 text-white
rounded-lg hover:bg-blue-700
transition-colors font-medium"
px-6 py-2.5 bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] rounded-sm hover:from-[#f5d76e]
hover:to-[#d4af37] transition-all duration-300
font-medium tracking-wide shadow-lg shadow-[#d4af37]/20
hover:shadow-[#d4af37]/30 relative overflow-hidden group"
>
<UserPlus className="w-4 h-4" />
<span>Register</span>
<span className="absolute inset-0 bg-gradient-to-r from-white/0 via-white/20 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700"></span>
<UserPlus className="w-4 h-4 relative z-10" />
<span className="relative z-10">Register</span>
</Link>
</>
) : (
<div className="relative">
<div className="relative" ref={userMenuRef}>
<button
onClick={toggleUserMenu}
className="flex items-center space-x-3
px-3 py-2 rounded-lg hover:bg-gray-100
transition-colors"
px-3 py-2 rounded-sm hover:bg-white/10
transition-all duration-300 border border-transparent
hover:border-[#d4af37]/30"
>
{userInfo?.avatar ? (
<img
src={userInfo.avatar}
alt={userInfo.name}
className="w-8 h-8 rounded-full
object-cover"
className="w-9 h-9 rounded-full
object-cover ring-2 ring-[#d4af37]/50"
/>
) : (
<div className="w-8 h-8 bg-blue-500
<div className="w-9 h-9 bg-gradient-to-br from-[#d4af37] to-[#c9a227]
rounded-full flex items-center
justify-center"
justify-center ring-2 ring-[#d4af37]/50 shadow-lg"
>
<span className="text-white
<span className="text-[#0f0f0f]
font-semibold text-sm"
>
{userInfo?.name?.charAt(0)
@@ -161,7 +211,7 @@ const Header: React.FC<HeaderProps> = ({
</span>
</div>
)}
<span className="font-medium text-gray-700">
<span className="font-light text-white/90 tracking-wide">
{userInfo?.name}
</span>
</button>
@@ -169,18 +219,21 @@ const Header: React.FC<HeaderProps> = ({
{/* User Dropdown Menu */}
{isUserMenuOpen && (
<div className="absolute right-0 mt-2
w-48 bg-white rounded-lg shadow-lg
py-2 border border-gray-200 z-50"
w-52 bg-gradient-to-b from-[#1a1a1a] to-[#0f0f0f]
rounded-sm shadow-2xl py-2 border border-[#d4af37]/20
z-50 backdrop-blur-xl animate-fade-in"
>
<Link
to="/profile"
onClick={() => setIsUserMenuOpen(false)}
className="flex items-center space-x-2
px-4 py-2 text-gray-700
hover:bg-gray-100 transition-colors"
className="flex items-center space-x-3
px-4 py-2.5 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
transition-all duration-300 border-l-2 border-transparent
hover:border-[#d4af37]"
>
<User className="w-4 h-4" />
<span>Profile</span>
<span className="font-light tracking-wide">Profile</span>
</Link>
{userInfo?.role === 'admin' && (
<Link
@@ -189,22 +242,26 @@ const Header: React.FC<HeaderProps> = ({
setIsUserMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-2 text-gray-700
hover:bg-gray-100 transition-colors"
space-x-3 px-4 py-2.5 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
transition-all duration-300 border-l-2 border-transparent
hover:border-[#d4af37]"
>
<User className="w-4 h-4" />
<span>Admin</span>
<span className="font-light tracking-wide">Admin</span>
</Link>
)}
<div className="border-t border-[#d4af37]/20 my-1"></div>
<button
onClick={handleLogout}
className="w-full flex items-center
space-x-2 px-4 py-2 text-red-600
hover:bg-gray-100 transition-colors
text-left"
space-x-3 px-4 py-2.5 text-red-400/90
hover:bg-red-500/10 hover:text-red-400
transition-all duration-300 border-l-2 border-transparent
hover:border-red-500/50 text-left"
>
<LogOut className="w-4 h-4" />
<span>Logout</span>
<span className="font-light tracking-wide">Logout</span>
</button>
</div>
)}
@@ -215,13 +272,14 @@ const Header: React.FC<HeaderProps> = ({
{/* Mobile Menu Button */}
<button
onClick={toggleMobileMenu}
className="md:hidden p-2 rounded-lg
hover:bg-gray-100 transition-colors"
className="md:hidden p-2 rounded-sm
hover:bg-white/10 border border-transparent
hover:border-[#d4af37]/30 transition-all duration-300"
>
{isMobileMenuOpen ? (
<X className="w-6 h-6" />
<X className="w-6 h-6 text-[#d4af37]" />
) : (
<Menu className="w-6 h-6" />
<Menu className="w-6 h-6 text-white/90" />
)}
</button>
</div>
@@ -229,42 +287,52 @@ const Header: React.FC<HeaderProps> = ({
{/* Mobile Menu */}
{isMobileMenuOpen && (
<div className="md:hidden py-4 border-t
border-gray-200 mt-4"
border-[#d4af37]/20 mt-4 bg-[#0a0a0a]/50
backdrop-blur-xl animate-fade-in rounded-b-sm"
>
<div className="flex flex-col space-y-2">
<div className="flex flex-col space-y-1">
<Link
to="/"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-2 text-gray-700
hover:bg-gray-100 rounded-lg
transition-colors"
className="px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
Home
</Link>
<Link
to="/rooms"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-2 text-gray-700
hover:bg-gray-100 rounded-lg
transition-colors"
className="px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
Rooms
</Link>
<Link
to="/bookings"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-2 text-gray-700
hover:bg-gray-100 rounded-lg
transition-colors"
className="px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
Bookings
</Link>
<Link
to="/favorites"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-2 text-gray-700
hover:bg-gray-100 rounded-lg
transition-colors flex items-center gap-2"
className="px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide
flex items-center gap-2"
>
<Heart className="w-4 h-4" />
Favorites
@@ -272,15 +340,17 @@ const Header: React.FC<HeaderProps> = ({
<Link
to="/about"
onClick={() => setIsMobileMenuOpen(false)}
className="px-4 py-2 text-gray-700
hover:bg-gray-100 rounded-lg
transition-colors"
className="px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
About
</Link>
<div className="border-t border-gray-200
pt-2 mt-2"
<div className="border-t border-[#d4af37]/20
pt-3 mt-3"
>
{!isAuthenticated ? (
<>
@@ -290,9 +360,11 @@ const Header: React.FC<HeaderProps> = ({
setIsMobileMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-2 text-blue-600
hover:bg-gray-100 rounded-lg
transition-colors"
space-x-2 px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
<LogIn className="w-4 h-4" />
<span>Login</span>
@@ -303,9 +375,12 @@ const Header: React.FC<HeaderProps> = ({
setIsMobileMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-2 text-blue-600
hover:bg-gray-100 rounded-lg
transition-colors"
space-x-2 px-4 py-3 bg-gradient-to-r
from-[#d4af37] to-[#c9a227] text-[#0f0f0f]
rounded-sm hover:from-[#f5d76e]
hover:to-[#d4af37] transition-all
duration-300 font-medium tracking-wide
mt-2"
>
<UserPlus className="w-4 h-4" />
<span>Register</span>
@@ -314,7 +389,7 @@ const Header: React.FC<HeaderProps> = ({
) : (
<>
<div className="px-4 py-2 text-sm
text-gray-500"
text-[#d4af37]/70 font-light tracking-wide"
>
Hello, {userInfo?.name}
</div>
@@ -324,9 +399,11 @@ const Header: React.FC<HeaderProps> = ({
setIsMobileMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-2 text-gray-700
hover:bg-gray-100 rounded-lg
transition-colors"
space-x-2 px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
<User className="w-4 h-4" />
<span>Profile</span>
@@ -338,9 +415,11 @@ const Header: React.FC<HeaderProps> = ({
setIsMobileMenuOpen(false)
}
className="flex items-center
space-x-2 px-4 py-2
text-gray-700 hover:bg-gray-100
rounded-lg transition-colors"
space-x-2 px-4 py-3 text-white/90
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-[#d4af37] font-light tracking-wide"
>
<User className="w-4 h-4" />
<span>Admin</span>
@@ -349,9 +428,12 @@ const Header: React.FC<HeaderProps> = ({
<button
onClick={handleLogout}
className="w-full flex items-center
space-x-2 px-4 py-2 text-red-600
hover:bg-gray-100 rounded-lg
transition-colors text-left"
space-x-2 px-4 py-3 text-red-400/90
hover:bg-red-500/10 hover:text-red-400
rounded-sm transition-all duration-300
border-l-2 border-transparent
hover:border-red-500/50 text-left
font-light tracking-wide"
>
<LogOut className="w-4 h-4" />
<span>Logout</span>

View File

@@ -29,7 +29,7 @@ const LayoutMain: React.FC<LayoutMainProps> = ({
/>
{/* Main Content Area - Outlet renders child routes */}
<main className="flex-1 bg-gray-50">
<main className="flex-1 bg-gradient-to-b from-gray-50 to-gray-100/50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<Outlet />
</div>

View File

@@ -15,7 +15,8 @@ import {
ChevronRight,
Star,
LogIn,
LogOut
LogOut,
ClipboardList
} from 'lucide-react';
interface SidebarAdminProps {
@@ -105,6 +106,11 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
icon: BarChart3,
label: 'Reports'
},
{
path: '/admin/audit-logs',
icon: ClipboardList,
label: 'Audit Logs'
},
{
path: '/admin/settings',
icon: FileText,

View File

@@ -4,12 +4,15 @@ import type { Banner } from '../../services/api/bannerService';
interface BannerCarouselProps {
banners: Banner[];
children?: React.ReactNode;
}
const BannerCarousel: React.FC<BannerCarouselProps> = ({
banners
banners,
children
}) => {
const [currentIndex, setCurrentIndex] = useState(0);
const [isAnimating, setIsAnimating] = useState(false);
// Auto-slide every 5 seconds
useEffect(() => {
@@ -25,19 +28,28 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
}, [banners.length]);
const goToPrevious = () => {
if (isAnimating) return;
setIsAnimating(true);
setCurrentIndex((prev) =>
prev === 0 ? banners.length - 1 : prev - 1
);
setTimeout(() => setIsAnimating(false), 800);
};
const goToNext = () => {
if (isAnimating) return;
setIsAnimating(true);
setCurrentIndex((prev) =>
prev === banners.length - 1 ? 0 : prev + 1
);
setTimeout(() => setIsAnimating(false), 800);
};
const goToSlide = (index: number) => {
if (isAnimating || index === currentIndex) return;
setIsAnimating(true);
setCurrentIndex(index);
setTimeout(() => setIsAnimating(false), 800);
};
// Default fallback banner if no banners provided
@@ -59,92 +71,341 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
return (
<div
className="relative w-full h-[500px] md:h-[640px] \
overflow-hidden rounded-xl shadow-lg"
className="relative w-full h-[400px] sm:h-[500px] md:h-[600px] lg:h-[700px] xl:h-[800px] overflow-hidden"
>
{/* Banner Image */}
{/* Banner Image with smooth transitions */}
<div className="relative w-full h-full">
{displayBanners.map((banner, index) => (
<div
key={banner.id || index}
className={`absolute inset-0 transition-opacity duration-1000 ease-in-out ${
index === currentIndex ? 'opacity-100 z-0 pointer-events-auto' : 'opacity-0 z-0 pointer-events-none'
}`}
>
{banner.link ? (
<a
href={banner.link}
target={banner.link.startsWith('http') ? '_blank' : '_self'}
rel={banner.link.startsWith('http') ? 'noopener noreferrer' : undefined}
className="block w-full h-full"
>
<img
src={currentBanner.image_url}
alt={currentBanner.title}
className="w-full h-full object-cover"
src={banner.image_url}
alt={banner.title}
className="w-full h-full object-cover object-center transition-transform duration-1000 ease-out hover:scale-105"
onError={(e) => {
// Fallback to placeholder if image fails to load
e.currentTarget.src = '/images/default-banner.jpg';
}}
/>
</a>
) : (
<img
src={banner.image_url}
alt={banner.title}
className="w-full h-full object-cover object-center transition-transform duration-1000 ease-out hover:scale-105"
onError={(e) => {
e.currentTarget.src = '/images/default-banner.jpg';
}}
/>
)}
</div>
))}
{/* Overlay */}
{/* Overlay - Enhanced for luxury text readability */}
<div
className="absolute inset-0 bg-gradient-to-t
from-black/60 via-black/20 to-transparent"
from-black/70 via-black/30 via-black/15 to-black/5
transition-opacity duration-1000 ease-in-out"
/>
{/* Title */}
{/* Animated gradient overlay for luxury effect */}
<div
className="absolute inset-0 bg-gradient-to-br
from-transparent via-transparent to-black/10
animate-pulse"
style={{
animation: 'luxuryGlow 8s ease-in-out infinite',
}}
/>
{/* Title - Positioned at top when search form is present */}
{currentBanner.title && (
<div
className="absolute bottom-8 left-8 right-8
text-white"
key={currentIndex}
className={`absolute ${children ? 'top-12 sm:top-16 md:top-20 lg:top-24' : 'bottom-16 sm:bottom-20 md:bottom-24'}
left-1/2 -translate-x-1/2
text-white z-10 flex flex-col items-center justify-center
w-full max-w-5xl px-4 sm:px-6 md:px-8 lg:px-12
animate-fadeInUp`}
style={{
animation: 'luxuryFadeInUp 1s cubic-bezier(0.4, 0, 0.2, 1) forwards',
}}
>
{/* Animated border glow */}
<div
className="absolute inset-0
rounded-2xl
-mx-2 sm:-mx-4 md:-mx-6 lg:-mx-8
pointer-events-none
opacity-0 animate-borderGlow"
style={{
background: 'linear-gradient(90deg, transparent, rgba(212, 175, 55, 0.3), transparent)',
animation: 'borderGlow 3s ease-in-out infinite',
}}
/>
<div className="relative w-full flex flex-col items-center">
{/* Animated decorative line above title */}
<div
className="w-0 h-0.5 bg-gradient-to-r from-transparent via-[#d4af37] to-transparent mb-4 sm:mb-6 opacity-90
animate-lineExpand"
style={{
animation: 'lineExpand 1.2s cubic-bezier(0.4, 0, 0.2, 1) 0.3s forwards',
maxWidth: '120px',
}}
/>
<h2
className="text-3xl md:text-5xl font-bold
mb-2 drop-shadow-lg"
className={`${children ? 'text-2xl sm:text-3xl md:text-4xl lg:text-5xl xl:text-6xl' : 'text-2xl sm:text-3xl md:text-4xl lg:text-5xl xl:text-6xl'}
font-serif font-light tracking-[0.02em] sm:tracking-[0.03em] md:tracking-[0.04em] lg:tracking-[0.05em]
text-center leading-[1.1] sm:leading-[1.15] md:leading-[1.2]
drop-shadow-[0_2px_8px_rgba(0,0,0,0.5)]
[text-shadow:_0_2px_20px_rgba(0,0,0,0.8),_0_4px_40px_rgba(0,0,0,0.6)]
mb-3 sm:mb-4
transform transition-all duration-700 ease-out
px-2 sm:px-4 md:px-6
opacity-0 animate-textReveal`}
style={{
letterSpacing: '0.08em',
fontWeight: 300,
animation: 'textReveal 1s cubic-bezier(0.4, 0, 0.2, 1) 0.5s forwards',
}}
>
<span
className="bg-gradient-to-b from-white via-white via-[#f5d76e] to-[#d4af37] bg-clip-text text-transparent
animate-gradientShift"
style={{
backgroundSize: '200% 200%',
animation: 'gradientShift 5s ease infinite',
}}
>
{currentBanner.title}
</span>
</h2>
{/* Animated decorative line below title */}
<div
className="w-0 h-0.5 bg-gradient-to-r from-transparent via-[#d4af37] to-transparent mt-3 sm:mt-4 opacity-90
animate-lineExpand"
style={{
animation: 'lineExpand 1.2s cubic-bezier(0.4, 0, 0.2, 1) 0.6s forwards',
maxWidth: '120px',
}}
/>
</div>
{/* Enhanced luxury gold accent glow with animation */}
<div className="absolute inset-0 pointer-events-none overflow-hidden">
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
w-full max-w-5xl h-32 sm:h-40 md:h-48 lg:h-56
blur-3xl opacity-0
animate-glowPulse"
style={{
background: 'radial-gradient(circle, rgba(212, 175, 55, 0.25) 0%, rgba(212, 175, 55, 0.12) 40%, transparent 70%)',
animation: 'glowPulse 4s ease-in-out infinite',
}}
/>
</div>
{/* Floating particles effect */}
<div className="absolute inset-0 pointer-events-none overflow-hidden">
{[...Array(3)].map((_, i) => (
<div
key={i}
className="absolute w-1 h-1 bg-[#d4af37] rounded-full opacity-40"
style={{
left: `${20 + i * 30}%`,
top: `${30 + i * 20}%`,
animation: `floatParticle ${3 + i}s ease-in-out infinite`,
animationDelay: `${i * 0.5}s`,
}}
/>
))}
</div>
</div>
)}
{/* Search Form Overlay - Centered in lower third */}
{children && (
<div className="absolute inset-0 flex items-end justify-center z-30 px-2 sm:px-4 md:px-6 lg:px-8 pb-2 sm:pb-4 md:pb-8 lg:pb-12 xl:pb-16 pointer-events-none">
<div className="w-full max-w-6xl pointer-events-auto">
<div className="bg-white/95 rounded-lg shadow-2xl border border-white/20 p-2 sm:p-3 md:p-4 lg:p-6">
{children}
</div>
</div>
</div>
)}
</div>
{/* Navigation Buttons */}
{/* Navigation Buttons - Enhanced luxury style */}
{displayBanners.length > 1 && (
<>
<button
onClick={goToPrevious}
className="absolute left-4 top-1/2
-translate-y-1/2 bg-white/80
hover:bg-white text-gray-800 p-2
rounded-full shadow-lg transition-all"
type="button"
className="absolute left-2 sm:left-4 top-1/2
-translate-y-1/2
bg-white/90
hover:bg-white text-gray-800
p-2 sm:p-2.5 md:p-3
rounded-full
shadow-xl border border-white/20
transition-all duration-300 z-40
hover:scale-110 hover:shadow-2xl
active:scale-95 cursor-pointer
group"
aria-label="Previous banner"
>
<ChevronLeft className="w-6 h-6" />
<ChevronLeft className="w-5 h-5 sm:w-6 sm:h-6 transition-transform duration-300 group-hover:-translate-x-1" />
</button>
<button
onClick={goToNext}
className="absolute right-4 top-1/2
-translate-y-1/2 bg-white/80
hover:bg-white text-gray-800 p-2
rounded-full shadow-lg transition-all"
type="button"
className="absolute right-2 sm:right-4 top-1/2
-translate-y-1/2
bg-white/90
hover:bg-white text-gray-800
p-2 sm:p-2.5 md:p-3
rounded-full
shadow-xl border border-white/20
transition-all duration-300 z-40
hover:scale-110 hover:shadow-2xl
active:scale-95 cursor-pointer
group"
aria-label="Next banner"
>
<ChevronRight className="w-6 h-6" />
<ChevronRight className="w-5 h-5 sm:w-6 sm:h-6 transition-transform duration-300 group-hover:translate-x-1" />
</button>
</>
)}
{/* Dots Indicator */}
{/* Dots Indicator - Enhanced luxury style */}
{displayBanners.length > 1 && (
<div
className="absolute bottom-4 left-1/2
-translate-x-1/2 flex gap-2"
className="absolute bottom-2 sm:bottom-4 left-1/2
-translate-x-1/2 flex gap-2 sm:gap-2.5 z-40 pointer-events-auto
bg-black/40 px-3 py-2 rounded-full
border border-white/10"
>
{displayBanners.map((_, index) => (
<button
key={index}
type="button"
onClick={() => goToSlide(index)}
className={`w-2 h-2 rounded-full
transition-all
className={`h-2 sm:h-2.5 rounded-full
transition-all duration-300 cursor-pointer
${
index === currentIndex
? 'bg-white w-8'
: 'bg-white/50 hover:bg-white/75'
? 'bg-gradient-to-r from-[#d4af37] to-[#f5d76e] w-8 sm:w-10 shadow-lg shadow-[#d4af37]/50'
: 'bg-white/40 hover:bg-white/70 w-2 sm:h-2.5 hover:scale-125'
}`}
aria-label={`Go to banner ${index + 1}`}
/>
))}
</div>
)}
{/* CSS Animations */}
<style>{`
@keyframes luxuryFadeInUp {
from {
opacity: 0;
transform: translate(-50%, 30px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
@keyframes textReveal {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes lineExpand {
from {
width: 0;
opacity: 0;
}
to {
width: 100%;
opacity: 1;
}
}
@keyframes gradientShift {
0%, 100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
@keyframes glowPulse {
0%, 100% {
opacity: 0.2;
transform: translate(-50%, -50%) scale(1);
}
50% {
opacity: 0.4;
transform: translate(-50%, -50%) scale(1.1);
}
}
@keyframes borderGlow {
0%, 100% {
opacity: 0;
}
50% {
opacity: 0.6;
}
}
@keyframes floatParticle {
0%, 100% {
transform: translateY(0) translateX(0);
opacity: 0.3;
}
33% {
transform: translateY(-20px) translateX(10px);
opacity: 0.6;
}
66% {
transform: translateY(-10px) translateX(-10px);
opacity: 0.4;
}
}
@keyframes luxuryGlow {
0%, 100% {
opacity: 0.3;
}
50% {
opacity: 0.6;
}
}
`}</style>
</div>
);
};

View File

@@ -3,13 +3,12 @@ import React from 'react';
const BannerSkeleton: React.FC = () => {
return (
<div
className="w-full h-[500px] md:h-[640px] \
bg-gray-300 rounded-xl shadow-lg animate-pulse"
className="w-full h-[400px] sm:h-[500px] md:h-[600px] lg:h-[700px] xl:h-[800px] bg-gray-300 animate-pulse"
>
<div className="w-full h-full flex items-end p-8">
<div className="w-full h-full flex items-end p-4 sm:p-8">
<div className="w-full max-w-xl space-y-3">
<div className="h-12 bg-gray-400 rounded w-3/4" />
<div className="h-8 bg-gray-400 rounded w-1/2" />
<div className="h-8 sm:h-12 bg-gray-400 rounded w-3/4" />
<div className="h-6 sm:h-8 bg-gray-400 rounded w-1/2" />
</div>
</div>
</div>

View File

@@ -70,13 +70,13 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
return (
<div
className="bg-white rounded-lg shadow-md
overflow-hidden hover:shadow-xl
transition-shadow duration-300 group"
className="luxury-card overflow-hidden group
border-t-2 border-transparent hover:border-[#d4af37]
hover:shadow-luxury-gold"
>
{/* Image */}
<div className="relative h-48 overflow-hidden
bg-gray-200"
<div className="relative h-52 overflow-hidden
bg-gradient-to-br from-gray-200 to-gray-300"
>
<img
src={imageUrl}
@@ -84,14 +84,21 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
loading="lazy"
className="w-full h-full object-cover
group-hover:scale-110 transition-transform
duration-300"
duration-500 ease-out"
onLoad={(e) =>
e.currentTarget.classList.add('loaded')
}
/>
{/* Overlay gradient on hover */}
<div className="absolute inset-0 bg-gradient-to-t
from-black/60 via-transparent to-transparent
opacity-0 group-hover:opacity-100 transition-opacity
duration-300"
/>
{/* Favorite Button */}
<div className="absolute top-3 right-3 z-5">
<div className="absolute top-3 right-3 z-10">
<FavoriteButton roomId={room.id} size="md" />
</div>
@@ -99,8 +106,10 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
{room.featured && (
<div
className="absolute top-3 left-3
bg-yellow-500 text-white px-3 py-1
rounded-full text-xs font-semibold"
bg-gradient-to-r from-[#d4af37] to-[#c9a227]
text-[#0f0f0f] px-3 py-1.5
rounded-sm text-xs font-medium tracking-wide
shadow-lg shadow-[#d4af37]/30 backdrop-blur-sm"
>
Featured
</div>
@@ -108,14 +117,15 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
{/* Status Badge */}
<div
className={`absolute bottom-3 left-3 px-3 py-1
rounded-full text-xs font-semibold
className={`absolute bottom-3 left-3 px-3 py-1.5
rounded-sm text-xs font-medium tracking-wide
backdrop-blur-sm shadow-lg
${
room.status === 'available'
? 'bg-green-500 text-white'
? 'bg-green-500/90 text-white border border-green-400/50'
: room.status === 'occupied'
? 'bg-red-500 text-white'
: 'bg-gray-500 text-white'
? 'bg-red-500/90 text-white border border-red-400/50'
: 'bg-gray-500/90 text-white border border-gray-400/50'
}`}
>
{room.status === 'available'
@@ -127,33 +137,34 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
</div>
{/* Content */}
<div className="p-5">
<div className="p-6">
{/* Room Type Name */}
<h3 className="text-xl font-bold text-gray-900 mb-2">
<h3 className="text-xl font-serif font-semibold text-gray-900 mb-2 tracking-tight">
{roomType.name}
</h3>
{/* Room Number & Floor */}
<div
className="flex items-center text-sm
text-gray-600 mb-3"
text-gray-600 mb-3 font-light tracking-wide"
>
<MapPin className="w-4 h-4 mr-1" />
<MapPin className="w-4 h-4 mr-1.5 text-[#d4af37]" />
<span>
Room {room.room_number} - Floor {room.floor}
</span>
</div>
{/* Description (truncated) */}
<p className="text-gray-600 text-sm mb-3 line-clamp-2">
<p className="text-gray-600 text-sm mb-4 line-clamp-2
leading-relaxed font-light">
{roomType.description}
</p>
{/* Capacity & Rating */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center text-gray-700">
<Users className="w-4 h-4 mr-1" />
<span className="text-sm">
<Users className="w-4 h-4 mr-1.5 text-[#d4af37]" />
<span className="text-sm font-light tracking-wide">
{roomType.capacity} guests
</span>
</div>
@@ -161,13 +172,13 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
{room.average_rating != null && (
<div className="flex items-center">
<Star
className="w-4 h-4 text-yellow-500 mr-1"
fill="currentColor"
className="w-4 h-4 text-[#d4af37] mr-1"
fill="#d4af37"
/>
<span className="text-sm font-semibold text-gray-900">
{Number(room.average_rating).toFixed(1)}
</span>
<span className="text-xs text-gray-500 ml-1">
<span className="text-xs text-gray-500 ml-1 font-light">
({Number(room.total_reviews || 0)})
</span>
</div>
@@ -176,17 +187,21 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
{/* Amenities */}
{amenities.length > 0 && (
<div className="flex items-center gap-2 mb-4">
<div className="flex items-center gap-2 mb-5">
{amenities.map((amenity, index) => (
<div
key={index}
className="flex items-center gap-1
text-gray-600 text-xs bg-gray-100
px-2 py-1 rounded"
text-gray-700 text-xs bg-[#d4af37]/10
border border-[#d4af37]/20
px-2.5 py-1.5 rounded-sm
font-light tracking-wide"
title={amenity}
>
<span className="text-[#d4af37]">
{amenityIcons[amenity.toLowerCase()] ||
<span></span>}
</span>
<span className="capitalize">{amenity}</span>
</div>
))}
@@ -194,24 +209,24 @@ const RoomCard: React.FC<RoomCardProps> = ({ room }) => {
)}
{/* Price & Action */}
<div className="flex items-center justify-between pt-3 border-t">
<div className="flex items-center justify-between pt-4
border-t border-gray-200">
<div>
<p className="text-xs text-gray-500">From</p>
<p className="text-xl font-bold text-indigo-600">
<p className="text-xs text-gray-500 font-light tracking-wide mb-0.5">From</p>
<p className="text-2xl font-serif font-semibold
text-gradient-luxury tracking-tight">
{formattedPrice}
</p>
<p className="text-xs text-gray-500">/ night</p>
<p className="text-xs text-gray-500 font-light tracking-wide mt-0.5">/ night</p>
</div>
<Link
to={`/rooms/${room.id}`}
className="flex items-center gap-1
bg-indigo-600 text-white px-4 py-2
rounded-lg hover:bg-indigo-700
transition-colors text-sm font-medium"
className="btn-luxury-primary flex items-center gap-2
text-sm px-5 py-2.5 relative"
>
View Details
<ArrowRight className="w-4 h-4" />
<span className="relative z-10">View Details</span>
<ArrowRight className="w-4 h-4 relative z-10" />
</Link>
</div>
</div>

View File

@@ -234,17 +234,20 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
};
return (
<div className="bg-white rounded-lg shadow-md p-4 mb-6">
<h2 className="text-xl font-semibold mb-4 text-gray-800">
<div className="luxury-card rounded-sm shadow-lg p-6 mb-6 border border-gray-100">
<div className="flex items-center gap-3 mb-6">
<div className="w-1 h-8 bg-gradient-to-b from-[#d4af37] to-[#c9a227]"></div>
<h2 className="text-xl font-serif font-semibold mb-0 text-gray-900 tracking-tight">
Room Filters
</h2>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Room Type */}
<div>
<label
htmlFor="type"
className="block text-sm font-medium
text-gray-700 mb-1"
text-gray-700 mb-2 tracking-wide"
>
Room Type
</label>
@@ -253,9 +256,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
name="type"
value={filters.type || ''}
onChange={handleInputChange}
className="w-full px-4 py-2 border border-gray-300
rounded-lg focus:ring-2 focus:ring-blue-500
focus:border-transparent"
className="luxury-input"
>
<option value="">All</option>
<option value="Standard Room">Standard Room</option>
@@ -271,7 +272,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
<div>
<label
htmlFor="from"
className="block text-sm font-medium text-gray-700 mb-1"
className="block text-sm font-medium text-gray-700 mb-2 tracking-wide"
>
Check-in Date
</label>
@@ -284,14 +285,14 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
minDate={new Date()}
dateFormat="dd/MM/yyyy"
placeholderText=""
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
className="luxury-input"
/>
</div>
<div>
<label
htmlFor="to"
className="block text-sm font-medium text-gray-700 mb-1"
className="block text-sm font-medium text-gray-700 mb-2 tracking-wide"
>
Check-out Date
</label>
@@ -304,7 +305,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
minDate={checkInDate || new Date()}
dateFormat="dd/MM/yyyy"
placeholderText=""
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
className="luxury-input"
/>
</div>
</div>
@@ -315,7 +316,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
<label
htmlFor="minPrice"
className="block text-sm font-medium
text-gray-700 mb-1"
text-gray-700 mb-2 tracking-wide"
>
Min Price
</label>
@@ -332,17 +333,14 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
placeholder="0"
inputMode="numeric"
pattern="[0-9.]*"
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2 focus:ring-blue-500
focus:border-transparent"
className="luxury-input"
/>
</div>
<div>
<label
htmlFor="maxPrice"
className="block text-sm font-medium
text-gray-700 mb-1"
text-gray-700 mb-2 tracking-wide"
>
Max Price
</label>
@@ -359,10 +357,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
placeholder="10.000.000"
inputMode="numeric"
pattern="[0-9.]*"
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2 focus:ring-blue-500
focus:border-transparent"
className="luxury-input"
/>
</div>
</div>
@@ -372,7 +367,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
<label
htmlFor="capacity"
className="block text-sm font-medium
text-gray-700 mb-1"
text-gray-700 mb-2 tracking-wide"
>
Number of Guests
</label>
@@ -385,32 +380,29 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
placeholder="1"
min="1"
max="10"
className="w-full px-4 py-2 border
border-gray-300 rounded-lg
focus:ring-2 focus:ring-blue-500
focus:border-transparent"
className="luxury-input"
/>
</div>
{/* Amenities */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-gray-700 mb-2 tracking-wide">
Amenities
</label>
{availableAmenities.length === 0 ? (
<div className="text-sm text-gray-500">Loading amenities...</div>
<div className="text-sm text-gray-500 font-light">Loading amenities...</div>
) : (
<div className="flex flex-col gap-2 max-h-40 overflow-auto pr-2">
{availableAmenities.map((amenity) => (
<label
key={amenity}
className="flex items-center gap-2 text-sm w-full"
className="flex items-center gap-2 text-sm w-full font-light tracking-wide hover:text-[#d4af37] transition-colors cursor-pointer"
>
<input
type="checkbox"
checked={selectedAmenities.includes(amenity)}
onChange={() => toggleAmenity(amenity)}
className="h-4 w-4"
className="h-4 w-4 accent-[#d4af37] cursor-pointer"
/>
<span className="text-gray-700">{amenity}</span>
</label>
@@ -423,18 +415,17 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
<div className="flex gap-3 pt-2">
<button
type="submit"
className="flex-1 bg-blue-600 text-white
py-2 px-4 rounded-lg hover:bg-blue-700
transition-colors font-medium"
className="btn-luxury-primary flex-1 py-2.5 px-4 relative"
>
Apply
<span className="relative z-10">Apply</span>
</button>
<button
type="button"
onClick={handleReset}
className="flex-1 bg-gray-200 text-gray-700
py-2 px-4 rounded-lg hover:bg-gray-300
transition-colors font-medium"
className="flex-1 bg-white/80 backdrop-blur-sm text-gray-700
py-2.5 px-4 rounded-sm border border-gray-300
hover:bg-white hover:border-[#d4af37]/30 hover:text-[#d4af37]
transition-all font-medium tracking-wide"
>
Reset
</button>

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
@@ -18,6 +18,17 @@ const SearchRoomForm: React.FC<SearchRoomFormProps> = ({
const [roomType, setRoomType] = useState('');
const [guestCount, setGuestCount] = useState<number>(1);
const [isSearching, setIsSearching] = useState(false);
const [isMobile, setIsMobile] = useState(false);
// Check if mobile on mount and resize
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 640);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
// Set minimum date to today
const today = new Date();
@@ -89,18 +100,25 @@ const SearchRoomForm: React.FC<SearchRoomFormProps> = ({
// setGuestCount(1);
// };
const isOverlay = className.includes('overlay');
return (
<div className={`w-full bg-white rounded-lg shadow-sm p-4 ${className}`}>
<div className="flex items-center justify-center gap-3 mb-6">
<h3 className="text-xl font-bold text-gray-900">
Find Available Rooms
<div className={`w-full ${isOverlay ? 'bg-transparent shadow-none border-none p-0' : 'luxury-glass rounded-sm shadow-2xl p-6 border border-[#d4af37]/20'} ${className}`}>
{/* Title - Hidden on mobile when in overlay mode */}
{(!isOverlay || !isMobile) && (
<div className={`flex items-center justify-center gap-2 sm:gap-3 ${isOverlay ? 'mb-2 sm:mb-3 md:mb-4 lg:mb-6' : 'mb-6'}`}>
<div className={`${isOverlay ? 'w-0.5 sm:w-1' : 'w-1'} ${isOverlay ? 'h-4 sm:h-6 md:h-8' : 'h-8'} bg-gradient-to-b from-[#d4af37] to-[#c9a227]`}></div>
<h3 className={`${isOverlay ? 'text-sm sm:text-base md:text-lg lg:text-xl xl:text-2xl' : 'text-2xl'} font-serif font-semibold text-gray-900 tracking-tight`}>
{isOverlay && isMobile ? 'Find Rooms' : 'Find Available Rooms'}
</h3>
<div className={`${isOverlay ? 'w-0.5 sm:w-1' : 'w-1'} ${isOverlay ? 'h-4 sm:h-6 md:h-8' : 'h-8'} bg-gradient-to-b from-[#d4af37] to-[#c9a227]`}></div>
</div>
)}
<form onSubmit={handleSearch}>
<div className="grid grid-cols-1 md:grid-cols-12 gap-3 items-center">
<div className="md:col-span-3">
<label className="sr-only">Check-in Date</label>
<div className={`grid ${isOverlay ? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-12' : 'grid-cols-1 md:grid-cols-12'} ${isOverlay ? 'gap-2 sm:gap-3 md:gap-4' : 'gap-4'} items-end`}>
<div className={isOverlay ? 'sm:col-span-1 lg:col-span-3' : 'md:col-span-3'}>
<label className={`block ${isOverlay ? 'text-xs sm:text-sm' : 'text-sm'} font-medium text-gray-700 ${isOverlay ? 'mb-1 sm:mb-2' : 'mb-2'} tracking-wide`}>Check-in</label>
<DatePicker
selected={checkInDate}
onChange={(date) => setCheckInDate(date)}
@@ -108,14 +126,14 @@ const SearchRoomForm: React.FC<SearchRoomFormProps> = ({
startDate={checkInDate}
endDate={checkOutDate}
minDate={today}
placeholderText="Check-in Date"
dateFormat="dd/MM"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
placeholderText="Check-in"
dateFormat={isOverlay && isMobile ? "dd/MM" : "dd/MM/yyyy"}
className={`luxury-input ${isOverlay ? 'text-xs sm:text-sm' : 'text-sm'} ${isOverlay ? 'py-1.5 sm:py-2' : ''}`}
/>
</div>
<div className="md:col-span-3">
<label className="sr-only">Check-out Date</label>
<div className={isOverlay ? 'sm:col-span-1 lg:col-span-3' : 'md:col-span-3'}>
<label className={`block ${isOverlay ? 'text-xs sm:text-sm' : 'text-sm'} font-medium text-gray-700 ${isOverlay ? 'mb-1 sm:mb-2' : 'mb-2'} tracking-wide`}>Check-out</label>
<DatePicker
selected={checkOutDate}
onChange={(date) => setCheckOutDate(date)}
@@ -123,18 +141,18 @@ const SearchRoomForm: React.FC<SearchRoomFormProps> = ({
startDate={checkInDate}
endDate={checkOutDate}
minDate={checkInDate || today}
placeholderText="Check-out Date"
dateFormat="dd/MM"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
placeholderText="Check-out"
dateFormat={isOverlay && isMobile ? "dd/MM" : "dd/MM/yyyy"}
className={`luxury-input ${isOverlay ? 'text-xs sm:text-sm' : 'text-sm'} ${isOverlay ? 'py-1.5 sm:py-2' : ''}`}
/>
</div>
<div className="md:col-span-2">
<label className="sr-only">Room Type</label>
<div className={isOverlay ? 'sm:col-span-1 lg:col-span-2' : 'md:col-span-2'}>
<label className={`block ${isOverlay ? 'text-xs sm:text-sm' : 'text-sm'} font-medium text-gray-700 ${isOverlay ? 'mb-1 sm:mb-2' : 'mb-2'} tracking-wide`}>Room Type</label>
<select
value={roomType}
onChange={(e) => setRoomType(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
className={`luxury-input ${isOverlay ? 'text-xs sm:text-sm' : 'text-sm'} ${isOverlay ? 'py-1.5 sm:py-2' : ''}`}
>
<option value="">All Types</option>
<option value="Standard Room">Standard Room</option>
@@ -145,27 +163,28 @@ const SearchRoomForm: React.FC<SearchRoomFormProps> = ({
</select>
</div>
<div className="md:col-span-2">
<label className="sr-only">Number of Guests</label>
<div className={isOverlay ? 'sm:col-span-1 lg:col-span-2' : 'md:col-span-2'}>
<label className={`block ${isOverlay ? 'text-xs sm:text-sm' : 'text-sm'} font-medium text-gray-700 ${isOverlay ? 'mb-1 sm:mb-2' : 'mb-2'} tracking-wide`}>Guests</label>
<select
value={guestCount}
onChange={(e) => setGuestCount(Number(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
className={`luxury-input ${isOverlay ? 'text-xs sm:text-sm' : 'text-sm'} ${isOverlay ? 'py-1.5 sm:py-2' : ''}`}
>
{Array.from({ length: 6 }).map((_, i) => (
<option key={i} value={i + 1}>{i + 1} guest{i !== 0 ? 's' : ''}</option>
))}
</select>
</div>
<div className="md:col-span-2 flex items-center mt-3 md:mt-0">
<div className={`${isOverlay ? 'sm:col-span-2 lg:col-span-2' : 'md:col-span-2'} flex items-end ${isOverlay ? 'mt-1 sm:mt-2 md:mt-0' : 'mt-3 md:mt-0'}`}>
<button
type="submit"
disabled={isSearching}
className="w-full bg-indigo-600 text-white px-3 py-2 rounded-md text-sm hover:bg-indigo-700 disabled:bg-gray-400"
className={`btn-luxury-primary w-full flex items-center justify-center gap-1.5 sm:gap-2 ${isOverlay ? 'text-xs sm:text-sm py-1.5 sm:py-2 md:py-3' : 'text-sm'} relative`}
>
<span className="inline-flex items-center gap-2 justify-center w-full">
<Search className="w-4 h-4" />
{isSearching ? 'Searching...' : 'Search Rooms'}
<Search className={`${isOverlay ? 'w-3 h-3 sm:w-4 sm:h-4' : 'w-4 h-4'} relative z-10`} />
<span className="relative z-10">
{isSearching ? 'Searching...' : 'Search'}
</span>
</button>
</div>

View File

@@ -0,0 +1,112 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import privacyService, {
CookieCategoryPreferences,
CookieConsent,
UpdateCookieConsentRequest,
} from '../services/api/privacyService';
type CookieConsentContextValue = {
consent: CookieConsent | null;
isLoading: boolean;
hasDecided: boolean;
updateConsent: (payload: UpdateCookieConsentRequest) => Promise<void>;
};
const CookieConsentContext = createContext<CookieConsentContextValue | undefined>(
undefined
);
export const CookieConsentProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [consent, setConsent] = useState<CookieConsent | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [hasDecided, setHasDecided] = useState<boolean>(false);
useEffect(() => {
let isMounted = true;
const loadConsent = async () => {
try {
const data = await privacyService.getCookieConsent();
if (!isMounted) return;
setConsent(data);
// Prefer explicit local decision flag, fall back to server flag
const localFlag =
typeof window !== 'undefined'
? window.localStorage.getItem('cookieConsentDecided')
: null;
const decided =
(localFlag === 'true') || Boolean((data as any).has_decided);
setHasDecided(decided);
} catch (error) {
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.error('Failed to load cookie consent', error);
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
void loadConsent();
return () => {
isMounted = false;
};
}, []);
const updateConsent = useCallback(
async (payload: UpdateCookieConsentRequest) => {
const updated = await privacyService.updateCookieConsent(payload);
setConsent(updated);
setHasDecided(true);
if (typeof window !== 'undefined') {
window.localStorage.setItem('cookieConsentDecided', 'true');
}
},
[]
);
const value: CookieConsentContextValue = {
consent,
isLoading,
hasDecided,
updateConsent,
};
return (
<CookieConsentContext.Provider value={value}>
{children}
</CookieConsentContext.Provider>
);
};
export const useCookieConsent = (): CookieConsentContextValue => {
const ctx = useContext(CookieConsentContext);
if (!ctx) {
// Fallback to a safe default instead of throwing, to avoid crashes
// if components are rendered outside the provider (e.g. during hot reload).
return {
consent: null,
isLoading: false,
hasDecided: false,
updateConsent: async () => {
// no-op when context is not yet available
return;
},
};
}
return ctx;
};

View File

@@ -0,0 +1,40 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
import GlobalLoading from '../components/common/GlobalLoading';
interface GlobalLoadingContextType {
setLoading: (loading: boolean, text?: string) => void;
isLoading: boolean;
loadingText: string;
}
const GlobalLoadingContext = createContext<GlobalLoadingContextType | undefined>(undefined);
export const useGlobalLoading = () => {
const context = useContext(GlobalLoadingContext);
if (!context) {
throw new Error('useGlobalLoading must be used within GlobalLoadingProvider');
}
return context;
};
interface GlobalLoadingProviderProps {
children: ReactNode;
}
export const GlobalLoadingProvider: React.FC<GlobalLoadingProviderProps> = ({ children }) => {
const [isLoading, setIsLoading] = useState(false);
const [loadingText, setLoadingText] = useState('Loading...');
const setLoading = (loading: boolean, text: string = 'Loading...') => {
setIsLoading(loading);
setLoadingText(text);
};
return (
<GlobalLoadingContext.Provider value={{ setLoading, isLoading, loadingText }}>
{children}
<GlobalLoading isLoading={isLoading} text={loadingText} />
</GlobalLoadingContext.Provider>
);
};

View File

@@ -0,0 +1,7 @@
export { default as useDebounce } from './useDebounce';
export { useAsync } from './useAsync';
export { useLocalStorage } from './useLocalStorage';
export { useOffline } from './useOffline';
export { useClickOutside } from './useClickOutside';
export { default as usePagePerformance } from './usePagePerformance';

View File

@@ -0,0 +1,86 @@
import { useState, useEffect, useCallback, useRef } from 'react';
interface UseAsyncOptions<T> {
immediate?: boolean;
onSuccess?: (data: T) => void;
onError?: (error: Error) => void;
}
interface UseAsyncState<T> {
data: T | null;
loading: boolean;
error: Error | null;
execute: (...args: any[]) => Promise<T | undefined>;
reset: () => void;
}
/**
* Hook for handling async operations with loading and error states
*/
export function useAsync<T>(
asyncFunction: (...args: any[]) => Promise<T>,
options: UseAsyncOptions<T> = {}
): UseAsyncState<T> {
const { immediate = false, onSuccess, onError } = options;
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState<boolean>(immediate);
const [error, setError] = useState<Error | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const execute = useCallback(
async (...args: any[]): Promise<T | undefined> => {
try {
setLoading(true);
setError(null);
const result = await asyncFunction(...args);
if (!mountedRef.current) return;
setData(result);
setLoading(false);
if (onSuccess) {
onSuccess(result);
}
return result;
} catch (err) {
if (!mountedRef.current) return;
const error = err instanceof Error ? err : new Error(String(err));
setError(error);
setLoading(false);
if (onError) {
onError(error);
}
throw error;
}
},
[asyncFunction, onSuccess, onError]
);
const reset = useCallback(() => {
setData(null);
setError(null);
setLoading(false);
}, []);
useEffect(() => {
if (immediate) {
execute();
}
}, [immediate]); // eslint-disable-line react-hooks/exhaustive-deps
return { data, loading, error, execute, reset };
}

View File

@@ -0,0 +1,28 @@
import { useEffect, RefObject } from 'react';
/**
* Hook to detect clicks outside of a ref element
*/
export function useClickOutside<T extends HTMLElement>(
ref: RefObject<T>,
handler: (event: MouseEvent | TouchEvent) => void
): void {
useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
// Do nothing if clicking ref's element or descendent elements
if (!ref.current || ref.current.contains(event.target as Node)) {
return;
}
handler(event);
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler]);
}

View File

@@ -0,0 +1,68 @@
import { useState, useEffect, useCallback } from 'react';
type SetValue<T> = T | ((val: T) => T);
/**
* Hook for managing localStorage with React state
*/
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: SetValue<T>) => void, () => void] {
// State to store our value
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
// Return a wrapped version of useState's setter function that
// persists the new value to localStorage.
const setValue = useCallback(
(value: SetValue<T>) => {
try {
// Allow value to be a function so we have the same API as useState
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
},
[key, storedValue]
);
const removeValue = useCallback(() => {
try {
window.localStorage.removeItem(key);
setStoredValue(initialValue);
} catch (error) {
console.error(`Error removing localStorage key "${key}":`, error);
}
}, [key, initialValue]);
// Listen for changes to the key in other tabs/windows
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue) {
try {
setStoredValue(JSON.parse(e.newValue));
} catch (error) {
console.error(`Error parsing localStorage value for key "${key}":`, error);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key]);
return [storedValue, setValue, removeValue];
}

View File

@@ -0,0 +1,29 @@
import { useState, useEffect } from 'react';
/**
* Hook to detect if the user is offline
*/
export function useOffline(): boolean {
const [isOffline, setIsOffline] = useState(() => {
if (typeof navigator !== 'undefined' && 'onLine' in navigator) {
return !navigator.onLine;
}
return false;
});
useEffect(() => {
const handleOnline = () => setIsOffline(false);
const handleOffline = () => setIsOffline(true);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOffline;
}

View File

@@ -0,0 +1,244 @@
import React from 'react';
import {
Hotel,
Award,
Users,
Heart,
MapPin,
Phone,
Mail,
Star,
Shield,
Clock
} from 'lucide-react';
import { Link } from 'react-router-dom';
const AboutPage: React.FC = () => {
return (
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-white">
{/* Hero Section */}
<div className="relative bg-gradient-to-b from-[#1a1a1a] to-[#0f0f0f] text-white py-20">
<div className="container mx-auto px-4">
<div className="max-w-3xl mx-auto text-center animate-fade-in">
<div className="flex justify-center mb-6">
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37] to-[#f5d76e] rounded-full blur-2xl opacity-30"></div>
<Hotel className="relative w-20 h-20 text-[#d4af37] drop-shadow-lg" />
</div>
</div>
<h1 className="text-5xl md:text-6xl font-serif font-bold mb-6 tracking-tight">
About Luxury Hotel
</h1>
<p className="text-xl text-gray-300 font-light leading-relaxed">
Where Excellence Meets Unforgettable Experiences
</p>
</div>
</div>
</div>
{/* Our Story Section */}
<section className="py-16 bg-white">
<div className="container mx-auto px-4">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-12 animate-fade-in">
<h2 className="text-4xl font-serif font-bold text-gray-900 mb-4">
Our Story
</h2>
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
</div>
<div className="prose prose-lg max-w-none text-gray-700 leading-relaxed space-y-6 animate-slide-up">
<p>
Welcome to Luxury Hotel, where timeless elegance meets modern sophistication.
Since our founding, we have been dedicated to providing exceptional hospitality
and creating unforgettable memories for our guests.
</p>
<p>
Nestled in the heart of the city, our hotel combines classic architecture with
contemporary amenities, offering a perfect blend of comfort and luxury. Every
detail has been carefully curated to ensure your stay exceeds expectations.
</p>
<p>
Our commitment to excellence extends beyond our beautiful rooms and facilities.
We believe in creating meaningful connections with our guests, understanding
their needs, and delivering personalized service that makes each visit special.
</p>
</div>
</div>
</div>
</section>
{/* Values Section */}
<section className="py-16 bg-gray-50">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12 animate-fade-in">
<h2 className="text-4xl font-serif font-bold text-gray-900 mb-4">
Our Values
</h2>
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{[
{
icon: Heart,
title: 'Passion',
description: 'We are passionate about hospitality and dedicated to creating exceptional experiences for every guest.'
},
{
icon: Award,
title: 'Excellence',
description: 'We strive for excellence in every aspect of our service, from the smallest detail to the grandest gesture.'
},
{
icon: Shield,
title: 'Integrity',
description: 'We conduct our business with honesty, transparency, and respect for our guests and community.'
},
{
icon: Users,
title: 'Service',
description: 'Our guests are at the heart of everything we do. Your comfort and satisfaction are our top priorities.'
}
].map((value, index) => (
<div
key={value.title}
className="bg-white p-6 rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 animate-slide-up"
style={{ animationDelay: `${index * 0.1}s` }}
>
<div className="w-12 h-12 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-lg flex items-center justify-center mb-4">
<value.icon className="w-6 h-6 text-white" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
{value.title}
</h3>
<p className="text-gray-600 leading-relaxed">
{value.description}
</p>
</div>
))}
</div>
</div>
</div>
</section>
{/* Features Section */}
<section className="py-16 bg-white">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12 animate-fade-in">
<h2 className="text-4xl font-serif font-bold text-gray-900 mb-4">
Why Choose Us
</h2>
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{[
{
icon: Star,
title: 'Premium Accommodations',
description: 'Luxuriously appointed rooms and suites designed for ultimate comfort and relaxation.'
},
{
icon: Clock,
title: '24/7 Service',
description: 'Round-the-clock concierge and room service to attend to your needs at any time.'
},
{
icon: Award,
title: 'Award-Winning',
description: 'Recognized for excellence in hospitality and guest satisfaction.'
}
].map((feature, index) => (
<div
key={feature.title}
className="text-center p-6 animate-slide-up"
style={{ animationDelay: `${index * 0.1}s` }}
>
<div className="w-16 h-16 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full flex items-center justify-center mx-auto mb-4">
<feature.icon className="w-8 h-8 text-white" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">
{feature.title}
</h3>
<p className="text-gray-600 leading-relaxed">
{feature.description}
</p>
</div>
))}
</div>
</div>
</div>
</section>
{/* Contact Section */}
<section className="py-16 bg-gradient-to-b from-gray-50 to-white">
<div className="container mx-auto px-4">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-12 animate-fade-in">
<h2 className="text-4xl font-serif font-bold text-gray-900 mb-4">
Get In Touch
</h2>
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
<p className="text-gray-600 mt-4">
We'd love to hear from you. Contact us for reservations or inquiries.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="text-center p-6 bg-white rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 animate-slide-up">
<div className="w-12 h-12 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full flex items-center justify-center mx-auto mb-4">
<MapPin className="w-6 h-6 text-white" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Address
</h3>
<p className="text-gray-600">
123 Luxury Street<br />
City, State 12345<br />
Country
</p>
</div>
<div className="text-center p-6 bg-white rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 animate-slide-up" style={{ animationDelay: '0.1s' }}>
<div className="w-12 h-12 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full flex items-center justify-center mx-auto mb-4">
<Phone className="w-6 h-6 text-white" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Phone
</h3>
<p className="text-gray-600">
<a href="tel:+1234567890" className="hover:text-[#d4af37] transition-colors">
+1 (234) 567-890
</a>
</p>
</div>
<div className="text-center p-6 bg-white rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 animate-slide-up" style={{ animationDelay: '0.2s' }}>
<div className="w-12 h-12 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full flex items-center justify-center mx-auto mb-4">
<Mail className="w-6 h-6 text-white" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Email
</h3>
<p className="text-gray-600">
<a href="mailto:info@luxuryhotel.com" className="hover:text-[#d4af37] transition-colors">
info@luxuryhotel.com
</a>
</p>
</div>
</div>
<div className="text-center mt-12 animate-fade-in">
<Link
to="/rooms"
className="inline-flex items-center space-x-2 px-8 py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-white rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 font-medium shadow-lg shadow-[#d4af37]/20 hover:shadow-[#d4af37]/30"
>
<span>Explore Our Rooms</span>
<Hotel className="w-5 h-5" />
</Link>
</div>
</div>
</div>
</section>
</div>
);
};
export default AboutPage;

View File

@@ -137,44 +137,50 @@ const HomePage: React.FC = () => {
}, []);
return (
<div className="min-h-screen bg-gray-50">
{/* Banner Section */}
<section className="container mx-auto px-4 pb-8">
<>
{/* Banner Section - Full Width, breaks out of container */}
<section
className="relative w-screen -mt-6"
style={{
marginLeft: 'calc(50% - 50vw)',
marginRight: 'calc(50% - 50vw)'
}}
>
{isLoadingBanners ? (
<BannerSkeleton />
) : (
<BannerCarousel banners={banners} />
<div className="animate-fade-in">
<BannerCarousel banners={banners}>
<SearchRoomForm className="overlay" />
</BannerCarousel>
</div>
)}
</section>
{/* Search Section */}
<section className="container mx-auto px-4 py-8">
<SearchRoomForm />
</section>
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-gray-100/50 to-gray-50">
{/* Featured Rooms Section */}
<section className="container mx-auto px-4 py-12">
<section className="container mx-auto px-4 py-16">
{/* Section Header */}
<div className="flex items-center justify-between mb-8">
<div className="luxury-section-header flex items-center justify-between animate-fade-in">
<div className="flex items-center gap-3">
<div>
<h2
className="text-3xl font-bold
text-gray-900"
>
<h2 className="luxury-section-title">
Featured Rooms
</h2>
<p className="luxury-section-subtitle">
Discover our most popular accommodations
</p>
</div>
</div>
<Link
to="/rooms"
className="hidden md:flex items-center gap-2
text-indigo-600 hover:text-indigo-700
font-semibold transition-colors"
btn-luxury-secondary group text-white"
>
View All Rooms
<ArrowRight className="w-5 h-5" />
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</Link>
</div>
@@ -193,21 +199,25 @@ const HomePage: React.FC = () => {
{/* Error State */}
{error && !isLoadingRooms && (
<div
className="bg-red-50 border border-red-200
rounded-lg p-6 text-center"
className="luxury-card p-8 text-center animate-fade-in
border-red-200 bg-gradient-to-br from-red-50 to-red-100/50"
>
<div className="inline-flex items-center justify-center w-16 h-16
bg-red-100 rounded-full mb-4">
<AlertCircle
className="w-12 h-12 text-red-500
mx-auto mb-3"
className="w-8 h-8 text-red-600"
/>
<p className="text-red-700 font-medium">
</div>
<p className="text-red-800 font-serif font-semibold text-lg mb-2 tracking-tight">
{error}
</p>
<button
onClick={() => window.location.reload()}
className="mt-4 px-4 py-2 bg-red-600
text-white rounded-lg
hover:bg-red-700 transition-colors"
className="mt-4 px-6 py-2.5 bg-gradient-to-r from-red-600 to-red-700
text-white rounded-sm font-medium tracking-wide
hover:from-red-700 hover:to-red-800
transition-all duration-300 shadow-lg shadow-red-500/30
hover:shadow-xl hover:shadow-red-500/40 hover:-translate-y-0.5"
>
Try Again
</button>
@@ -228,10 +238,9 @@ const HomePage: React.FC = () => {
</div>
) : (
<div
className="bg-gray-100 rounded-lg
p-12 text-center"
className="luxury-card p-12 text-center animate-fade-in"
>
<p className="text-gray-600 text-lg">
<p className="text-gray-600 text-lg font-light tracking-wide">
No featured rooms available
</p>
</div>
@@ -239,16 +248,13 @@ const HomePage: React.FC = () => {
{/* View All Button (Mobile) */}
{featuredRooms.length > 0 && (
<div className="mt-8 text-center md:hidden">
<div className="mt-10 text-center md:hidden animate-slide-up">
<Link
to="/rooms"
className="inline-flex items-center gap-2
bg-indigo-600 text-white px-6 py-3
rounded-lg hover:bg-indigo-700
transition-colors font-semibold"
className="btn-luxury-primary inline-flex items-center gap-2"
>
View All Rooms
<ArrowRight className="w-5 h-5" />
<span className="relative z-10">View All Rooms</span>
<ArrowRight className="w-5 h-5 relative z-10" />
</Link>
</div>
)}
@@ -257,28 +263,27 @@ const HomePage: React.FC = () => {
</section>
{/* Newest Rooms Section */}
<section className="container mx-auto px-4 py-12">
<section className="container mx-auto px-4 py-16">
{/* Section Header */}
<div className="flex items-center justify-between mb-8">
<div className="luxury-section-header flex items-center justify-between animate-fade-in">
<div className="flex items-center gap-3">
<div>
<h2
className="text-3xl font-bold
text-gray-900"
>
<h2 className="luxury-section-title">
Newest Rooms
</h2>
<p className="luxury-section-subtitle">
Explore our latest additions
</p>
</div>
</div>
<Link
to="/rooms"
className="hidden md:flex items-center gap-2
text-indigo-600 hover:text-indigo-700
font-semibold transition-colors"
btn-luxury-secondary group text-white"
>
View All Rooms
<ArrowRight className="w-5 h-5" />
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</Link>
</div>
@@ -308,10 +313,9 @@ const HomePage: React.FC = () => {
</div>
) : (
<div
className="bg-gray-100 rounded-lg
p-12 text-center"
className="luxury-card p-12 text-center animate-fade-in"
>
<p className="text-gray-600 text-lg">
<p className="text-gray-600 text-lg font-light tracking-wide">
No new rooms available
</p>
</div>
@@ -319,16 +323,13 @@ const HomePage: React.FC = () => {
{/* View All Button (Mobile) */}
{newestRooms.length > 0 && (
<div className="mt-8 text-center md:hidden">
<div className="mt-10 text-center md:hidden animate-slide-up">
<Link
to="/rooms"
className="inline-flex items-center gap-2
bg-indigo-600 text-white px-6 py-3
rounded-lg hover:bg-indigo-700
transition-colors font-semibold"
className="btn-luxury-primary inline-flex items-center gap-2"
>
View All Rooms
<ArrowRight className="w-5 h-5" />
<span className="relative z-10">View All Rooms</span>
<ArrowRight className="w-5 h-5 relative z-10" />
</Link>
</div>
)}
@@ -337,73 +338,79 @@ const HomePage: React.FC = () => {
</section>
{/* Features Section */}
<section
className="container mx-auto px-4 py-12
bg-white rounded-xl shadow-sm mx-4"
>
<section className="container mx-auto px-4 py-16">
<div className="luxury-card-gold p-12 animate-fade-in relative overflow-hidden">
{/* Decorative gold accent */}
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e]"></div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
<div className="text-center group">
<div
className="grid grid-cols-1 md:grid-cols-3
gap-8"
className="w-20 h-20 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
rounded-sm flex items-center justify-center mx-auto mb-6
shadow-lg shadow-[#d4af37]/20 border border-[#d4af37]/30
group-hover:scale-110 group-hover:shadow-xl group-hover:shadow-[#d4af37]/30
transition-all duration-300"
>
<div className="text-center">
<div
className="w-16 h-16 bg-indigo-100
rounded-full flex items-center
justify-center mx-auto mb-4"
>
<span className="text-3xl">🏨</span>
<span className="text-4xl">🏨</span>
</div>
<h3
className="text-xl font-semibold mb-2
text-gray-900"
className="text-xl font-serif font-semibold mb-3
text-gray-900 group-hover:text-[#d4af37] transition-colors tracking-tight"
>
Easy Booking
</h3>
<p className="text-gray-600">
<p className="text-gray-600 leading-relaxed font-light tracking-wide">
Search and book rooms with just a few clicks
</p>
</div>
<div className="text-center">
<div className="text-center group">
<div
className="w-16 h-16 bg-green-100
rounded-full flex items-center
justify-center mx-auto mb-4"
className="w-20 h-20 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
rounded-sm flex items-center justify-center mx-auto mb-6
shadow-lg shadow-[#d4af37]/20 border border-[#d4af37]/30
group-hover:scale-110 group-hover:shadow-xl group-hover:shadow-[#d4af37]/30
transition-all duration-300"
>
<span className="text-3xl">💰</span>
<span className="text-4xl">💰</span>
</div>
<h3
className="text-xl font-semibold mb-2
text-gray-900"
className="text-xl font-serif font-semibold mb-3
text-gray-900 group-hover:text-[#d4af37] transition-colors tracking-tight"
>
Best Prices
</h3>
<p className="text-gray-600">
<p className="text-gray-600 leading-relaxed font-light tracking-wide">
Best price guarantee in the market
</p>
</div>
<div className="text-center">
<div className="text-center group">
<div
className="w-16 h-16 bg-blue-100
rounded-full flex items-center
justify-center mx-auto mb-4"
className="w-20 h-20 bg-gradient-to-br from-[#d4af37]/20 to-[#f5d76e]/20
rounded-sm flex items-center justify-center mx-auto mb-6
shadow-lg shadow-[#d4af37]/20 border border-[#d4af37]/30
group-hover:scale-110 group-hover:shadow-xl group-hover:shadow-[#d4af37]/30
transition-all duration-300"
>
<span className="text-3xl">🎧</span>
<span className="text-4xl">🎧</span>
</div>
<h3
className="text-xl font-semibold mb-2
text-gray-900"
className="text-xl font-serif font-semibold mb-3
text-gray-900 group-hover:text-[#d4af37] transition-colors tracking-tight"
>
24/7 Support
</h3>
<p className="text-gray-600">
<p className="text-gray-600 leading-relaxed font-light tracking-wide">
Support team always ready to serve
</p>
</div>
</div>
</div>
</section>
</div>
</>
);
};

View File

@@ -0,0 +1,446 @@
import React, { useState, useEffect } from 'react';
import {
FileText,
Search,
Filter,
Eye,
Calendar,
User,
Activity,
AlertCircle,
CheckCircle,
XCircle,
Info
} from 'lucide-react';
import { toast } from 'react-toastify';
import { Loading, EmptyState } from '../../components/common';
import Pagination from '../../components/common/Pagination';
import { auditService, AuditLog, AuditLogFilters } from '../../services/api/auditService';
import { formatDate } from '../../utils/format';
const AuditLogsPage: React.FC = () => {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
const [showDetails, setShowDetails] = useState(false);
const itemsPerPage = 20;
const [filters, setFilters] = useState<AuditLogFilters>({
page: 1,
limit: itemsPerPage,
});
useEffect(() => {
fetchLogs();
}, [filters, currentPage]);
useEffect(() => {
setFilters(prev => ({ ...prev, page: currentPage }));
}, [currentPage]);
const fetchLogs = async () => {
try {
setLoading(true);
const response = await auditService.getAuditLogs({
...filters,
page: currentPage,
limit: itemsPerPage,
});
setLogs(response.data.logs);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
console.error('Error fetching audit logs:', error);
toast.error(error.response?.data?.message || 'Unable to load audit logs');
} finally {
setLoading(false);
}
};
const handleFilterChange = (key: keyof AuditLogFilters, value: any) => {
setFilters(prev => ({ ...prev, [key]: value }));
setCurrentPage(1);
};
const handleSearch = (searchTerm: string) => {
handleFilterChange('search', searchTerm || undefined);
};
const handleViewDetails = (log: AuditLog) => {
setSelectedLog(log);
setShowDetails(true);
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'success':
return <CheckCircle className="w-5 h-5 text-green-500" />;
case 'failed':
return <XCircle className="w-5 h-5 text-red-500" />;
case 'error':
return <AlertCircle className="w-5 h-5 text-orange-500" />;
default:
return <Info className="w-5 h-5 text-gray-500" />;
}
};
const getStatusBadge = (status: string) => {
const baseClasses = "px-2 py-1 rounded-full text-xs font-medium";
switch (status) {
case 'success':
return `${baseClasses} bg-green-100 text-green-800`;
case 'failed':
return `${baseClasses} bg-red-100 text-red-800`;
case 'error':
return `${baseClasses} bg-orange-100 text-orange-800`;
default:
return `${baseClasses} bg-gray-100 text-gray-800`;
}
};
if (loading && logs.length === 0) {
return <Loading fullScreen text="Loading audit logs..." />;
}
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Audit Logs</h1>
<p className="text-gray-600">View all system activity and actions</p>
</div>
{/* Filters */}
<div className="mb-6 bg-white p-4 rounded-lg shadow-md">
<div className="flex items-center space-x-2 mb-4">
<Filter className="w-5 h-5 text-gray-500" />
<h3 className="text-lg font-semibold text-gray-900">Filters</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Search
</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Search actions, types..."
value={filters.search || ''}
onChange={(e) => handleSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Action
</label>
<input
type="text"
placeholder="Filter by action"
value={filters.action || ''}
onChange={(e) => handleFilterChange('action', e.target.value || undefined)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Resource Type
</label>
<input
type="text"
placeholder="Filter by type"
value={filters.resource_type || ''}
onChange={(e) => handleFilterChange('resource_type', e.target.value || undefined)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
value={filters.status || ''}
onChange={(e) => handleFilterChange('status', e.target.value || undefined)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
>
<option value="">All</option>
<option value="success">Success</option>
<option value="failed">Failed</option>
<option value="error">Error</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Start Date
</label>
<input
type="date"
value={filters.start_date || ''}
onChange={(e) => handleFilterChange('start_date', e.target.value || undefined)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
End Date
</label>
<input
type="date"
value={filters.end_date || ''}
onChange={(e) => handleFilterChange('end_date', e.target.value || undefined)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
</div>
</div>
{/* Stats Summary */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white p-4 rounded-lg shadow-md">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Total Logs</p>
<p className="text-2xl font-bold text-gray-900">{totalItems}</p>
</div>
<FileText className="w-8 h-8 text-[#d4af37]" />
</div>
</div>
<div className="bg-white p-4 rounded-lg shadow-md">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Current Page</p>
<p className="text-2xl font-bold text-gray-900">{currentPage}</p>
</div>
<Activity className="w-8 h-8 text-blue-500" />
</div>
</div>
</div>
{/* Logs Table */}
{logs.length === 0 ? (
<EmptyState
title="No Audit Logs Found"
description="No audit logs match your current filters."
/>
) : (
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Action
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Resource
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
IP Address
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{logs.map((log) => (
<tr key={log.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
#{log.id}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{log.action}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{log.resource_type}</div>
{log.resource_id && (
<div className="text-xs text-gray-500">ID: {log.resource_id}</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{log.user ? (
<div>
<div className="text-sm font-medium text-gray-900">{log.user.full_name}</div>
<div className="text-xs text-gray-500">{log.user.email}</div>
</div>
) : (
<span className="text-sm text-gray-400">System</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center space-x-2">
{getStatusIcon(log.status)}
<span className={getStatusBadge(log.status)}>
{log.status}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{log.ip_address || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(log.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => handleViewDetails(log)}
className="text-[#d4af37] hover:text-[#c9a227] flex items-center space-x-1"
>
<Eye className="w-4 h-4" />
<span>View</span>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-6">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
{/* Details Modal */}
{showDetails && selectedLog && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-3xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-bold text-gray-900">Audit Log Details</h2>
<button
onClick={() => setShowDetails(false)}
className="text-gray-400 hover:text-gray-600"
>
<XCircle className="w-6 h-6" />
</button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">ID</label>
<p className="mt-1 text-sm text-gray-900">#{selectedLog.id}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Status</label>
<div className="mt-1 flex items-center space-x-2">
{getStatusIcon(selectedLog.status)}
<span className={getStatusBadge(selectedLog.status)}>
{selectedLog.status}
</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Action</label>
<p className="mt-1 text-sm text-gray-900">{selectedLog.action}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Resource Type</label>
<p className="mt-1 text-sm text-gray-900">{selectedLog.resource_type}</p>
</div>
{selectedLog.resource_id && (
<div>
<label className="block text-sm font-medium text-gray-700">Resource ID</label>
<p className="mt-1 text-sm text-gray-900">{selectedLog.resource_id}</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700">Date</label>
<p className="mt-1 text-sm text-gray-900">{formatDate(selectedLog.created_at)}</p>
</div>
{selectedLog.user && (
<>
<div>
<label className="block text-sm font-medium text-gray-700">User</label>
<p className="mt-1 text-sm text-gray-900">{selectedLog.user.full_name}</p>
<p className="text-xs text-gray-500">{selectedLog.user.email}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">User ID</label>
<p className="mt-1 text-sm text-gray-900">{selectedLog.user_id}</p>
</div>
</>
)}
{selectedLog.ip_address && (
<div>
<label className="block text-sm font-medium text-gray-700">IP Address</label>
<p className="mt-1 text-sm text-gray-900">{selectedLog.ip_address}</p>
</div>
)}
{selectedLog.request_id && (
<div>
<label className="block text-sm font-medium text-gray-700">Request ID</label>
<p className="mt-1 text-sm text-gray-900 font-mono text-xs">{selectedLog.request_id}</p>
</div>
)}
</div>
{selectedLog.user_agent && (
<div>
<label className="block text-sm font-medium text-gray-700">User Agent</label>
<p className="mt-1 text-sm text-gray-900 break-all">{selectedLog.user_agent}</p>
</div>
)}
{selectedLog.error_message && (
<div>
<label className="block text-sm font-medium text-red-700">Error Message</label>
<p className="mt-1 text-sm text-red-900 bg-red-50 p-3 rounded">{selectedLog.error_message}</p>
</div>
)}
{selectedLog.details && (
<div>
<label className="block text-sm font-medium text-gray-700">Details</label>
<pre className="mt-1 text-sm text-gray-900 bg-gray-50 p-3 rounded overflow-x-auto">
{JSON.stringify(selectedLog.details, null, 2)}
</pre>
</div>
)}
</div>
<div className="mt-6 flex justify-end">
<button
onClick={() => setShowDetails(false)}
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors"
>
Close
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default AuditLogsPage;

View File

@@ -0,0 +1,677 @@
import React, { useEffect, useState } from 'react';
import { Plus, Search, Edit, Trash2, X, Image as ImageIcon, Eye, EyeOff, Loader2 } from 'lucide-react';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
import { ConfirmationDialog } from '../../components/common';
import bannerServiceModule from '../../services/api/bannerService';
import type { Banner } from '../../services/api/bannerService';
// Extract functions from default export - workaround for TypeScript cache issue
// All functions are properly exported in bannerService.ts
const {
getAllBanners,
createBanner,
updateBanner,
deleteBanner,
uploadBannerImage,
} = bannerServiceModule as any;
const BannerManagementPage: React.FC = () => {
const [banners, setBanners] = useState<Banner[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingBanner, setEditingBanner] = useState<Banner | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; id: number | null }>({
show: false,
id: null,
});
const [filters, setFilters] = useState({
search: '',
position: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const itemsPerPage = 10;
const [formData, setFormData] = useState({
title: '',
description: '',
image_url: '',
link: '',
position: 'home',
display_order: 0,
is_active: true,
start_date: '',
end_date: '',
});
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [uploadingImage, setUploadingImage] = useState(false);
const [useFileUpload, setUseFileUpload] = useState(true);
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
fetchBanners();
}, [filters, currentPage]);
const fetchBanners = async () => {
try {
setLoading(true);
const response = await getAllBanners({
position: filters.position || undefined,
page: currentPage,
limit: itemsPerPage,
});
let allBanners = response.data?.banners || [];
// Filter by search if provided
if (filters.search) {
allBanners = allBanners.filter((banner: Banner) =>
banner.title.toLowerCase().includes(filters.search.toLowerCase())
);
}
setBanners(allBanners);
setTotalPages(Math.ceil(allBanners.length / itemsPerPage));
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load banners');
} finally {
setLoading(false);
}
};
const handleImageFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
toast.error('Please select an image file');
return;
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
toast.error('Image size must be less than 5MB');
return;
}
setImageFile(file);
// Create preview
const reader = new FileReader();
reader.onloadend = () => {
setImagePreview(reader.result as string);
};
reader.readAsDataURL(file);
// Upload image immediately
try {
setUploadingImage(true);
const response = await uploadBannerImage(file);
if (response.status === 'success' || response.success) {
setFormData({ ...formData, image_url: response.data.image_url });
toast.success('Image uploaded successfully');
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to upload image');
setImageFile(null);
setImagePreview(null);
} finally {
setUploadingImage(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate image URL or file
if (!formData.image_url && !imageFile) {
toast.error('Please upload an image or provide an image URL');
return;
}
try {
// If there's a file but no URL yet, upload it first
let imageUrl = formData.image_url;
if (imageFile && !imageUrl) {
setUploadingImage(true);
const uploadResponse = await uploadBannerImage(imageFile);
if (uploadResponse.status === 'success' || uploadResponse.success) {
imageUrl = uploadResponse.data.image_url;
} else {
throw new Error('Failed to upload image');
}
setUploadingImage(false);
}
const submitData = {
...formData,
image_url: imageUrl,
start_date: formData.start_date || undefined,
end_date: formData.end_date || undefined,
};
if (editingBanner) {
await updateBanner(editingBanner.id, submitData);
toast.success('Banner updated successfully');
} else {
await createBanner(submitData);
toast.success('Banner created successfully');
}
setShowModal(false);
resetForm();
fetchBanners();
} catch (error: any) {
toast.error(error.response?.data?.message || 'An error occurred');
setUploadingImage(false);
}
};
const handleEdit = (banner: Banner) => {
setEditingBanner(banner);
setFormData({
title: banner.title || '',
description: '',
image_url: banner.image_url || '',
link: banner.link || '',
position: banner.position || 'home',
display_order: banner.display_order || 0,
is_active: banner.is_active ?? true,
start_date: banner.start_date ? banner.start_date.split('T')[0] : '',
end_date: banner.end_date ? banner.end_date.split('T')[0] : '',
});
setImageFile(null);
// Normalize image URL for preview (handle both relative and absolute URLs)
const previewUrl = banner.image_url
? (banner.image_url.startsWith('http')
? banner.image_url
: `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}${banner.image_url}`)
: null;
setImagePreview(previewUrl);
setUseFileUpload(false); // When editing, show URL by default
setShowModal(true);
};
const handleDelete = async () => {
if (!deleteConfirm.id) return;
try {
await deleteBanner(deleteConfirm.id);
toast.success('Banner deleted successfully');
setDeleteConfirm({ show: false, id: null });
fetchBanners();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to delete banner');
}
};
const resetForm = () => {
setFormData({
title: '',
description: '',
image_url: '',
link: '',
position: 'home',
display_order: 0,
is_active: true,
start_date: '',
end_date: '',
});
setImageFile(null);
setImagePreview(null);
setUseFileUpload(true);
setEditingBanner(null);
};
const toggleActive = async (banner: Banner) => {
try {
await updateBanner(banner.id, {
is_active: !banner.is_active,
});
toast.success(`Banner ${!banner.is_active ? 'activated' : 'deactivated'}`);
fetchBanners();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to update banner');
}
};
if (loading && banners.length === 0) {
return <Loading fullScreen text="Loading banners..." />;
}
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8 flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Banner Management</h1>
<p className="text-gray-600">Manage promotional banners and advertisements</p>
</div>
<button
onClick={() => {
resetForm();
setShowModal(true);
}}
className="flex items-center space-x-2 px-4 py-2 bg-[#d4af37] text-white rounded-lg hover:bg-[#c9a227] transition-colors"
>
<Plus className="w-5 h-5" />
<span>Add Banner</span>
</button>
</div>
{/* Filters */}
<div className="mb-6 bg-white p-4 rounded-lg shadow-md">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Search
</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Search by title..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Position
</label>
<select
value={filters.position}
onChange={(e) => setFilters({ ...filters, position: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
>
<option value="">All Positions</option>
<option value="home">Home</option>
<option value="rooms">Rooms</option>
<option value="about">About</option>
</select>
</div>
</div>
</div>
{/* Banners Table */}
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Image
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Title
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Position
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Order
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{banners.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">
No banners found
</td>
</tr>
) : (
banners.map((banner) => (
<tr key={banner.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
{banner.image_url ? (
<img
src={banner.image_url}
alt={banner.title}
className="w-16 h-16 object-cover rounded"
/>
) : (
<div className="w-16 h-16 bg-gray-200 rounded flex items-center justify-center">
<ImageIcon className="w-8 h-8 text-gray-400" />
</div>
)}
</td>
<td className="px-6 py-4">
<div className="text-sm font-medium text-gray-900">{banner.title}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="px-2 py-1 text-xs font-medium rounded bg-blue-100 text-blue-800">
{banner.position}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{banner.display_order}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={() => toggleActive(banner)}
className={`flex items-center space-x-1 px-2 py-1 rounded text-xs font-medium ${
banner.is_active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{banner.is_active ? (
<>
<Eye className="w-4 h-4" />
<span>Active</span>
</>
) : (
<>
<EyeOff className="w-4 h-4" />
<span>Inactive</span>
</>
)}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
<button
onClick={() => handleEdit(banner)}
className="text-[#d4af37] hover:text-[#c9a227]"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => setDeleteConfirm({ show: true, id: banner.id })}
className="text-red-600 hover:text-red-800"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-6">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
{/* Create/Edit Modal */}
{showModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200 flex justify-between items-center">
<h2 className="text-2xl font-bold text-gray-900">
{editingBanner ? 'Edit Banner' : 'Create Banner'}
</h2>
<button
onClick={() => {
setShowModal(false);
resetForm();
}}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Title *
</label>
<input
type="text"
required
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
{/* Image Upload/URL Section */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Banner Image *
</label>
{/* Toggle between file upload and URL */}
<div className="flex space-x-4 mb-3">
<button
type="button"
onClick={() => {
setUseFileUpload(true);
setImageFile(null);
setImagePreview(null);
setFormData({ ...formData, image_url: '' });
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
useFileUpload
? 'bg-[#d4af37] text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
Upload File
</button>
<button
type="button"
onClick={() => {
setUseFileUpload(false);
setImageFile(null);
setImagePreview(null);
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
!useFileUpload
? 'bg-[#d4af37] text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
Use URL
</button>
</div>
{useFileUpload ? (
<div>
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100 transition-colors">
{uploadingImage ? (
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<Loader2 className="w-8 h-8 text-[#d4af37] animate-spin mb-2" />
<p className="text-sm text-gray-500">Uploading...</p>
</div>
) : imagePreview ? (
<div className="relative w-full h-full">
<img
src={imagePreview}
alt="Preview"
className="w-full h-32 object-cover rounded-lg"
/>
<button
type="button"
onClick={() => {
setImageFile(null);
setImagePreview(null);
setFormData({ ...formData, image_url: '' });
}}
className="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<ImageIcon className="w-10 h-10 mb-2 text-gray-400" />
<p className="mb-2 text-sm text-gray-500">
<span className="font-semibold">Click to upload</span> or drag and drop
</p>
<p className="text-xs text-gray-500">PNG, JPG, GIF up to 5MB</p>
</div>
)}
<input
type="file"
className="hidden"
accept="image/*"
onChange={handleImageFileChange}
disabled={uploadingImage}
/>
</label>
</div>
) : (
<div>
<input
type="url"
required={!imageFile}
value={formData.image_url}
onChange={(e) => {
setFormData({ ...formData, image_url: e.target.value });
setImagePreview(e.target.value || null);
}}
placeholder="https://example.com/image.jpg"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
{imagePreview && (
<div className="mt-3 relative">
<img
src={imagePreview}
alt="Preview"
className="w-full h-32 object-cover rounded-lg border border-gray-300"
onError={() => setImagePreview(null)}
/>
</div>
)}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Position
</label>
<select
value={formData.position}
onChange={(e) => setFormData({ ...formData, position: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
>
<option value="home">Home</option>
<option value="rooms">Rooms</option>
<option value="about">About</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Display Order
</label>
<input
type="number"
value={formData.display_order}
onChange={(e) => setFormData({ ...formData, display_order: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Link URL
</label>
<input
type="url"
value={formData.link}
onChange={(e) => setFormData({ ...formData, link: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Start Date
</label>
<input
type="date"
value={formData.start_date}
onChange={(e) => setFormData({ ...formData, start_date: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
End Date
</label>
<input
type="date"
value={formData.end_date}
onChange={(e) => setFormData({ ...formData, end_date: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
</div>
{editingBanner && (
<div>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
className="rounded border-gray-300 text-[#d4af37] focus:ring-[#d4af37]"
/>
<span className="text-sm font-medium text-gray-700">Active</span>
</label>
</div>
)}
<div className="flex justify-end space-x-4 pt-4">
<button
type="button"
onClick={() => {
setShowModal(false);
resetForm();
}}
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="px-6 py-2 bg-[#d4af37] text-white rounded-lg hover:bg-[#c9a227]"
>
{editingBanner ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Confirmation Dialog */}
<ConfirmationDialog
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, id: null })}
onConfirm={handleDelete}
title="Delete Banner"
message="Are you sure you want to delete this banner? This action cannot be undone."
confirmText="Delete"
cancelText="Cancel"
variant="danger"
/>
</div>
);
};
export default BannerManagementPage;

View File

@@ -97,13 +97,13 @@ const BookingManagementPage: React.FC = () => {
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Booking Management</h1>
<p className="text-gray-500 mt-1">Manage bookings</p>
<div className="space-y-8">
<div className="animate-fade-in">
<h1 className="enterprise-section-title">Booking Management</h1>
<p className="enterprise-section-subtitle mt-2">Manage and track all hotel bookings</p>
</div>
<div className="bg-white rounded-lg shadow-md p-4">
<div className="enterprise-card p-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
@@ -112,13 +112,13 @@ const BookingManagementPage: React.FC = () => {
placeholder="Search by booking number, guest name..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
className="enterprise-input pl-10"
/>
</div>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
className="enterprise-input"
>
<option value="">All statuses</option>
<option value="pending">Pending confirmation</option>
@@ -130,34 +130,20 @@ const BookingManagementPage: React.FC = () => {
</div>
</div>
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<div className="enterprise-card overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
<table className="enterprise-table">
<thead>
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Booking Number
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Customer
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Room
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Check-in/out
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Total Price
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Actions
</th>
<th>Booking Number</th>
<th>Customer</th>
<th>Room</th>
<th>Check-in/out</th>
<th>Total Price</th>
<th>Status</th>
<th className="text-right">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody>
{bookings.map((booking) => (
<tr key={booking.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
@@ -242,11 +228,14 @@ const BookingManagementPage: React.FC = () => {
{/* Detail Modal */}
{showDetailModal && selectedBooking && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Booking Details</h2>
<button onClick={() => setShowDetailModal(false)} className="text-gray-500 hover:text-gray-700">
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in">
<div className="enterprise-card p-8 w-full max-w-2xl max-h-[90vh] overflow-y-auto animate-scale-in">
<div className="flex justify-between items-center mb-6 pb-4 border-b border-gray-200">
<h2 className="text-2xl font-bold text-gray-900">Booking Details</h2>
<button
onClick={() => setShowDetailModal(false)}
className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
>
</button>
</div>
@@ -296,10 +285,10 @@ const BookingManagementPage: React.FC = () => {
</div>
)}
</div>
<div className="mt-6 flex justify-end">
<div className="mt-8 flex justify-end">
<button
onClick={() => setShowDetailModal(false)}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
className="btn-enterprise-secondary"
>
Close
</button>

View File

@@ -0,0 +1,357 @@
import React, { useEffect, useState } from 'react';
import { Shield, SlidersHorizontal, Info, Save, Globe } from 'lucide-react';
import { toast } from 'react-toastify';
import adminPrivacyService, {
CookieIntegrationSettings,
CookieIntegrationSettingsResponse,
CookiePolicySettings,
CookiePolicySettingsResponse,
} from '../../services/api/adminPrivacyService';
import { Loading } from '../../components/common';
const CookieSettingsPage: React.FC = () => {
const [policy, setPolicy] = useState<CookiePolicySettings>({
analytics_enabled: true,
marketing_enabled: true,
preferences_enabled: true,
});
const [integrations, setIntegrations] = useState<CookieIntegrationSettings>({
ga_measurement_id: '',
fb_pixel_id: '',
});
const [policyMeta, setPolicyMeta] = useState<
Pick<CookiePolicySettingsResponse, 'updated_at' | 'updated_by'> | null
>(null);
const [integrationMeta, setIntegrationMeta] = useState<
Pick<CookieIntegrationSettingsResponse, 'updated_at' | 'updated_by'> | null
>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const loadSettings = async () => {
try {
setLoading(true);
const [policyRes, integrationRes] = await Promise.all([
adminPrivacyService.getCookiePolicy(),
adminPrivacyService.getIntegrations(),
]);
setPolicy(policyRes.data);
setPolicyMeta({
updated_at: policyRes.updated_at,
updated_by: policyRes.updated_by,
});
setIntegrations(integrationRes.data || {});
setIntegrationMeta({
updated_at: integrationRes.updated_at,
updated_by: integrationRes.updated_by,
});
} catch (error: any) {
toast.error(error.message || 'Failed to load cookie & integration settings');
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadSettings();
}, []);
const handleToggle = (key: keyof CookiePolicySettings) => {
setPolicy((prev) => ({
...prev,
[key]: !prev[key],
}));
};
const handleSave = async () => {
try {
setSaving(true);
const [policyRes, integrationRes] = await Promise.all([
adminPrivacyService.updateCookiePolicy(policy),
adminPrivacyService.updateIntegrations(integrations),
]);
setPolicy(policyRes.data);
setPolicyMeta({
updated_at: policyRes.updated_at,
updated_by: policyRes.updated_by,
});
setIntegrations(integrationRes.data || {});
setIntegrationMeta({
updated_at: integrationRes.updated_at,
updated_by: integrationRes.updated_by,
});
toast.success('Cookie policy and integrations updated successfully');
} catch (error: any) {
toast.error(error.message || 'Failed to update cookie settings');
} finally {
setSaving(false);
}
};
const handleSaveIntegrations = async () => {
try {
setSaving(true);
const integrationRes = await adminPrivacyService.updateIntegrations(
integrations
);
setIntegrations(integrationRes.data || {});
setIntegrationMeta({
updated_at: integrationRes.updated_at,
updated_by: integrationRes.updated_by,
});
toast.success('Integration IDs updated successfully');
} catch (error: any) {
toast.error(error.message || 'Failed to update integration IDs');
} finally {
setSaving(false);
}
};
if (loading) {
return <Loading fullScreen={false} text="Loading cookie policy..." />;
}
return (
<div className="space-y-8">
{/* Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Shield className="w-6 h-6 text-amber-500" />
<h1 className="enterprise-section-title">Cookie & Privacy Controls</h1>
</div>
<p className="enterprise-section-subtitle max-w-2xl">
Define which cookie categories are allowed in the application. These
settings control which types of cookies your users can consent to.
</p>
</div>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="btn-enterprise-primary inline-flex items-center gap-2"
>
<Save className={`w-4 h-4 ${saving ? 'animate-pulse' : ''}`} />
{saving ? 'Saving...' : 'Save changes'}
</button>
</div>
{/* Info card */}
<div className="enterprise-card flex gap-4 p-4 sm:p-5">
<div className="mt-1">
<Info className="w-5 h-5 text-amber-500" />
</div>
<div className="space-y-1 text-sm text-gray-700">
<p className="font-semibold text-gray-900">
How these settings affect the guest experience
</p>
<p>
Disabling a category here prevents it from being offered to guests as
part of the cookie consent flow. For example, if marketing cookies are
disabled, the website should not load marketing pixels even if a guest
previously opted in.
</p>
{policyMeta?.updated_at && (
<p className="text-xs text-gray-500">
Last updated on{' '}
{new Date(policyMeta.updated_at).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
})}{' '}
{policyMeta.updated_by ? `by ${policyMeta.updated_by}` : ''}
</p>
)}
</div>
</div>
{/* Toggles */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="enterprise-card p-5 space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-semibold text-gray-900 flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4 text-emerald-500" />
Analytics cookies
</p>
<p className="text-xs text-gray-500 mt-1">
Anonymous traffic and performance measurement (e.g. page views,
conversion funnels).
</p>
</div>
<button
type="button"
onClick={() => handleToggle('analytics_enabled')}
className={`relative inline-flex h-7 w-12 items-center rounded-full transition ${
policy.analytics_enabled ? 'bg-emerald-500' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
policy.analytics_enabled ? 'translate-x-5' : 'translate-x-1'
}`}
/>
</button>
</div>
<p className="text-[11px] text-gray-500">
When disabled, analytics tracking scripts should not be executed,
regardless of user consent.
</p>
</div>
<div className="enterprise-card p-5 space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-semibold text-gray-900 flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4 text-pink-500" />
Marketing cookies
</p>
<p className="text-xs text-gray-500 mt-1">
Personalised offers, remarketing campaigns, and external ad
networks.
</p>
</div>
<button
type="button"
onClick={() => handleToggle('marketing_enabled')}
className={`relative inline-flex h-7 w-12 items-center rounded-full transition ${
policy.marketing_enabled ? 'bg-pink-500' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
policy.marketing_enabled ? 'translate-x-5' : 'translate-x-1'
}`}
/>
</button>
</div>
<p className="text-[11px] text-gray-500">
When disabled, do not load any marketing pixels or share data with ad
platforms.
</p>
</div>
<div className="enterprise-card p-5 space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-semibold text-gray-900 flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4 text-indigo-500" />
Preference cookies
</p>
<p className="text-xs text-gray-500 mt-1">
Remember guest choices like language, currency, and layout.
</p>
</div>
<button
type="button"
onClick={() => handleToggle('preferences_enabled')}
className={`relative inline-flex h-7 w-12 items-center rounded-full transition ${
policy.preferences_enabled ? 'bg-indigo-500' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
policy.preferences_enabled ? 'translate-x-5' : 'translate-x-1'
}`}
/>
</button>
</div>
<p className="text-[11px] text-gray-500">
When disabled, the application should avoid persisting non-essential
preferences client-side.
</p>
</div>
</div>
{/* Integration IDs */}
<div className="enterprise-card p-5 space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-blue-500" />
<div>
<p className="font-semibold text-gray-900">
Third-party integrations (IDs only)
</p>
<p className="text-xs text-gray-500">
Configure IDs for supported analytics and marketing platforms. The
application will only load these when both the policy and user consent
allow it.
</p>
</div>
</div>
<div className="flex flex-col items-start gap-2 md:items-end">
{integrationMeta?.updated_at && (
<p className="text-[11px] text-gray-500">
Last changed{' '}
{new Date(integrationMeta.updated_at).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
})}{' '}
{integrationMeta.updated_by ? `by ${integrationMeta.updated_by}` : ''}
</p>
)}
<button
type="button"
onClick={handleSaveIntegrations}
disabled={saving}
className="btn-enterprise-secondary inline-flex items-center gap-1.5 px-3 py-1.5 text-xs"
>
<Save className="w-3.5 h-3.5" />
{saving ? 'Saving IDs...' : 'Save integration IDs'}
</button>
</div>
</div>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-800">
Google Analytics 4 Measurement ID
</label>
<input
type="text"
value={integrations.ga_measurement_id || ''}
onChange={(e) =>
setIntegrations((prev) => ({
...prev,
ga_measurement_id: e.target.value || undefined,
}))
}
placeholder="G-XXXXXXXXXX"
className="enterprise-input text-sm"
/>
<p className="text-[11px] text-gray-500">
Example: <code className="font-mono">G-ABCDE12345</code>. This is used to
load GA4 via gtag.js when analytics cookies are allowed.
</p>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-800">
Meta (Facebook) Pixel ID
</label>
<input
type="text"
value={integrations.fb_pixel_id || ''}
onChange={(e) =>
setIntegrations((prev) => ({
...prev,
fb_pixel_id: e.target.value || undefined,
}))
}
placeholder="123456789012345"
className="enterprise-input text-sm"
/>
<p className="text-[11px] text-gray-500">
Numeric ID from your Meta Pixel. The application will only fire pixel
events when marketing cookies are allowed.
</p>
</div>
</div>
</div>
</div>
);
};
export default CookieSettingsPage;

View File

@@ -5,81 +5,108 @@ import {
Hotel,
DollarSign,
Calendar,
TrendingUp
TrendingUp,
RefreshCw,
TrendingDown
} from 'lucide-react';
import { reportService, ReportData } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import { Loading, EmptyState } from '../../components/common';
import { formatCurrency, formatDate } from '../../utils/format';
import { useAsync } from '../../hooks/useAsync';
const DashboardPage: React.FC = () => {
const [stats, setStats] = useState<ReportData | null>(null);
const [loading, setLoading] = useState(true);
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],
});
useEffect(() => {
fetchDashboardData();
}, [dateRange]);
const fetchDashboardData = async () => {
try {
setLoading(true);
const response = await reportService.getReports({
from: dateRange.from,
to: dateRange.to,
});
setStats(response.data);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load dashboard data');
} finally {
setLoading(false);
}
return response.data;
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(amount);
const { data: stats, loading, error, execute } = useAsync<ReportData>(
fetchDashboardData,
{
immediate: true,
onError: (error: any) => {
toast.error(error.message || 'Unable to load dashboard data');
}
}
);
useEffect(() => {
execute();
}, [dateRange]); // eslint-disable-line react-hooks/exhaustive-deps
const handleRefresh = () => {
execute();
};
if (loading) {
return <Loading />;
return <Loading fullScreen text="Loading dashboard..." />;
}
if (error || !stats) {
return (
<div className="space-y-6">
<EmptyState
title="Unable to Load Dashboard"
description={error?.message || 'Something went wrong. Please try again.'}
action={{
label: 'Retry',
onClick: handleRefresh
}}
/>
</div>
);
}
return (
<div className="space-y-6">
<div className="space-y-8">
{/* Header */}
<div className="flex justify-between items-center">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-6 animate-fade-in">
<div>
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-500 mt-1">Hotel operations overview</p>
<h1 className="enterprise-section-title">Dashboard</h1>
<p className="enterprise-section-subtitle mt-2">Hotel operations overview and analytics</p>
</div>
{/* Date Range Filter */}
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
<div className="flex gap-3 items-center">
<input
type="date"
value={dateRange.from}
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
className="enterprise-input text-sm"
/>
<span className="text-gray-500">to</span>
<span className="text-gray-500 font-medium">to</span>
<input
type="date"
value={dateRange.to}
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
className="enterprise-input text-sm"
/>
</div>
<button
onClick={handleRefresh}
disabled={loading}
className="btn-enterprise-primary flex items-center gap-2 text-sm"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Total Revenue */}
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-green-500">
<div className="enterprise-stat-card border-l-4 border-green-500 animate-slide-up" style={{ animationDelay: '0.1s' }}>
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm font-medium">Total Revenue</p>
@@ -91,15 +118,16 @@ const DashboardPage: React.FC = () => {
<DollarSign className="w-6 h-6 text-green-600" />
</div>
</div>
{/* Trend indicator - can be enhanced with actual comparison data */}
<div className="flex items-center mt-4 text-sm">
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
<span className="text-green-600 font-medium">+12.5%</span>
<span className="text-gray-500 ml-2">compared to last month</span>
<span className="text-green-600 font-medium">Active</span>
<span className="text-gray-500 ml-2">All time revenue</span>
</div>
</div>
{/* Total Bookings */}
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-blue-500">
<div className="enterprise-stat-card border-l-4 border-blue-500 animate-slide-up" style={{ animationDelay: '0.2s' }}>
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm font-medium">Total Bookings</p>
@@ -112,14 +140,14 @@ const DashboardPage: React.FC = () => {
</div>
</div>
<div className="flex items-center mt-4 text-sm">
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
<span className="text-green-600 font-medium">+8.2%</span>
<span className="text-gray-500 ml-2">compared to last month</span>
<span className="text-gray-500">
{stats.total_bookings > 0 ? 'Total bookings recorded' : 'No bookings yet'}
</span>
</div>
</div>
{/* Available Rooms */}
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-purple-500">
<div className="enterprise-stat-card border-l-4 border-purple-500 animate-slide-up" style={{ animationDelay: '0.3s' }}>
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm font-medium">Available Rooms</p>
@@ -139,7 +167,7 @@ const DashboardPage: React.FC = () => {
</div>
{/* Total Customers */}
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-orange-500">
<div className="enterprise-stat-card border-l-4 border-orange-500 animate-slide-up" style={{ animationDelay: '0.4s' }}>
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 text-sm font-medium">Customers</p>
@@ -152,9 +180,9 @@ const DashboardPage: React.FC = () => {
</div>
</div>
<div className="flex items-center mt-4 text-sm">
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
<span className="text-green-600 font-medium">+15.3%</span>
<span className="text-gray-500 ml-2">new customers</span>
<span className="text-gray-500">
Unique customers with bookings
</span>
</div>
</div>
</div>
@@ -162,45 +190,57 @@ const DashboardPage: React.FC = () => {
{/* Charts Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Revenue Chart */}
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900">Daily Revenue</h2>
<BarChart3 className="w-5 h-5 text-gray-400" />
<div className="enterprise-card p-6 animate-fade-in">
<div className="flex items-center justify-between mb-6 pb-3 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">Daily Revenue</h2>
<div className="p-2 bg-blue-100 rounded-lg">
<BarChart3 className="w-5 h-5 text-blue-600" />
</div>
</div>
{stats?.revenue_by_date && stats.revenue_by_date.length > 0 ? (
<div className="space-y-3">
{stats.revenue_by_date.slice(0, 7).map((item, index) => (
{stats.revenue_by_date.slice(0, 7).map((item, index) => {
const maxRevenue = Math.max(...stats.revenue_by_date!.map(r => r.revenue));
return (
<div key={index} className="flex items-center">
<span className="text-sm text-gray-600 w-24">
{new Date(item.date).toLocaleDateString('en-US')}
{formatDate(item.date, 'short')}
</span>
<div className="flex-1 mx-3">
<div className="bg-gray-200 rounded-full h-4 overflow-hidden">
<div
className="bg-blue-500 h-4 rounded-full transition-all"
style={{
width: `${Math.min((item.revenue / (stats.revenue_by_date?.[0]?.revenue || 1)) * 100, 100)}%`,
width: `${Math.min((item.revenue / (maxRevenue || 1)) * 100, 100)}%`,
}}
/>
</div>
</div>
<span className="text-sm font-semibold text-gray-900 w-32 text-right">
{formatCurrency(item.revenue)}
{formatCurrency(item.revenue, 'VND')}
</span>
</div>
))}
);
})}
</div>
) : (
<p className="text-gray-500 text-center py-8">No data available</p>
<EmptyState
title="No Revenue Data"
description="No revenue data available for the selected date range"
/>
)}
</div>
{/* Bookings by Status */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Booking Status</h2>
{stats?.bookings_by_status ? (
<div className="enterprise-card p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
<div className="flex items-center justify-between mb-6 pb-3 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">Booking Status</h2>
</div>
{stats?.bookings_by_status && Object.keys(stats.bookings_by_status).length > 0 ? (
<div className="space-y-4">
{Object.entries(stats.bookings_by_status).map(([status, count]) => {
{Object.entries(stats.bookings_by_status)
.filter(([_, count]) => count > 0)
.map(([status, count]) => {
const statusColors: Record<string, string> = {
pending: 'bg-yellow-500',
confirmed: 'bg-blue-500',
@@ -218,8 +258,8 @@ const DashboardPage: React.FC = () => {
return (
<div key={status} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${statusColors[status]}`} />
<span className="text-gray-700">{statusLabels[status]}</span>
<div className={`w-3 h-3 rounded-full ${statusColors[status] || 'bg-gray-500'}`} />
<span className="text-gray-700">{statusLabels[status] || status}</span>
</div>
<span className="font-semibold text-gray-900">{count}</span>
</div>
@@ -227,7 +267,10 @@ const DashboardPage: React.FC = () => {
})}
</div>
) : (
<p className="text-gray-500 text-center py-8">No data available</p>
<EmptyState
title="No Booking Data"
description="No booking status data available"
/>
)}
</div>
</div>
@@ -235,51 +278,57 @@ const DashboardPage: React.FC = () => {
{/* Top Rooms and Services */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Top Rooms */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Top Booked Rooms</h2>
<div className="enterprise-card p-6 animate-fade-in">
<h2 className="text-xl font-bold text-gray-900 mb-6 pb-3 border-b border-gray-200">Top Booked Rooms</h2>
{stats?.top_rooms && stats.top_rooms.length > 0 ? (
<div className="space-y-3">
{stats.top_rooms.map((room, index) => (
<div key={room.room_id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-3">
<span className="flex items-center justify-center w-8 h-8 bg-blue-500 text-white rounded-full font-bold">
<div key={room.room_id} className="flex items-center justify-between p-4 bg-gradient-to-r from-gray-50 to-gray-100/50 rounded-xl hover:from-blue-50 hover:to-indigo-50 transition-all duration-300 border border-gray-200 hover:border-blue-200 hover:shadow-md">
<div className="flex items-center gap-4">
<span className="flex items-center justify-center w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 text-white rounded-xl font-bold shadow-lg shadow-blue-500/30">
{index + 1}
</span>
<div>
<p className="font-medium text-gray-900">Room {room.room_number}</p>
<p className="text-sm text-gray-500">{room.bookings} bookings</p>
<p className="text-sm text-gray-500">{room.bookings} booking{room.bookings !== 1 ? 's' : ''}</p>
</div>
</div>
<span className="font-semibold text-green-600">
{formatCurrency(room.revenue)}
{formatCurrency(room.revenue, 'VND')}
</span>
</div>
))}
</div>
) : (
<p className="text-gray-500 text-center py-8">No data available</p>
<EmptyState
title="No Room Data"
description="No room booking data available for the selected date range"
/>
)}
</div>
{/* Service Usage */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Services Used</h2>
<div className="enterprise-card p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
<h2 className="text-xl font-bold text-gray-900 mb-6 pb-3 border-b border-gray-200">Services Used</h2>
{stats?.service_usage && stats.service_usage.length > 0 ? (
<div className="space-y-3">
{stats.service_usage.map((service) => (
<div key={service.service_id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div key={service.service_id} className="flex items-center justify-between p-4 bg-gradient-to-r from-gray-50 to-gray-100/50 rounded-xl hover:from-purple-50 hover:to-indigo-50 transition-all duration-300 border border-gray-200 hover:border-purple-200 hover:shadow-md">
<div>
<p className="font-medium text-gray-900">{service.service_name}</p>
<p className="text-sm text-gray-500">{service.usage_count} times used</p>
<p className="text-sm text-gray-500">{service.usage_count} time{service.usage_count !== 1 ? 's' : ''} used</p>
</div>
<span className="font-semibold text-purple-600">
{formatCurrency(service.total_revenue)}
{formatCurrency(service.total_revenue, 'VND')}
</span>
</div>
))}
</div>
) : (
<p className="text-gray-500 text-center py-8">No data available</p>
<EmptyState
title="No Service Data"
description="No service usage data available for the selected date range"
/>
)}
</div>
</div>

View File

@@ -0,0 +1,376 @@
import React, { useState, useEffect } from 'react';
import {
Calendar,
DollarSign,
Users,
Hotel,
TrendingUp,
Download,
Filter,
BarChart3,
PieChart
} from 'lucide-react';
import { toast } from 'react-toastify';
import { Loading, EmptyState } from '../../components/common';
import { useAsync } from '../../hooks/useAsync';
import { reportService, ReportData } from '../../services/api/reportService';
import { formatCurrency, formatDate } from '../../utils/format';
const ReportsPage: React.FC = () => {
const [dateRange, setDateRange] = useState({
from: '',
to: '',
});
const [reportType, setReportType] = useState<'daily' | 'weekly' | 'monthly' | 'yearly' | ''>('');
const fetchReports = async (): Promise<ReportData> => {
const params: any = {};
if (dateRange.from) params.from = dateRange.from;
if (dateRange.to) params.to = dateRange.to;
if (reportType) params.type = reportType;
const response = await reportService.getReports(params);
return response.data;
};
const {
data: reportData,
loading,
error,
execute: refetchReports
} = useAsync<ReportData>(fetchReports, {
immediate: true,
onError: (error: any) => {
toast.error(error.message || 'Unable to load reports');
}
});
const handleExport = async () => {
try {
const params: any = {};
if (dateRange.from) params.from = dateRange.from;
if (dateRange.to) params.to = dateRange.to;
if (reportType) params.type = reportType;
const blob = await reportService.exportReport(params);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `report-${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Report exported successfully');
} catch (error: any) {
toast.error(error.message || 'Failed to export report');
}
};
const handleFilter = () => {
refetchReports();
};
if (loading && !reportData) {
return <Loading fullScreen text="Loading reports..." />;
}
if (error && !reportData) {
return (
<div className="container mx-auto px-4 py-8">
<EmptyState
title="Unable to Load Reports"
description={error.message || 'Something went wrong. Please try again.'}
action={{
label: 'Retry',
onClick: refetchReports
}}
/>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8 flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Reports & Analytics</h1>
<p className="text-gray-600">View comprehensive reports and statistics</p>
</div>
<button
onClick={handleExport}
className="flex items-center space-x-2 px-4 py-2 bg-[#d4af37] text-white rounded-lg hover:bg-[#c9a227] transition-colors"
>
<Download className="w-5 h-5" />
<span>Export CSV</span>
</button>
</div>
{/* Filters */}
<div className="mb-6 bg-white p-4 rounded-lg shadow-md">
<div className="flex items-center space-x-2 mb-4">
<Filter className="w-5 h-5 text-gray-500" />
<h3 className="text-lg font-semibold text-gray-900">Filters</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
From Date
</label>
<input
type="date"
value={dateRange.from}
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
To Date
</label>
<input
type="date"
value={dateRange.to}
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Report Type
</label>
<select
value={reportType}
onChange={(e) => setReportType(e.target.value as any)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-[#d4af37]"
>
<option value="">All</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
</div>
<div className="flex items-end">
<button
onClick={handleFilter}
className="w-full px-4 py-2 bg-[#d4af37] text-white rounded-lg hover:bg-[#c9a227] transition-colors"
>
Apply Filters
</button>
</div>
</div>
</div>
{reportData && (
<>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="bg-white p-6 rounded-lg shadow-md border-l-4 border-blue-500">
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-blue-100 rounded-lg">
<Calendar className="w-6 h-6 text-blue-600" />
</div>
<TrendingUp className="w-5 h-5 text-green-500" />
</div>
<h3 className="text-gray-500 text-sm font-medium mb-1">
Total Bookings
</h3>
<p className="text-3xl font-bold text-gray-800">
{reportData.total_bookings || 0}
</p>
</div>
<div className="bg-white p-6 rounded-lg shadow-md border-l-4 border-green-500">
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-green-100 rounded-lg">
<DollarSign className="w-6 h-6 text-green-600" />
</div>
<TrendingUp className="w-5 h-5 text-green-500" />
</div>
<h3 className="text-gray-500 text-sm font-medium mb-1">
Total Revenue
</h3>
<p className="text-3xl font-bold text-gray-800">
{formatCurrency(reportData.total_revenue || 0)}
</p>
</div>
<div className="bg-white p-6 rounded-lg shadow-md border-l-4 border-purple-500">
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-purple-100 rounded-lg">
<Users className="w-6 h-6 text-purple-600" />
</div>
<TrendingUp className="w-5 h-5 text-green-500" />
</div>
<h3 className="text-gray-500 text-sm font-medium mb-1">
Total Customers
</h3>
<p className="text-3xl font-bold text-gray-800">
{reportData.total_customers || 0}
</p>
</div>
<div className="bg-white p-6 rounded-lg shadow-md border-l-4 border-orange-500">
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-orange-100 rounded-lg">
<Hotel className="w-6 h-6 text-orange-600" />
</div>
</div>
<h3 className="text-gray-500 text-sm font-medium mb-1">
Available Rooms
</h3>
<p className="text-3xl font-bold text-gray-800">
{reportData.available_rooms || 0}
</p>
<p className="text-sm text-gray-500 mt-1">
{reportData.occupied_rooms || 0} occupied
</p>
</div>
</div>
{/* Bookings by Status */}
{reportData.bookings_by_status && (
<div className="bg-white p-6 rounded-lg shadow-md mb-8">
<div className="flex items-center space-x-2 mb-6">
<PieChart className="w-5 h-5 text-gray-500" />
<h2 className="text-xl font-bold text-gray-900">Bookings by Status</h2>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{Object.entries(reportData.bookings_by_status).map(([status, count]) => (
<div key={status} className="text-center p-4 bg-gray-50 rounded-lg">
<p className="text-2xl font-bold text-gray-800">{count}</p>
<p className="text-sm text-gray-600 capitalize mt-1">{status.replace('_', ' ')}</p>
</div>
))}
</div>
</div>
)}
{/* Revenue by Date */}
{reportData.revenue_by_date && reportData.revenue_by_date.length > 0 && (
<div className="bg-white p-6 rounded-lg shadow-md mb-8">
<div className="flex items-center space-x-2 mb-6">
<BarChart3 className="w-5 h-5 text-gray-500" />
<h2 className="text-xl font-bold text-gray-900">Revenue by Date</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Bookings
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Revenue
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{reportData.revenue_by_date.map((item, index) => (
<tr key={index} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatDate(new Date(item.date), 'short')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.bookings}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{formatCurrency(item.revenue)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Top Rooms */}
{reportData.top_rooms && reportData.top_rooms.length > 0 && (
<div className="bg-white p-6 rounded-lg shadow-md mb-8">
<h2 className="text-xl font-bold text-gray-900 mb-6">Top Performing Rooms</h2>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Room Number
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Bookings
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Revenue
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{reportData.top_rooms.map((room) => (
<tr key={room.room_id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{room.room_number}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{room.bookings}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{formatCurrency(room.revenue)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Service Usage */}
{reportData.service_usage && reportData.service_usage.length > 0 && (
<div className="bg-white p-6 rounded-lg shadow-md">
<h2 className="text-xl font-bold text-gray-900 mb-6">Service Usage</h2>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Service Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Usage Count
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Total Revenue
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{reportData.service_usage.map((service) => (
<tr key={service.service_id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{service.service_name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{service.usage_count}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{formatCurrency(service.total_revenue)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
</div>
);
};
export default ReportsPage;

View File

@@ -8,3 +8,4 @@ export { default as ReviewManagementPage } from './ReviewManagementPage';
export { default as PromotionManagementPage } from './PromotionManagementPage';
export { default as CheckInPage } from './CheckInPage';
export { default as CheckOutPage } from './CheckOutPage';
export { default as AuditLogsPage } from './AuditLogsPage';

Some files were not shown because too many files have changed in this diff Show More