This commit is contained in:
Iliyan Angelov
2025-11-20 21:06:30 +02:00
parent 44e11520c5
commit a38ab4fa82
77 changed files with 7169 additions and 360 deletions

View File

@@ -0,0 +1,246 @@
"""add_section_title_fields_to_page_content
Revision ID: 1444eb61188e
Revises: ff515d77abbe
Create Date: 2025-11-20 15:51:29.671843
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '1444eb61188e'
down_revision = 'ff515d77abbe'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
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.drop_index('ix_bookings_promotion_code', 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.drop_constraint('bookings_ibfk_1', 'bookings', type_='foreignkey')
op.drop_constraint('bookings_ibfk_2', 'bookings', type_='foreignkey')
op.create_foreign_key(None, 'bookings', 'rooms', ['room_id'], ['id'])
op.create_foreign_key(None, 'bookings', 'users', ['user_id'], ['id'])
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.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.create_foreign_key(None, 'checkin_checkout', 'users', ['checkin_by'], ['id'])
op.create_foreign_key(None, 'checkin_checkout', 'bookings', ['booking_id'], ['id'])
op.create_foreign_key(None, 'checkin_checkout', 'users', ['checkout_by'], ['id'])
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.drop_constraint('favorites_ibfk_1', 'favorites', type_='foreignkey')
op.drop_constraint('favorites_ibfk_2', 'favorites', type_='foreignkey')
op.create_foreign_key(None, 'favorites', 'rooms', ['room_id'], ['id'])
op.create_foreign_key(None, 'favorites', 'users', ['user_id'], ['id'])
op.add_column('page_contents', sa.Column('luxury_gallery_section_title', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('luxury_gallery_section_subtitle', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('luxury_testimonials_section_title', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('luxury_testimonials_section_subtitle', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('luxury_services_section_title', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('luxury_services_section_subtitle', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('luxury_experiences_section_title', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('luxury_experiences_section_subtitle', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('awards_section_title', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('awards_section_subtitle', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('partners_section_title', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('partners_section_subtitle', sa.Text(), nullable=True))
op.alter_column('password_reset_tokens', 'used',
existing_type=mysql.TINYINT(display_width=1),
nullable=False,
existing_server_default=sa.text("'0'"))
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.drop_constraint('password_reset_tokens_ibfk_1', 'password_reset_tokens', type_='foreignkey')
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)
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.drop_constraint('payments_ibfk_1', 'payments', type_='foreignkey')
op.drop_constraint('payments_related_payment_id_foreign_idx', 'payments', type_='foreignkey')
op.create_foreign_key(None, 'payments', 'payments', ['related_payment_id'], ['id'])
op.create_foreign_key(None, 'payments', 'bookings', ['booking_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)
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.drop_constraint('refresh_tokens_ibfk_1', 'refresh_tokens', type_='foreignkey')
op.create_foreign_key(None, 'refresh_tokens', 'users', ['user_id'], ['id'])
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.drop_constraint('reviews_ibfk_2', 'reviews', type_='foreignkey')
op.drop_constraint('reviews_ibfk_1', 'reviews', type_='foreignkey')
op.create_foreign_key(None, 'reviews', 'users', ['user_id'], ['id'])
op.create_foreign_key(None, 'reviews', 'rooms', ['room_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)
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.drop_constraint('rooms_ibfk_1', 'rooms', type_='foreignkey')
op.create_foreign_key(None, 'rooms', 'room_types', ['room_type_id'], ['id'])
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.drop_constraint('service_usages_ibfk_2', 'service_usages', type_='foreignkey')
op.drop_constraint('service_usages_ibfk_1', 'service_usages', type_='foreignkey')
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)
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.drop_constraint('users_ibfk_1', 'users', type_='foreignkey')
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_1', 'service_usages', 'bookings', ['booking_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT')
op.create_foreign_key('service_usages_ibfk_2', 'service_usages', 'services', ['service_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_related_payment_id_foreign_idx', 'payments', 'payments', ['related_payment_id'], ['id'], onupdate='CASCADE', ondelete='SET NULL')
op.create_foreign_key('payments_ibfk_1', 'payments', 'bookings', ['booking_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT')
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_column('page_contents', 'partners_section_subtitle')
op.drop_column('page_contents', 'partners_section_title')
op.drop_column('page_contents', 'awards_section_subtitle')
op.drop_column('page_contents', 'awards_section_title')
op.drop_column('page_contents', 'luxury_experiences_section_subtitle')
op.drop_column('page_contents', 'luxury_experiences_section_title')
op.drop_column('page_contents', 'luxury_services_section_subtitle')
op.drop_column('page_contents', 'luxury_services_section_title')
op.drop_column('page_contents', 'luxury_testimonials_section_subtitle')
op.drop_column('page_contents', 'luxury_testimonials_section_title')
op.drop_column('page_contents', 'luxury_gallery_section_subtitle')
op.drop_column('page_contents', 'luxury_gallery_section_title')
op.drop_constraint(None, 'favorites', type_='foreignkey')
op.drop_constraint(None, 'favorites', type_='foreignkey')
op.create_foreign_key('favorites_ibfk_2', 'favorites', 'rooms', ['room_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE')
op.create_foreign_key('favorites_ibfk_1', 'favorites', 'users', ['user_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_2', 'bookings', 'rooms', ['room_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT')
op.create_foreign_key('bookings_ibfk_1', 'bookings', 'users', ['user_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('ix_bookings_promotion_code', 'bookings', ['promotion_code'], unique=False)
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)
# ### end Alembic commands ###

View File

@@ -0,0 +1,38 @@
"""add_luxury_section_fields_to_page_content
Revision ID: 17efc6439cc3
Revises: bfa74be4b256
Create Date: 2025-11-20 13:37:20.015422
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '17efc6439cc3'
down_revision = 'bfa74be4b256'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add luxury section fields to page_contents table
# Use TEXT instead of VARCHAR to avoid MySQL row size limits
op.add_column('page_contents', sa.Column('luxury_section_title', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('luxury_section_subtitle', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('luxury_section_image', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('luxury_features', sa.Text(), nullable=True)) # JSON array
op.add_column('page_contents', sa.Column('luxury_gallery', sa.Text(), nullable=True)) # JSON array of image URLs
op.add_column('page_contents', sa.Column('luxury_testimonials', sa.Text(), nullable=True)) # JSON array of testimonials
def downgrade() -> None:
# Remove luxury section fields
op.drop_column('page_contents', 'luxury_testimonials')
op.drop_column('page_contents', 'luxury_gallery')
op.drop_column('page_contents', 'luxury_features')
op.drop_column('page_contents', 'luxury_section_image')
op.drop_column('page_contents', 'luxury_section_subtitle')
op.drop_column('page_contents', 'luxury_section_title')

View File

@@ -0,0 +1,36 @@
"""add_about_page_fields
Revision ID: f2a3b4c5d6e7
Revises: a1b2c3d4e5f6
Create Date: 2025-11-20 17:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f2a3b4c5d6e7'
down_revision = 'a1b2c3d4e5f6'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add about page specific fields (all as TEXT to avoid row size issues)
op.add_column('page_contents', sa.Column('about_hero_image', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('mission', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('vision', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('team', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('timeline', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('achievements', sa.Text(), nullable=True))
def downgrade() -> None:
# Remove about page specific fields
op.drop_column('page_contents', 'achievements')
op.drop_column('page_contents', 'timeline')
op.drop_column('page_contents', 'team')
op.drop_column('page_contents', 'vision')
op.drop_column('page_contents', 'mission')
op.drop_column('page_contents', 'about_hero_image')

View File

@@ -0,0 +1,26 @@
"""add_copyright_text_to_page_content
Revision ID: a1b2c3d4e5f6
Revises: ff515d77abbe
Create Date: 2025-11-20 16:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a1b2c3d4e5f6'
down_revision = '1444eb61188e'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add copyright_text column to page_contents table
op.add_column('page_contents', sa.Column('copyright_text', sa.Text(), nullable=True))
def downgrade() -> None:
# Remove copyright_text column from page_contents table
op.drop_column('page_contents', 'copyright_text')

View File

@@ -0,0 +1,53 @@
"""add_luxury_content_fields_to_page_content
Revision ID: bfa74be4b256
Revises: bd309b0742c1
Create Date: 2025-11-20 13:27:52.106013
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'bfa74be4b256'
down_revision = 'bd309b0742c1'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add luxury content fields to page_contents table
op.add_column('page_contents', sa.Column('amenities_section_title', sa.String(500), nullable=True))
op.add_column('page_contents', sa.Column('amenities_section_subtitle', sa.String(1000), nullable=True))
op.add_column('page_contents', sa.Column('amenities', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('testimonials_section_title', sa.String(500), nullable=True))
op.add_column('page_contents', sa.Column('testimonials_section_subtitle', sa.String(1000), nullable=True))
op.add_column('page_contents', sa.Column('testimonials', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('gallery_section_title', sa.String(500), nullable=True))
op.add_column('page_contents', sa.Column('gallery_section_subtitle', sa.String(1000), nullable=True))
op.add_column('page_contents', sa.Column('gallery_images', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('about_preview_title', sa.String(500), nullable=True))
op.add_column('page_contents', sa.Column('about_preview_subtitle', sa.String(1000), nullable=True))
op.add_column('page_contents', sa.Column('about_preview_content', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('about_preview_image', sa.String(1000), nullable=True))
op.add_column('page_contents', sa.Column('stats', sa.Text(), nullable=True))
def downgrade() -> None:
# Remove luxury content fields
op.drop_column('page_contents', 'stats')
op.drop_column('page_contents', 'about_preview_image')
op.drop_column('page_contents', 'about_preview_content')
op.drop_column('page_contents', 'about_preview_subtitle')
op.drop_column('page_contents', 'about_preview_title')
op.drop_column('page_contents', 'gallery_images')
op.drop_column('page_contents', 'gallery_section_subtitle')
op.drop_column('page_contents', 'gallery_section_title')
op.drop_column('page_contents', 'testimonials')
op.drop_column('page_contents', 'testimonials_section_subtitle')
op.drop_column('page_contents', 'testimonials_section_title')
op.drop_column('page_contents', 'amenities')
op.drop_column('page_contents', 'amenities_section_subtitle')
op.drop_column('page_contents', 'amenities_section_title')

View File

@@ -0,0 +1,43 @@
"""add_more_luxury_sections_to_page_content
Revision ID: ff515d77abbe
Revises: 17efc6439cc3
Create Date: 2025-11-20 15:17:50.977961
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ff515d77abbe'
down_revision = '17efc6439cc3'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add more luxury sections to page_contents table
op.add_column('page_contents', sa.Column('luxury_services', sa.Text(), nullable=True)) # JSON array of services
op.add_column('page_contents', sa.Column('luxury_experiences', sa.Text(), nullable=True)) # JSON array of experiences
op.add_column('page_contents', sa.Column('awards', sa.Text(), nullable=True)) # JSON array of awards
op.add_column('page_contents', sa.Column('cta_title', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('cta_subtitle', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('cta_button_text', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('cta_button_link', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('cta_image', sa.Text(), nullable=True))
op.add_column('page_contents', sa.Column('partners', sa.Text(), nullable=True)) # JSON array of partners
def downgrade() -> None:
# Remove luxury sections fields
op.drop_column('page_contents', 'partners')
op.drop_column('page_contents', 'cta_image')
op.drop_column('page_contents', 'cta_button_link')
op.drop_column('page_contents', 'cta_button_text')
op.drop_column('page_contents', 'cta_subtitle')
op.drop_column('page_contents', 'cta_title')
op.drop_column('page_contents', 'awards')
op.drop_column('page_contents', 'luxury_experiences')
op.drop_column('page_contents', 'luxury_services')

217
Backend/seed_about_page.py Normal file
View File

@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""
Seed sample data for the About page
"""
import sys
import os
from pathlib import Path
import json
# Add the parent directory to the path so we can import from src
sys.path.insert(0, str(Path(__file__).parent))
from sqlalchemy.orm import Session
from src.config.database import SessionLocal
from src.models.page_content import PageContent, PageType
from datetime import datetime
def get_db():
"""Get database session"""
db = SessionLocal()
try:
return db
finally:
pass
def seed_about_page(db: Session):
"""Seed about page content"""
print("=" * 80)
print("SEEDING ABOUT PAGE CONTENT")
print("=" * 80)
# Sample data
about_data = {
"title": "About Luxury Hotel",
"subtitle": "Where Excellence Meets Unforgettable Experiences",
"description": "Discover the story behind our commitment to luxury hospitality and exceptional service.",
"story_content": """Welcome to Luxury Hotel, where timeless elegance meets modern sophistication. Since our founding in 2010, we have been dedicated to providing exceptional hospitality and creating unforgettable memories for our guests.
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.
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.
Over the years, we have hosted thousands of guests from around the world, each leaving with cherished memories and a desire to return. Our team of dedicated professionals works tirelessly to ensure that every moment of your stay is perfect.""",
"mission": "To provide unparalleled luxury hospitality experiences that exceed expectations, creating lasting memories for our guests through exceptional service, attention to detail, and genuine care.",
"vision": "To be recognized as the world's premier luxury hotel brand, setting the standard for excellence in hospitality while maintaining our commitment to sustainability and community engagement.",
"about_hero_image": "https://images.unsplash.com/photo-1566073771259-6a8506099945?w=1920&h=1080&fit=crop",
"values": json.dumps([
{
"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."
}
]),
"features": json.dumps([
{
"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."
}
]),
"team": json.dumps([
{
"name": "Sarah Johnson",
"role": "General Manager",
"image": "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=400&h=400&fit=crop",
"bio": "With over 15 years of experience in luxury hospitality, Sarah leads our team with passion and dedication.",
"social_links": {
"linkedin": "https://linkedin.com/in/sarahjohnson",
"twitter": "https://twitter.com/sarahjohnson"
}
},
{
"name": "Michael Chen",
"role": "Head Chef",
"image": "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=400&fit=crop",
"bio": "Award-winning chef with expertise in international cuisine, bringing world-class dining experiences to our guests.",
"social_links": {
"linkedin": "https://linkedin.com/in/michaelchen",
"twitter": "https://twitter.com/michaelchen"
}
},
{
"name": "Emily Rodriguez",
"role": "Guest Relations Manager",
"image": "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=400&h=400&fit=crop",
"bio": "Ensuring every guest feels valued and receives personalized attention throughout their stay.",
"social_links": {
"linkedin": "https://linkedin.com/in/emilyrodriguez"
}
}
]),
"timeline": json.dumps([
{
"year": "2010",
"title": "Grand Opening",
"description": "Luxury Hotel opened its doors, welcoming guests to a new standard of luxury hospitality.",
"image": "https://images.unsplash.com/photo-1566073771259-6a8506099945?w=800&h=600&fit=crop"
},
{
"year": "2015",
"title": "First Award",
"description": "Received our first 'Best Luxury Hotel' award, recognizing our commitment to excellence.",
"image": "https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=800&h=600&fit=crop"
},
{
"year": "2018",
"title": "Major Renovation",
"description": "Completed a comprehensive renovation, adding state-of-the-art facilities and expanding our capacity.",
"image": "https://images.unsplash.com/photo-1590490360182-c33d57733427?w=800&h=600&fit=crop"
},
{
"year": "2020",
"title": "Sustainability Initiative",
"description": "Launched our sustainability program, committing to eco-friendly practices and community engagement.",
"image": "https://images.unsplash.com/photo-1611892440504-42a792e24d32?w=800&h=600&fit=crop"
},
{
"year": "2023",
"title": "International Recognition",
"description": "Achieved international recognition as one of the world's top luxury hotels.",
"image": "https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=800&h=600&fit=crop"
}
]),
"achievements": json.dumps([
{
"icon": "Award",
"title": "Best Luxury Hotel 2023",
"description": "Recognized as the best luxury hotel in the region for exceptional service and amenities.",
"year": "2023",
"image": "https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=400&h=300&fit=crop"
},
{
"icon": "Star",
"title": "5-Star Rating",
"description": "Maintained our prestigious 5-star rating for over a decade, a testament to our consistent excellence.",
"year": "2022",
"image": "https://images.unsplash.com/photo-1566073771259-6a8506099945?w=400&h=300&fit=crop"
},
{
"icon": "Award",
"title": "Sustainable Hotel of the Year",
"description": "Awarded for our commitment to environmental sustainability and green practices.",
"year": "2021",
"image": "https://images.unsplash.com/photo-1611892440504-42a792e24d32?w=400&h=300&fit=crop"
},
{
"icon": "Users",
"title": "Guest Satisfaction Excellence",
"description": "Achieved 98% guest satisfaction rate, the highest in our category.",
"year": "2023",
"image": "https://images.unsplash.com/photo-1590490360182-c33d57733427?w=400&h=300&fit=crop"
}
]),
"meta_title": "About Us - Luxury Hotel | Our Story, Mission & Vision",
"meta_description": "Learn about Luxury Hotel's commitment to excellence, our story, values, and the dedicated team that makes every stay unforgettable."
}
# Check if about page content exists
existing = db.query(PageContent).filter(PageContent.page_type == PageType.ABOUT).first()
if existing:
# Update existing
for key, value in about_data.items():
setattr(existing, key, value)
existing.updated_at = datetime.utcnow()
print("✓ Updated existing about page content")
else:
# Create new
new_content = PageContent(
page_type=PageType.ABOUT,
**about_data
)
db.add(new_content)
print("✓ Created new about page content")
db.commit()
print("\n✅ About page content seeded successfully!")
print("=" * 80)
if __name__ == "__main__":
db = get_db()
try:
seed_about_page(db)
except Exception as e:
print(f"\n❌ Error: {e}")
import traceback
traceback.print_exc()
db.rollback()
finally:
db.close()

View File

@@ -0,0 +1,214 @@
"""
Seed script to populate banners and company information with sample data.
Run this script to add default banners and company settings.
"""
import sys
import os
from datetime import datetime, timedelta
# 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.banner import Banner
from src.models.system_settings import SystemSettings
from src.models.user import User
def seed_banners(db: Session):
"""Seed sample banners"""
print("Seeding banners...")
# Get admin user for updated_by_id (if exists)
admin_user = db.query(User).filter(User.email == "admin@hotel.com").first()
admin_id = admin_user.id if admin_user else None
# Delete all existing banners
existing_banners = db.query(Banner).all()
if existing_banners:
for banner in existing_banners:
db.delete(banner)
db.commit()
print(f" ✓ Removed {len(existing_banners)} existing banner(s)")
# New luxury banners with premium content
banners_data = [
{
"title": "Welcome to Unparalleled Luxury",
"description": "Where timeless elegance meets modern sophistication. Experience the pinnacle of hospitality in our award-winning luxury hotel.",
"image_url": "https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=1920",
"link_url": "/rooms",
"position": "home",
"display_order": 1,
"is_active": True,
"start_date": datetime.utcnow() - timedelta(days=30),
"end_date": datetime.utcnow() + timedelta(days=365),
},
{
"title": "Exclusive Presidential Suites",
"description": "Indulge in our most opulent accommodations. Spacious suites with panoramic views, private terraces, and personalized butler service.",
"image_url": "https://images.unsplash.com/photo-1590490360182-c33d57733427?w=1920",
"link_url": "/rooms",
"position": "home",
"display_order": 2,
"is_active": True,
"start_date": datetime.utcnow() - timedelta(days=7),
"end_date": datetime.utcnow() + timedelta(days=365),
},
{
"title": "World-Class Spa & Wellness",
"description": "Rejuvenate your mind, body, and soul. Our award-winning spa offers bespoke treatments using the finest luxury products.",
"image_url": "https://images.unsplash.com/photo-1544161515-4ab6ce6db874?w=1920",
"link_url": "/services",
"position": "home",
"display_order": 3,
"is_active": True,
"start_date": datetime.utcnow() - timedelta(days=1),
"end_date": datetime.utcnow() + timedelta(days=365),
},
{
"title": "Michelin-Starred Culinary Excellence",
"description": "Savor extraordinary flavors crafted by world-renowned chefs. Our fine dining restaurants offer an unforgettable gastronomic journey.",
"image_url": "https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=1920",
"link_url": "/services",
"position": "home",
"display_order": 4,
"is_active": True,
"start_date": datetime.utcnow(),
"end_date": datetime.utcnow() + timedelta(days=365),
},
{
"title": "Private Yacht & Exclusive Experiences",
"description": "Create unforgettable memories with our curated luxury experiences. From private yacht charters to exclusive cultural tours.",
"image_url": "https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=1920",
"link_url": "/services",
"position": "home",
"display_order": 5,
"is_active": True,
"start_date": datetime.utcnow() - timedelta(days=15),
"end_date": datetime.utcnow() + timedelta(days=365),
}
]
for banner_data in banners_data:
# Create new banner
new_banner = Banner(**banner_data)
db.add(new_banner)
print(f" ✓ Created banner: {banner_data['title']}")
db.commit()
print("✓ Banners seeded successfully!\n")
def seed_company_info(db: Session):
"""Seed company information"""
print("Seeding company information...")
# Get admin user for updated_by_id (if exists)
admin_user = db.query(User).filter(User.email == "admin@hotel.com").first()
admin_id = admin_user.id if admin_user else None
# Company settings
company_settings = [
{
"key": "company_name",
"value": "Luxury Hotel",
"description": "Company name displayed throughout the application"
},
{
"key": "company_tagline",
"value": "Experience Unparalleled Elegance",
"description": "Company tagline or slogan"
},
{
"key": "company_logo_url",
"value": "",
"description": "URL to company logo image (upload via admin dashboard)"
},
{
"key": "company_favicon_url",
"value": "",
"description": "URL to company favicon image (upload via admin dashboard)"
},
{
"key": "company_phone",
"value": "+1 (555) 123-4567",
"description": "Company contact phone number"
},
{
"key": "company_email",
"value": "info@luxuryhotel.com",
"description": "Company contact email address"
},
{
"key": "company_address",
"value": "123 Luxury Avenue, Premium District, City 12345, Country",
"description": "Company physical address"
},
{
"key": "tax_rate",
"value": "10.0",
"description": "Default tax rate percentage (e.g., 10.0 for 10%)"
},
{
"key": "platform_currency",
"value": "EUR",
"description": "Platform-wide currency setting for displaying prices"
}
]
for setting_data in company_settings:
# Check if setting exists
existing = db.query(SystemSettings).filter(
SystemSettings.key == setting_data["key"]
).first()
if existing:
# Update existing setting
existing.value = setting_data["value"]
existing.description = setting_data["description"]
if admin_id:
existing.updated_by_id = admin_id
print(f" ✓ Updated setting: {setting_data['key']}")
else:
# Create new setting
new_setting = SystemSettings(
key=setting_data["key"],
value=setting_data["value"],
description=setting_data["description"],
updated_by_id=admin_id
)
db.add(new_setting)
print(f" ✓ Created setting: {setting_data['key']}")
db.commit()
print("✓ Company information seeded successfully!\n")
def main():
"""Main seed function"""
db: Session = SessionLocal()
try:
print("=" * 80)
print("SEEDING BANNERS AND COMPANY INFORMATION")
print("=" * 80)
print()
seed_banners(db)
seed_company_info(db)
print("=" * 80)
print("✓ All data seeded successfully!")
print("=" * 80)
except Exception as e:
db.rollback()
print(f"\n✗ Error seeding data: {e}")
import traceback
traceback.print_exc()
raise
finally:
db.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,456 @@
"""
Comprehensive seed script to populate homepage and footer with sample luxury content.
Run this script to add default content to the page_content table.
"""
import sys
import os
import json
# 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.page_content import PageContent, PageType
def seed_homepage_content(db: Session):
"""Seed comprehensive homepage content"""
existing = db.query(PageContent).filter(PageContent.page_type == PageType.HOME).first()
# Luxury Features
luxury_features = [
{
"icon": "Sparkles",
"title": "Premium Amenities",
"description": "World-class facilities designed for your comfort and relaxation"
},
{
"icon": "Crown",
"title": "Royal Service",
"description": "Dedicated concierge service available 24/7 for all your needs"
},
{
"icon": "Award",
"title": "Award-Winning",
"description": "Recognized for excellence in hospitality and guest satisfaction"
},
{
"icon": "Shield",
"title": "Secure & Private",
"description": "Your privacy and security are our top priorities"
},
{
"icon": "Heart",
"title": "Personalized Care",
"description": "Tailored experiences crafted just for you"
},
{
"icon": "Gem",
"title": "Luxury Design",
"description": "Elegantly designed spaces with attention to every detail"
}
]
# Luxury Gallery
luxury_gallery = [
"https://images.unsplash.com/photo-1566073771259-6a8506099945?w=800",
"https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=800",
"https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=800",
"https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=800",
"https://images.unsplash.com/photo-1590490360182-c33d57733427?w=800",
"https://images.unsplash.com/photo-1551882547-ff40c63fe5fa?w=800"
]
# Luxury Testimonials
luxury_testimonials = [
{
"name": "Sarah Johnson",
"title": "Business Executive",
"quote": "An absolutely stunning experience. The attention to detail and level of service exceeded all expectations.",
"image": "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200"
},
{
"name": "Michael Chen",
"title": "Travel Enthusiast",
"quote": "The epitome of luxury. Every moment was perfect, from check-in to check-out.",
"image": "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200"
},
{
"name": "Emma Williams",
"title": "Luxury Traveler",
"quote": "This hotel redefines what luxury means. I will definitely return.",
"image": "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=200"
}
]
# Luxury Services
luxury_services = [
{
"icon": "UtensilsCrossed",
"title": "Fine Dining",
"description": "Michelin-starred restaurants offering world-class cuisine",
"image": "https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=600"
},
{
"icon": "Wine",
"title": "Premium Bar",
"description": "Extensive wine collection and craft cocktails in elegant settings",
"image": "https://images.unsplash.com/photo-1514362545857-3bc16c4c7d1b?w=600"
},
{
"icon": "Dumbbell",
"title": "Spa & Wellness",
"description": "Rejuvenating spa treatments and state-of-the-art fitness center",
"image": "https://images.unsplash.com/photo-1544161515-4ab6ce6db874?w=600"
},
{
"icon": "Car",
"title": "Concierge Services",
"description": "Personalized assistance for all your travel and entertainment needs",
"image": "https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=600"
}
]
# Luxury Experiences
luxury_experiences = [
{
"icon": "Sunset",
"title": "Sunset Rooftop",
"description": "Breathtaking views and exclusive rooftop experiences",
"image": "https://images.unsplash.com/photo-1514933651103-005eec06c04b?w=600"
},
{
"icon": "Ship",
"title": "Yacht Excursions",
"description": "Private yacht charters for unforgettable sea adventures",
"image": "https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=600"
},
{
"icon": "Music",
"title": "Live Entertainment",
"description": "World-class performances and exclusive events",
"image": "https://images.unsplash.com/photo-1470229722913-7c0e2dbbafd3?w=600"
},
{
"icon": "Palette",
"title": "Art & Culture",
"description": "Curated art collections and cultural experiences",
"image": "https://images.unsplash.com/photo-1578301978018-3005759f48f7?w=600"
}
]
# Awards
awards = [
{
"icon": "Trophy",
"title": "Best Luxury Hotel 2024",
"description": "Awarded by International Luxury Travel Association",
"image": "https://images.unsplash.com/photo-1579783902614-a3fb3927b6a5?w=400",
"year": "2024"
},
{
"icon": "Star",
"title": "5-Star Excellence",
"description": "Consistently rated 5 stars by leading travel publications",
"image": "https://images.unsplash.com/photo-1606761568499-6d2451b23c66?w=400",
"year": "2023"
},
{
"icon": "Award",
"title": "Sustainable Luxury",
"description": "Recognized for environmental responsibility and sustainability",
"image": "https://images.unsplash.com/photo-1473341304170-971dccb5ac1e?w=400",
"year": "2024"
}
]
# Partners
partners = [
{
"name": "Luxury Travel Group",
"logo": "https://images.unsplash.com/photo-1599305445671-ac291c95aaa9?w=200",
"link": "#"
},
{
"name": "Premium Airlines",
"logo": "https://images.unsplash.com/photo-1436491865332-7a61a109cc05?w=200",
"link": "#"
},
{
"name": "Exclusive Events",
"logo": "https://images.unsplash.com/photo-1511578314322-379afb476865?w=200",
"link": "#"
},
{
"name": "Fine Dining Network",
"logo": "https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=200",
"link": "#"
}
]
# Stats
stats = [
{
"icon": "Users",
"number": "50,000+",
"label": "Happy Guests"
},
{
"icon": "Award",
"number": "25+",
"label": "Awards Won"
},
{
"icon": "Star",
"number": "4.9",
"label": "Average Rating"
},
{
"icon": "Globe",
"number": "100+",
"label": "Countries Served"
}
]
# Amenities
amenities = [
{
"icon": "Wifi",
"title": "High-Speed WiFi",
"description": "Complimentary high-speed internet throughout the property",
"image": ""
},
{
"icon": "Coffee",
"title": "24/7 Room Service",
"description": "Round-the-clock dining and beverage service",
"image": ""
},
{
"icon": "Car",
"title": "Valet Parking",
"description": "Complimentary valet parking for all guests",
"image": ""
},
{
"icon": "Plane",
"title": "Airport Transfer",
"description": "Luxury airport transfer service available",
"image": ""
}
]
# Testimonials
testimonials = [
{
"name": "Robert Martinez",
"role": "CEO, Tech Corp",
"image": "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=200",
"rating": 5,
"comment": "Exceptional service and attention to detail. The staff went above and beyond to make our stay memorable."
},
{
"name": "Lisa Anderson",
"role": "Travel Blogger",
"image": "https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?w=200",
"rating": 5,
"comment": "The most luxurious hotel experience I've ever had. Every detail was perfect."
},
{
"name": "David Thompson",
"role": "Investment Banker",
"image": "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=200",
"rating": 5,
"comment": "Outstanding facilities and impeccable service. Highly recommend for business travelers."
}
]
# Gallery Images
gallery_images = [
"https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=800",
"https://images.unsplash.com/photo-1590490360182-c33d57733427?w=800",
"https://images.unsplash.com/photo-1551882547-ff40c63fe5fa?w=800",
"https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=800",
"https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=800",
"https://images.unsplash.com/photo-1566073771259-6a8506099945?w=800"
]
homepage_data = {
"page_type": PageType.HOME,
"title": "Luxury Hotel - Experience Unparalleled Elegance",
"subtitle": "Where timeless luxury meets modern sophistication",
"description": "Discover a world of refined elegance and exceptional service",
"hero_title": "Welcome to Luxury",
"hero_subtitle": "Experience the pinnacle of hospitality",
"hero_image": "https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=1200",
"luxury_section_title": "Experience Unparalleled Luxury",
"luxury_section_subtitle": "Where elegance meets comfort in every detail",
"luxury_section_image": "https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=1200",
"luxury_features": json.dumps(luxury_features),
"luxury_gallery_section_title": "Our Luxury Gallery",
"luxury_gallery_section_subtitle": "A glimpse into our world of elegance",
"luxury_gallery": json.dumps(luxury_gallery),
"luxury_testimonials_section_title": "What Our Guests Say",
"luxury_testimonials_section_subtitle": "Testimonials from our valued guests",
"luxury_testimonials": json.dumps(luxury_testimonials),
"luxury_services_section_title": "Premium Services",
"luxury_services_section_subtitle": "Indulge in our world-class amenities",
"luxury_services": json.dumps(luxury_services),
"luxury_experiences_section_title": "Exclusive Experiences",
"luxury_experiences_section_subtitle": "Create unforgettable memories",
"luxury_experiences": json.dumps(luxury_experiences),
"awards_section_title": "Awards & Recognition",
"awards_section_subtitle": "Recognized for excellence worldwide",
"awards": json.dumps(awards),
"partners_section_title": "Our Partners",
"partners_section_subtitle": "Trusted by leading brands",
"partners": json.dumps(partners),
"amenities_section_title": "Premium Amenities",
"amenities_section_subtitle": "Everything you need for a perfect stay",
"amenities": json.dumps(amenities),
"testimonials_section_title": "Guest Reviews",
"testimonials_section_subtitle": "Hear from our satisfied guests",
"testimonials": json.dumps(testimonials),
"gallery_section_title": "Photo Gallery",
"gallery_section_subtitle": "Explore our beautiful spaces",
"gallery_images": json.dumps(gallery_images),
"about_preview_title": "About Our Luxury Hotel",
"about_preview_subtitle": "A legacy of excellence",
"about_preview_content": "Discover a world of refined elegance and exceptional service. Our hotel combines timeless luxury with modern amenities to create an unforgettable experience. With over 50,000 satisfied guests and numerous awards, we continue to set the standard for luxury hospitality.",
"about_preview_image": "https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=800",
"stats": json.dumps(stats),
"cta_title": "Ready to Experience Luxury?",
"cta_subtitle": "Book your stay today and discover the difference",
"cta_button_text": "Book Now",
"cta_button_link": "/rooms",
"cta_image": "https://images.unsplash.com/photo-1566073771259-6a8506099945?w=1200",
"is_active": True
}
if existing:
for key, value in homepage_data.items():
if key != "page_type":
setattr(existing, key, value)
print("✓ Updated existing homepage content")
else:
new_content = PageContent(**homepage_data)
db.add(new_content)
print("✓ Created new homepage content")
db.commit()
def seed_footer_content(db: Session):
"""Seed comprehensive footer content"""
existing = db.query(PageContent).filter(PageContent.page_type == PageType.FOOTER).first()
# Contact Info
contact_info = {
"phone": "+1 (555) 123-4567",
"email": "info@luxuryhotel.com",
"address": "123 Luxury Avenue, Premium District, City 12345"
}
# Social Links
social_links = {
"facebook": "https://facebook.com/luxuryhotel",
"twitter": "https://twitter.com/luxuryhotel",
"instagram": "https://instagram.com/luxuryhotel",
"linkedin": "https://linkedin.com/company/luxuryhotel",
"youtube": "https://youtube.com/luxuryhotel"
}
# Footer Links
footer_links = {
"quick_links": [
{"label": "Home", "url": "/"},
{"label": "Rooms & Suites", "url": "/rooms"},
{"label": "About Us", "url": "/about"},
{"label": "Contact", "url": "/contact"},
{"label": "Gallery", "url": "/gallery"}
],
"support_links": [
{"label": "FAQ", "url": "/faq"},
{"label": "Privacy Policy", "url": "/privacy"},
{"label": "Terms of Service", "url": "/terms"},
{"label": "Cancellation Policy", "url": "/cancellation"},
{"label": "Accessibility", "url": "/accessibility"}
]
}
# Badges
badges = [
{
"text": "5-Star Rated",
"icon": "Star"
},
{
"text": "Award Winning",
"icon": "Award"
},
{
"text": "Eco Certified",
"icon": "Leaf"
},
{
"text": "Luxury Collection",
"icon": "Crown"
}
]
footer_data = {
"page_type": PageType.FOOTER,
"title": "Luxury Hotel",
"subtitle": "Experience Unparalleled Elegance",
"description": "Your gateway to luxury hospitality and exceptional service",
"contact_info": json.dumps(contact_info),
"social_links": json.dumps(social_links),
"footer_links": json.dumps(footer_links),
"badges": json.dumps(badges),
"copyright_text": "© {YEAR} Luxury Hotel. All rights reserved.",
"is_active": True
}
if existing:
for key, value in footer_data.items():
if key != "page_type":
setattr(existing, key, value)
print("✓ Updated existing footer content")
else:
new_content = PageContent(**footer_data)
db.add(new_content)
print("✓ Created new footer content")
db.commit()
def main():
"""Main seed function"""
db: Session = SessionLocal()
try:
print("=" * 80)
print("SEEDING HOMEPAGE AND FOOTER CONTENT")
print("=" * 80)
print()
print("Seeding homepage content...")
seed_homepage_content(db)
print("\nSeeding footer content...")
seed_footer_content(db)
print("\n" + "=" * 80)
print("✓ All content seeded successfully!")
print("=" * 80)
except Exception as e:
db.rollback()
print(f"\n✗ Error seeding content: {e}")
import traceback
traceback.print_exc()
raise
finally:
db.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,123 @@
"""
Seed script to populate initial luxury content for the homepage.
Run this script to add default luxury content to the page_content table.
"""
import sys
import os
import json
# 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, engine
from src.models.page_content import PageContent
from src.models.user import User
def seed_luxury_content():
"""Seed luxury content for the homepage"""
db: Session = SessionLocal()
try:
# Check if home page content already exists
existing = db.query(PageContent).filter(PageContent.page_type == 'home').first()
luxury_features = [
{
"icon": "Sparkles",
"title": "Premium Amenities",
"description": "World-class facilities designed for your comfort and relaxation"
},
{
"icon": "Crown",
"title": "Royal Service",
"description": "Dedicated concierge service available 24/7 for all your needs"
},
{
"icon": "Award",
"title": "Award-Winning",
"description": "Recognized for excellence in hospitality and guest satisfaction"
},
{
"icon": "Shield",
"title": "Secure & Private",
"description": "Your privacy and security are our top priorities"
},
{
"icon": "Heart",
"title": "Personalized Care",
"description": "Tailored experiences crafted just for you"
},
{
"icon": "Gem",
"title": "Luxury Design",
"description": "Elegantly designed spaces with attention to every detail"
}
]
luxury_testimonials = [
{
"name": "Sarah Johnson",
"title": "Business Executive",
"quote": "An absolutely stunning experience. The attention to detail and level of service exceeded all expectations.",
"image": ""
},
{
"name": "Michael Chen",
"title": "Travel Enthusiast",
"quote": "The epitome of luxury. Every moment was perfect, from check-in to check-out.",
"image": ""
},
{
"name": "Emma Williams",
"title": "Luxury Traveler",
"quote": "This hotel redefines what luxury means. I will definitely return.",
"image": ""
}
]
if existing:
# Update existing content
existing.luxury_section_title = "Experience Unparalleled Luxury"
existing.luxury_section_subtitle = "Where elegance meets comfort in every detail"
existing.luxury_section_image = None
existing.luxury_features = json.dumps(luxury_features)
existing.luxury_gallery = json.dumps([])
existing.luxury_testimonials = json.dumps(luxury_testimonials)
existing.about_preview_title = "About Our Luxury Hotel"
existing.about_preview_content = "Discover a world of refined elegance and exceptional service. Our hotel combines timeless luxury with modern amenities to create an unforgettable experience."
existing.about_preview_image = None
print("✓ Updated existing home page content with luxury sections")
else:
# Create new content
new_content = PageContent(
page_type='home',
luxury_section_title="Experience Unparalleled Luxury",
luxury_section_subtitle="Where elegance meets comfort in every detail",
luxury_section_image=None,
luxury_features=json.dumps(luxury_features),
luxury_gallery=json.dumps([]),
luxury_testimonials=json.dumps(luxury_testimonials),
about_preview_title="About Our Luxury Hotel",
about_preview_content="Discover a world of refined elegance and exceptional service. Our hotel combines timeless luxury with modern amenities to create an unforgettable experience.",
about_preview_image=None,
is_active=True
)
db.add(new_content)
print("✓ Created new home page content with luxury sections")
db.commit()
print("✓ Luxury content seeded successfully!")
except Exception as e:
db.rollback()
print(f"✗ Error seeding luxury content: {e}")
raise
finally:
db.close()
if __name__ == "__main__":
print("Seeding luxury content...")
seed_luxury_content()
print("Done!")

317
Backend/seed_rooms.py Normal file
View File

@@ -0,0 +1,317 @@
#!/usr/bin/env python3
"""
Seed script to delete all existing rooms and create 50 sample luxury hotel rooms
"""
import sys
import os
from pathlib import Path
# Add the parent directory to the path so we can import from src
sys.path.insert(0, str(Path(__file__).parent))
from sqlalchemy.orm import Session
from src.config.database import SessionLocal, engine
from src.models.room import Room, RoomStatus
from src.models.room_type import RoomType
from datetime import datetime
import json
import random
def get_db():
"""Get database session"""
db = SessionLocal()
try:
return db
finally:
pass
def seed_rooms(db: Session):
"""Delete all existing rooms and create 50 sample luxury rooms"""
print("=" * 80)
print("SEEDING ROOMS - DELETING EXISTING AND CREATING 50 NEW LUXURY ROOMS")
print("=" * 80)
# Get all room types
room_types = db.query(RoomType).all()
if not room_types:
print("❌ No room types found! Please create room types first.")
return
print(f"\n✓ Found {len(room_types)} room type(s)")
for rt in room_types:
print(f" - {rt.name} (ID: {rt.id}, Base Price: {rt.base_price})")
# Delete all existing rooms
# First, we need to handle related records that reference these rooms
from src.models.booking import Booking
from src.models.review import Review
from src.models.favorite import Favorite
existing_rooms = db.query(Room).all()
if existing_rooms:
print(f"\n🗑️ Deleting {len(existing_rooms)} existing room(s)...")
# Get all room IDs
room_ids = [room.id for room in existing_rooms]
# Delete bookings that reference these rooms
bookings_with_rooms = db.query(Booking).filter(Booking.room_id.in_(room_ids)).all()
if bookings_with_rooms:
print(f" ⚠️ Found {len(bookings_with_rooms)} booking(s) referencing these rooms")
for booking in bookings_with_rooms:
db.delete(booking)
print(f" ✓ Deleted {len(bookings_with_rooms)} booking(s)")
# Delete reviews that reference these rooms
reviews_with_rooms = db.query(Review).filter(Review.room_id.in_(room_ids)).all()
if reviews_with_rooms:
print(f" ⚠️ Found {len(reviews_with_rooms)} review(s) referencing these rooms")
for review in reviews_with_rooms:
db.delete(review)
print(f" ✓ Deleted {len(reviews_with_rooms)} review(s)")
# Delete favorites that reference these rooms
favorites_with_rooms = db.query(Favorite).filter(Favorite.room_id.in_(room_ids)).all()
if favorites_with_rooms:
print(f" ⚠️ Found {len(favorites_with_rooms)} favorite(s) referencing these rooms")
for favorite in favorites_with_rooms:
db.delete(favorite)
print(f" ✓ Deleted {len(favorites_with_rooms)} favorite(s)")
# Now delete the rooms
for room in existing_rooms:
db.delete(room)
db.commit()
print(f"✓ Deleted {len(existing_rooms)} room(s)")
# Luxury room configurations
views = [
"Ocean View", "City View", "Garden View", "Mountain View",
"Pool View", "Beach View", "Panoramic View", "Sea View"
]
room_sizes = [
"35 sqm", "40 sqm", "45 sqm", "50 sqm", "55 sqm",
"60 sqm", "70 sqm", "80 sqm", "90 sqm", "100 sqm",
"120 sqm", "150 sqm", "180 sqm", "200 sqm", "250 sqm"
]
# Real luxury hotel room images from Unsplash (defined once, used for all rooms)
luxury_room_images = [
# Luxury hotel rooms
"https://images.unsplash.com/photo-1631049307264-da0ec9d70304?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1611892440504-42a792e24d32?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1590490360182-c33d57733427?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1578683010236-d716f9a3f461?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1596394516093-501ba68a0ba6?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1618221195710-dd6b41faaea8?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1566665797739-1674de7a421a?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1566073771259-6a8506099945?w=1200&h=800&fit=crop",
# Suite images
"https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1582719508461-905c673771fd?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1595576508898-0ad5c879a061?w=1200&h=800&fit=crop",
# Additional luxury rooms
"https://images.unsplash.com/photo-1571003123894-1f0594d2b5d9?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1566073771259-6a8506099945?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1578683010236-d716f9a3f461?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1596394516093-501ba68a0ba6?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1631049307264-da0ec9d70304?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1611892440504-42a792e24d32?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1590490360182-c33d57733427?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1618221195710-dd6b41faaea8?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1566665797739-1674de7a421a?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1582719508461-905c673771fd?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1595576508898-0ad5c879a061?w=1200&h=800&fit=crop",
"https://images.unsplash.com/photo-1571003123894-1f0594d2b5d9?w=1200&h=800&fit=crop",
]
# Comprehensive luxury amenities
all_amenities = [
"Free WiFi", "High-Speed Internet", "Smart TV", "Netflix",
"Air Conditioning", "Climate Control", "Private Balcony",
"Ocean View", "City View", "Minibar", "Coffee Maker",
"Espresso Machine", "Refrigerator", "Safe", "Iron & Ironing Board",
"Hair Dryer", "Premium Toiletries", "Bathrobes", "Slippers",
"Work Desk", "Ergonomic Chair", "USB Charging Ports",
"Bluetooth Speaker", "Sound System", "Blackout Curtains",
"Pillow Menu", "Turndown Service", "Room Service", "24/7 Concierge"
]
premium_amenities = [
"Jacuzzi Bathtub", "Steam Shower", "Rain Shower", "Bidet",
"Private Pool", "Outdoor Terrace", "Fireplace", "Wine Cellar",
"Private Bar", "Butler Service", "Private Elevator", "Helipad Access"
]
# Room configurations: (floor_range, room_numbers_per_floor, view, size_range, amenities_count, featured)
room_configs = [
# Standard Rooms (Floors 1-3)
((1, 3), 8, "Garden View", (35, 45), 8, False),
((1, 3), 8, "City View", (40, 50), 9, False),
# Superior Rooms (Floors 4-6)
((4, 6), 6, "City View", (45, 55), 10, False),
((4, 6), 6, "Pool View", (50, 60), 11, True),
# Deluxe Rooms (Floors 7-9)
((7, 9), 5, "Ocean View", (55, 70), 12, True),
((7, 9), 5, "Mountain View", (60, 75), 12, False),
# Executive Suites (Floors 10-12)
((10, 12), 4, "Panoramic View", (80, 100), 14, True),
((10, 12), 4, "Sea View", (90, 110), 15, True),
# Luxury Suites (Floors 13-15)
((13, 15), 3, "Ocean View", (120, 150), 16, True),
((13, 15), 3, "Beach View", (130, 160), 17, True),
# Presidential Suites (Floor 16)
((16, 16), 2, "Panoramic View", (200, 250), 20, True),
]
rooms_created = []
room_counter = 101 # Starting room number
print(f"\n🏨 Creating 50 luxury rooms...\n")
for config in room_configs:
floor_range, rooms_per_floor, view_type, size_range, amenities_count, featured = config
for floor in range(floor_range[0], floor_range[1] + 1):
for _ in range(rooms_per_floor):
if len(rooms_created) >= 50:
break
# Select random room type based on floor
if floor <= 3:
room_type = random.choice([rt for rt in room_types if 'standard' in rt.name.lower() or 'superior' in rt.name.lower()] or room_types)
elif floor <= 6:
room_type = random.choice([rt for rt in room_types if 'superior' in rt.name.lower() or 'deluxe' in rt.name.lower()] or room_types)
elif floor <= 9:
room_type = random.choice([rt for rt in room_types if 'deluxe' in rt.name.lower() or 'executive' in rt.name.lower()] or room_types)
elif floor <= 12:
room_type = random.choice([rt for rt in room_types if 'executive' in rt.name.lower() or 'suite' in rt.name.lower()] or room_types)
else:
room_type = random.choice([rt for rt in room_types if 'suite' in rt.name.lower() or 'presidential' in rt.name.lower()] or room_types)
# If no matching room type, use random
if not room_type:
room_type = random.choice(room_types)
# Calculate price (base price + floor premium + view premium + random variation)
base_price = float(room_type.base_price)
floor_premium = (floor - 1) * 5 # +5 per floor
view_premium = 20 if "Ocean" in view_type or "Sea" in view_type or "Beach" in view_type else 0
view_premium += 15 if "Panoramic" in view_type else 0
view_premium += 10 if "Mountain" in view_type else 0
view_premium += 5 if "Pool" in view_type else 0
# Add random variation (-5% to +10% of base price)
random_variation = base_price * random.uniform(-0.05, 0.10)
# Size premium (larger rooms cost more)
size_min, size_max = size_range
size_premium = (size_min + size_max) / 2 * 0.5 # ~0.5 per sqm
price = base_price + floor_premium + view_premium + random_variation + size_premium
# Ensure minimum price and round to 2 decimal places
price = max(base_price * 0.95, price)
price = round(price, 2)
# Select amenities
selected_amenities = random.sample(all_amenities, min(amenities_count, len(all_amenities)))
if floor >= 13: # Add premium amenities for luxury suites
premium_count = min(2, len(premium_amenities))
selected_amenities.extend(random.sample(premium_amenities, premium_count))
# Room size
size_min, size_max = size_range
room_size = f"{random.randint(size_min, size_max)} sqm"
# Capacity (based on room type, with some variation)
capacity = room_type.capacity
if random.random() > 0.7: # 30% chance to have different capacity
capacity = max(1, capacity + random.randint(-1, 1))
# Room number
room_number = f"{floor}{room_counter % 100:02d}"
room_counter += 1
# Select 3 unique images for each room (ensure we always have images)
# Shuffle the list each time to get different combinations
shuffled_images = luxury_room_images.copy()
random.shuffle(shuffled_images)
image_urls = shuffled_images[:3] # Always take first 3 after shuffle
# Description
descriptions = [
f"Elegantly designed {view_type.lower()} room with modern luxury amenities and breathtaking views.",
f"Spacious {view_type.lower()} accommodation featuring premium furnishings and world-class comfort.",
f"Luxurious {view_type.lower()} room with sophisticated decor and exceptional attention to detail.",
f"Exquisite {view_type.lower()} suite offering unparalleled elegance and personalized service.",
f"Opulent {view_type.lower()} accommodation with bespoke interiors and premium amenities.",
]
description = random.choice(descriptions)
# Status (mostly available, some in maintenance/cleaning)
status_weights = [0.85, 0.05, 0.05, 0.05] # available, occupied, maintenance, cleaning
status = random.choices(
[RoomStatus.available, RoomStatus.occupied, RoomStatus.maintenance, RoomStatus.cleaning],
weights=status_weights
)[0]
# Create room
room = Room(
room_type_id=room_type.id,
room_number=room_number,
floor=floor,
status=status,
price=price,
featured=featured,
capacity=capacity,
room_size=room_size,
view=view_type,
images=json.dumps(image_urls),
amenities=json.dumps(selected_amenities),
description=description
)
db.add(room)
rooms_created.append({
'number': room_number,
'floor': floor,
'type': room_type.name,
'view': view_type,
'price': price
})
print(f" ✓ Created Room {room_number} - Floor {floor}, {room_type.name}, {view_type}, {room_size}, €{price:.2f}")
db.commit()
print(f"\n✅ Successfully created {len(rooms_created)} luxury rooms!")
print(f"\n📊 Summary:")
featured_count = sum(1 for r in rooms_created if any(
config[5] and r['floor'] >= config[0][0] and r['floor'] <= config[0][1]
for config in room_configs
))
print(f" - Featured rooms: {featured_count}")
print(f" - Floors: {min(r['floor'] for r in rooms_created)} - {max(r['floor'] for r in rooms_created)}")
print(f" - Price range: €{min(r['price'] for r in rooms_created):.2f} - €{max(r['price'] for r in rooms_created):.2f}")
print("=" * 80)
if __name__ == "__main__":
db = get_db()
try:
seed_rooms(db)
except Exception as e:
print(f"\n❌ Error: {e}")
import traceback
traceback.print_exc()
db.rollback()
finally:
db.close()

View File

@@ -200,7 +200,8 @@ from .routes import (
room_routes, booking_routes, payment_routes, invoice_routes, banner_routes,
favorite_routes, service_routes, service_booking_routes, promotion_routes, report_routes,
review_routes, user_routes, audit_routes, admin_privacy_routes,
system_settings_routes, contact_routes, page_content_routes
system_settings_routes, contact_routes, page_content_routes,
home_routes, about_routes, contact_content_routes, footer_routes
)
# Legacy routes (maintain backward compatibility)
@@ -220,6 +221,10 @@ app.include_router(audit_routes.router, prefix="/api")
app.include_router(admin_privacy_routes.router, prefix="/api")
app.include_router(system_settings_routes.router, prefix="/api")
app.include_router(contact_routes.router, prefix="/api")
app.include_router(home_routes.router, prefix="/api")
app.include_router(about_routes.router, prefix="/api")
app.include_router(contact_content_routes.router, prefix="/api")
app.include_router(footer_routes.router, prefix="/api")
# Versioned routes (v1)
app.include_router(room_routes.router, prefix=settings.API_V1_PREFIX)
@@ -238,6 +243,10 @@ app.include_router(audit_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(admin_privacy_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(system_settings_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(contact_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(home_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(about_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(contact_content_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(footer_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(page_content_routes.router, prefix="/api")
app.include_router(page_content_routes.router, prefix=settings.API_V1_PREFIX)

View File

@@ -8,6 +8,7 @@ import os
from ..config.database import get_db
from ..config.settings import settings
from ..models.user import User
from ..models.role import Role
security = HTTPBearer()
@@ -46,10 +47,20 @@ def authorize_roles(*allowed_roles: str):
"""
Check if user has required role
"""
def role_checker(current_user: User = Depends(get_current_user)) -> User:
# Map role IDs to role names
role_map = {1: "admin", 2: "staff", 3: "customer"}
user_role_name = role_map.get(current_user.role_id)
def role_checker(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
) -> User:
# Query the role from database instead of using hardcoded IDs
role = db.query(Role).filter(Role.id == current_user.role_id).first()
if not role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User role not found"
)
user_role_name = role.name
if user_role_name not in allowed_roles:
raise HTTPException(

View File

@@ -41,6 +41,7 @@ class PageContent(Base):
social_links = Column(Text, nullable=True) # JSON: facebook, twitter, instagram, etc.
footer_links = Column(Text, nullable=True) # JSON: quick links, support links
badges = Column(Text, nullable=True) # JSON: array of badges with text and icon
copyright_text = Column(Text, nullable=True) # Copyright text with {YEAR} placeholder for automatic year
# Home page specific
hero_title = Column(String(500), nullable=True)
@@ -51,6 +52,57 @@ class PageContent(Base):
story_content = Column(Text, nullable=True)
values = Column(Text, nullable=True) # JSON array of values
features = Column(Text, nullable=True) # JSON array of features
about_hero_image = Column(Text, nullable=True) # Hero image for about page
mission = Column(Text, nullable=True) # Mission statement
vision = Column(Text, nullable=True) # Vision statement
team = Column(Text, nullable=True) # JSON array of team members with name, role, image, bio, social_links
timeline = Column(Text, nullable=True) # JSON array of timeline events with year, title, description, image
achievements = Column(Text, nullable=True) # JSON array of achievements with icon, title, description, year, image
# Home page luxury sections
luxury_section_title = Column(Text, nullable=True)
luxury_section_subtitle = Column(Text, nullable=True)
luxury_section_image = Column(Text, nullable=True)
luxury_features = Column(Text, nullable=True) # JSON array of features with icon, title, description
luxury_gallery_section_title = Column(Text, nullable=True)
luxury_gallery_section_subtitle = Column(Text, nullable=True)
luxury_gallery = Column(Text, nullable=True) # JSON array of image URLs
luxury_testimonials_section_title = Column(Text, nullable=True)
luxury_testimonials_section_subtitle = Column(Text, nullable=True)
luxury_testimonials = Column(Text, nullable=True) # JSON array of testimonials
amenities_section_title = Column(String(500), nullable=True)
amenities_section_subtitle = Column(String(1000), nullable=True)
amenities = Column(Text, nullable=True) # JSON array of amenities with icon, title, description, image
testimonials_section_title = Column(String(500), nullable=True)
testimonials_section_subtitle = Column(String(1000), nullable=True)
testimonials = Column(Text, nullable=True) # JSON array of testimonials with name, role, image, rating, comment
gallery_section_title = Column(String(500), nullable=True)
gallery_section_subtitle = Column(String(1000), nullable=True)
gallery_images = Column(Text, nullable=True) # JSON array of image URLs
about_preview_title = Column(String(500), nullable=True)
about_preview_subtitle = Column(String(1000), nullable=True)
about_preview_content = Column(Text, nullable=True)
about_preview_image = Column(String(1000), nullable=True)
stats = Column(Text, nullable=True) # JSON array of stats with number, label, icon
# Additional luxury sections
luxury_services_section_title = Column(Text, nullable=True)
luxury_services_section_subtitle = Column(Text, nullable=True)
luxury_services = Column(Text, nullable=True) # JSON array of services with icon, title, description, image
luxury_experiences_section_title = Column(Text, nullable=True)
luxury_experiences_section_subtitle = Column(Text, nullable=True)
luxury_experiences = Column(Text, nullable=True) # JSON array of experiences with icon, title, description, image
awards_section_title = Column(Text, nullable=True)
awards_section_subtitle = Column(Text, nullable=True)
awards = Column(Text, nullable=True) # JSON array of awards with icon, title, description, image, year
cta_title = Column(Text, nullable=True)
cta_subtitle = Column(Text, nullable=True)
cta_button_text = Column(Text, nullable=True)
cta_button_link = Column(Text, nullable=True)
cta_image = Column(Text, nullable=True)
partners_section_title = Column(Text, nullable=True)
partners_section_subtitle = Column(Text, nullable=True)
partners = Column(Text, nullable=True) # JSON array of partners with name, logo, link
# Status
is_active = Column(Boolean, default=True, nullable=False)

View File

@@ -0,0 +1,75 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
import json
from ..config.database import get_db
from ..config.logging_config import get_logger
from ..models.page_content import PageContent, PageType
logger = get_logger(__name__)
router = APIRouter(prefix="/about", tags=["about"])
def serialize_page_content(content: PageContent) -> dict:
"""Serialize PageContent model to dictionary"""
return {
"id": content.id,
"page_type": content.page_type.value,
"title": content.title,
"subtitle": content.subtitle,
"description": content.description,
"content": content.content,
"meta_title": content.meta_title,
"meta_description": content.meta_description,
"meta_keywords": content.meta_keywords,
"og_title": content.og_title,
"og_description": content.og_description,
"og_image": content.og_image,
"canonical_url": content.canonical_url,
"story_content": content.story_content,
"values": json.loads(content.values) if content.values else None,
"features": json.loads(content.features) if content.features else None,
"about_hero_image": content.about_hero_image,
"mission": content.mission,
"vision": content.vision,
"team": json.loads(content.team) if content.team else None,
"timeline": json.loads(content.timeline) if content.timeline else None,
"achievements": json.loads(content.achievements) if content.achievements else None,
"is_active": content.is_active,
"created_at": content.created_at.isoformat() if content.created_at else None,
"updated_at": content.updated_at.isoformat() if content.updated_at else None,
}
@router.get("/")
async def get_about_content(
db: Session = Depends(get_db)
):
"""Get about page content"""
try:
content = db.query(PageContent).filter(PageContent.page_type == PageType.ABOUT).first()
if not content:
return {
"status": "success",
"data": {
"page_content": None
}
}
content_dict = serialize_page_content(content)
return {
"status": "success",
"data": {
"page_content": content_dict
}
}
except Exception as e:
logger.error(f"Error fetching about content: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error fetching about content: {str(e)}"
)

View File

@@ -495,6 +495,7 @@ async def upload_avatar(
full_url = normalize_image_url(image_url, base_url)
return {
"success": True,
"status": "success",
"message": "Avatar uploaded successfully",
"data": {

View File

@@ -246,11 +246,25 @@ async def upload_banner_image(
):
"""Upload banner image (Admin only)"""
try:
# Validate file exists
if not image:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No file provided"
)
# 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"
detail=f"File must be an image. Received: {image.content_type}"
)
# Validate filename
if not image.filename:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Filename is required"
)
# Create uploads directory
@@ -258,21 +272,27 @@ async def upload_banner_image(
upload_dir.mkdir(parents=True, exist_ok=True)
# Generate filename
ext = Path(image.filename).suffix
ext = Path(image.filename).suffix or '.jpg'
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()
if not content:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File is empty"
)
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 {
"success": True,
"status": "success",
"message": "Image uploaded successfully",
"data": {

View File

@@ -61,6 +61,11 @@ def _generate_invoice_email_html(invoice: dict, is_proforma: bool = False) -> st
text-align: center;
border-radius: 8px 8px 0 0;
}}
.company-logo {{
max-width: 150px;
max-height: 80px;
margin-bottom: 10px;
}}
.content {{
background-color: #ffffff;
padding: 30px;
@@ -109,6 +114,8 @@ def _generate_invoice_email_html(invoice: dict, is_proforma: bool = False) -> st
<body>
<div class="container">
<div class="header">
{f'<img src="{invoice.get("company_logo_url")}" alt="Company Logo" class="company-logo" />' if invoice.get('company_logo_url') else ''}
{f'<h2 style="margin: 10px 0; color: #0f0f0f;">{invoice.get("company_name", "")}</h2>' if invoice.get('company_name') else ''}
<h1>{invoice_type}</h1>
</div>
<div class="content">
@@ -139,6 +146,7 @@ def _generate_invoice_email_html(invoice: dict, is_proforma: bool = False) -> st
<div class="total">
<p>Subtotal: {invoice.get('subtotal', 0):.2f}</p>
{f'<p style="color: #059669;">Discount: -{invoice.get("discount_amount", 0):.2f}</p>' if invoice.get('discount_amount', 0) > 0 else ''}
{f'<p style="color: #059669; font-size: 14px;">Promotion Code: {invoice.get("promotion_code", "")}</p>' if invoice.get('promotion_code') else ''}
<p>Tax: {invoice.get('tax_amount', 0):.2f}</p>
<p><strong>Total Amount: {invoice.get('total_amount', 0):.2f}</strong></p>
<p>Amount Paid: {invoice.get('amount_paid', 0):.2f}</p>
@@ -729,19 +737,31 @@ async def create_booking(
# Get discount from booking
booking_discount = float(booking.discount_amount) if booking.discount_amount else 0.0
# Add promotion code to invoice notes if present
invoice_notes = invoice_kwargs.get("notes", "")
if booking.promotion_code:
promotion_note = f"Promotion Code: {booking.promotion_code}"
invoice_notes = f"{promotion_note}\n{invoice_notes}".strip() if invoice_notes else promotion_note
invoice_kwargs["notes"] = invoice_notes
# Create invoices based on payment method
if payment_method == "cash":
# For cash bookings: create invoice for 20% deposit + proforma for 80% remaining
deposit_amount = float(total_price) * 0.2
remaining_amount = float(total_price) * 0.8
# Calculate proportional discount for partial invoices
# Deposit invoice gets 20% of the discount, proforma gets 80%
deposit_discount = booking_discount * 0.2 if booking_discount > 0 else 0.0
proforma_discount = booking_discount * 0.8 if booking_discount > 0 else 0.0
# Create invoice for deposit (20%)
deposit_invoice = InvoiceService.create_invoice_from_booking(
booking_id=booking.id,
db=db,
created_by_id=current_user.id,
tax_rate=tax_rate,
discount_amount=booking_discount,
discount_amount=deposit_discount,
due_days=30,
is_proforma=False,
invoice_amount=deposit_amount,
@@ -754,7 +774,7 @@ async def create_booking(
db=db,
created_by_id=current_user.id,
tax_rate=tax_rate,
discount_amount=booking_discount,
discount_amount=proforma_discount,
due_days=30,
is_proforma=True,
invoice_amount=remaining_amount,

View File

@@ -0,0 +1,68 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
import json
from ..config.database import get_db
from ..config.logging_config import get_logger
from ..models.page_content import PageContent, PageType
logger = get_logger(__name__)
router = APIRouter(prefix="/contact-content", tags=["contact-content"])
def serialize_page_content(content: PageContent) -> dict:
"""Serialize PageContent model to dictionary"""
return {
"id": content.id,
"page_type": content.page_type.value,
"title": content.title,
"subtitle": content.subtitle,
"description": content.description,
"content": content.content,
"meta_title": content.meta_title,
"meta_description": content.meta_description,
"meta_keywords": content.meta_keywords,
"og_title": content.og_title,
"og_description": content.og_description,
"og_image": content.og_image,
"canonical_url": content.canonical_url,
"contact_info": json.loads(content.contact_info) if content.contact_info else None,
"map_url": content.map_url,
"is_active": content.is_active,
"created_at": content.created_at.isoformat() if content.created_at else None,
"updated_at": content.updated_at.isoformat() if content.updated_at else None,
}
@router.get("/")
async def get_contact_content(
db: Session = Depends(get_db)
):
"""Get contact page content"""
try:
content = db.query(PageContent).filter(PageContent.page_type == PageType.CONTACT).first()
if not content:
return {
"status": "success",
"data": {
"page_content": None
}
}
content_dict = serialize_page_content(content)
return {
"status": "success",
"data": {
"page_content": content_dict
}
}
except Exception as e:
logger.error(f"Error fetching contact content: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error fetching contact content: {str(e)}"
)

View File

@@ -0,0 +1,63 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
import json
from ..config.database import get_db
from ..config.logging_config import get_logger
from ..models.page_content import PageContent, PageType
logger = get_logger(__name__)
router = APIRouter(prefix="/footer", tags=["footer"])
def serialize_page_content(content: PageContent) -> dict:
"""Serialize PageContent model to dictionary"""
return {
"id": content.id,
"page_type": content.page_type.value,
"title": content.title,
"subtitle": content.subtitle,
"description": content.description,
"content": content.content,
"social_links": json.loads(content.social_links) if content.social_links else None,
"footer_links": json.loads(content.footer_links) if content.footer_links else None,
"badges": json.loads(content.badges) if content.badges else None,
"copyright_text": content.copyright_text,
"is_active": content.is_active,
"created_at": content.created_at.isoformat() if content.created_at else None,
"updated_at": content.updated_at.isoformat() if content.updated_at else None,
}
@router.get("/")
async def get_footer_content(
db: Session = Depends(get_db)
):
"""Get footer content"""
try:
content = db.query(PageContent).filter(PageContent.page_type == PageType.FOOTER).first()
if not content:
return {
"status": "success",
"data": {
"page_content": None
}
}
content_dict = serialize_page_content(content)
return {
"status": "success",
"data": {
"page_content": content_dict
}
}
except Exception as e:
logger.error(f"Error fetching footer content: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error fetching footer content: {str(e)}"
)

View File

@@ -0,0 +1,110 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
import json
from ..config.database import get_db
from ..config.logging_config import get_logger
from ..models.page_content import PageContent, PageType
logger = get_logger(__name__)
router = APIRouter(prefix="/home", tags=["home"])
def serialize_page_content(content: PageContent) -> dict:
"""Serialize PageContent model to dictionary"""
return {
"id": content.id,
"page_type": content.page_type.value,
"title": content.title,
"subtitle": content.subtitle,
"description": content.description,
"content": content.content,
"meta_title": content.meta_title,
"meta_description": content.meta_description,
"meta_keywords": content.meta_keywords,
"og_title": content.og_title,
"og_description": content.og_description,
"og_image": content.og_image,
"canonical_url": content.canonical_url,
"hero_title": content.hero_title,
"hero_subtitle": content.hero_subtitle,
"hero_image": content.hero_image,
"amenities_section_title": content.amenities_section_title,
"amenities_section_subtitle": content.amenities_section_subtitle,
"amenities": json.loads(content.amenities) if content.amenities else None,
"testimonials_section_title": content.testimonials_section_title,
"testimonials_section_subtitle": content.testimonials_section_subtitle,
"testimonials": json.loads(content.testimonials) if content.testimonials else None,
"gallery_section_title": content.gallery_section_title,
"gallery_section_subtitle": content.gallery_section_subtitle,
"gallery_images": json.loads(content.gallery_images) if content.gallery_images else None,
"luxury_section_title": content.luxury_section_title,
"luxury_section_subtitle": content.luxury_section_subtitle,
"luxury_section_image": content.luxury_section_image,
"luxury_features": json.loads(content.luxury_features) if content.luxury_features else None,
"luxury_gallery_section_title": content.luxury_gallery_section_title,
"luxury_gallery_section_subtitle": content.luxury_gallery_section_subtitle,
"luxury_gallery": json.loads(content.luxury_gallery) if content.luxury_gallery else None,
"luxury_testimonials_section_title": content.luxury_testimonials_section_title,
"luxury_testimonials_section_subtitle": content.luxury_testimonials_section_subtitle,
"luxury_testimonials": json.loads(content.luxury_testimonials) if content.luxury_testimonials else None,
"about_preview_title": content.about_preview_title,
"about_preview_subtitle": content.about_preview_subtitle,
"about_preview_content": content.about_preview_content,
"about_preview_image": content.about_preview_image,
"stats": json.loads(content.stats) if content.stats else None,
"luxury_services_section_title": content.luxury_services_section_title,
"luxury_services_section_subtitle": content.luxury_services_section_subtitle,
"luxury_services": json.loads(content.luxury_services) if content.luxury_services else None,
"luxury_experiences_section_title": content.luxury_experiences_section_title,
"luxury_experiences_section_subtitle": content.luxury_experiences_section_subtitle,
"luxury_experiences": json.loads(content.luxury_experiences) if content.luxury_experiences else None,
"awards_section_title": content.awards_section_title,
"awards_section_subtitle": content.awards_section_subtitle,
"awards": json.loads(content.awards) if content.awards else None,
"cta_title": content.cta_title,
"cta_subtitle": content.cta_subtitle,
"cta_button_text": content.cta_button_text,
"cta_button_link": content.cta_button_link,
"cta_image": content.cta_image,
"partners_section_title": content.partners_section_title,
"partners_section_subtitle": content.partners_section_subtitle,
"partners": json.loads(content.partners) if content.partners else None,
"is_active": content.is_active,
"created_at": content.created_at.isoformat() if content.created_at else None,
"updated_at": content.updated_at.isoformat() if content.updated_at else None,
}
@router.get("/")
async def get_home_content(
db: Session = Depends(get_db)
):
"""Get homepage content"""
try:
content = db.query(PageContent).filter(PageContent.page_type == PageType.HOME).first()
if not content:
return {
"status": "success",
"data": {
"page_content": None
}
}
content_dict = serialize_page_content(content)
return {
"status": "success",
"data": {
"page_content": content_dict
}
}
except Exception as e:
logger.error(f"Error fetching home content: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error fetching home content: {str(e)}"
)

View File

@@ -87,11 +87,38 @@ async def create_invoice(
if not booking_id:
raise HTTPException(status_code=400, detail="booking_id is required")
# Ensure booking_id is an integer
try:
booking_id = int(booking_id)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="booking_id must be a valid integer")
# Check if booking exists
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise HTTPException(status_code=404, detail="Booking not found")
# Prepare invoice kwargs
invoice_kwargs = {
"company_name": invoice_data.get("company_name"),
"company_address": invoice_data.get("company_address"),
"company_phone": invoice_data.get("company_phone"),
"company_email": invoice_data.get("company_email"),
"company_tax_id": invoice_data.get("company_tax_id"),
"company_logo_url": invoice_data.get("company_logo_url"),
"customer_tax_id": invoice_data.get("customer_tax_id"),
"notes": invoice_data.get("notes"),
"terms_and_conditions": invoice_data.get("terms_and_conditions"),
"payment_instructions": invoice_data.get("payment_instructions"),
}
# Add promotion code to invoice notes if present in booking
invoice_notes = invoice_kwargs.get("notes", "")
if booking.promotion_code:
promotion_note = f"Promotion Code: {booking.promotion_code}"
invoice_notes = f"{promotion_note}\n{invoice_notes}".strip() if invoice_notes else promotion_note
invoice_kwargs["notes"] = invoice_notes
# Create invoice
invoice = InvoiceService.create_invoice_from_booking(
booking_id=booking_id,
@@ -100,16 +127,7 @@ async def create_invoice(
tax_rate=invoice_data.get("tax_rate", 0.0),
discount_amount=invoice_data.get("discount_amount", 0.0),
due_days=invoice_data.get("due_days", 30),
company_name=invoice_data.get("company_name"),
company_address=invoice_data.get("company_address"),
company_phone=invoice_data.get("company_phone"),
company_email=invoice_data.get("company_email"),
company_tax_id=invoice_data.get("company_tax_id"),
company_logo_url=invoice_data.get("company_logo_url"),
customer_tax_id=invoice_data.get("customer_tax_id"),
notes=invoice_data.get("notes"),
terms_and_conditions=invoice_data.get("terms_and_conditions"),
payment_instructions=invoice_data.get("payment_instructions"),
**invoice_kwargs
)
return {

View File

@@ -1,14 +1,21 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Request, UploadFile, File
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime
from pathlib import Path
import json
import os
import aiofiles
import uuid
from ..config.database import get_db
from ..config.logging_config import get_logger
from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User
from ..models.page_content import PageContent, PageType
logger = get_logger(__name__)
router = APIRouter(prefix="/page-content", tags=["page-content"])
@@ -40,12 +47,60 @@ async def get_all_page_contents(
"social_links": json.loads(content.social_links) if content.social_links else None,
"footer_links": json.loads(content.footer_links) if content.footer_links else None,
"badges": json.loads(content.badges) if content.badges else None,
"copyright_text": content.copyright_text,
"hero_title": content.hero_title,
"hero_subtitle": content.hero_subtitle,
"hero_image": content.hero_image,
"story_content": content.story_content,
"values": json.loads(content.values) if content.values else None,
"features": json.loads(content.features) if content.features else None,
"about_hero_image": content.about_hero_image,
"mission": content.mission,
"vision": content.vision,
"team": json.loads(content.team) if content.team else None,
"timeline": json.loads(content.timeline) if content.timeline else None,
"achievements": json.loads(content.achievements) if content.achievements else None,
"amenities_section_title": content.amenities_section_title,
"amenities_section_subtitle": content.amenities_section_subtitle,
"amenities": json.loads(content.amenities) if content.amenities else None,
"testimonials_section_title": content.testimonials_section_title,
"testimonials_section_subtitle": content.testimonials_section_subtitle,
"testimonials": json.loads(content.testimonials) if content.testimonials else None,
"gallery_section_title": content.gallery_section_title,
"gallery_section_subtitle": content.gallery_section_subtitle,
"gallery_images": json.loads(content.gallery_images) if content.gallery_images else None,
"luxury_section_title": content.luxury_section_title,
"luxury_section_subtitle": content.luxury_section_subtitle,
"luxury_section_image": content.luxury_section_image,
"luxury_features": json.loads(content.luxury_features) if content.luxury_features else None,
"luxury_gallery_section_title": content.luxury_gallery_section_title,
"luxury_gallery_section_subtitle": content.luxury_gallery_section_subtitle,
"luxury_gallery": json.loads(content.luxury_gallery) if content.luxury_gallery else None,
"luxury_testimonials_section_title": content.luxury_testimonials_section_title,
"luxury_testimonials_section_subtitle": content.luxury_testimonials_section_subtitle,
"luxury_testimonials": json.loads(content.luxury_testimonials) if content.luxury_testimonials else None,
"about_preview_title": content.about_preview_title,
"about_preview_subtitle": content.about_preview_subtitle,
"about_preview_content": content.about_preview_content,
"about_preview_image": content.about_preview_image,
"stats": json.loads(content.stats) if content.stats else None,
"luxury_services_section_title": content.luxury_services_section_title,
"luxury_services_section_subtitle": content.luxury_services_section_subtitle,
"luxury_services": json.loads(content.luxury_services) if content.luxury_services else None,
"luxury_experiences_section_title": content.luxury_experiences_section_title,
"luxury_experiences_section_subtitle": content.luxury_experiences_section_subtitle,
"luxury_experiences": json.loads(content.luxury_experiences) if content.luxury_experiences else None,
"awards_section_title": content.awards_section_title,
"awards_section_subtitle": content.awards_section_subtitle,
"awards": json.loads(content.awards) if content.awards else None,
"cta_title": content.cta_title,
"cta_subtitle": content.cta_subtitle,
"cta_button_text": content.cta_button_text,
"cta_button_link": content.cta_button_link,
"cta_image": content.cta_image,
"partners_section_title": content.partners_section_title,
"partners_section_subtitle": content.partners_section_subtitle,
"partners": json.loads(content.partners) if content.partners else None,
"is_active": content.is_active,
"created_at": content.created_at.isoformat() if content.created_at else None,
"updated_at": content.updated_at.isoformat() if content.updated_at else None,
@@ -65,6 +120,104 @@ async def get_all_page_contents(
)
def get_base_url(request: Request) -> str:
"""Get base URL for image normalization"""
return os.getenv("SERVER_URL") or f"http://{request.headers.get('host', 'localhost:8000')}"
def normalize_image_url(image_url: str, base_url: str) -> str:
"""Normalize image URL to absolute URL"""
if not image_url:
return image_url
if image_url.startswith('http://') or image_url.startswith('https://'):
return image_url
if image_url.startswith('/'):
return f"{base_url}{image_url}"
return f"{base_url}/{image_url}"
@router.post("/upload", dependencies=[Depends(authorize_roles("admin"))])
async def upload_page_content_image(
request: Request,
image: UploadFile = File(...),
current_user: User = Depends(authorize_roles("admin")),
):
"""Upload page content image (Admin only)"""
try:
logger.info(f"Upload request received: filename={image.filename}, content_type={image.content_type}")
# Validate file exists
if not image:
logger.error("No file provided in upload request")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No file provided"
)
# Validate file type
if not image.content_type or not image.content_type.startswith('image/'):
logger.error(f"Invalid file type: {image.content_type}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File must be an image. Received: {image.content_type}"
)
# Validate filename
if not image.filename:
logger.error("No filename provided in upload request")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Filename is required"
)
# Create uploads directory
upload_dir = Path(__file__).parent.parent.parent / "uploads" / "page-content"
upload_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Upload directory: {upload_dir}")
# Generate filename
ext = Path(image.filename).suffix or '.jpg'
filename = f"page-content-{uuid.uuid4()}{ext}"
file_path = upload_dir / filename
# Save file
async with aiofiles.open(file_path, 'wb') as f:
content = await image.read()
if not content:
logger.error("Empty file uploaded")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File is empty"
)
await f.write(content)
logger.info(f"File saved successfully: {file_path}, size: {len(content)} bytes")
# Return the image URL
image_url = f"/uploads/page-content/{filename}"
base_url = get_base_url(request)
full_url = normalize_image_url(image_url, base_url)
logger.info(f"Upload successful: {image_url}")
return {
"success": True,
"status": "success",
"message": "Image uploaded successfully",
"data": {
"image_url": image_url,
"full_url": full_url
}
}
except HTTPException as e:
logger.error(f"HTTPException in upload: {e.detail}")
raise
except Exception as e:
logger.error(f"Unexpected error uploading image: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error uploading image: {str(e)}"
)
@router.get("/{page_type}")
async def get_page_content(
page_type: PageType,
@@ -102,12 +255,60 @@ async def get_page_content(
"social_links": json.loads(content.social_links) if content.social_links else None,
"footer_links": json.loads(content.footer_links) if content.footer_links else None,
"badges": json.loads(content.badges) if content.badges else None,
"copyright_text": content.copyright_text,
"hero_title": content.hero_title,
"hero_subtitle": content.hero_subtitle,
"hero_image": content.hero_image,
"story_content": content.story_content,
"values": json.loads(content.values) if content.values else None,
"features": json.loads(content.features) if content.features else None,
"about_hero_image": content.about_hero_image,
"mission": content.mission,
"vision": content.vision,
"team": json.loads(content.team) if content.team else None,
"timeline": json.loads(content.timeline) if content.timeline else None,
"achievements": json.loads(content.achievements) if content.achievements else None,
"amenities_section_title": content.amenities_section_title,
"amenities_section_subtitle": content.amenities_section_subtitle,
"amenities": json.loads(content.amenities) if content.amenities else None,
"testimonials_section_title": content.testimonials_section_title,
"testimonials_section_subtitle": content.testimonials_section_subtitle,
"testimonials": json.loads(content.testimonials) if content.testimonials else None,
"gallery_section_title": content.gallery_section_title,
"gallery_section_subtitle": content.gallery_section_subtitle,
"gallery_images": json.loads(content.gallery_images) if content.gallery_images else None,
"luxury_section_title": content.luxury_section_title,
"luxury_section_subtitle": content.luxury_section_subtitle,
"luxury_section_image": content.luxury_section_image,
"luxury_features": json.loads(content.luxury_features) if content.luxury_features else None,
"luxury_gallery_section_title": content.luxury_gallery_section_title,
"luxury_gallery_section_subtitle": content.luxury_gallery_section_subtitle,
"luxury_gallery": json.loads(content.luxury_gallery) if content.luxury_gallery else None,
"luxury_testimonials_section_title": content.luxury_testimonials_section_title,
"luxury_testimonials_section_subtitle": content.luxury_testimonials_section_subtitle,
"luxury_testimonials": json.loads(content.luxury_testimonials) if content.luxury_testimonials else None,
"about_preview_title": content.about_preview_title,
"about_preview_subtitle": content.about_preview_subtitle,
"about_preview_content": content.about_preview_content,
"about_preview_image": content.about_preview_image,
"stats": json.loads(content.stats) if content.stats else None,
"luxury_services_section_title": content.luxury_services_section_title,
"luxury_services_section_subtitle": content.luxury_services_section_subtitle,
"luxury_services": json.loads(content.luxury_services) if content.luxury_services else None,
"luxury_experiences_section_title": content.luxury_experiences_section_title,
"luxury_experiences_section_subtitle": content.luxury_experiences_section_subtitle,
"luxury_experiences": json.loads(content.luxury_experiences) if content.luxury_experiences else None,
"awards_section_title": content.awards_section_title,
"awards_section_subtitle": content.awards_section_subtitle,
"awards": json.loads(content.awards) if content.awards else None,
"cta_title": content.cta_title,
"cta_subtitle": content.cta_subtitle,
"cta_button_text": content.cta_button_text,
"cta_button_link": content.cta_button_link,
"cta_image": content.cta_image,
"partners_section_title": content.partners_section_title,
"partners_section_subtitle": content.partners_section_subtitle,
"partners": json.loads(content.partners) if content.partners else None,
"is_active": content.is_active,
"created_at": content.created_at.isoformat() if content.created_at else None,
"updated_at": content.updated_at.isoformat() if content.updated_at else None,
@@ -151,6 +352,12 @@ async def create_or_update_page_content(
story_content: Optional[str] = None,
values: Optional[str] = None,
features: Optional[str] = None,
about_hero_image: Optional[str] = None,
mission: Optional[str] = None,
vision: Optional[str] = None,
team: Optional[str] = None,
timeline: Optional[str] = None,
achievements: Optional[str] = None,
is_active: bool = True,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
@@ -263,6 +470,18 @@ async def create_or_update_page_content(
existing_content.values = values
if features is not None:
existing_content.features = features
if about_hero_image is not None:
existing_content.about_hero_image = about_hero_image
if mission is not None:
existing_content.mission = mission
if vision is not None:
existing_content.vision = vision
if team is not None:
existing_content.team = team
if timeline is not None:
existing_content.timeline = timeline
if achievements is not None:
existing_content.achievements = achievements
if is_active is not None:
existing_content.is_active = is_active
@@ -308,6 +527,12 @@ async def create_or_update_page_content(
story_content=story_content,
values=values,
features=features,
about_hero_image=about_hero_image,
mission=mission,
vision=vision,
team=team,
timeline=timeline,
achievements=achievements,
is_active=is_active,
)
@@ -362,7 +587,10 @@ async def update_page_content(
for key, value in page_data.items():
if hasattr(existing_content, key):
# Handle JSON fields - convert dict/list to JSON string
if key in ["contact_info", "social_links", "footer_links", "badges", "values", "features"] and value is not None:
if key in ["contact_info", "social_links", "footer_links", "badges", "values", "features",
"amenities", "testimonials", "gallery_images", "stats", "luxury_features",
"luxury_gallery", "luxury_testimonials", "luxury_services", "luxury_experiences",
"awards", "partners", "team", "timeline", "achievements"] and value is not None:
if isinstance(value, str):
# Already a string, validate it's valid JSON
try:
@@ -403,12 +631,60 @@ async def update_page_content(
"social_links": json.loads(existing_content.social_links) if existing_content.social_links else None,
"footer_links": json.loads(existing_content.footer_links) if existing_content.footer_links else None,
"badges": json.loads(existing_content.badges) if existing_content.badges else None,
"copyright_text": existing_content.copyright_text,
"hero_title": existing_content.hero_title,
"hero_subtitle": existing_content.hero_subtitle,
"hero_image": existing_content.hero_image,
"story_content": existing_content.story_content,
"values": json.loads(existing_content.values) if existing_content.values else None,
"features": json.loads(existing_content.features) if existing_content.features else None,
"about_hero_image": existing_content.about_hero_image,
"mission": existing_content.mission,
"vision": existing_content.vision,
"team": json.loads(existing_content.team) if existing_content.team else None,
"timeline": json.loads(existing_content.timeline) if existing_content.timeline else None,
"achievements": json.loads(existing_content.achievements) if existing_content.achievements else None,
"amenities_section_title": existing_content.amenities_section_title,
"amenities_section_subtitle": existing_content.amenities_section_subtitle,
"amenities": json.loads(existing_content.amenities) if existing_content.amenities else None,
"testimonials_section_title": existing_content.testimonials_section_title,
"testimonials_section_subtitle": existing_content.testimonials_section_subtitle,
"testimonials": json.loads(existing_content.testimonials) if existing_content.testimonials else None,
"gallery_section_title": existing_content.gallery_section_title,
"gallery_section_subtitle": existing_content.gallery_section_subtitle,
"gallery_images": json.loads(existing_content.gallery_images) if existing_content.gallery_images else None,
"luxury_section_title": existing_content.luxury_section_title,
"luxury_section_subtitle": existing_content.luxury_section_subtitle,
"luxury_section_image": existing_content.luxury_section_image,
"luxury_features": json.loads(existing_content.luxury_features) if existing_content.luxury_features else None,
"luxury_gallery_section_title": existing_content.luxury_gallery_section_title,
"luxury_gallery_section_subtitle": existing_content.luxury_gallery_section_subtitle,
"luxury_gallery": json.loads(existing_content.luxury_gallery) if existing_content.luxury_gallery else None,
"luxury_testimonials_section_title": existing_content.luxury_testimonials_section_title,
"luxury_testimonials_section_subtitle": existing_content.luxury_testimonials_section_subtitle,
"luxury_testimonials": json.loads(existing_content.luxury_testimonials) if existing_content.luxury_testimonials else None,
"about_preview_title": existing_content.about_preview_title,
"about_preview_subtitle": existing_content.about_preview_subtitle,
"about_preview_content": existing_content.about_preview_content,
"about_preview_image": existing_content.about_preview_image,
"stats": json.loads(existing_content.stats) if existing_content.stats else None,
"luxury_services_section_title": existing_content.luxury_services_section_title,
"luxury_services_section_subtitle": existing_content.luxury_services_section_subtitle,
"luxury_services": json.loads(existing_content.luxury_services) if existing_content.luxury_services else None,
"luxury_experiences_section_title": existing_content.luxury_experiences_section_title,
"luxury_experiences_section_subtitle": existing_content.luxury_experiences_section_subtitle,
"luxury_experiences": json.loads(existing_content.luxury_experiences) if existing_content.luxury_experiences else None,
"awards_section_title": existing_content.awards_section_title,
"awards_section_subtitle": existing_content.awards_section_subtitle,
"awards": json.loads(existing_content.awards) if existing_content.awards else None,
"cta_title": existing_content.cta_title,
"cta_subtitle": existing_content.cta_subtitle,
"cta_button_text": existing_content.cta_button_text,
"cta_button_link": existing_content.cta_button_link,
"cta_image": existing_content.cta_image,
"partners_section_title": existing_content.partners_section_title,
"partners_section_subtitle": existing_content.partners_section_subtitle,
"partners": json.loads(existing_content.partners) if existing_content.partners else None,
"is_active": existing_content.is_active,
"updated_at": existing_content.updated_at.isoformat() if existing_content.updated_at else None,
}

View File

@@ -694,18 +694,24 @@ async def upload_room_images(
image_urls = []
for image in images:
# Validate file type
if not image.content_type.startswith('image/'):
if not image.content_type or not image.content_type.startswith('image/'):
continue
# Validate filename
if not image.filename:
continue
# Generate filename
import uuid
ext = Path(image.filename).suffix
ext = Path(image.filename).suffix or '.jpg'
filename = f"room-{uuid.uuid4()}{ext}"
file_path = upload_dir / filename
# Save file
async with aiofiles.open(file_path, 'wb') as f:
content = await image.read()
if not content:
continue
await f.write(content)
image_urls.append(f"/uploads/rooms/{filename}")
@@ -717,6 +723,7 @@ async def upload_room_images(
db.commit()
return {
"success": True,
"status": "success",
"message": "Images uploaded successfully",
"data": {"images": updated_images}

View File

@@ -1127,6 +1127,7 @@ async def upload_company_logo(
full_url = normalize_image_url(image_url, base_url)
return {
"success": True,
"status": "success",
"message": "Logo uploaded successfully",
"data": {
@@ -1235,6 +1236,7 @@ async def upload_company_favicon(
full_url = normalize_image_url(image_url, base_url)
return {
"success": True,
"status": "success",
"message": "Favicon uploaded successfully",
"data": {

View File

@@ -146,16 +146,28 @@ class AuthService:
async def login(self, db: Session, email: str, password: str, remember_me: bool = False, mfa_token: str = None) -> dict:
"""Login user with optional MFA verification"""
# Normalize email (lowercase and strip whitespace)
email = email.lower().strip() if email else ""
if not email:
raise ValueError("Invalid email or password")
# Find user with role and password
user = db.query(User).filter(User.email == email).first()
if not user:
logger.warning(f"Login attempt with non-existent email: {email}")
raise ValueError("Invalid email or password")
# Check if user is active
if not user.is_active:
logger.warning(f"Login attempt for inactive user: {email}")
raise ValueError("Account is disabled. Please contact support.")
# Load role
user.role = db.query(Role).filter(Role.id == user.role_id).first()
# Check password
if not self.verify_password(password, user.password):
logger.warning(f"Login attempt with invalid password for user: {email}")
raise ValueError("Invalid email or password")
# Check if MFA is enabled

View File

@@ -87,10 +87,20 @@ class InvoiceService:
# Calculate amounts - subtotal will be recalculated after adding items
# Initial subtotal is booking total (room + services) or invoice_amount if specified
booking_total = float(booking.total_price)
if invoice_amount is not None:
subtotal = float(invoice_amount)
# For partial invoices, ensure discount is proportional
# If discount_amount seems too large (greater than subtotal), recalculate proportionally
if invoice_amount < booking_total and discount_amount > 0:
# Check if discount seems disproportionate (greater than 50% of subtotal suggests it's the full discount)
if discount_amount > subtotal * 0.5:
# Recalculate proportionally from booking's original discount
proportion = float(invoice_amount) / booking_total
original_discount = float(booking.discount_amount) if booking.discount_amount else discount_amount
discount_amount = original_discount * proportion
else:
subtotal = float(booking.total_price)
subtotal = booking_total
# Calculate tax and total amounts
tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
@@ -388,6 +398,14 @@ class InvoiceService:
@staticmethod
def invoice_to_dict(invoice: Invoice) -> Dict[str, Any]:
"""Convert invoice model to dictionary"""
# Extract promotion code from notes if present
promotion_code = None
if invoice.notes and "Promotion Code:" in invoice.notes:
try:
promotion_code = invoice.notes.split("Promotion Code:")[1].split("\n")[0].strip()
except:
pass
return {
"id": invoice.id,
"invoice_number": invoice.invoice_number,
@@ -419,6 +437,7 @@ class InvoiceService:
"terms_and_conditions": invoice.terms_and_conditions,
"payment_instructions": invoice.payment_instructions,
"is_proforma": invoice.is_proforma if hasattr(invoice, 'is_proforma') else False,
"promotion_code": promotion_code,
"items": [
{
"id": item.id,

View File

@@ -505,10 +505,36 @@ class PayPalService:
except Exception as email_error:
logger.error(f"Failed to send booking confirmation email: {str(email_error)}")
# Send invoice email if payment is completed and invoice is now paid
from ..utils.mailer import send_email
from ..services.invoice_service import InvoiceService
from ..routes.booking_routes import _generate_invoice_email_html
# Load user for email
from ..models.user import User
user = db.query(User).filter(User.id == booking.user_id).first()
for invoice in invoices:
if invoice.status == InvoiceStatus.paid and invoice.balance_due <= 0:
try:
invoice_dict = InvoiceService.invoice_to_dict(invoice)
invoice_html = _generate_invoice_email_html(invoice_dict, is_proforma=invoice.is_proforma)
invoice_type = "Proforma Invoice" if invoice.is_proforma else "Invoice"
if user:
await send_email(
to=user.email,
subject=f"{invoice_type} {invoice.invoice_number} - Payment Confirmed",
html=invoice_html
)
logger.info(f"{invoice_type} {invoice.invoice_number} sent to {user.email}")
except Exception as email_error:
logger.error(f"Failed to send invoice email: {str(email_error)}")
# Send invoice email if payment is completed and invoice is now paid
from ..utils.mailer import send_email
from ..services.invoice_service import InvoiceService
from ..models.invoice import InvoiceStatus
from ..routes.booking_routes import _generate_invoice_email_html
# Load user for email
from ..models.user import User

View File

@@ -404,6 +404,7 @@ class StripeService:
# Send invoice email if payment is completed and invoice is now paid
from ..utils.mailer import send_email
from ..services.invoice_service import InvoiceService
from ..routes.booking_routes import _generate_invoice_email_html
# Load user for email
from ..models.user import User

View File

@@ -58,6 +58,8 @@ const ResetPasswordPage = lazy(() => import('./pages/auth/ResetPasswordPage'));
// Lazy load admin pages
const AdminDashboardPage = lazy(() => import('./pages/admin/DashboardPage'));
const InvoiceManagementPage = lazy(() => import('./pages/admin/InvoiceManagementPage'));
const PaymentManagementPage = lazy(() => import('./pages/admin/PaymentManagementPage'));
const UserManagementPage = lazy(() => import('./pages/admin/UserManagementPage'));
const PageContentDashboardPage = lazy(() => import('./pages/admin/PageContentDashboard'));
const AnalyticsDashboardPage = lazy(() => import('./pages/admin/AnalyticsDashboardPage'));
@@ -321,6 +323,18 @@ function App() {
path="settings"
element={<SettingsPage />}
/>
<Route
path="invoices"
element={<InvoiceManagementPage />}
/>
<Route
path="invoices/:id"
element={<InvoicePage />}
/>
<Route
path="payments"
element={<PaymentManagementPage />}
/>
</Route>
{/* 404 Route */}

View File

@@ -0,0 +1,225 @@
import React, { useState, useMemo } from 'react';
import * as LucideIcons from 'lucide-react';
import { Search, X } from 'lucide-react';
// Popular icons for hotel/luxury content
const popularIcons = [
'Sparkles', 'Star', 'Award', 'Shield', 'Heart', 'Crown', 'Gem',
'Zap', 'Wifi', 'Coffee', 'Utensils', 'Bed', 'Home', 'MapPin',
'Phone', 'Mail', 'Calendar', 'Clock', 'Users', 'UserCheck',
'Car', 'Plane', 'Ship', 'Train', 'Bike', 'Umbrella', 'Sun',
'Moon', 'Cloud', 'Droplet', 'Flame', 'TreePine', 'Mountain',
'Palette', 'Music', 'Camera', 'Video', 'Gamepad2', 'Book',
'Briefcase', 'ShoppingBag', 'Gift', 'Trophy', 'Medal', 'Ribbon',
'CheckCircle', 'XCircle', 'AlertCircle', 'Info', 'HelpCircle',
'Lock', 'Key', 'Eye', 'EyeOff', 'Bell', 'Settings', 'Menu',
'Grid', 'List', 'Layout', 'Maximize', 'Minimize', 'ArrowRight',
'ArrowLeft', 'ArrowUp', 'ArrowDown', 'ChevronRight', 'ChevronLeft',
'ChevronUp', 'ChevronDown', 'Plus', 'Minus', 'X', 'Check',
'Trash2', 'Edit', 'Save', 'Download', 'Upload', 'Share',
'Copy', 'Scissors', 'FileText', 'Image', 'Film', 'Headphones',
'Mic', 'Radio', 'Tv', 'Monitor', 'Laptop', 'Smartphone',
'Tablet', 'Watch', 'Printer', 'HardDrive', 'Database', 'Server',
'Cloud', 'Globe', 'Compass', 'Navigation', 'Map', 'Route',
'Building', 'Building2', 'Hotel', 'Home', 'Store', 'ShoppingCart',
'CreditCard', 'DollarSign', 'Euro', 'PoundSterling', 'Yen',
'Bitcoin', 'TrendingUp', 'TrendingDown', 'BarChart', 'LineChart',
'PieChart', 'Activity', 'Target', 'Flag', 'Tag', 'Bookmark',
'Folder', 'File', 'Archive', 'Inbox', 'Send', 'Inbox',
'MessageSquare', 'MessageCircle', 'MessageCircleMore', 'PhoneCall',
'Video', 'Voicemail', 'AtSign', 'Hash', 'Link', 'ExternalLink',
'Unlink', 'Code', 'Terminal', 'Command', 'Slash', 'Brackets',
'Braces', 'Parentheses', 'Percent', 'Infinity', 'Pi', 'Sigma',
'Omega', 'Alpha', 'Beta', 'Gamma', 'Delta', 'Theta', 'Lambda',
'Mu', 'Nu', 'Xi', 'Omicron', 'Rho', 'Tau', 'Upsilon', 'Phi',
'Chi', 'Psi'
];
interface IconPickerProps {
value?: string;
onChange: (iconName: string) => void;
label?: string;
}
const IconPicker: React.FC<IconPickerProps> = ({ value, onChange, label = 'Icon' }) => {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
// Get all available Lucide icons
const allIcons = useMemo(() => {
const icons: string[] = [];
const excludedNames = new Set([
'createLucideIcon',
'Icon',
'default',
'lucideReact',
'lucide',
'createElement',
'Fragment',
'forwardRef',
'memo'
]);
for (const iconName in LucideIcons) {
// Skip non-icon exports
if (
excludedNames.has(iconName) ||
iconName.startsWith('_') ||
iconName[0] !== iconName[0].toUpperCase() // Lucide icons start with uppercase
) {
continue;
}
const iconComponent = (LucideIcons as any)[iconName];
// Check if it's a React component (function)
if (typeof iconComponent === 'function') {
icons.push(iconName);
}
}
const sorted = icons.sort();
return sorted;
}, []);
// Filter icons based on search
const filteredIcons = useMemo(() => {
if (!searchQuery.trim()) {
// Show popular icons first, then others
const popular = popularIcons.filter(icon => allIcons.includes(icon));
const others = allIcons.filter(icon => !popularIcons.includes(icon));
return [...popular, ...others];
}
return allIcons.filter((icon) =>
icon.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [searchQuery, allIcons]);
const selectedIcon = value && (LucideIcons as any)[value] ? (LucideIcons as any)[value] : null;
const handleIconSelect = (iconName: string) => {
onChange(iconName);
setIsOpen(false);
setSearchQuery('');
};
return (
<div className="relative">
<label className="block text-sm font-semibold text-gray-700 mb-2">{label}</label>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full px-4 py-2 border-2 border-gray-200 rounded-lg bg-white hover:border-purple-400 focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 flex items-center justify-between"
>
<div className="flex items-center gap-3">
{selectedIcon ? (
<>
{React.createElement(selectedIcon, { className: 'w-5 h-5 text-gray-700' })}
<span className="text-gray-700 font-medium">{value}</span>
</>
) : (
<span className="text-gray-400">Select an icon</span>
)}
</div>
<Search className="w-4 h-4 text-gray-400" />
</button>
{isOpen && (
<>
<div
className="fixed inset-0 z-[9998]"
onClick={() => setIsOpen(false)}
/>
<div className="absolute z-[9999] mt-2 w-full bg-white border-2 border-gray-200 rounded-xl shadow-2xl max-h-96 overflow-hidden flex flex-col">
<div className="p-4 border-b border-gray-200 sticky top-0 bg-white z-10">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search icons..."
className="w-full pl-10 pr-4 py-2 border-2 border-gray-200 rounded-lg focus:border-purple-400 focus:ring-4 focus:ring-purple-100 outline-none"
autoFocus
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
<div className="overflow-y-auto p-4">
{filteredIcons.length > 0 ? (
<>
{!searchQuery.trim() && (
<div className="mb-3">
<p className="text-sm text-gray-600">
Showing {filteredIcons.length} icons. Popular icons appear first.
</p>
</div>
)}
<div className="grid grid-cols-6 sm:grid-cols-8 md:grid-cols-10 gap-2">
{filteredIcons.slice(0, searchQuery.trim() ? 500 : 300).map((iconName) => {
const IconComponent = (LucideIcons as any)[iconName];
if (!IconComponent) return null;
const isSelected = value === iconName;
const isPopular = !searchQuery.trim() && popularIcons.includes(iconName);
try {
return (
<button
key={iconName}
type="button"
onClick={() => handleIconSelect(iconName)}
className={`
p-3 rounded-lg border-2 transition-all duration-200
flex items-center justify-center relative
${
isSelected
? 'border-purple-500 bg-purple-50 text-purple-600'
: isPopular
? 'border-amber-200 hover:border-amber-300 hover:bg-amber-50 text-gray-700'
: 'border-gray-200 hover:border-purple-300 hover:bg-purple-50 text-gray-700'
}
`}
title={iconName}
>
{React.createElement(IconComponent, { className: 'w-5 h-5' })}
{isPopular && !isSelected && (
<span className="absolute top-0 right-0 w-2 h-2 bg-amber-400 rounded-full"></span>
)}
</button>
);
} catch (error) {
console.warn(`Failed to render icon: ${iconName}`, error);
return null;
}
})}
</div>
{filteredIcons.length > (searchQuery.trim() ? 500 : 300) && (
<div className="text-center mt-4 text-sm text-gray-500">
Showing first {searchQuery.trim() ? 500 : 300} of {filteredIcons.length} icons. {!searchQuery.trim() && 'Use search to find more.'}
</div>
)}
</>
) : (
<div className="text-center py-8 text-gray-500">
<p>No icons found matching "{searchQuery}"</p>
<p className="text-xs mt-2">Try a different search term</p>
</div>
)}
</div>
</div>
</>
)}
</div>
);
};
export default IconPicker;

View File

@@ -37,7 +37,7 @@ const Footer: React.FC = () => {
useEffect(() => {
const fetchPageContent = async () => {
try {
const response = await pageContentService.getPageContent('footer');
const response = await pageContentService.getFooterContent();
if (response.status === 'success' && response.data?.page_content) {
setPageContent(response.data.page_content);
}
@@ -344,7 +344,12 @@ const Footer: React.FC = () => {
{/* Copyright - Enhanced */}
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
<div className="text-sm text-gray-500 font-light tracking-wide">
&copy; {new Date().getFullYear()} Luxury Hotel. All rights reserved.
{(() => {
const currentYear = new Date().getFullYear();
const copyrightText = pageContent?.copyright_text || '© {YEAR} Luxury Hotel. All rights reserved.';
// Replace {YEAR} placeholder with current year
return copyrightText.replace(/{YEAR}/g, currentYear.toString());
})()}
</div>
<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>

View File

@@ -82,11 +82,11 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
index === currentIndex ? 'opacity-100 z-0 pointer-events-auto' : 'opacity-0 z-0 pointer-events-none'
}`}
>
{banner.link ? (
{banner.link_url ? (
<a
href={banner.link}
target={banner.link.startsWith('http') ? '_blank' : '_self'}
rel={banner.link.startsWith('http') ? 'noopener noreferrer' : undefined}
href={banner.link_url}
target={banner.link_url.startsWith('http') ? '_blank' : '_self'}
rel={banner.link_url.startsWith('http') ? 'noopener noreferrer' : undefined}
className="block w-full h-full"
>
<img
@@ -128,11 +128,11 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
}}
/>
{/* Title - Positioned at top when search form is present */}
{/* Title - Positioned higher up on the banner */}
{currentBanner.title && (
<div
key={currentIndex}
className={`absolute ${children ? 'top-12 sm:top-16 md:top-20 lg:top-24' : 'bottom-16 sm:bottom-20 md:bottom-24'}
className={`absolute ${children ? 'top-[25%] sm:top-[28%] md:top-[30%] lg:top-[32%]' : 'top-[30%] sm:top-[35%] md:top-[38%] lg:top-[40%]'}
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
@@ -194,12 +194,32 @@ const BannerCarousel: React.FC<BannerCarouselProps> = ({
</span>
</h2>
{/* Animated decorative line below title */}
{/* Description text - centered below title */}
{currentBanner.description && (
<p
className="text-sm sm:text-base md:text-lg lg:text-xl xl:text-2xl
font-light text-white/95 text-center
max-w-3xl mx-auto
leading-relaxed sm:leading-relaxed md:leading-relaxed
drop-shadow-[0_2px_6px_rgba(0,0,0,0.5)]
[text-shadow:_0_1px_10px_rgba(0,0,0,0.7)]
mt-2 sm:mt-3 md:mt-4
px-2 sm:px-4 md:px-6
opacity-0 animate-textReveal"
style={{
animation: 'textReveal 1s cubic-bezier(0.4, 0, 0.2, 1) 0.7s forwards',
}}
>
{currentBanner.description}
</p>
)}
{/* Animated decorative line below title/description */}
<div
className="w-0 h-0.5 bg-gradient-to-r from-transparent via-[#d4af37] to-transparent mt-3 sm:mt-4 opacity-90
animate-lineExpand"
style={{
animation: 'lineExpand 1.2s cubic-bezier(0.4, 0, 0.2, 1) 0.6s forwards',
animation: 'lineExpand 1.2s cubic-bezier(0.4, 0, 0.2, 1) 0.9s forwards',
maxWidth: '120px',
}}
/>

View File

@@ -0,0 +1,65 @@
// Seed data for luxury hotel content
export const luxuryContentSeed = {
home: {
luxury_section_title: 'Experience Unparalleled Luxury',
luxury_section_subtitle: 'Where elegance meets comfort in every detail',
luxury_section_image: '',
luxury_features: [
{
icon: 'Sparkles',
title: 'Premium Amenities',
description: 'World-class facilities designed for your comfort and relaxation'
},
{
icon: 'Crown',
title: 'Royal Service',
description: 'Dedicated concierge service available 24/7 for all your needs'
},
{
icon: 'Award',
title: 'Award-Winning',
description: 'Recognized for excellence in hospitality and guest satisfaction'
},
{
icon: 'Shield',
title: 'Secure & Private',
description: 'Your privacy and security are our top priorities'
},
{
icon: 'Heart',
title: 'Personalized Care',
description: 'Tailored experiences crafted just for you'
},
{
icon: 'Gem',
title: 'Luxury Design',
description: 'Elegantly designed spaces with attention to every detail'
}
],
luxury_gallery: [],
luxury_testimonials: [
{
name: 'Sarah Johnson',
title: 'Business Executive',
quote: 'An absolutely stunning experience. The attention to detail and level of service exceeded all expectations.',
image: ''
},
{
name: 'Michael Chen',
title: 'Travel Enthusiast',
quote: 'The epitome of luxury. Every moment was perfect, from check-in to check-out.',
image: ''
},
{
name: 'Emma Williams',
title: 'Luxury Traveler',
quote: 'This hotel redefines what luxury means. I will definitely return.',
image: ''
}
],
about_preview_title: 'About Our Luxury Hotel',
about_preview_content: 'Discover a world of refined elegance and exceptional service. Our hotel combines timeless luxury with modern amenities to create an unforgettable experience.',
about_preview_image: ''
}
};

View File

@@ -1,16 +1,14 @@
import React, { useState, useEffect } from 'react';
import {
Hotel,
Award,
Users,
Heart,
MapPin,
Phone,
Mail,
Star,
Shield,
Clock
Linkedin,
Twitter
} from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { Link } from 'react-router-dom';
import { pageContentService } from '../services/api';
import type { PageContent } from '../services/api/pageContentService';
@@ -23,7 +21,7 @@ const AboutPage: React.FC = () => {
useEffect(() => {
const fetchPageContent = async () => {
try {
const response = await pageContentService.getPageContent('about');
const response = await pageContentService.getAboutContent();
if (response.status === 'success' && response.data?.page_content) {
setPageContent(response.data.page_content);
@@ -58,22 +56,22 @@ const AboutPage: React.FC = () => {
// Default values
const defaultValues = [
{
icon: Heart,
icon: 'Heart',
title: 'Passion',
description: 'We are passionate about hospitality and dedicated to creating exceptional experiences for every guest.'
},
{
icon: Award,
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,
icon: 'Shield',
title: 'Integrity',
description: 'We conduct our business with honesty, transparency, and respect for our guests and community.'
},
{
icon: Users,
icon: 'Users',
title: 'Service',
description: 'Our guests are at the heart of everything we do. Your comfort and satisfaction are our top priorities.'
}
@@ -81,17 +79,17 @@ const AboutPage: React.FC = () => {
const defaultFeatures = [
{
icon: Star,
icon: 'Star',
title: 'Premium Accommodations',
description: 'Luxuriously appointed rooms and suites designed for ultimate comfort and relaxation.'
},
{
icon: Clock,
icon: 'Clock',
title: '24/7 Service',
description: 'Round-the-clock concierge and room service to attend to your needs at any time.'
},
{
icon: Award,
icon: 'Award',
title: 'Award-Winning',
description: 'Recognized for excellence in hospitality and guest satisfaction.'
}
@@ -99,7 +97,7 @@ const AboutPage: React.FC = () => {
const values = pageContent?.values && pageContent.values.length > 0
? pageContent.values.map((v: any) => ({
icon: defaultValues.find(d => d.title === v.title)?.icon || Heart,
icon: v.icon || defaultValues.find(d => d.title === v.title)?.icon || 'Heart',
title: v.title,
description: v.description
}))
@@ -107,60 +105,122 @@ const AboutPage: React.FC = () => {
const features = pageContent?.features && pageContent.features.length > 0
? pageContent.features.map((f: any) => ({
icon: defaultFeatures.find(d => d.title === f.title)?.icon || Star,
icon: f.icon || defaultFeatures.find(d => d.title === f.title)?.icon || 'Star',
title: f.title,
description: f.description
}))
: defaultFeatures;
// Parse JSON fields
const team = pageContent?.team && typeof pageContent.team === 'string'
? JSON.parse(pageContent.team)
: (Array.isArray(pageContent?.team) ? pageContent.team : []);
const timeline = pageContent?.timeline && typeof pageContent.timeline === 'string'
? JSON.parse(pageContent.timeline)
: (Array.isArray(pageContent?.timeline) ? pageContent.timeline : []);
const achievements = pageContent?.achievements && typeof pageContent.achievements === 'string'
? JSON.parse(pageContent.achievements)
: (Array.isArray(pageContent?.achievements) ? pageContent.achievements : []);
// Helper to get icon component
const getIconComponent = (iconName?: string) => {
if (!iconName) return Heart;
const IconComponent = (LucideIcons as any)[iconName] || Heart;
return IconComponent;
};
return (
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-white">
<div className="min-h-screen bg-gradient-to-b from-slate-50 via-white to-slate-50">
{/* 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 className={`relative ${pageContent?.about_hero_image ? '' : 'bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900'} text-white py-20 md:py-24 ${pageContent?.about_hero_image ? 'h-[400px] md:h-[450px] lg:h-[500px]' : ''} overflow-hidden`}>
{pageContent?.about_hero_image && (
<div className="absolute inset-0">
<img
src={pageContent.about_hero_image.startsWith('http') ? pageContent.about_hero_image : `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}${pageContent.about_hero_image}`}
alt="About Hero"
className="w-full h-full object-cover scale-105 transition-transform duration-[20s] ease-out hover:scale-100"
/>
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/50 to-black/70"></div>
<div className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/10 via-transparent to-[#d4af37]/10"></div>
</div>
)}
{!pageContent?.about_hero_image && (
<div className="absolute inset-0">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(212,175,55,0.1),transparent_70%)]"></div>
<div className="absolute top-0 left-0 w-full h-full bg-[linear-gradient(45deg,transparent_30%,rgba(212,175,55,0.05)_50%,transparent_70%)]"></div>
</div>
)}
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
<div className="max-w-4xl mx-auto text-center">
{!pageContent?.about_hero_image && (
<div className="flex justify-center mb-8">
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37] via-[#f5d76e] to-[#d4af37] rounded-full blur-3xl opacity-40 animate-pulse"></div>
<div className="relative bg-gradient-to-br from-[#d4af37] to-[#c9a227] p-6 rounded-2xl shadow-2xl shadow-[#d4af37]/30">
<Hotel className="w-12 h-12 md:w-16 md:h-16 text-white drop-shadow-lg" />
</div>
</div>
</div>
)}
<div className="mb-6">
<div className="inline-block mb-4">
<div className="h-px w-20 bg-gradient-to-r from-transparent via-[#d4af37] to-transparent mx-auto"></div>
</div>
</div>
<h1 className="text-5xl md:text-6xl font-serif font-bold mb-6 tracking-tight">
{pageContent?.title || 'About Luxury Hotel'}
<h1 className="text-5xl md:text-6xl lg:text-7xl font-serif font-light mb-6 tracking-[0.02em] leading-tight">
<span className="bg-gradient-to-b from-white via-[#f5d76e] to-[#d4af37] bg-clip-text text-transparent drop-shadow-2xl">
{pageContent?.title || 'About Luxury Hotel'}
</span>
</h1>
<p className="text-xl text-gray-300 font-light leading-relaxed">
<p className="text-lg md:text-xl lg:text-2xl text-gray-200 font-light leading-relaxed max-w-2xl mx-auto tracking-wide">
{pageContent?.subtitle || pageContent?.description || 'Where Excellence Meets Unforgettable Experiences'}
</p>
<div className="mt-8">
<div className="inline-block">
<div className="h-px w-20 bg-gradient-to-r from-transparent via-[#d4af37] to-transparent mx-auto"></div>
</div>
</div>
</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">
<section className="py-20 md:py-28 bg-white relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[#d4af37]/30 to-transparent"></div>
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-16">
<div className="inline-block mb-4">
<span className="text-sm font-semibold text-[#d4af37] tracking-[0.2em] uppercase">Our Heritage</span>
</div>
<h2 className="text-4xl md:text-5xl lg:text-6xl font-serif font-light text-gray-900 mb-6 tracking-tight">
Our Story
</h2>
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
<div className="flex items-center justify-center gap-4 mb-6">
<div className="h-px w-12 bg-gradient-to-r from-transparent to-[#d4af37]"></div>
<div className="w-2 h-2 bg-[#d4af37] rounded-full"></div>
<div className="h-px w-12 bg-gradient-to-l from-transparent to-[#d4af37]"></div>
</div>
</div>
<div className="prose prose-lg max-w-none text-gray-700 leading-relaxed space-y-6 animate-slide-up">
<div className="prose prose-lg md:prose-xl max-w-none text-gray-700 leading-relaxed space-y-8">
{pageContent?.story_content ? (
<div dangerouslySetInnerHTML={{ __html: pageContent.story_content.replace(/\n/g, '<br />') }} />
<div
className="text-lg md:text-xl leading-relaxed font-light tracking-wide"
dangerouslySetInnerHTML={{ __html: pageContent.story_content.replace(/\n/g, '<br />') }}
/>
) : (
<>
<p>
<p className="text-lg md:text-xl leading-relaxed font-light tracking-wide first-letter:text-5xl first-letter:font-serif first-letter:text-[#d4af37] first-letter:float-left first-letter:mr-2 first-letter:leading-none">
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>
<p className="text-lg md:text-xl leading-relaxed font-light tracking-wide">
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>
<p className="text-lg md:text-xl leading-relaxed font-light tracking-wide">
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.
@@ -170,34 +230,49 @@ const AboutPage: React.FC = () => {
</div>
</div>
</div>
<div className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[#d4af37]/30 to-transparent"></div>
</section>
{/* Values Section */}
<section className="py-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">
<section className="py-20 md:py-28 bg-gradient-to-b from-slate-50 via-white to-slate-50 relative">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_50%,rgba(212,175,55,0.03),transparent_50%)]"></div>
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-16">
<div className="inline-block mb-4">
<span className="text-sm font-semibold text-[#d4af37] tracking-[0.2em] uppercase">Core Principles</span>
</div>
<h2 className="text-4xl md:text-5xl lg:text-6xl font-serif font-light text-gray-900 mb-6 tracking-tight">
Our Values
</h2>
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
<div className="flex items-center justify-center gap-4 mb-6">
<div className="h-px w-12 bg-gradient-to-r from-transparent to-[#d4af37]"></div>
<div className="w-2 h-2 bg-[#d4af37] rounded-full"></div>
<div className="h-px w-12 bg-gradient-to-l from-transparent to-[#d4af37]"></div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8">
{values.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"
className="group relative bg-white/80 backdrop-blur-sm p-8 rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-500 border border-gray-100 hover:border-[#d4af37]/30 hover:-translate-y-2"
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 className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-[#d4af37]/5 to-transparent rounded-bl-full opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
<div className="relative">
<div className="w-16 h-16 bg-gradient-to-br from-[#d4af37] via-[#f5d76e] to-[#d4af37] rounded-xl flex items-center justify-center mb-6 shadow-lg shadow-[#d4af37]/20 group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500">
{(() => {
const ValueIcon = getIconComponent(value.icon);
return <ValueIcon className="w-8 h-8 text-white drop-shadow-md" />;
})()}
</div>
<h3 className="text-xl md:text-2xl font-serif font-semibold text-gray-900 mb-3 group-hover:text-[#d4af37] transition-colors duration-300">
{value.title}
</h3>
<p className="text-gray-600 leading-relaxed font-light text-sm md:text-base">
{value.description}
</p>
</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>
@@ -206,60 +281,303 @@ const AboutPage: React.FC = () => {
</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">
<section className="py-20 md:py-28 bg-white relative overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(135deg,transparent_0%,rgba(212,175,55,0.02)_50%,transparent_100%)]"></div>
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-16">
<div className="inline-block mb-4">
<span className="text-sm font-semibold text-[#d4af37] tracking-[0.2em] uppercase">Excellence Defined</span>
</div>
<h2 className="text-4xl md:text-5xl lg:text-6xl font-serif font-light text-gray-900 mb-6 tracking-tight">
Why Choose Us
</h2>
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
<div className="flex items-center justify-center gap-4 mb-6">
<div className="h-px w-12 bg-gradient-to-r from-transparent to-[#d4af37]"></div>
<div className="w-2 h-2 bg-[#d4af37] rounded-full"></div>
<div className="h-px w-12 bg-gradient-to-l from-transparent to-[#d4af37]"></div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{features.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 className="grid grid-cols-1 md:grid-cols-3 gap-8 lg:gap-12">
{features.map((feature, index) => {
const FeatureIcon = getIconComponent(feature.icon);
return (
<div
key={feature.title || index}
className="group text-center p-8 relative"
style={{ animationDelay: `${index * 0.1}s` }}
>
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37]/5 to-transparent rounded-3xl opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
<div className="relative">
<div className="w-20 h-20 bg-gradient-to-br from-[#d4af37] via-[#f5d76e] to-[#d4af37] rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-xl shadow-[#d4af37]/30 group-hover:scale-110 group-hover:shadow-2xl group-hover:shadow-[#d4af37]/40 transition-all duration-500">
<FeatureIcon className="w-10 h-10 text-white drop-shadow-lg" />
</div>
<h3 className="text-xl md:text-2xl font-serif font-semibold text-gray-900 mb-4 group-hover:text-[#d4af37] transition-colors duration-300">
{feature.title}
</h3>
<p className="text-gray-600 leading-relaxed font-light text-sm md:text-base">
{feature.description}
</p>
</div>
</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>
{/* Mission & Vision Section */}
{(pageContent?.mission || pageContent?.vision) && (
<section className="py-20 md:py-28 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 relative overflow-hidden">
<div className="absolute inset-0">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(212,175,55,0.1),transparent_70%)]"></div>
<div className="absolute top-0 left-0 w-full h-full bg-[linear-gradient(45deg,transparent_30%,rgba(212,175,55,0.05)_50%,transparent_70%)]"></div>
</div>
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-2 gap-8 lg:gap-12">
{pageContent.mission && (
<div className="group relative bg-white/95 backdrop-blur-sm p-10 md:p-12 rounded-2xl shadow-2xl border border-[#d4af37]/20 hover:border-[#d4af37]/40 transition-all duration-500 hover:shadow-[#d4af37]/20 hover:-translate-y-1">
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-[#d4af37]/10 to-transparent rounded-bl-full opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
<div className="relative">
<div className="flex items-center gap-3 mb-6">
<div className="w-1 h-12 bg-gradient-to-b from-[#d4af37] to-[#f5d76e] rounded-full"></div>
<h2 className="text-3xl md:text-4xl font-serif font-light text-gray-900">Our Mission</h2>
</div>
<p className="text-gray-700 leading-relaxed text-base md:text-lg font-light tracking-wide">{pageContent.mission}</p>
</div>
</div>
)}
{pageContent.vision && (
<div className="group relative bg-white/95 backdrop-blur-sm p-10 md:p-12 rounded-2xl shadow-2xl border border-[#d4af37]/20 hover:border-[#d4af37]/40 transition-all duration-500 hover:shadow-[#d4af37]/20 hover:-translate-y-1">
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-[#d4af37]/10 to-transparent rounded-bl-full opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
<div className="relative">
<div className="flex items-center gap-3 mb-6">
<div className="w-1 h-12 bg-gradient-to-b from-[#d4af37] to-[#f5d76e] rounded-full"></div>
<h2 className="text-3xl md:text-4xl font-serif font-light text-gray-900">Our Vision</h2>
</div>
<p className="text-gray-700 leading-relaxed text-base md:text-lg font-light tracking-wide">{pageContent.vision}</p>
</div>
</div>
)}
</div>
</div>
</section>
)}
{/* Team Section */}
{team && team.length > 0 && (
<section className="py-20 md:py-28 bg-gradient-to-b from-white via-slate-50 to-white relative">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-16">
<div className="inline-block mb-4">
<span className="text-sm font-semibold text-[#d4af37] tracking-[0.2em] uppercase">Meet The Experts</span>
</div>
<h2 className="text-4xl md:text-5xl lg:text-6xl font-serif font-light text-gray-900 mb-6 tracking-tight">
Our Team
</h2>
<div className="flex items-center justify-center gap-4 mb-6">
<div className="h-px w-12 bg-gradient-to-r from-transparent to-[#d4af37]"></div>
<div className="w-2 h-2 bg-[#d4af37] rounded-full"></div>
<div className="h-px w-12 bg-gradient-to-l from-transparent to-[#d4af37]"></div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 lg:gap-10">
{team.map((member: any, index: number) => (
<div
key={index}
className="group relative bg-white rounded-2xl shadow-xl overflow-hidden hover:shadow-2xl transition-all duration-500 border border-gray-100 hover:border-[#d4af37]/30 hover:-translate-y-2"
>
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37]/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-10"></div>
{member.image && (
<div className="relative overflow-hidden h-72">
<img
src={member.image.startsWith('http') ? member.image : `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}${member.image}`}
alt={member.name}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
</div>
)}
<div className="p-8 relative z-10">
<h3 className="text-2xl font-serif font-semibold text-gray-900 mb-2 group-hover:text-[#d4af37] transition-colors duration-300">{member.name}</h3>
<p className="text-[#d4af37] font-medium mb-4 text-sm tracking-wide uppercase">{member.role}</p>
{member.bio && <p className="text-gray-600 text-sm mb-6 leading-relaxed font-light">{member.bio}</p>}
{member.social_links && (
<div className="flex gap-4 pt-4 border-t border-gray-100">
{member.social_links.linkedin && (
<a
href={member.social_links.linkedin}
target="_blank"
rel="noopener noreferrer"
className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 hover:bg-[#d4af37] hover:text-white transition-all duration-300 group-hover:scale-110"
>
<Linkedin className="w-5 h-5" />
</a>
)}
{member.social_links.twitter && (
<a
href={member.social_links.twitter}
target="_blank"
rel="noopener noreferrer"
className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 hover:bg-[#d4af37] hover:text-white transition-all duration-300 group-hover:scale-110"
>
<Twitter className="w-5 h-5" />
</a>
)}
</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
</section>
)}
{/* Timeline Section */}
{timeline && timeline.length > 0 && (
<section className="py-20 md:py-28 bg-gradient-to-b from-slate-50 via-white to-slate-50 relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_70%_50%,rgba(212,175,55,0.03),transparent_50%)]"></div>
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16">
<div className="inline-block mb-4">
<span className="text-sm font-semibold text-[#d4af37] tracking-[0.2em] uppercase">Our Journey</span>
</div>
<h2 className="text-4xl md:text-5xl lg:text-6xl font-serif font-light text-gray-900 mb-6 tracking-tight">
Our History
</h2>
<div className="flex items-center justify-center gap-4 mb-6">
<div className="h-px w-12 bg-gradient-to-r from-transparent to-[#d4af37]"></div>
<div className="w-2 h-2 bg-[#d4af37] rounded-full"></div>
<div className="h-px w-12 bg-gradient-to-l from-transparent to-[#d4af37]"></div>
</div>
</div>
<div className="relative">
<div className="absolute left-8 md:left-1/2 transform md:-translate-x-1/2 w-1 h-full bg-gradient-to-b from-[#d4af37] via-[#f5d76e] to-[#d4af37] shadow-lg"></div>
<div className="space-y-12 md:space-y-16">
{timeline.map((event: any, index: number) => (
<div key={index} className={`relative flex items-center ${index % 2 === 0 ? 'md:flex-row' : 'md:flex-row-reverse'}`}>
<div className="absolute left-6 md:left-1/2 transform md:-translate-x-1/2 w-6 h-6 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full border-4 border-white shadow-xl z-10 group-hover:scale-125 transition-transform duration-300"></div>
<div className={`ml-20 md:ml-0 md:w-5/12 ${index % 2 === 0 ? 'md:mr-auto md:pr-8' : 'md:ml-auto md:pl-8'}`}>
<div className="group bg-white/90 backdrop-blur-sm p-8 rounded-2xl shadow-xl hover:shadow-2xl transition-all duration-500 border border-gray-100 hover:border-[#d4af37]/30">
<div className="flex items-center gap-3 mb-4">
<div className="text-[#d4af37] font-bold text-2xl md:text-3xl font-serif">{event.year}</div>
<div className="h-px flex-1 bg-gradient-to-r from-[#d4af37] to-transparent"></div>
</div>
<h3 className="text-2xl md:text-3xl font-serif font-semibold text-gray-900 mb-3 group-hover:text-[#d4af37] transition-colors duration-300">{event.title}</h3>
<p className="text-gray-600 leading-relaxed font-light mb-4">{event.description}</p>
{event.image && (
<div className="mt-6 overflow-hidden rounded-xl">
<img
src={event.image.startsWith('http') ? event.image : `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}${event.image}`}
alt={event.title}
className="w-full h-56 md:h-64 object-cover group-hover:scale-110 transition-transform duration-700"
/>
</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</section>
)}
{/* Achievements Section */}
{achievements && achievements.length > 0 && (
<section className="py-20 md:py-28 bg-white relative overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(135deg,transparent_0%,rgba(212,175,55,0.02)_50%,transparent_100%)]"></div>
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-16">
<div className="inline-block mb-4">
<span className="text-sm font-semibold text-[#d4af37] tracking-[0.2em] uppercase">Recognition</span>
</div>
<h2 className="text-4xl md:text-5xl lg:text-6xl font-serif font-light text-gray-900 mb-6 tracking-tight">
Achievements & Awards
</h2>
<div className="flex items-center justify-center gap-4 mb-6">
<div className="h-px w-12 bg-gradient-to-r from-transparent to-[#d4af37]"></div>
<div className="w-2 h-2 bg-[#d4af37] rounded-full"></div>
<div className="h-px w-12 bg-gradient-to-l from-transparent to-[#d4af37]"></div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 lg:gap-10">
{achievements.map((achievement: any, index: number) => {
const AchievementIcon = getIconComponent(achievement.icon);
return (
<div
key={index}
className="group relative bg-gradient-to-br from-white to-slate-50 p-8 rounded-2xl shadow-xl hover:shadow-2xl transition-all duration-500 border border-gray-100 hover:border-[#d4af37]/40 hover:-translate-y-2"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-[#d4af37]/10 to-transparent rounded-bl-full opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
<div className="relative">
<div className="flex items-center gap-4 mb-6">
<div className="w-16 h-16 bg-gradient-to-br from-[#d4af37] via-[#f5d76e] to-[#d4af37] rounded-xl flex items-center justify-center shadow-lg shadow-[#d4af37]/20 group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500">
<AchievementIcon className="w-8 h-8 text-white drop-shadow-md" />
</div>
{achievement.year && (
<div className="text-[#d4af37] font-bold text-2xl font-serif">{achievement.year}</div>
)}
</div>
<h3 className="text-xl md:text-2xl font-serif font-semibold text-gray-900 mb-3 group-hover:text-[#d4af37] transition-colors duration-300">{achievement.title}</h3>
<p className="text-gray-600 text-sm md:text-base leading-relaxed font-light mb-4">{achievement.description}</p>
{achievement.image && (
<div className="mt-6 overflow-hidden rounded-xl">
<img
src={achievement.image.startsWith('http') ? achievement.image : `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}${achievement.image}`}
alt={achievement.title}
className="w-full h-40 object-cover group-hover:scale-110 transition-transform duration-700"
/>
</div>
)}
</div>
</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">
<section className="py-20 md:py-28 bg-gradient-to-br from-slate-50 via-white to-slate-50 relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(212,175,55,0.03),transparent_70%)]"></div>
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16">
<div className="inline-block mb-4">
<span className="text-sm font-semibold text-[#d4af37] tracking-[0.2em] uppercase">Connect With Us</span>
</div>
<h2 className="text-4xl md:text-5xl lg:text-6xl font-serif font-light text-gray-900 mb-6 tracking-tight">
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">
<div className="flex items-center justify-center gap-4 mb-6">
<div className="h-px w-12 bg-gradient-to-r from-transparent to-[#d4af37]"></div>
<div className="w-2 h-2 bg-[#d4af37] rounded-full"></div>
<div className="h-px w-12 bg-gradient-to-l from-transparent to-[#d4af37]"></div>
</div>
<p className="text-gray-600 mt-6 text-lg font-light max-w-2xl mx-auto">
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 className="grid grid-cols-1 md:grid-cols-3 gap-8 lg:gap-10 mb-16">
<div className="group text-center p-8 bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl hover:shadow-2xl transition-all duration-500 border border-gray-100 hover:border-[#d4af37]/40 hover:-translate-y-2">
<div className="w-16 h-16 bg-gradient-to-br from-[#d4af37] via-[#f5d76e] to-[#d4af37] rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg shadow-[#d4af37]/20 group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500">
<MapPin className="w-8 h-8 text-white drop-shadow-md" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
<h3 className="text-xl font-serif font-semibold text-gray-900 mb-4 group-hover:text-[#d4af37] transition-colors duration-300">
Address
</h3>
<p className="text-gray-600">
<p className="text-gray-600 leading-relaxed font-light">
{displayAddress
.split('\n').map((line, i) => (
<React.Fragment key={i}>
@@ -269,40 +587,41 @@ const AboutPage: React.FC = () => {
))}
</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 className="group text-center p-8 bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl hover:shadow-2xl transition-all duration-500 border border-gray-100 hover:border-[#d4af37]/40 hover:-translate-y-2" style={{ animationDelay: '0.1s' }}>
<div className="w-16 h-16 bg-gradient-to-br from-[#d4af37] via-[#f5d76e] to-[#d4af37] rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg shadow-[#d4af37]/20 group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500">
<Phone className="w-8 h-8 text-white drop-shadow-md" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
<h3 className="text-xl font-serif font-semibold text-gray-900 mb-4 group-hover:text-[#d4af37] transition-colors duration-300">
Phone
</h3>
<p className="text-gray-600">
<a href={`tel:${displayPhone.replace(/\s+/g, '').replace(/[()]/g, '')}`} className="hover:text-[#d4af37] transition-colors">
<p className="text-gray-600 font-light">
<a href={`tel:${displayPhone.replace(/\s+/g, '').replace(/[()]/g, '')}`} className="hover:text-[#d4af37] transition-colors duration-300">
{displayPhone}
</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 className="group text-center p-8 bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl hover:shadow-2xl transition-all duration-500 border border-gray-100 hover:border-[#d4af37]/40 hover:-translate-y-2" style={{ animationDelay: '0.2s' }}>
<div className="w-16 h-16 bg-gradient-to-br from-[#d4af37] via-[#f5d76e] to-[#d4af37] rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg shadow-[#d4af37]/20 group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500">
<Mail className="w-8 h-8 text-white drop-shadow-md" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
<h3 className="text-xl font-serif font-semibold text-gray-900 mb-4 group-hover:text-[#d4af37] transition-colors duration-300">
Email
</h3>
<p className="text-gray-600">
<a href={`mailto:${displayEmail}`} className="hover:text-[#d4af37] transition-colors">
<p className="text-gray-600 font-light">
<a href={`mailto:${displayEmail}`} className="hover:text-[#d4af37] transition-colors duration-300">
{displayEmail}
</a>
</p>
</div>
</div>
<div className="text-center mt-12 animate-fade-in">
<div className="text-center">
<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"
className="group inline-flex items-center space-x-3 px-10 py-4 bg-gradient-to-r from-[#d4af37] via-[#f5d76e] to-[#d4af37] text-white rounded-xl hover:shadow-2xl hover:shadow-[#d4af37]/40 transition-all duration-500 font-medium text-lg tracking-wide relative overflow-hidden"
>
<span>Explore Our Rooms</span>
<Hotel className="w-5 h-5" />
<span className="relative z-10">Explore Our Rooms</span>
<Hotel className="w-5 h-5 relative z-10 group-hover:translate-x-1 transition-transform duration-300" />
<div className="absolute inset-0 bg-gradient-to-r from-[#f5d76e] to-[#d4af37] opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
</Link>
</div>
</div>

View File

@@ -99,7 +99,7 @@ const ContactPage: React.FC = () => {
useEffect(() => {
const fetchPageContent = async () => {
try {
const response = await pageContentService.getPageContent('contact');
const response = await pageContentService.getContactContent();
if (response.status === 'success' && response.data?.page_content) {
setPageContent(response.data.page_content);

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,23 @@
import React, { useEffect, useState } from 'react';
import { Search, Eye, XCircle, CheckCircle, Loader2 } from 'lucide-react';
import { bookingService, Booking } from '../../services/api';
import { Search, Eye, XCircle, CheckCircle, Loader2, FileText } from 'lucide-react';
import { bookingService, Booking, invoiceService } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import Pagination from '../../components/common/Pagination';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { parseDateLocal } from '../../utils/format';
import { useNavigate } from 'react-router-dom';
const BookingManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const navigate = useNavigate();
const [bookings, setBookings] = useState<Booking[]>([]);
const [loading, setLoading] = useState(true);
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false);
const [updatingBookingId, setUpdatingBookingId] = useState<number | null>(null);
const [cancellingBookingId, setCancellingBookingId] = useState<number | null>(null);
const [creatingInvoice, setCreatingInvoice] = useState(false);
const [filters, setFilters] = useState({
search: '',
status: '',
@@ -80,6 +83,32 @@ const BookingManagementPage: React.FC = () => {
}
};
const handleCreateInvoice = async (bookingId: number) => {
try {
setCreatingInvoice(true);
// Ensure bookingId is a number
const invoiceData = {
booking_id: Number(bookingId),
};
const response = await invoiceService.createInvoice(invoiceData);
if (response.status === 'success' && response.data?.invoice) {
toast.success('Invoice created successfully!');
setShowDetailModal(false);
navigate(`/admin/invoices/${response.data.invoice.id}`);
} else {
throw new Error('Failed to create invoice');
}
} catch (error: any) {
const errorMessage = error.response?.data?.detail || error.response?.data?.message || error.message || 'Unable to create invoice';
toast.error(errorMessage);
console.error('Invoice creation error:', error);
} finally {
setCreatingInvoice(false);
}
};
const getStatusBadge = (status: string) => {
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
pending: {
@@ -622,7 +651,24 @@ const BookingManagementPage: React.FC = () => {
</div>
{/* Modal Footer */}
<div className="mt-8 pt-6 border-t border-slate-200 flex justify-end">
<div className="mt-8 pt-6 border-t border-slate-200 flex justify-between items-center">
<button
onClick={() => handleCreateInvoice(selectedBooking.id)}
disabled={creatingInvoice}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-lg hover:shadow-xl"
>
{creatingInvoice ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Creating Invoice...
</>
) : (
<>
<FileText className="w-5 h-5" />
Create Invoice
</>
)}
</button>
<button
onClick={() => setShowDetailModal(false)}
className="px-8 py-3 bg-gradient-to-r from-slate-700 to-slate-800 text-white rounded-xl font-semibold hover:from-slate-800 hover:to-slate-900 transition-all duration-200 shadow-lg hover:shadow-xl border border-slate-600"

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Search, User, Hotel, CheckCircle, AlertCircle } from 'lucide-react';
import React, { useState, useEffect } from 'react';
import { Search, User, Hotel, CheckCircle, AlertCircle, Calendar, LogIn, LogOut } from 'lucide-react';
import { bookingService, Booking } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
@@ -14,7 +14,14 @@ interface GuestInfo {
const CheckInPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
const [bookingNumber, setBookingNumber] = useState('');
const [selectedDate, setSelectedDate] = useState<string>(() => {
const today = new Date();
return today.toISOString().split('T')[0];
});
const [searchQuery, setSearchQuery] = useState('');
const [checkInBookings, setCheckInBookings] = useState<Booking[]>([]);
const [checkOutBookings, setCheckOutBookings] = useState<Booking[]>([]);
const [loadingBookings, setLoadingBookings] = useState(false);
const [booking, setBooking] = useState<Booking | null>(null);
const [loading, setLoading] = useState(false);
const [searching, setSearching] = useState(false);
@@ -24,19 +31,77 @@ const CheckInPage: React.FC = () => {
const [children, setChildren] = useState(0);
const [additionalFee, setAdditionalFee] = useState(0);
const handleSearch = async () => {
if (!bookingNumber.trim()) {
// Fetch bookings for the selected date
useEffect(() => {
fetchBookingsForDate();
}, [selectedDate, searchQuery]);
const fetchBookingsForDate = async () => {
if (!selectedDate) return;
try {
setLoadingBookings(true);
const date = new Date(selectedDate);
date.setHours(0, 0, 0, 0);
const nextDay = new Date(date);
nextDay.setDate(nextDay.getDate() + 1);
// Fetch all bookings (we'll filter on client side for accuracy)
const params: any = {
limit: 200, // Fetch more to ensure we get all bookings for the date range
};
if (searchQuery) {
params.search = searchQuery;
}
// Fetch bookings around the selected date (3 days before and after to be safe)
const startDate = new Date(date);
startDate.setDate(startDate.getDate() - 3);
const endDate = new Date(date);
endDate.setDate(endDate.getDate() + 3);
params.startDate = startDate.toISOString();
params.endDate = endDate.toISOString();
const response = await bookingService.getAllBookings(params);
const allBookings = response.data.bookings || [];
// Filter check-ins: confirmed bookings with check_in_date matching selected date
const filteredCheckIns = allBookings.filter((booking) => {
if (!booking.check_in_date || booking.status !== 'confirmed') return false;
const checkInDate = new Date(booking.check_in_date);
checkInDate.setHours(0, 0, 0, 0);
return checkInDate.getTime() === date.getTime();
});
setCheckInBookings(filteredCheckIns);
// Filter check-outs: checked_in bookings with check_out_date matching selected date
const filteredCheckOuts = allBookings.filter((booking) => {
if (!booking.check_out_date || booking.status !== 'checked_in') return false;
const checkOutDate = new Date(booking.check_out_date);
checkOutDate.setHours(0, 0, 0, 0);
return checkOutDate.getTime() === date.getTime();
});
setCheckOutBookings(filteredCheckOuts);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load bookings');
} finally {
setLoadingBookings(false);
}
};
const handleSearchByNumber = async () => {
if (!searchQuery.trim()) {
toast.error('Please enter booking number');
return;
}
try {
setSearching(true);
const response = await bookingService.checkBookingByNumber(bookingNumber);
const response = await bookingService.checkBookingByNumber(searchQuery);
setBooking(response.data.booking);
setActualRoomNumber(response.data.booking.room?.room_number || '');
// Show warning if there's remaining balance
if ((response as any).warning) {
const warning = (response as any).warning;
toast.warning(
@@ -54,6 +119,30 @@ const CheckInPage: React.FC = () => {
}
};
const handleSelectBooking = async (bookingNumber: string) => {
try {
setSearching(true);
const response = await bookingService.checkBookingByNumber(bookingNumber);
setBooking(response.data.booking);
setActualRoomNumber(response.data.booking.room?.room_number || '');
setSearchQuery(bookingNumber);
if ((response as any).warning) {
const warning = (response as any).warning;
toast.warning(
`⚠️ Payment Reminder: Guest has remaining balance of ${formatCurrency(warning.remaining_balance)} (${warning.payment_percentage.toFixed(1)}% paid)`,
{ autoClose: 8000 }
);
} else {
toast.success('Booking selected');
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load booking');
} finally {
setSearching(false);
}
};
const handleAddGuest = () => {
setGuests([...guests, { name: '', id_number: '', phone: '' }]);
};
@@ -71,9 +160,8 @@ const CheckInPage: React.FC = () => {
};
const calculateAdditionalFee = () => {
// Logic to calculate additional fees: children and extra person
const extraPersonFee = extraPersons * 200000; // 200k/person
const childrenFee = children * 100000; // 100k/child
const extraPersonFee = extraPersons * 200000;
const childrenFee = children * 100000;
const total = extraPersonFee + childrenFee;
setAdditionalFee(total);
return total;
@@ -82,7 +170,6 @@ const CheckInPage: React.FC = () => {
const handleCheckIn = async () => {
if (!booking) return;
// Validate
if (!actualRoomNumber.trim()) {
toast.error('Please enter actual room number');
return;
@@ -96,15 +183,12 @@ const CheckInPage: React.FC = () => {
try {
setLoading(true);
// Calculate additional fee
calculateAdditionalFee();
const response = await bookingService.updateBooking(booking.id, {
status: 'checked_in',
// Can send additional data about guests, room_number, additional_fee
} as any);
// Show warning if there's remaining balance
if ((response as any).warning) {
const warning = (response as any).warning;
toast.warning(
@@ -115,14 +199,15 @@ const CheckInPage: React.FC = () => {
toast.success('Check-in successful');
}
// Reset form
// Reset form and refresh bookings
setBooking(null);
setBookingNumber('');
setSearchQuery('');
setActualRoomNumber('');
setGuests([{ name: '', id_number: '', phone: '' }]);
setExtraPersons(0);
setChildren(0);
setAdditionalFee(0);
await fetchBookingsForDate();
} catch (error: any) {
toast.error(error.response?.data?.message || 'An error occurred during check-in');
} finally {
@@ -130,6 +215,15 @@ const CheckInPage: React.FC = () => {
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
if (loading) {
return <Loading />;
@@ -144,39 +238,187 @@ const CheckInPage: React.FC = () => {
</div>
</div>
{/* Search Booking */}
{/* Date and Search Filters */}
<div className="bg-white p-6 rounded-lg shadow-sm">
<h2 className="text-lg font-semibold mb-4">1. Search booking</h2>
<div className="flex gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Calendar className="w-4 h-4 inline mr-2" />
Select Date
</label>
<input
type="text"
value={bookingNumber}
onChange={(e) => setBookingNumber(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
placeholder="Enter booking number"
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
onClick={handleSearch}
disabled={searching}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 flex items-center gap-2"
>
{searching ? 'Searching...' : 'Search'}
</button>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Search className="w-4 h-4 inline mr-2" />
Search by Booking Number
</label>
<div className="flex gap-2">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearchByNumber()}
placeholder="Enter booking number"
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={handleSearchByNumber}
disabled={searching}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400"
>
{searching ? '...' : 'Search'}
</button>
</div>
</div>
<div className="flex items-end">
<button
onClick={() => {
const today = new Date();
setSelectedDate(today.toISOString().split('T')[0]);
setSearchQuery('');
}}
className="w-full px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
>
Reset to Today
</button>
</div>
</div>
</div>
{/* Booking Info */}
{/* Check-ins and Check-outs Lists */}
{!booking && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Check-ins for Today */}
<div className="bg-white p-6 rounded-lg shadow-sm">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold flex items-center gap-2">
<LogIn className="w-5 h-5 text-green-600" />
Check-ins for {formatDate(selectedDate)}
</h2>
<span className="px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm font-medium">
{checkInBookings.length}
</span>
</div>
{loadingBookings ? (
<div className="text-center py-8">
<Loading />
</div>
) : checkInBookings.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<LogIn className="w-12 h-12 mx-auto mb-2 text-gray-300" />
<p>No check-ins scheduled for this date</p>
</div>
) : (
<div className="space-y-3 max-h-96 overflow-y-auto">
{checkInBookings.map((b) => (
<div
key={b.id}
onClick={() => handleSelectBooking(b.booking_number)}
className="p-4 border border-gray-200 rounded-lg hover:border-blue-500 hover:shadow-md cursor-pointer transition-all"
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="font-semibold text-gray-900">{b.booking_number}</div>
<div className="text-sm text-gray-600 mt-1">{b.user?.full_name}</div>
<div className="text-xs text-gray-500 mt-1">
{b.room?.room_type?.name} {formatCurrency(b.total_price)}
</div>
</div>
<div className="text-right">
<span className={`px-2 py-1 rounded text-xs font-medium ${
b.status === 'confirmed' ? 'bg-green-100 text-green-800' :
b.status === 'checked_in' ? 'bg-blue-100 text-blue-800' :
'bg-gray-100 text-gray-800'
}`}>
{b.status}
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Check-outs for Today */}
<div className="bg-white p-6 rounded-lg shadow-sm">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold flex items-center gap-2">
<LogOut className="w-5 h-5 text-orange-600" />
Check-outs for {formatDate(selectedDate)}
</h2>
<span className="px-3 py-1 bg-orange-100 text-orange-800 rounded-full text-sm font-medium">
{checkOutBookings.length}
</span>
</div>
{loadingBookings ? (
<div className="text-center py-8">
<Loading />
</div>
) : checkOutBookings.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<LogOut className="w-12 h-12 mx-auto mb-2 text-gray-300" />
<p>No check-outs scheduled for this date</p>
</div>
) : (
<div className="space-y-3 max-h-96 overflow-y-auto">
{checkOutBookings.map((b) => (
<div
key={b.id}
onClick={() => handleSelectBooking(b.booking_number)}
className="p-4 border border-gray-200 rounded-lg hover:border-orange-500 hover:shadow-md cursor-pointer transition-all"
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="font-semibold text-gray-900">{b.booking_number}</div>
<div className="text-sm text-gray-600 mt-1">{b.user?.full_name}</div>
<div className="text-xs text-gray-500 mt-1">
{b.room?.room_type?.name} {formatCurrency(b.total_price)}
</div>
</div>
<div className="text-right">
<span className={`px-2 py-1 rounded text-xs font-medium ${
b.status === 'checked_in' ? 'bg-blue-100 text-blue-800' :
b.status === 'checked_out' ? 'bg-gray-100 text-gray-800' :
'bg-gray-100 text-gray-800'
}`}>
{b.status}
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Booking Info and Check-in Form */}
{booking && (
<>
<div className="bg-white p-6 rounded-lg shadow-sm">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
2. Booking Information
</h2>
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
2. Booking Information
</h2>
<button
onClick={() => {
setBooking(null);
setSearchQuery('');
}}
className="text-gray-500 hover:text-gray-700"
>
Close
</button>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<div className="space-y-3">
@@ -299,7 +541,6 @@ const CheckInPage: React.FC = () => {
<div>
<div className="space-y-2">
{(() => {
// Use payment_balance from API if available, otherwise calculate from payments
const paymentBalance = booking.payment_balance || (() => {
const completedPayments = booking.payments?.filter(
(p) => p.payment_status === 'completed'
@@ -545,19 +786,6 @@ const CheckInPage: React.FC = () => {
</div>
</>
)}
{/* Empty State */}
{!booking && !searching && (
<div className="bg-gray-50 rounded-lg p-12 text-center">
<Search className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
No booking selected
</h3>
<p className="text-gray-600">
Please enter booking number above to start check-in process
</p>
</div>
)}
</div>
);
};

View File

@@ -47,7 +47,8 @@ const InvoiceManagementPage: React.FC = () => {
invoiceList = invoiceList.filter((inv) =>
inv.invoice_number.toLowerCase().includes(filters.search.toLowerCase()) ||
inv.customer_name.toLowerCase().includes(filters.search.toLowerCase()) ||
inv.customer_email.toLowerCase().includes(filters.search.toLowerCase())
inv.customer_email.toLowerCase().includes(filters.search.toLowerCase()) ||
(inv.promotion_code && inv.promotion_code.toLowerCase().includes(filters.search.toLowerCase()))
);
}
@@ -130,11 +131,11 @@ const InvoiceManagementPage: React.FC = () => {
<p className="text-slate-600 mt-3 text-lg font-light">Manage and track all invoices</p>
</div>
<button
onClick={() => navigate('/admin/invoices/create')}
onClick={() => navigate('/admin/bookings')}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
>
<Plus className="w-5 h-5" />
Create Invoice
Create Invoice from Booking
</button>
</div>
@@ -190,6 +191,9 @@ const InvoiceManagementPage: React.FC = () => {
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
Amount
</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
Promotion
</th>
<th className="px-8 py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">
Status
</th>
@@ -235,6 +239,25 @@ const InvoiceManagementPage: React.FC = () => {
Due: {formatCurrency(invoice.balance_due)}
</div>
)}
{invoice.discount_amount > 0 && (
<div className="text-xs text-green-600 font-medium mt-1">
Discount: -{formatCurrency(invoice.discount_amount)}
</div>
)}
</td>
<td className="px-8 py-5 whitespace-nowrap">
{invoice.promotion_code ? (
<span className="px-3 py-1 text-xs font-semibold rounded-full bg-gradient-to-r from-purple-50 to-pink-50 text-purple-700 border border-purple-200">
{invoice.promotion_code}
</span>
) : (
<span className="text-xs text-slate-400"></span>
)}
{invoice.is_proforma && (
<div className="text-xs text-blue-600 font-medium mt-1">
Proforma
</div>
)}
</td>
<td className="px-8 py-5 whitespace-nowrap">
<span className={`px-4 py-1.5 text-xs font-semibold rounded-full border shadow-sm ${statusBadge.bg} ${statusBadge.text} ${statusBadge.border || ''}`}>
@@ -274,7 +297,7 @@ const InvoiceManagementPage: React.FC = () => {
})
) : (
<tr>
<td colSpan={7} className="px-8 py-12 text-center">
<td colSpan={8} className="px-8 py-12 text-center">
<div className="text-slate-500">
<FileText className="w-16 h-16 mx-auto mb-4 text-slate-300" />
<p className="text-lg font-semibold">No invoices found</p>

File diff suppressed because it is too large Load Diff

View File

@@ -125,8 +125,17 @@ const InvoicePage: React.FC = () => {
{/* Invoice Header */}
<div className="flex justify-between items-start mb-8 pb-8 border-b-2 border-gray-200">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Invoice</h1>
<h1 className="text-3xl font-bold text-gray-900 mb-2">
{invoice.is_proforma ? 'Proforma Invoice' : 'Invoice'}
</h1>
<p className="text-gray-600">#{invoice.invoice_number}</p>
{invoice.promotion_code && (
<div className="mt-2">
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold bg-gradient-to-r from-purple-100 to-pink-100 text-purple-700 border border-purple-200">
Promotion Code: {invoice.promotion_code}
</span>
</div>
)}
</div>
<div className={`flex items-center gap-2 px-4 py-2 rounded-lg border ${getStatusColor(invoice.status)}`}>
{getStatusIcon(invoice.status)}
@@ -252,8 +261,10 @@ const InvoicePage: React.FC = () => {
<span>{formatCurrency(invoice.subtotal)}</span>
</div>
{invoice.discount_amount > 0 && (
<div className="flex justify-between text-gray-600">
<span>Discount:</span>
<div className="flex justify-between text-green-600 font-medium">
<span>
Discount{invoice.promotion_code ? ` (${invoice.promotion_code})` : ''}:
</span>
<span>-{formatCurrency(invoice.discount_amount)}</span>
</div>
)}

View File

@@ -62,6 +62,11 @@ apiClient.interceptors.request.use(
config.url = config.url.replace(/\/\/+/, '/');
}
// Handle FormData - remove Content-Type header to let browser set it with boundary
if (config.data instanceof FormData) {
delete config.headers['Content-Type'];
}
// Add authorization token
const token = localStorage.getItem('token');
if (token) {

View File

@@ -212,14 +212,10 @@ const authService = {
const formData = new FormData();
formData.append('image', file);
// Don't set Content-Type header - let the browser set it with the correct boundary
const response = await apiClient.post<AuthResponse>(
'/api/auth/avatar/upload',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
formData
);
return response.data;
},

View File

@@ -10,8 +10,9 @@ import apiClient from './apiClient';
export interface Banner {
id: number;
title: string;
description?: string;
image_url: string;
link?: string;
link_url?: string;
position: string;
display_order: number;
is_active: boolean;
@@ -157,13 +158,20 @@ export const createBanner = async (
description?: string;
image_url: string;
link?: string;
link_url?: string;
position?: string;
display_order?: number;
start_date?: string;
end_date?: string;
}
): Promise<{ success: boolean; data: { banner: Banner }; message: string }> => {
const response = await apiClient.post('/banners', data);
// Map link_url to link for backend compatibility
const requestData = {
...data,
link: data.link_url || data.link,
};
delete requestData.link_url;
const response = await apiClient.post('/banners', requestData);
return response.data;
};
@@ -177,6 +185,7 @@ export const updateBanner = async (
description?: string;
image_url?: string;
link?: string;
link_url?: string;
position?: string;
display_order?: number;
is_active?: boolean;
@@ -184,7 +193,13 @@ export const updateBanner = async (
end_date?: string;
}
): Promise<{ success: boolean; data: { banner: Banner }; message: string }> => {
const response = await apiClient.put(`/banners/${id}`, data);
// Map link_url to link for backend compatibility
const requestData = {
...data,
link: data.link_url || data.link,
};
delete requestData.link_url;
const response = await apiClient.put(`/banners/${id}`, requestData);
return response.data;
};
@@ -207,11 +222,8 @@ export const uploadBannerImage = async (
const formData = new FormData();
formData.append('image', file);
const response = await apiClient.post('/banners/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
// Don't set Content-Type header - let the browser set it with the correct boundary
const response = await apiClient.post('/banners/upload', formData);
return response.data;
};

View File

@@ -18,6 +18,7 @@ export interface BookingData {
service_id: number;
quantity: number;
}>;
promotion_code?: string;
}
export interface Booking {
@@ -205,6 +206,8 @@ export const getAllBookings = async (
search?: string;
page?: number;
limit?: number;
startDate?: string;
endDate?: string;
}
): Promise<BookingsResponse> => {
const response = await apiClient.get<BookingsResponse>('/bookings', { params });
@@ -286,14 +289,10 @@ export const notifyPayment = async (
formData.append('receipt', file);
}
// Don't set Content-Type header - let the browser set it with the correct boundary
const response = await apiClient.post(
'/notify/payment',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
formData
);
return response.data;

View File

@@ -42,6 +42,8 @@ export interface Invoice {
notes?: string;
terms_and_conditions?: string;
payment_instructions?: string;
is_proforma?: boolean;
promotion_code?: string;
items: InvoiceItem[];
created_at: string;
updated_at: string;

View File

@@ -34,12 +34,61 @@ export interface PageContent {
support_links?: Array<{ label: string; url: string }>;
};
badges?: Array<{ text: string; icon: string }>;
copyright_text?: string;
hero_title?: string;
hero_subtitle?: string;
hero_image?: string;
story_content?: string;
values?: Array<{ icon?: string; title: string; description: string }>;
features?: Array<{ icon?: string; title: string; description: string }>;
features?: Array<{ icon?: string; title: string; description: string; image?: string }>;
about_hero_image?: string;
mission?: string;
vision?: string;
team?: Array<{ name: string; role: string; image?: string; bio?: string; social_links?: { linkedin?: string; twitter?: string; email?: string } }>;
timeline?: Array<{ year: string; title: string; description: string; image?: string }>;
achievements?: Array<{ icon?: string; title: string; description: string; year?: string; image?: string }>;
// Home page luxury sections
luxury_section_title?: string;
luxury_section_subtitle?: string;
luxury_section_image?: string;
luxury_features?: Array<{ icon?: string; title: string; description: string }>;
luxury_gallery_section_title?: string;
luxury_gallery_section_subtitle?: string;
luxury_gallery?: Array<string>;
luxury_testimonials_section_title?: string;
luxury_testimonials_section_subtitle?: string;
luxury_testimonials?: Array<{ name: string; title?: string; quote: string; image?: string }>;
amenities_section_title?: string;
amenities_section_subtitle?: string;
amenities?: Array<{ icon?: string; title: string; description: string; image?: string }>;
testimonials_section_title?: string;
testimonials_section_subtitle?: string;
testimonials?: Array<{ name: string; role: string; image?: string; rating: number; comment: string }>;
gallery_section_title?: string;
gallery_section_subtitle?: string;
gallery_images?: Array<string>;
about_preview_title?: string;
about_preview_subtitle?: string;
about_preview_content?: string;
about_preview_image?: string;
stats?: Array<{ number: string; label: string; icon?: string }>;
luxury_services_section_title?: string;
luxury_services_section_subtitle?: string;
luxury_services?: Array<{ icon?: string; title: string; description: string; image?: string }>;
luxury_experiences_section_title?: string;
luxury_experiences_section_subtitle?: string;
luxury_experiences?: Array<{ icon?: string; title: string; description: string; image?: string }>;
awards_section_title?: string;
awards_section_subtitle?: string;
awards?: Array<{ icon?: string; title: string; description: string; image?: string; year?: string }>;
cta_title?: string;
cta_subtitle?: string;
cta_button_text?: string;
cta_button_link?: string;
cta_image?: string;
partners_section_title?: string;
partners_section_subtitle?: string;
partners?: Array<{ name: string; logo: string; link?: string }>;
is_active?: boolean;
created_at?: string;
updated_at?: string;
@@ -84,12 +133,61 @@ export interface UpdatePageContentData {
support_links?: Array<{ label: string; url: string }>;
};
badges?: Array<{ text: string; icon: string }>;
copyright_text?: string;
hero_title?: string;
hero_subtitle?: string;
hero_image?: string;
story_content?: string;
values?: Array<{ icon?: string; title: string; description: string }>;
features?: Array<{ icon?: string; title: string; description: string }>;
features?: Array<{ icon?: string; title: string; description: string; image?: string }>;
about_hero_image?: string;
mission?: string;
vision?: string;
team?: Array<{ name: string; role: string; image?: string; bio?: string; social_links?: { linkedin?: string; twitter?: string; email?: string } }>;
timeline?: Array<{ year: string; title: string; description: string; image?: string }>;
achievements?: Array<{ icon?: string; title: string; description: string; year?: string; image?: string }>;
// Home page luxury sections
luxury_section_title?: string;
luxury_section_subtitle?: string;
luxury_section_image?: string;
luxury_features?: Array<{ icon?: string; title: string; description: string }>;
luxury_gallery_section_title?: string;
luxury_gallery_section_subtitle?: string;
luxury_gallery?: Array<string>;
luxury_testimonials_section_title?: string;
luxury_testimonials_section_subtitle?: string;
luxury_testimonials?: Array<{ name: string; title?: string; quote: string; image?: string }>;
amenities_section_title?: string;
amenities_section_subtitle?: string;
amenities?: Array<{ icon?: string; title: string; description: string; image?: string }>;
testimonials_section_title?: string;
testimonials_section_subtitle?: string;
testimonials?: Array<{ name: string; role: string; image?: string; rating: number; comment: string }>;
gallery_section_title?: string;
gallery_section_subtitle?: string;
gallery_images?: Array<string>;
about_preview_title?: string;
about_preview_subtitle?: string;
about_preview_content?: string;
about_preview_image?: string;
stats?: Array<{ number: string; label: string; icon?: string }>;
luxury_services_section_title?: string;
luxury_services_section_subtitle?: string;
luxury_services?: Array<{ icon?: string; title: string; description: string; image?: string }>;
luxury_experiences_section_title?: string;
luxury_experiences_section_subtitle?: string;
luxury_experiences?: Array<{ icon?: string; title: string; description: string; image?: string }>;
awards_section_title?: string;
awards_section_subtitle?: string;
awards?: Array<{ icon?: string; title: string; description: string; image?: string; year?: string }>;
cta_title?: string;
cta_subtitle?: string;
cta_button_text?: string;
cta_button_link?: string;
cta_image?: string;
partners_section_title?: string;
partners_section_subtitle?: string;
partners?: Array<{ name: string; logo: string; link?: string }>;
is_active?: boolean;
}
@@ -103,13 +201,45 @@ const pageContentService = {
},
/**
* Get content for a specific page
* Get content for a specific page (legacy method - kept for backward compatibility)
*/
getPageContent: async (pageType: PageType): Promise<PageContentResponse> => {
const response = await apiClient.get<PageContentResponse>(`/page-content/${pageType}`);
return response.data;
},
/**
* Get homepage content
*/
getHomeContent: async (): Promise<PageContentResponse> => {
const response = await apiClient.get<PageContentResponse>('/home');
return response.data;
},
/**
* Get about page content
*/
getAboutContent: async (): Promise<PageContentResponse> => {
const response = await apiClient.get<PageContentResponse>('/about');
return response.data;
},
/**
* Get contact page content
*/
getContactContent: async (): Promise<PageContentResponse> => {
const response = await apiClient.get<PageContentResponse>('/contact-content');
return response.data;
},
/**
* Get footer content
*/
getFooterContent: async (): Promise<PageContentResponse> => {
const response = await apiClient.get<PageContentResponse>('/footer');
return response.data;
},
/**
* Update page content
*/
@@ -162,6 +292,50 @@ const pageContentService = {
if (data.features) {
updateData.features = data.features; // Send as array, backend will convert to JSON
}
// Handle luxury content arrays
if (data.luxury_features) {
updateData.luxury_features = data.luxury_features; // Send as array, backend will convert to JSON
}
if (data.luxury_gallery) {
updateData.luxury_gallery = data.luxury_gallery; // Send as array, backend will convert to JSON
}
if (data.luxury_testimonials) {
updateData.luxury_testimonials = data.luxury_testimonials; // Send as array, backend will convert to JSON
}
if (data.amenities) {
updateData.amenities = data.amenities; // Send as array, backend will convert to JSON
}
if (data.testimonials) {
updateData.testimonials = data.testimonials; // Send as array, backend will convert to JSON
}
if (data.gallery_images) {
updateData.gallery_images = data.gallery_images; // Send as array, backend will convert to JSON
}
if (data.stats) {
updateData.stats = data.stats; // Send as array, backend will convert to JSON
}
if (data.luxury_services) {
updateData.luxury_services = data.luxury_services; // Send as array, backend will convert to JSON
}
if (data.luxury_experiences) {
updateData.luxury_experiences = data.luxury_experiences; // Send as array, backend will convert to JSON
}
if (data.awards) {
updateData.awards = data.awards; // Send as array, backend will convert to JSON
}
if (data.partners) {
updateData.partners = data.partners; // Send as array, backend will convert to JSON
}
if (data.team) {
updateData.team = data.team; // Send as array, backend will convert to JSON
}
if (data.timeline) {
updateData.timeline = data.timeline; // Send as array, backend will convert to JSON
}
if (data.achievements) {
updateData.achievements = data.achievements; // Send as array, backend will convert to JSON
}
const response = await apiClient.put<PageContentResponse>(
`/page-content/${pageType}`,
@@ -169,6 +343,20 @@ const pageContentService = {
);
return response.data;
},
/**
* Upload page content image
*/
uploadImage: async (
file: File
): Promise<{ success: boolean; data: { image_url: string; full_url: string }; message: string }> => {
const formData = new FormData();
formData.append('image', file);
// Don't set Content-Type header - let the browser set it with the correct boundary
const response = await apiClient.post('/page-content/upload', formData);
return response.data;
},
};
export default pageContentService;

View File

@@ -97,14 +97,10 @@ export const confirmBankTransfer = async (
formData.append('receipt', receipt);
}
// Don't set Content-Type header - let the browser set it with the correct boundary
const response = await apiClient.post(
'/payments/confirm',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
formData
);
return response.data;

View File

@@ -322,14 +322,10 @@ const systemSettingsService = {
const formData = new FormData();
formData.append('image', file);
// Don't set Content-Type header - let the browser set it with the correct boundary
const response = await apiClient.post<UploadLogoResponse>(
'/api/admin/system-settings/company/logo',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
formData
);
return response.data;
},
@@ -343,14 +339,10 @@ const systemSettingsService = {
const formData = new FormData();
formData.append('image', file);
// Don't set Content-Type header - let the browser set it with the correct boundary
const response = await apiClient.post<UploadFaviconResponse>(
'/api/admin/system-settings/company/favicon',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
formData
);
return response.data;
},