update
This commit is contained in:
@@ -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
|
||||
|
||||
BIN
Backend/alembic/__pycache__/env.cpython-312.pyc
Normal file
BIN
Backend/alembic/__pycache__/env.cpython-312.pyc
Normal file
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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 ###
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
|
||||
91
Backend/reset_user_passwords.py
Normal file
91
Backend/reset_user_passwords.py
Normal 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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
Backend/src/config/__pycache__/logging_config.cpython-312.pyc
Normal file
BIN
Backend/src/config/__pycache__/logging_config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/config/__pycache__/settings.cpython-312.pyc
Normal file
BIN
Backend/src/config/__pycache__/settings.cpython-312.pyc
Normal file
Binary file not shown.
@@ -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()
|
||||
|
||||
|
||||
96
Backend/src/config/logging_config.py
Normal file
96
Backend/src/config/logging_config.py
Normal 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)
|
||||
|
||||
119
Backend/src/config/settings.py
Normal file
119
Backend/src/config/settings.py
Normal 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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Backend/src/middleware/__pycache__/request_id.cpython-312.pyc
Normal file
BIN
Backend/src/middleware/__pycache__/request_id.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/middleware/__pycache__/security.cpython-312.pyc
Normal file
BIN
Backend/src/middleware/__pycache__/security.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/middleware/__pycache__/timeout.cpython-312.pyc
Normal file
BIN
Backend/src/middleware/__pycache__/timeout.cpython-312.pyc
Normal file
Binary file not shown.
@@ -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
|
||||
|
||||
89
Backend/src/middleware/cookie_consent.py
Normal file
89
Backend/src/middleware/cookie_consent.py
Normal 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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
65
Backend/src/middleware/request_id.py
Normal file
65
Backend/src/middleware/request_id.py
Normal 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
|
||||
|
||||
57
Backend/src/middleware/security.py
Normal file
57
Backend/src/middleware/security.py
Normal 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
|
||||
|
||||
41
Backend/src/middleware/timeout.py
Normal file
41
Backend/src/middleware/timeout.py
Normal 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."
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
Binary file not shown.
BIN
Backend/src/models/__pycache__/audit_log.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/audit_log.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
Backend/src/models/__pycache__/cookie_policy.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/cookie_policy.cpython-312.pyc
Normal file
Binary file not shown.
28
Backend/src/models/audit_log.py
Normal file
28
Backend/src/models/audit_log.py
Normal 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])
|
||||
|
||||
30
Backend/src/models/cookie_integration_config.py
Normal file
30
Backend/src/models/cookie_integration_config.py
Normal 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")
|
||||
|
||||
|
||||
31
Backend/src/models/cookie_policy.py
Normal file
31
Backend/src/models/cookie_policy.py
Normal 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")
|
||||
|
||||
|
||||
Binary file not shown.
BIN
Backend/src/routes/__pycache__/audit_routes.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/audit_routes.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Backend/src/routes/__pycache__/privacy_routes.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/privacy_routes.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
120
Backend/src/routes/admin_privacy_routes.py
Normal file
120
Backend/src/routes/admin_privacy_routes.py
Normal 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,
|
||||
)
|
||||
|
||||
|
||||
239
Backend/src/routes/audit_routes.py
Normal file
239
Backend/src/routes/audit_routes.py
Normal 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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
111
Backend/src/routes/privacy_routes.py
Normal file
111
Backend/src/routes/privacy_routes.py
Normal 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)
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
BIN
Backend/src/schemas/__pycache__/admin_privacy.cpython-312.pyc
Normal file
BIN
Backend/src/schemas/__pycache__/admin_privacy.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/schemas/__pycache__/privacy.cpython-312.pyc
Normal file
BIN
Backend/src/schemas/__pycache__/privacy.cpython-312.pyc
Normal file
Binary file not shown.
68
Backend/src/schemas/admin_privacy.py
Normal file
68
Backend/src/schemas/admin_privacy.py
Normal 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
|
||||
|
||||
|
||||
70
Backend/src/schemas/privacy.py
Normal file
70
Backend/src/schemas/privacy.py
Normal 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
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
82
Backend/src/services/audit_service.py
Normal file
82
Backend/src/services/audit_service.py
Normal 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()
|
||||
|
||||
@@ -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,14 +164,42 @@ class AuthService:
|
||||
expiry_days = 7 if remember_me else 1
|
||||
expires_at = datetime.utcnow() + timedelta(days=expiry_days)
|
||||
|
||||
# Save refresh token
|
||||
refresh_token = RefreshToken(
|
||||
user_id=user.id,
|
||||
token=tokens["refreshToken"],
|
||||
expires_at=expires_at
|
||||
)
|
||||
db.add(refresh_token)
|
||||
db.commit()
|
||||
# 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"],
|
||||
expires_at=expires_at
|
||||
)
|
||||
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,
|
||||
|
||||
98
Backend/src/services/privacy_admin_service.py
Normal file
98
Backend/src/services/privacy_admin_service.py
Normal 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()
|
||||
|
||||
|
||||
BIN
Backend/src/utils/__pycache__/email_templates.cpython-312.pyc
Normal file
BIN
Backend/src/utils/__pycache__/email_templates.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
261
Backend/src/utils/email_templates.py
Normal file
261
Backend/src/utils/email_templates.py
Normal 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}")
|
||||
|
||||
@@ -2,47 +2,96 @@ 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")
|
||||
|
||||
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."
|
||||
# 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):
|
||||
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_header
|
||||
message["To"] = to
|
||||
message["Subject"] = subject
|
||||
|
||||
if text:
|
||||
message.attach(MIMEText(text, "plain"))
|
||||
if html:
|
||||
message.attach(MIMEText(html, "html"))
|
||||
|
||||
# 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=use_tls,
|
||||
start_tls=start_tls,
|
||||
username=mail_user,
|
||||
password=mail_pass,
|
||||
)
|
||||
|
||||
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://', '')}")
|
||||
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()
|
||||
|
||||
# Create message
|
||||
message = MIMEMultipart("alternative")
|
||||
message["From"] = from_address
|
||||
message["To"] = to
|
||||
message["Subject"] = subject
|
||||
|
||||
if text:
|
||||
message.attach(MIMEText(text, "plain"))
|
||||
if html:
|
||||
message.attach(MIMEText(html, "html"))
|
||||
|
||||
# Send email
|
||||
await aiosmtplib.send(
|
||||
message,
|
||||
hostname=mail_host,
|
||||
port=mail_port,
|
||||
use_tls=not mail_secure and mail_port == 587,
|
||||
start_tls=not mail_secure and mail_port == 587,
|
||||
username=mail_user,
|
||||
password=mail_pass,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,8 +121,16 @@ function App() {
|
||||
};
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<GlobalLoadingProvider>
|
||||
<CookieConsentProvider>
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<Loading fullScreen text="Loading page..." />}>
|
||||
<Routes>
|
||||
{/* Public Routes with Main Layout */}
|
||||
<Route
|
||||
path="/"
|
||||
@@ -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>
|
||||
|
||||
@@ -320,18 +328,27 @@ function App() {
|
||||
/>
|
||||
</Routes>
|
||||
|
||||
<ToastContainer
|
||||
position="top-right"
|
||||
autoClose={3000}
|
||||
hideProgressBar={false}
|
||||
newestOnTop
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss
|
||||
draggable
|
||||
pauseOnHover
|
||||
/>
|
||||
</BrowserRouter>
|
||||
<ToastContainer
|
||||
position="top-right"
|
||||
autoClose={3000}
|
||||
hideProgressBar={false}
|
||||
newestOnTop
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss
|
||||
draggable
|
||||
pauseOnHover
|
||||
theme="light"
|
||||
toastClassName="rounded-lg shadow-lg"
|
||||
bodyClassName="text-sm font-medium"
|
||||
/>
|
||||
<OfflineIndicator />
|
||||
<CookieConsentBanner />
|
||||
<AnalyticsLoader />
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</CookieConsentProvider>
|
||||
</GlobalLoadingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
117
Frontend/src/components/common/AnalyticsLoader.tsx
Normal file
117
Frontend/src/components/common/AnalyticsLoader.tsx
Normal 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;
|
||||
|
||||
|
||||
164
Frontend/src/components/common/ConfirmationDialog.tsx
Normal file
164
Frontend/src/components/common/ConfirmationDialog.tsx
Normal 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;
|
||||
|
||||
200
Frontend/src/components/common/CookieConsentBanner.tsx
Normal file
200
Frontend/src/components/common/CookieConsentBanner.tsx
Normal 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;
|
||||
|
||||
|
||||
29
Frontend/src/components/common/CookiePreferencesLink.tsx
Normal file
29
Frontend/src/components/common/CookiePreferencesLink.tsx
Normal 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;
|
||||
|
||||
|
||||
30
Frontend/src/components/common/GlobalLoading.tsx
Normal file
30
Frontend/src/components/common/GlobalLoading.tsx
Normal 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;
|
||||
|
||||
25
Frontend/src/components/common/OfflineIndicator.tsx
Normal file
25
Frontend/src/components/common/OfflineIndicator.tsx
Normal 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;
|
||||
|
||||
47
Frontend/src/components/common/Skeleton.tsx
Normal file
47
Frontend/src/components/common/Skeleton.tsx
Normal 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;
|
||||
|
||||
12
Frontend/src/components/common/index.ts
Normal file
12
Frontend/src/components/common/index.ts
Normal 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';
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
Leading online hotel management and
|
||||
booking system.
|
||||
<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>
|
||||
<div className="flex space-x-4">
|
||||
|
||||
{/* 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">
|
||||
© {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">
|
||||
© {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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
</span>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
<img
|
||||
src={currentBanner.image_url}
|
||||
alt={currentBanner.title}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
// Fallback to placeholder if image fails to load
|
||||
e.currentTarget.src = '/images/default-banner.jpg';
|
||||
{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={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';
|
||||
}}
|
||||
/>
|
||||
</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 - Enhanced for luxury text readability */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-t
|
||||
from-black/70 via-black/30 via-black/15 to-black/5
|
||||
transition-opacity duration-1000 ease-in-out"
|
||||
/>
|
||||
|
||||
{/* Animated gradient overlay for luxury effect */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-br
|
||||
from-transparent via-transparent to-black/10
|
||||
animate-pulse"
|
||||
style={{
|
||||
animation: 'luxuryGlow 8s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-t
|
||||
from-black/60 via-black/20 to-transparent"
|
||||
/>
|
||||
|
||||
{/* Title */}
|
||||
{/* 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',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
className="text-3xl md:text-5xl font-bold
|
||||
mb-2 drop-shadow-lg"
|
||||
>
|
||||
{currentBanner.title}
|
||||
</h2>
|
||||
|
||||
{/* 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={`${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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
{amenityIcons[amenity.toLowerCase()] ||
|
||||
<span>•</span>}
|
||||
<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>
|
||||
|
||||
@@ -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">
|
||||
Room Filters
|
||||
</h2>
|
||||
<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>
|
||||
|
||||
@@ -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
|
||||
</h3>
|
||||
</div>
|
||||
<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,49 +141,50 @@ 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>
|
||||
<option value="Deluxe Room">Deluxe Room</option>
|
||||
<option value="Luxury Room">Luxury Room</option>
|
||||
<option value="Family Room">Family Room</option>
|
||||
<option value="Twin Room">Twin Room</option>
|
||||
<option value="Standard Room">Standard Room</option>
|
||||
<option value="Deluxe Room">Deluxe Room</option>
|
||||
<option value="Luxury Room">Luxury Room</option>
|
||||
<option value="Family Room">Family Room</option>
|
||||
<option value="Twin Room">Twin Room</option>
|
||||
</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>
|
||||
|
||||
112
Frontend/src/contexts/CookieConsentContext.tsx
Normal file
112
Frontend/src/contexts/CookieConsentContext.tsx
Normal 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;
|
||||
};
|
||||
|
||||
|
||||
40
Frontend/src/contexts/GlobalLoadingContext.tsx
Normal file
40
Frontend/src/contexts/GlobalLoadingContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
7
Frontend/src/hooks/index.ts
Normal file
7
Frontend/src/hooks/index.ts
Normal 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';
|
||||
|
||||
86
Frontend/src/hooks/useAsync.ts
Normal file
86
Frontend/src/hooks/useAsync.ts
Normal 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 };
|
||||
}
|
||||
|
||||
28
Frontend/src/hooks/useClickOutside.ts
Normal file
28
Frontend/src/hooks/useClickOutside.ts
Normal 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]);
|
||||
}
|
||||
|
||||
68
Frontend/src/hooks/useLocalStorage.ts
Normal file
68
Frontend/src/hooks/useLocalStorage.ts
Normal 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];
|
||||
}
|
||||
|
||||
29
Frontend/src/hooks/useOffline.ts
Normal file
29
Frontend/src/hooks/useOffline.ts
Normal 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;
|
||||
}
|
||||
|
||||
244
Frontend/src/pages/AboutPage.tsx
Normal file
244
Frontend/src/pages/AboutPage.tsx
Normal 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;
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<AlertCircle
|
||||
className="w-12 h-12 text-red-500
|
||||
mx-auto mb-3"
|
||||
/>
|
||||
<p className="text-red-700 font-medium">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16
|
||||
bg-red-100 rounded-full mb-4">
|
||||
<AlertCircle
|
||||
className="w-8 h-8 text-red-600"
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
<div
|
||||
className="grid grid-cols-1 md:grid-cols-3
|
||||
gap-8"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<h3
|
||||
className="text-xl font-semibold mb-2
|
||||
text-gray-900"
|
||||
>
|
||||
Easy Booking
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Search and book rooms with just a few clicks
|
||||
</p>
|
||||
</div>
|
||||
<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="text-center">
|
||||
<div
|
||||
className="w-16 h-16 bg-green-100
|
||||
rounded-full flex items-center
|
||||
justify-center mx-auto mb-4"
|
||||
>
|
||||
<span className="text-3xl">💰</span>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
|
||||
<div className="text-center group">
|
||||
<div
|
||||
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-4xl">🏨</span>
|
||||
</div>
|
||||
<h3
|
||||
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 leading-relaxed font-light tracking-wide">
|
||||
Search and book rooms with just a few clicks
|
||||
</p>
|
||||
</div>
|
||||
<h3
|
||||
className="text-xl font-semibold mb-2
|
||||
text-gray-900"
|
||||
>
|
||||
Best Prices
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Best price guarantee in the market
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="w-16 h-16 bg-blue-100
|
||||
rounded-full flex items-center
|
||||
justify-center mx-auto mb-4"
|
||||
>
|
||||
<span className="text-3xl">🎧</span>
|
||||
<div className="text-center group">
|
||||
<div
|
||||
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-4xl">💰</span>
|
||||
</div>
|
||||
<h3
|
||||
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 leading-relaxed font-light tracking-wide">
|
||||
Best price guarantee in the market
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center group">
|
||||
<div
|
||||
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-4xl">🎧</span>
|
||||
</div>
|
||||
<h3
|
||||
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 leading-relaxed font-light tracking-wide">
|
||||
Support team always ready to serve
|
||||
</p>
|
||||
</div>
|
||||
<h3
|
||||
className="text-xl font-semibold mb-2
|
||||
text-gray-900"
|
||||
>
|
||||
24/7 Support
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Support team always ready to serve
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
446
Frontend/src/pages/admin/AuditLogsPage.tsx
Normal file
446
Frontend/src/pages/admin/AuditLogsPage.tsx
Normal 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;
|
||||
|
||||
677
Frontend/src/pages/admin/BannerManagementPage.tsx
Normal file
677
Frontend/src/pages/admin/BannerManagementPage.tsx
Normal 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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
357
Frontend/src/pages/admin/CookieSettingsPage.tsx
Normal file
357
Frontend/src/pages/admin/CookieSettingsPage.tsx
Normal 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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
const response = await reportService.getReports({
|
||||
from: dateRange.from,
|
||||
to: dateRange.to,
|
||||
});
|
||||
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 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"
|
||||
/>
|
||||
<span className="text-gray-500">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"
|
||||
/>
|
||||
<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="enterprise-input text-sm"
|
||||
/>
|
||||
<span className="text-gray-500 font-medium">to</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.to}
|
||||
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
|
||||
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,72 +190,87 @@ 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) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<span className="text-sm text-gray-600 w-24">
|
||||
{new Date(item.date).toLocaleDateString('en-US')}
|
||||
</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)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-900 w-32 text-right">
|
||||
{formatCurrency(item.revenue)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">No data available</p>
|
||||
)}
|
||||
</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="space-y-4">
|
||||
{Object.entries(stats.bookings_by_status).map(([status, count]) => {
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'bg-yellow-500',
|
||||
confirmed: 'bg-blue-500',
|
||||
checked_in: 'bg-green-500',
|
||||
checked_out: 'bg-gray-500',
|
||||
cancelled: 'bg-red-500',
|
||||
};
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: 'Pending confirmation',
|
||||
confirmed: 'Confirmed',
|
||||
checked_in: 'Checked in',
|
||||
checked_out: 'Checked out',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
{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={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 key={index} className="flex items-center">
|
||||
<span className="text-sm text-gray-600 w-24">
|
||||
{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 / (maxRevenue || 1)) * 100, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900">{count}</span>
|
||||
<span className="text-sm font-semibold text-gray-900 w-32 text-right">
|
||||
{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="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)
|
||||
.filter(([_, count]) => count > 0)
|
||||
.map(([status, count]) => {
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'bg-yellow-500',
|
||||
confirmed: 'bg-blue-500',
|
||||
checked_in: 'bg-green-500',
|
||||
checked_out: 'bg-gray-500',
|
||||
cancelled: 'bg-red-500',
|
||||
};
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: 'Pending confirmation',
|
||||
confirmed: 'Confirmed',
|
||||
checked_in: 'Checked in',
|
||||
checked_out: 'Checked out',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
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] || 'bg-gray-500'}`} />
|
||||
<span className="text-gray-700">{statusLabels[status] || status}</span>
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900">{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
376
Frontend/src/pages/admin/ReportsPage.tsx
Normal file
376
Frontend/src/pages/admin/ReportsPage.tsx
Normal 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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user