update
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
51
Backend/alembic/versions/add_service_detail_fields.py
Normal file
51
Backend/alembic/versions/add_service_detail_fields.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""add_service_detail_fields
|
||||
|
||||
Revision ID: service_detail_001
|
||||
Revises: staff_shifts_001
|
||||
Create Date: 2024-12-04 22:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'service_detail_001'
|
||||
down_revision = 'staff_shifts_001'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add slug column with unique index
|
||||
op.add_column('services', sa.Column('slug', sa.String(200), nullable=True))
|
||||
op.create_index(op.f('ix_services_slug'), 'services', ['slug'], unique=True)
|
||||
|
||||
# Add image column
|
||||
op.add_column('services', sa.Column('image', sa.String(1000), nullable=True))
|
||||
|
||||
# Add content column for full HTML content
|
||||
op.add_column('services', sa.Column('content', sa.Text(), nullable=True))
|
||||
|
||||
# Add sections column for advanced content blocks (stored as JSON)
|
||||
op.add_column('services', sa.Column('sections', sa.Text(), nullable=True))
|
||||
|
||||
# Add SEO meta fields
|
||||
op.add_column('services', sa.Column('meta_title', sa.String(500), nullable=True))
|
||||
op.add_column('services', sa.Column('meta_description', sa.Text(), nullable=True))
|
||||
op.add_column('services', sa.Column('meta_keywords', sa.String(1000), nullable=True))
|
||||
|
||||
# Rename is_active to status for consistency (optional, but let's keep is_active and add status)
|
||||
# Actually, let's keep is_active as is for backward compatibility
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_services_slug'), table_name='services')
|
||||
op.drop_column('services', 'meta_keywords')
|
||||
op.drop_column('services', 'meta_description')
|
||||
op.drop_column('services', 'meta_title')
|
||||
op.drop_column('services', 'sections')
|
||||
op.drop_column('services', 'content')
|
||||
op.drop_column('services', 'image')
|
||||
op.drop_column('services', 'slug')
|
||||
|
||||
Binary file not shown.
@@ -12,7 +12,60 @@ def seed_homepage_content(db: Session):
|
||||
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 = ['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 = [{'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 = [{'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_services = [
|
||||
{
|
||||
'icon': 'UtensilsCrossed',
|
||||
'title': 'Fine Dining',
|
||||
'description': 'Michelin-starred restaurants offering world-class cuisine',
|
||||
'image': 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=600',
|
||||
'slug': 'fine-dining',
|
||||
'category': 'Dining',
|
||||
'content': '<p>Experience culinary excellence at our Michelin-starred restaurants. Our world-renowned chefs craft exquisite dishes using the finest ingredients sourced from around the globe. From intimate dining experiences to grand celebrations, we offer a variety of settings to suit every occasion.</p><p>Our restaurants feature seasonal menus that showcase the best of local and international cuisine, paired with an extensive wine collection curated by our master sommelier.</p>',
|
||||
'sections': [],
|
||||
'meta_title': 'Fine Dining Experience - Luxury Hotel',
|
||||
'meta_description': 'Discover our Michelin-starred restaurants offering world-class cuisine in elegant settings.',
|
||||
'meta_keywords': 'fine dining, michelin star, luxury restaurant, gourmet cuisine'
|
||||
},
|
||||
{
|
||||
'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',
|
||||
'slug': 'premium-bar',
|
||||
'category': 'Dining',
|
||||
'content': '<p>Unwind in sophistication at our premium bar, featuring an extensive collection of rare wines, vintage spirits, and expertly crafted cocktails. Our master mixologists create unique beverages tailored to your preferences.</p><p>The elegant ambiance, combined with live music on select evenings, creates the perfect setting for business meetings, romantic evenings, or casual gatherings with friends.</p>',
|
||||
'sections': [],
|
||||
'meta_title': 'Premium Bar & Lounge - Luxury Hotel',
|
||||
'meta_description': 'Enjoy an extensive wine collection and craft cocktails in our elegant bar and lounge.',
|
||||
'meta_keywords': 'premium bar, wine collection, craft cocktails, luxury lounge'
|
||||
},
|
||||
{
|
||||
'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',
|
||||
'slug': 'spa-wellness',
|
||||
'category': 'Wellness',
|
||||
'content': '<p>Escape to tranquility at our world-class spa and wellness center. Indulge in a range of rejuvenating treatments designed to restore balance and vitality. Our expert therapists use premium products and ancient techniques to provide an unparalleled wellness experience.</p><p>Our state-of-the-art fitness center is equipped with the latest equipment and offers personal training sessions, yoga classes, and wellness programs tailored to your needs.</p>',
|
||||
'sections': [],
|
||||
'meta_title': 'Spa & Wellness Center - Luxury Hotel',
|
||||
'meta_description': 'Rejuvenate with our spa treatments and state-of-the-art fitness facilities.',
|
||||
'meta_keywords': 'spa, wellness, fitness center, massage, luxury spa'
|
||||
},
|
||||
{
|
||||
'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',
|
||||
'slug': 'concierge-services',
|
||||
'category': 'Services',
|
||||
'content': '<p>Our dedicated concierge team is available 24/7 to ensure your stay is nothing short of extraordinary. From restaurant reservations and event tickets to private tours and transportation arrangements, we handle every detail with precision and care.</p><p>Whether you need assistance with business arrangements, special celebrations, or unique local experiences, our concierge team has the expertise and connections to make it happen.</p>',
|
||||
'sections': [],
|
||||
'meta_title': 'Concierge Services - Luxury Hotel',
|
||||
'meta_description': 'Personalized assistance for all your travel and entertainment needs, available 24/7.',
|
||||
'meta_keywords': 'concierge, personal assistant, travel services, luxury service'
|
||||
}
|
||||
]
|
||||
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 = [{'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 = [{'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': '#'}]
|
||||
|
||||
218
Backend/seeds_data/seed_services.py
Normal file
218
Backend/seeds_data/seed_services.py
Normal file
@@ -0,0 +1,218 @@
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
# Add parent directory to path to import from src
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from sqlalchemy.orm import Session
|
||||
from src.shared.config.database import SessionLocal
|
||||
# Import all models to ensure relationships are set up
|
||||
from src.models import * # This ensures all models are loaded and relationships are configured
|
||||
from src.hotel_services.models.service import Service
|
||||
|
||||
def generate_slug(name: str) -> str:
|
||||
"""Generate a URL-friendly slug from a name"""
|
||||
slug = name.lower()
|
||||
slug = re.sub(r'[^a-z0-9\s-]', '', slug)
|
||||
slug = re.sub(r'\s+', '-', slug)
|
||||
slug = re.sub(r'-+', '-', slug)
|
||||
return slug.strip('-')
|
||||
|
||||
def seed_services(db: Session):
|
||||
"""Seed initial services data"""
|
||||
print('Seeding services...')
|
||||
|
||||
services_data = [
|
||||
{
|
||||
'name': 'Room Service',
|
||||
'description': '24/7 in-room dining service with a wide selection of gourmet meals, snacks, and beverages delivered directly to your room.',
|
||||
'price': 25.00,
|
||||
'category': 'Dining',
|
||||
'slug': 'room-service',
|
||||
'image': 'https://images.unsplash.com/photo-1556911220-bff31c812dba?w=600',
|
||||
'content': '<p>Enjoy the convenience of 24/7 room service with our extensive menu featuring international cuisine, local specialties, and premium beverages. Our professional staff ensures your meals are delivered hot, fresh, and beautifully presented.</p><p>From breakfast in bed to late-night snacks, we cater to all your dining needs with the highest standards of quality and service.</p>',
|
||||
'sections': json.dumps([]),
|
||||
'meta_title': 'Room Service - Luxury Hotel',
|
||||
'meta_description': '24/7 in-room dining service with gourmet meals delivered to your room.',
|
||||
'meta_keywords': 'room service, in-room dining, hotel service, 24/7 service',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Spa & Massage',
|
||||
'description': 'Relaxing spa treatments and professional massage therapy to rejuvenate your mind and body.',
|
||||
'price': 120.00,
|
||||
'category': 'Wellness',
|
||||
'slug': 'spa-massage',
|
||||
'image': 'https://images.unsplash.com/photo-1544161515-4ab6ce6db874?w=600',
|
||||
'content': '<p>Indulge in our world-class spa treatments designed to restore balance and vitality. Our expert therapists offer a range of services including Swedish massage, deep tissue massage, hot stone therapy, and aromatherapy.</p><p>Each treatment is customized to your preferences, using premium products and ancient techniques to provide an unparalleled wellness experience.</p>',
|
||||
'sections': json.dumps([]),
|
||||
'meta_title': 'Spa & Massage Services - Luxury Hotel',
|
||||
'meta_description': 'Professional spa treatments and massage therapy for ultimate relaxation and rejuvenation.',
|
||||
'meta_keywords': 'spa, massage, wellness, relaxation, therapy',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Laundry Service',
|
||||
'description': 'Professional dry cleaning and laundry service with same-day or next-day delivery options.',
|
||||
'price': 15.00,
|
||||
'category': 'Housekeeping',
|
||||
'slug': 'laundry-service',
|
||||
'image': 'https://images.unsplash.com/photo-1582735689369-4fe89db7114c?w=600',
|
||||
'content': '<p>Keep your wardrobe fresh and clean with our professional laundry and dry cleaning service. We handle everything from delicate garments to business suits with the utmost care and attention.</p><p>Choose from same-day express service or standard next-day delivery. All items are professionally cleaned, pressed, and returned to your room.</p>',
|
||||
'sections': json.dumps([]),
|
||||
'meta_title': 'Laundry & Dry Cleaning Service - Luxury Hotel',
|
||||
'meta_description': 'Professional laundry and dry cleaning service with same-day or next-day delivery.',
|
||||
'meta_keywords': 'laundry, dry cleaning, hotel service, cleaning',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Airport Transfer',
|
||||
'description': 'Complimentary airport pickup and drop-off service in luxury vehicles with professional drivers.',
|
||||
'price': 50.00,
|
||||
'category': 'Transportation',
|
||||
'slug': 'airport-transfer',
|
||||
'image': 'https://images.unsplash.com/photo-1449824913935-59a10b8d2000?w=600',
|
||||
'content': '<p>Travel in comfort and style with our premium airport transfer service. Our fleet of luxury vehicles, including sedans and SUVs, ensures a smooth and comfortable journey to or from the airport.</p><p>Our professional drivers are punctual, courteous, and knowledgeable about the local area. Flight monitoring ensures we adjust to any delays or early arrivals.</p>',
|
||||
'sections': json.dumps([]),
|
||||
'meta_title': 'Airport Transfer Service - Luxury Hotel',
|
||||
'meta_description': 'Premium airport pickup and drop-off service in luxury vehicles.',
|
||||
'meta_keywords': 'airport transfer, transportation, airport shuttle, luxury car',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Concierge Service',
|
||||
'description': 'Personalized assistance for restaurant reservations, event tickets, tours, and special requests.',
|
||||
'price': 0.00,
|
||||
'category': 'Concierge',
|
||||
'slug': 'concierge-service',
|
||||
'image': 'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=600',
|
||||
'content': '<p>Our dedicated concierge team is available 24/7 to ensure your stay is nothing short of extraordinary. From restaurant reservations and event tickets to private tours and transportation arrangements, we handle every detail with precision and care.</p><p>Whether you need assistance with business arrangements, special celebrations, or unique local experiences, our concierge team has the expertise and connections to make it happen.</p>',
|
||||
'sections': json.dumps([]),
|
||||
'meta_title': 'Concierge Services - Luxury Hotel',
|
||||
'meta_description': 'Personalized assistance for all your travel and entertainment needs, available 24/7.',
|
||||
'meta_keywords': 'concierge, personal assistant, travel services, luxury service',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Fitness Center Access',
|
||||
'description': 'Access to our state-of-the-art fitness center with modern equipment and personal training options.',
|
||||
'price': 20.00,
|
||||
'category': 'Wellness',
|
||||
'slug': 'fitness-center',
|
||||
'image': 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=600',
|
||||
'content': '<p>Maintain your fitness routine at our fully equipped fitness center featuring the latest cardio and strength training equipment. Our facilities are open 24/7 for your convenience.</p><p>Enhance your workout with personal training sessions, group fitness classes, or yoga sessions led by certified instructors. Towels, water, and fresh fruit are provided.</p>',
|
||||
'sections': json.dumps([]),
|
||||
'meta_title': 'Fitness Center - Luxury Hotel',
|
||||
'meta_description': 'State-of-the-art fitness center with modern equipment and personal training options.',
|
||||
'meta_keywords': 'fitness center, gym, workout, exercise, personal training',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Business Center',
|
||||
'description': 'Fully equipped business center with meeting rooms, printing, copying, and secretarial services.',
|
||||
'price': 30.00,
|
||||
'category': 'Business',
|
||||
'slug': 'business-center',
|
||||
'image': 'https://images.unsplash.com/photo-1497366216548-37526070297c?w=600',
|
||||
'content': '<p>Stay productive with our fully equipped business center featuring private meeting rooms, high-speed internet, printing and copying services, and professional secretarial support.</p><p>Our meeting rooms can accommodate small groups and are equipped with presentation equipment, video conferencing capabilities, and refreshments.</p>',
|
||||
'sections': json.dumps([]),
|
||||
'meta_title': 'Business Center Services - Luxury Hotel',
|
||||
'meta_description': 'Fully equipped business center with meeting rooms and professional services.',
|
||||
'meta_keywords': 'business center, meeting room, conference, office services',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Valet Parking',
|
||||
'description': 'Secure valet parking service with 24/7 vehicle care and quick retrieval.',
|
||||
'price': 35.00,
|
||||
'category': 'Transportation',
|
||||
'slug': 'valet-parking',
|
||||
'image': 'https://images.unsplash.com/photo-1502877338535-766e1452684a?w=600',
|
||||
'content': '<p>Enjoy the convenience of valet parking with our professional service. Your vehicle will be safely parked and secured, ready for quick retrieval whenever you need it.</p><p>Our valet team is available 24/7 and can also assist with luggage handling and vehicle care services.</p>',
|
||||
'sections': json.dumps([]),
|
||||
'meta_title': 'Valet Parking Service - Luxury Hotel',
|
||||
'meta_description': 'Secure valet parking service with 24/7 vehicle care and quick retrieval.',
|
||||
'meta_keywords': 'valet parking, parking service, car service, vehicle care',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Babysitting Service',
|
||||
'description': 'Professional and certified babysitting service for your peace of mind.',
|
||||
'price': 25.00,
|
||||
'category': 'Family',
|
||||
'slug': 'babysitting-service',
|
||||
'image': 'https://images.unsplash.com/photo-1503454537195-1dcabb73ffb9?w=600',
|
||||
'content': '<p>Enjoy your time knowing your children are in safe hands with our professional babysitting service. Our certified caregivers are experienced, background-checked, and trained in child safety and first aid.</p><p>Available for both in-room care and supervised activities, we ensure your children are entertained and well-cared for while you enjoy your stay.</p>',
|
||||
'sections': json.dumps([]),
|
||||
'meta_title': 'Babysitting Service - Luxury Hotel',
|
||||
'meta_description': 'Professional and certified babysitting service for your peace of mind.',
|
||||
'meta_keywords': 'babysitting, childcare, kids service, family service',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Pet Care Service',
|
||||
'description': 'Pet-friendly accommodations with pet care services including walking, feeding, and grooming.',
|
||||
'price': 20.00,
|
||||
'category': 'Pet Services',
|
||||
'slug': 'pet-care-service',
|
||||
'image': 'https://images.unsplash.com/photo-1601758228041-f3b2795255f1?w=600',
|
||||
'content': '<p>Your furry friends are welcome at our hotel! We offer pet-friendly accommodations and a range of pet care services including daily walks, feeding, and grooming.</p><p>Our pet care team ensures your pets are comfortable and well-cared for during your stay. Pet amenities including beds, bowls, and treats are provided.</p>',
|
||||
'sections': json.dumps([]),
|
||||
'meta_title': 'Pet Care Services - Luxury Hotel',
|
||||
'meta_description': 'Pet-friendly accommodations with professional pet care services.',
|
||||
'meta_keywords': 'pet care, pet service, dog walking, pet friendly',
|
||||
'is_active': True
|
||||
}
|
||||
]
|
||||
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
|
||||
for service_data in services_data:
|
||||
# Check if service already exists by slug
|
||||
existing = db.query(Service).filter(Service.slug == service_data['slug']).first()
|
||||
|
||||
if existing:
|
||||
# Update existing service
|
||||
for key, value in service_data.items():
|
||||
if key != 'slug': # Don't update slug
|
||||
setattr(existing, key, value)
|
||||
updated_count += 1
|
||||
print(f' ✓ Updated service: {service_data["name"]}')
|
||||
else:
|
||||
# Create new service
|
||||
service = Service(**service_data)
|
||||
db.add(service)
|
||||
created_count += 1
|
||||
print(f' ✓ Created service: {service_data["name"]}')
|
||||
|
||||
db.commit()
|
||||
print(f'\n✓ Services seeded successfully!')
|
||||
print(f' - Created: {created_count}')
|
||||
print(f' - Updated: {updated_count}')
|
||||
print(f' - Total: {len(services_data)}\n')
|
||||
|
||||
def main():
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
print('=' * 80)
|
||||
print('SEEDING SERVICES')
|
||||
print('=' * 80)
|
||||
print()
|
||||
seed_services(db)
|
||||
print('=' * 80)
|
||||
print('✓ All services data seeded successfully!')
|
||||
print('=' * 80)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f'\n✗ Error seeding services: {e}')
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
756
Backend/seeds_data/seed_services_enterprise.py
Normal file
756
Backend/seeds_data/seed_services_enterprise.py
Normal file
@@ -0,0 +1,756 @@
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
# Add parent directory to path to import from src
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from sqlalchemy.orm import Session
|
||||
from src.shared.config.database import SessionLocal
|
||||
# Import all models to ensure relationships are set up
|
||||
from src.models import * # This ensures all models are loaded and relationships are configured
|
||||
from src.hotel_services.models.service import Service
|
||||
|
||||
def generate_slug(name: str) -> str:
|
||||
"""Generate a URL-friendly slug from a name"""
|
||||
slug = name.lower()
|
||||
slug = re.sub(r'[^a-z0-9\s-]', '', slug)
|
||||
slug = re.sub(r'\s+', '-', slug)
|
||||
slug = re.sub(r'-+', '-', slug)
|
||||
return slug.strip('-')
|
||||
|
||||
def seed_enterprise_services(db: Session):
|
||||
"""Seed enterprise-level services with rich detail page content"""
|
||||
print('Seeding enterprise services...')
|
||||
|
||||
services_data = [
|
||||
{
|
||||
'name': 'Luxury Spa & Wellness Retreat',
|
||||
'description': 'Indulge in our world-class spa treatments designed to restore balance and vitality. Experience ultimate relaxation with our expert therapists.',
|
||||
'price': 250.00,
|
||||
'category': 'Wellness',
|
||||
'slug': 'luxury-spa-wellness-retreat',
|
||||
'image': 'https://images.unsplash.com/photo-1544161515-4ab6ce6db874?w=1200&h=800&fit=crop',
|
||||
'content': '''<div class="service-detail-content">
|
||||
<h2>Experience Ultimate Relaxation</h2>
|
||||
<p>Our Luxury Spa & Wellness Retreat offers an unparalleled journey of rejuvenation and tranquility. Nestled in a serene environment, our spa combines ancient healing traditions with modern wellness techniques to create a transformative experience.</p>
|
||||
|
||||
<h3>What's Included</h3>
|
||||
<ul>
|
||||
<li>90-minute signature massage therapy</li>
|
||||
<li>Access to steam room and sauna</li>
|
||||
<li>Complimentary herbal tea and refreshments</li>
|
||||
<li>Premium aromatherapy oils and products</li>
|
||||
<li>Relaxation lounge access</li>
|
||||
</ul>
|
||||
|
||||
<h3>Our Signature Treatments</h3>
|
||||
<p>Choose from our curated selection of treatments including Swedish massage, deep tissue therapy, hot stone massage, and aromatherapy sessions. Each treatment is customized to your specific needs and preferences.</p>
|
||||
|
||||
<h3>Expert Therapists</h3>
|
||||
<p>Our team of certified therapists brings years of experience and a deep understanding of holistic wellness. They are trained in various massage techniques and wellness practices to ensure you receive the highest quality care.</p>
|
||||
</div>''',
|
||||
'sections': json.dumps([
|
||||
{
|
||||
'type': 'hero',
|
||||
'title': 'Transform Your Wellbeing',
|
||||
'content': 'Discover a sanctuary of peace and rejuvenation where every detail is designed to restore your mind, body, and spirit.',
|
||||
'image': 'https://images.unsplash.com/photo-1544161515-4ab6ce6db874?w=1200&h=600&fit=crop'
|
||||
},
|
||||
{
|
||||
'type': 'features',
|
||||
'title': 'Why Choose Our Spa',
|
||||
'features': [
|
||||
{'title': 'Expert Therapists', 'description': 'Certified professionals with years of experience', 'icon': 'User'},
|
||||
{'title': 'Premium Products', 'description': 'Luxury skincare and aromatherapy products', 'icon': 'Sparkles'},
|
||||
{'title': 'Customized Treatments', 'description': 'Personalized sessions tailored to your needs', 'icon': 'Heart'},
|
||||
{'title': 'Serene Environment', 'description': 'Peaceful setting designed for relaxation', 'icon': 'Leaf'}
|
||||
]
|
||||
},
|
||||
{
|
||||
'type': 'quote',
|
||||
'quote': 'The most luxurious spa experience I\'ve ever had. Truly transformative.',
|
||||
'author': 'Sarah M., Guest'
|
||||
}
|
||||
]),
|
||||
'meta_title': 'Luxury Spa & Wellness Retreat - Premium Hotel Services',
|
||||
'meta_description': 'Experience ultimate relaxation with our world-class spa treatments. Expert therapists, premium products, and a serene environment await you.',
|
||||
'meta_keywords': 'luxury spa, wellness retreat, massage therapy, hotel spa, relaxation, aromatherapy, wellness services',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Fine Dining Experience',
|
||||
'description': 'Savor culinary excellence at our Michelin-starred restaurants. World-renowned chefs craft exquisite dishes using the finest ingredients.',
|
||||
'price': 150.00,
|
||||
'category': 'Dining',
|
||||
'slug': 'fine-dining-experience',
|
||||
'image': 'https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=1200&h=800&fit=crop',
|
||||
'content': '''<div class="service-detail-content">
|
||||
<h2>Culinary Excellence Awaits</h2>
|
||||
<p>Embark on a gastronomic journey at our award-winning restaurants, where culinary artistry meets exceptional service. Our Michelin-starred chefs create masterpieces that celebrate both local traditions and international flavors.</p>
|
||||
|
||||
<h3>Our Restaurants</h3>
|
||||
<ul>
|
||||
<li><strong>The Grand Ballroom:</strong> Elegant fine dining with seasonal tasting menus</li>
|
||||
<li><strong>Sky Terrace:</strong> Rooftop dining with panoramic city views</li>
|
||||
<li><strong>Chef's Table:</strong> Intimate 8-seat private dining experience</li>
|
||||
<li><strong>Wine Cellar:</strong> Exclusive wine pairing dinners</li>
|
||||
</ul>
|
||||
|
||||
<h3>What's Included</h3>
|
||||
<ul>
|
||||
<li>Multi-course tasting menu</li>
|
||||
<li>Wine pairing recommendations</li>
|
||||
<li>Complimentary amuse-bouche</li>
|
||||
<li>Professional sommelier service</li>
|
||||
<li>Personalized menu customization</li>
|
||||
</ul>
|
||||
|
||||
<h3>Reservation Information</h3>
|
||||
<p>Reservations are recommended and can be made through our concierge or directly at the restaurant. Special dietary requirements and preferences are accommodated with advance notice.</p>
|
||||
</div>''',
|
||||
'sections': json.dumps([
|
||||
{
|
||||
'type': 'hero',
|
||||
'title': 'A Culinary Journey Like No Other',
|
||||
'content': 'Where every dish tells a story and every meal becomes a memory.',
|
||||
'image': 'https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=1200&h=600&fit=crop'
|
||||
},
|
||||
{
|
||||
'type': 'gallery',
|
||||
'title': 'Our Culinary Creations',
|
||||
'images': [
|
||||
'https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=800',
|
||||
'https://images.unsplash.com/photo-1556911220-bff31c812dba?w=800',
|
||||
'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=800'
|
||||
]
|
||||
},
|
||||
{
|
||||
'type': 'quote',
|
||||
'quote': 'An extraordinary dining experience that exceeded all expectations. The attention to detail is remarkable.',
|
||||
'author': 'Michael R., Food Critic'
|
||||
}
|
||||
]),
|
||||
'meta_title': 'Fine Dining Experience - Michelin-Starred Restaurant',
|
||||
'meta_description': 'Savor culinary excellence at our Michelin-starred restaurants. World-renowned chefs, exquisite dishes, and exceptional service.',
|
||||
'meta_keywords': 'fine dining, michelin star, luxury restaurant, gourmet cuisine, fine dining hotel',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': '24/7 Premium Room Service',
|
||||
'description': 'Enjoy the convenience of round-the-clock in-room dining with our extensive menu featuring international cuisine and premium beverages.',
|
||||
'price': 35.00,
|
||||
'category': 'Dining',
|
||||
'slug': 'premium-room-service',
|
||||
'image': 'https://images.unsplash.com/photo-1556911220-bff31c812dba?w=1200&h=800&fit=crop',
|
||||
'content': '''<div class="service-detail-content">
|
||||
<h2>Dining at Your Convenience</h2>
|
||||
<p>Experience the ultimate in-room dining service available 24 hours a day. From breakfast in bed to late-night snacks, our extensive menu caters to every craving with the highest standards of quality and presentation.</p>
|
||||
|
||||
<h3>Menu Highlights</h3>
|
||||
<ul>
|
||||
<li><strong>Breakfast:</strong> Continental, American, and healthy options</li>
|
||||
<li><strong>Lunch & Dinner:</strong> International cuisine, local specialties, and chef's recommendations</li>
|
||||
<li><strong>Desserts:</strong> Artisanal pastries and premium ice creams</li>
|
||||
<li><strong>Beverages:</strong> Premium wines, craft cocktails, and specialty coffees</li>
|
||||
</ul>
|
||||
|
||||
<h3>Service Features</h3>
|
||||
<ul>
|
||||
<li>Average delivery time: 25-30 minutes</li>
|
||||
<li>Professional table setup in your room</li>
|
||||
<li>Hot meals served at optimal temperature</li>
|
||||
<li>Special dietary accommodations available</li>
|
||||
<li>Complimentary bread and butter service</li>
|
||||
</ul>
|
||||
|
||||
<h3>How to Order</h3>
|
||||
<p>Simply call our room service line from your in-room phone or use our mobile app. Our team is available 24/7 to take your order and ensure prompt delivery.</p>
|
||||
</div>''',
|
||||
'sections': json.dumps([
|
||||
{
|
||||
'type': 'features',
|
||||
'title': 'Why Choose Our Room Service',
|
||||
'features': [
|
||||
{'title': '24/7 Availability', 'description': 'Order anytime, day or night', 'icon': 'Clock'},
|
||||
{'title': 'Fast Delivery', 'description': 'Average 25-30 minute delivery time', 'icon': 'Zap'},
|
||||
{'title': 'Premium Quality', 'description': 'Restaurant-quality meals in your room', 'icon': 'Award'},
|
||||
{'title': 'Professional Service', 'description': 'Elegant table setup and presentation', 'icon': 'UserCheck'}
|
||||
]
|
||||
}
|
||||
]),
|
||||
'meta_title': '24/7 Premium Room Service - Luxury Hotel',
|
||||
'meta_description': 'Enjoy round-the-clock in-room dining with our extensive menu. International cuisine, premium beverages, and professional service delivered to your room.',
|
||||
'meta_keywords': 'room service, in-room dining, 24/7 service, hotel dining, room service menu',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Executive Business Center',
|
||||
'description': 'Fully equipped business center with private meeting rooms, high-speed internet, printing services, and professional secretarial support.',
|
||||
'price': 75.00,
|
||||
'category': 'Business',
|
||||
'slug': 'executive-business-center',
|
||||
'image': 'https://images.unsplash.com/photo-1497366216548-37526070297c?w=1200&h=800&fit=crop',
|
||||
'content': '''<div class="service-detail-content">
|
||||
<h2>Your Office Away From Office</h2>
|
||||
<p>Stay productive with our state-of-the-art Executive Business Center, designed to meet all your professional needs. Whether you need a quiet workspace, a meeting room, or professional support services, we have everything you need.</p>
|
||||
|
||||
<h3>Facilities & Services</h3>
|
||||
<ul>
|
||||
<li><strong>Private Meeting Rooms:</strong> Various sizes accommodating 2-20 people</li>
|
||||
<li><strong>High-Speed Internet:</strong> Fiber-optic connection with secure Wi-Fi</li>
|
||||
<li><strong>Printing & Copying:</strong> Color and black & white printing, scanning, and binding</li>
|
||||
<li><strong>Presentation Equipment:</strong> Projectors, screens, video conferencing</li>
|
||||
<li><strong>Secretarial Services:</strong> Typing, translation, and administrative support</li>
|
||||
<li><strong>Refreshments:</strong> Complimentary coffee, tea, and water</li>
|
||||
</ul>
|
||||
|
||||
<h3>Meeting Room Features</h3>
|
||||
<ul>
|
||||
<li>Soundproof walls for privacy</li>
|
||||
<li>Ergonomic seating and modern furniture</li>
|
||||
<li>Whiteboards and flip charts</li>
|
||||
<li>Video conferencing capabilities</li>
|
||||
<li>Climate control and natural lighting</li>
|
||||
</ul>
|
||||
|
||||
<h3>Reservation</h3>
|
||||
<p>Meeting rooms can be reserved in advance through our concierge or business center. Hourly and daily rates available. Special packages for extended stays.</p>
|
||||
</div>''',
|
||||
'sections': json.dumps([
|
||||
{
|
||||
'type': 'hero',
|
||||
'title': 'Productivity Meets Luxury',
|
||||
'content': 'A professional workspace designed for success, equipped with everything you need to stay productive.',
|
||||
'image': 'https://images.unsplash.com/photo-1497366216548-37526070297c?w=1200&h=600&fit=crop'
|
||||
},
|
||||
{
|
||||
'type': 'features',
|
||||
'title': 'Everything You Need',
|
||||
'features': [
|
||||
{'title': 'Private Meeting Rooms', 'description': 'Various sizes for any business need', 'icon': 'Users'},
|
||||
{'title': 'High-Speed Internet', 'description': 'Fiber-optic connection with secure Wi-Fi', 'icon': 'Wifi'},
|
||||
{'title': 'Professional Support', 'description': 'Secretarial and administrative services', 'icon': 'Briefcase'},
|
||||
{'title': 'Modern Equipment', 'description': 'Latest technology and presentation tools', 'icon': 'Monitor'}
|
||||
]
|
||||
}
|
||||
]),
|
||||
'meta_title': 'Executive Business Center - Professional Workspace',
|
||||
'meta_description': 'Fully equipped business center with meeting rooms, high-speed internet, printing services, and professional support. Your office away from office.',
|
||||
'meta_keywords': 'business center, meeting room, conference room, office services, hotel business center',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Luxury Airport Transfer',
|
||||
'description': 'Travel in comfort and style with our premium airport transfer service. Luxury vehicles, professional drivers, and flight monitoring for a seamless journey.',
|
||||
'price': 85.00,
|
||||
'category': 'Transportation',
|
||||
'slug': 'luxury-airport-transfer',
|
||||
'image': 'https://images.unsplash.com/photo-1449824913935-59a10b8d2000?w=1200&h=800&fit=crop',
|
||||
'content': '''<div class="service-detail-content">
|
||||
<h2>Arrive in Style</h2>
|
||||
<p>Begin or end your journey with our premium airport transfer service. Our fleet of luxury vehicles and professional drivers ensure a comfortable, punctual, and stress-free experience.</p>
|
||||
|
||||
<h3>Our Fleet</h3>
|
||||
<ul>
|
||||
<li><strong>Luxury Sedans:</strong> Premium sedans for up to 3 passengers</li>
|
||||
<li><strong>Executive SUVs:</strong> Spacious SUVs for up to 6 passengers</li>
|
||||
<li><strong>Luxury Vans:</strong> Large vehicles for groups up to 8 passengers</li>
|
||||
<li><strong>VIP Limousines:</strong> Ultimate luxury for special occasions</li>
|
||||
</ul>
|
||||
|
||||
<h3>Service Features</h3>
|
||||
<ul>
|
||||
<li>Flight monitoring for delays and early arrivals</li>
|
||||
<li>Meet & greet service at the airport</li>
|
||||
<li>Complimentary bottled water and newspapers</li>
|
||||
<li>Luggage assistance</li>
|
||||
<li>Wi-Fi connectivity in vehicles</li>
|
||||
<li>Professional, licensed, and insured drivers</li>
|
||||
</ul>
|
||||
|
||||
<h3>Booking Information</h3>
|
||||
<p>Reservations should be made at least 24 hours in advance. Please provide flight details for arrival transfers. Our team monitors flights and adjusts pickup times accordingly.</p>
|
||||
</div>''',
|
||||
'sections': json.dumps([
|
||||
{
|
||||
'type': 'hero',
|
||||
'title': 'Your Journey Starts Here',
|
||||
'content': 'Experience the comfort and convenience of premium airport transfers.',
|
||||
'image': 'https://images.unsplash.com/photo-1449824913935-59a10b8d2000?w=1200&h=600&fit=crop'
|
||||
},
|
||||
{
|
||||
'type': 'features',
|
||||
'title': 'Why Choose Our Transfer Service',
|
||||
'features': [
|
||||
{'title': 'Luxury Fleet', 'description': 'Premium vehicles for every need', 'icon': 'Car'},
|
||||
{'title': 'Professional Drivers', 'description': 'Licensed, experienced, and courteous', 'icon': 'User'},
|
||||
{'title': 'Flight Monitoring', 'description': 'Automatic updates for delays', 'icon': 'Plane'},
|
||||
{'title': 'Complimentary Amenities', 'description': 'Water, Wi-Fi, and newspapers', 'icon': 'Gift'}
|
||||
]
|
||||
}
|
||||
]),
|
||||
'meta_title': 'Luxury Airport Transfer - Premium Transportation',
|
||||
'meta_description': 'Travel in comfort with our premium airport transfer service. Luxury vehicles, professional drivers, and flight monitoring for a seamless journey.',
|
||||
'meta_keywords': 'airport transfer, luxury transportation, airport shuttle, hotel transfer, airport service',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Personal Concierge Service',
|
||||
'description': 'Dedicated 24/7 concierge assistance for restaurant reservations, event tickets, tours, transportation, and all your special requests.',
|
||||
'price': 0.00,
|
||||
'category': 'Concierge',
|
||||
'slug': 'personal-concierge-service',
|
||||
'image': 'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1200&h=800&fit=crop',
|
||||
'content': '''<div class="service-detail-content">
|
||||
<h2>Your Personal Assistant</h2>
|
||||
<p>Our dedicated concierge team is available 24/7 to ensure your stay is nothing short of extraordinary. From restaurant reservations and event tickets to private tours and unique experiences, we handle every detail with precision and care.</p>
|
||||
|
||||
<h3>Services We Provide</h3>
|
||||
<ul>
|
||||
<li><strong>Dining:</strong> Restaurant reservations at the finest establishments</li>
|
||||
<li><strong>Entertainment:</strong> Theater tickets, concerts, and event access</li>
|
||||
<li><strong>Tours & Activities:</strong> Private tours, cultural experiences, and adventure activities</li>
|
||||
<li><strong>Transportation:</strong> Car rentals, chauffeur services, and travel arrangements</li>
|
||||
<li><strong>Business Services:</strong> Meeting room bookings, translation services, and more</li>
|
||||
<li><strong>Special Requests:</strong> Gift arrangements, celebrations, and unique experiences</li>
|
||||
</ul>
|
||||
|
||||
<h3>How We Can Help</h3>
|
||||
<p>Whether you need last-minute reservations at a popular restaurant, tickets to a sold-out show, or help planning a special celebration, our concierge team has the expertise and connections to make it happen. No request is too big or too small.</p>
|
||||
|
||||
<h3>Contact Us</h3>
|
||||
<p>Reach our concierge team 24/7 via phone, email, or in-person at the concierge desk. We're here to make your stay unforgettable.</p>
|
||||
</div>''',
|
||||
'sections': json.dumps([
|
||||
{
|
||||
'type': 'hero',
|
||||
'title': 'Making Your Dreams Come True',
|
||||
'content': 'Your personal assistant, available 24/7 to fulfill every request.',
|
||||
'image': 'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1200&h=600&fit=crop'
|
||||
},
|
||||
{
|
||||
'type': 'cta',
|
||||
'title': 'Need Assistance?',
|
||||
'cta_text': 'Contact Our Concierge',
|
||||
'cta_link': '/contact',
|
||||
'content': 'Our team is standing by to help with any request, big or small.'
|
||||
}
|
||||
]),
|
||||
'meta_title': 'Personal Concierge Service - 24/7 Assistance',
|
||||
'meta_description': 'Dedicated 24/7 concierge service for restaurant reservations, event tickets, tours, and all your special requests. Making your stay extraordinary.',
|
||||
'meta_keywords': 'concierge service, personal assistant, hotel concierge, travel services, luxury service',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'State-of-the-Art Fitness Center',
|
||||
'description': 'Access our fully equipped fitness center with modern equipment, personal training options, and group fitness classes. Open 24/7 for your convenience.',
|
||||
'price': 30.00,
|
||||
'category': 'Wellness',
|
||||
'slug': 'fitness-center-access',
|
||||
'image': 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=1200&h=800&fit=crop',
|
||||
'content': '''<div class="service-detail-content">
|
||||
<h2>Stay Fit, Stay Strong</h2>
|
||||
<p>Maintain your fitness routine at our state-of-the-art fitness center featuring the latest cardio and strength training equipment. Our facilities are designed to meet the needs of both casual exercisers and serious athletes.</p>
|
||||
|
||||
<h3>Equipment & Facilities</h3>
|
||||
<ul>
|
||||
<li><strong>Cardio Equipment:</strong> Treadmills, ellipticals, stationary bikes, and rowing machines</li>
|
||||
<li><strong>Strength Training:</strong> Free weights, weight machines, and functional training equipment</li>
|
||||
<li><strong>Group Fitness Studio:</strong> Space for yoga, Pilates, and group classes</li>
|
||||
<li><strong>Locker Rooms:</strong> Spacious lockers, showers, and sauna access</li>
|
||||
<li><strong>Complimentary Amenities:</strong> Towels, water, fresh fruit, and protein bars</li>
|
||||
</ul>
|
||||
|
||||
<h3>Additional Services</h3>
|
||||
<ul>
|
||||
<li><strong>Personal Training:</strong> One-on-one sessions with certified trainers</li>
|
||||
<li><strong>Group Classes:</strong> Yoga, Pilates, spinning, and HIIT classes</li>
|
||||
<li><strong>Fitness Assessments:</strong> Body composition analysis and fitness evaluations</li>
|
||||
<li><strong>Nutritional Guidance:</strong> Consultation with our wellness experts</li>
|
||||
</ul>
|
||||
|
||||
<h3>Operating Hours</h3>
|
||||
<p>Our fitness center is open 24/7 for your convenience. Personal training and group classes are available by appointment. Check the schedule at the front desk or contact our fitness team.</p>
|
||||
</div>''',
|
||||
'sections': json.dumps([
|
||||
{
|
||||
'type': 'hero',
|
||||
'title': 'Your Fitness Journey Continues',
|
||||
'content': 'World-class equipment and expert guidance to help you achieve your fitness goals.',
|
||||
'image': 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=1200&h=600&fit=crop'
|
||||
},
|
||||
{
|
||||
'type': 'features',
|
||||
'title': 'Why Our Fitness Center',
|
||||
'features': [
|
||||
{'title': '24/7 Access', 'description': 'Work out on your schedule', 'icon': 'Clock'},
|
||||
{'title': 'Modern Equipment', 'description': 'Latest cardio and strength training machines', 'icon': 'Activity'},
|
||||
{'title': 'Expert Trainers', 'description': 'Certified personal trainers available', 'icon': 'User'},
|
||||
{'title': 'Group Classes', 'description': 'Yoga, Pilates, and more', 'icon': 'Users'}
|
||||
]
|
||||
}
|
||||
]),
|
||||
'meta_title': 'State-of-the-Art Fitness Center - 24/7 Access',
|
||||
'meta_description': 'Fully equipped fitness center with modern equipment, personal training, and group classes. Open 24/7 for your convenience.',
|
||||
'meta_keywords': 'fitness center, gym, hotel gym, workout, exercise, personal training, 24/7 gym',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Premium Laundry & Dry Cleaning',
|
||||
'description': 'Professional laundry and dry cleaning service with same-day express or next-day standard delivery. Expert care for all your garments.',
|
||||
'price': 20.00,
|
||||
'category': 'Housekeeping',
|
||||
'slug': 'premium-laundry-dry-cleaning',
|
||||
'image': 'https://images.unsplash.com/photo-1582735689369-4fe89db7114c?w=1200&h=800&fit=crop',
|
||||
'content': '''<div class="service-detail-content">
|
||||
<h2>Expert Garment Care</h2>
|
||||
<p>Keep your wardrobe fresh and impeccably maintained with our professional laundry and dry cleaning service. We handle everything from delicate silk garments to business suits with the utmost care and attention to detail.</p>
|
||||
|
||||
<h3>Our Services</h3>
|
||||
<ul>
|
||||
<li><strong>Dry Cleaning:</strong> Professional cleaning for suits, dresses, and delicate fabrics</li>
|
||||
<li><strong>Laundry Service:</strong> Washing, drying, and pressing for everyday garments</li>
|
||||
<li><strong>Pressing Only:</strong> Professional pressing and steaming services</li>
|
||||
<li><strong>Alterations:</strong> Minor repairs and adjustments available</li>
|
||||
<li><strong>Specialty Items:</strong> Wedding dresses, formal wear, and designer garments</li>
|
||||
</ul>
|
||||
|
||||
<h3>Delivery Options</h3>
|
||||
<ul>
|
||||
<li><strong>Express Service:</strong> Same-day delivery (orders before 9 AM)</li>
|
||||
<li><strong>Standard Service:</strong> Next-day delivery</li>
|
||||
<li><strong>Bulk Service:</strong> Special rates for multiple items</li>
|
||||
</ul>
|
||||
|
||||
<h3>How to Use</h3>
|
||||
<p>Simply place your items in the laundry bag provided in your room and call housekeeping. Our team will collect your items, process them with care, and return them to your room at the specified time.</p>
|
||||
</div>''',
|
||||
'sections': json.dumps([
|
||||
{
|
||||
'type': 'features',
|
||||
'title': 'Why Choose Our Service',
|
||||
'features': [
|
||||
{'title': 'Expert Care', 'description': 'Professional handling of all garment types', 'icon': 'Shield'},
|
||||
{'title': 'Fast Service', 'description': 'Same-day express option available', 'icon': 'Zap'},
|
||||
{'title': 'Quality Guarantee', 'description': 'Satisfaction guaranteed on all services', 'icon': 'CheckCircle'},
|
||||
{'title': 'Eco-Friendly', 'description': 'Environmentally conscious cleaning methods', 'icon': 'Leaf'}
|
||||
]
|
||||
}
|
||||
]),
|
||||
'meta_title': 'Premium Laundry & Dry Cleaning - Professional Service',
|
||||
'meta_description': 'Professional laundry and dry cleaning with same-day or next-day delivery. Expert care for all your garments.',
|
||||
'meta_keywords': 'laundry service, dry cleaning, hotel laundry, garment care, cleaning service',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Valet Parking Service',
|
||||
'description': 'Secure valet parking with 24/7 vehicle care, quick retrieval, and optional car wash services. Your vehicle is in safe hands.',
|
||||
'price': 45.00,
|
||||
'category': 'Transportation',
|
||||
'slug': 'valet-parking-service',
|
||||
'image': 'https://images.unsplash.com/photo-1502877338535-766e1452684a?w=1200&h=800&fit=crop',
|
||||
'content': '''<div class="service-detail-content">
|
||||
<h2>Secure & Convenient Parking</h2>
|
||||
<p>Enjoy the ultimate convenience of valet parking with our professional service. Your vehicle will be safely parked and secured, ready for quick retrieval whenever you need it. Our valet team is available 24/7 to assist you.</p>
|
||||
|
||||
<h3>Service Features</h3>
|
||||
<ul>
|
||||
<li>Secure, monitored parking facility</li>
|
||||
<li>24/7 valet service availability</li>
|
||||
<li>Quick vehicle retrieval (average 5 minutes)</li>
|
||||
<li>Complimentary vehicle inspection</li>
|
||||
<li>Luggage assistance included</li>
|
||||
<li>Optional car wash and detailing services</li>
|
||||
</ul>
|
||||
|
||||
<h3>Additional Services</h3>
|
||||
<ul>
|
||||
<li><strong>Car Wash:</strong> Exterior wash and interior vacuum</li>
|
||||
<li><strong>Detailing:</strong> Full interior and exterior detailing</li>
|
||||
<li><strong>Battery Jump Start:</strong> Assistance with vehicle issues</li>
|
||||
<li><strong>Tire Pressure Check:</strong> Complimentary tire service</li>
|
||||
</ul>
|
||||
|
||||
<h3>Pricing</h3>
|
||||
<p>Daily rates available for extended stays. Weekly and monthly packages offer significant savings. Contact our valet team for special rates.</p>
|
||||
</div>''',
|
||||
'sections': json.dumps([
|
||||
{
|
||||
'type': 'features',
|
||||
'title': 'Why Choose Our Valet Service',
|
||||
'features': [
|
||||
{'title': 'Secure Facility', 'description': 'Monitored and protected parking', 'icon': 'Shield'},
|
||||
{'title': 'Quick Service', 'description': 'Average 5-minute retrieval time', 'icon': 'Clock'},
|
||||
{'title': '24/7 Availability', 'description': 'Service available around the clock', 'icon': 'Sun'},
|
||||
{'title': 'Additional Services', 'description': 'Car wash and detailing available', 'icon': 'Sparkles'}
|
||||
]
|
||||
}
|
||||
]),
|
||||
'meta_title': 'Valet Parking Service - Secure & Convenient',
|
||||
'meta_description': 'Secure valet parking with 24/7 service, quick retrieval, and optional car care services. Your vehicle is in safe hands.',
|
||||
'meta_keywords': 'valet parking, parking service, hotel parking, car service, valet',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Professional Babysitting Service',
|
||||
'description': 'Certified and experienced babysitters available for in-room care and supervised activities. Background-checked professionals for your peace of mind.',
|
||||
'price': 35.00,
|
||||
'category': 'Family',
|
||||
'slug': 'professional-babysitting-service',
|
||||
'image': 'https://images.unsplash.com/photo-1503454537195-1dcabb73ffb9?w=1200&h=800&fit=crop',
|
||||
'content': '''<div class="service-detail-content">
|
||||
<h2>Your Children in Safe Hands</h2>
|
||||
<p>Enjoy your time knowing your children are in the care of our professional babysitting service. Our certified caregivers are experienced, background-checked, and trained in child safety and first aid.</p>
|
||||
|
||||
<h3>Our Caregivers</h3>
|
||||
<ul>
|
||||
<li>Certified in CPR and first aid</li>
|
||||
<li>Background-checked and verified</li>
|
||||
<li>Experienced with children of all ages</li>
|
||||
<li>Multilingual capabilities available</li>
|
||||
<li>References available upon request</li>
|
||||
</ul>
|
||||
|
||||
<h3>Services Offered</h3>
|
||||
<ul>
|
||||
<li><strong>In-Room Care:</strong> Supervised care in your hotel room</li>
|
||||
<li><strong>Activity Supervision:</strong> Accompany children to hotel activities</li>
|
||||
<li><strong>Evening Care:</strong> Bedtime routines and overnight care</li>
|
||||
<li><strong>Group Care:</strong> Multiple children from the same family</li>
|
||||
</ul>
|
||||
|
||||
<h3>Booking Information</h3>
|
||||
<p>Reservations should be made at least 4 hours in advance. Same-day service may be available subject to caregiver availability. Minimum booking: 2 hours.</p>
|
||||
</div>''',
|
||||
'sections': json.dumps([
|
||||
{
|
||||
'type': 'hero',
|
||||
'title': 'Peace of Mind for Parents',
|
||||
'content': 'Certified professionals ensuring your children are safe, happy, and well-cared for.',
|
||||
'image': 'https://images.unsplash.com/photo-1503454537195-1dcabb73ffb9?w=1200&h=600&fit=crop'
|
||||
},
|
||||
{
|
||||
'type': 'features',
|
||||
'title': 'Why Parents Trust Us',
|
||||
'features': [
|
||||
{'title': 'Certified Caregivers', 'description': 'CPR and first aid certified', 'icon': 'Shield'},
|
||||
{'title': 'Background Checked', 'description': 'Thoroughly vetted professionals', 'icon': 'UserCheck'},
|
||||
{'title': 'Experienced', 'description': 'Years of childcare experience', 'icon': 'Heart'},
|
||||
{'title': 'Flexible Service', 'description': 'In-room or activity supervision', 'icon': 'Clock'}
|
||||
]
|
||||
}
|
||||
]),
|
||||
'meta_title': 'Professional Babysitting Service - Certified Caregivers',
|
||||
'meta_description': 'Certified and experienced babysitters for in-room care and supervised activities. Background-checked professionals for your peace of mind.',
|
||||
'meta_keywords': 'babysitting service, childcare, hotel babysitting, kids care, family services',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Pet Care & Accommodation',
|
||||
'description': 'Pet-friendly accommodations with professional pet care services including walking, feeding, grooming, and pet-sitting. Your furry friends are welcome!',
|
||||
'price': 25.00,
|
||||
'category': 'Pet Services',
|
||||
'slug': 'pet-care-accommodation',
|
||||
'image': 'https://images.unsplash.com/photo-1601758228041-f3b2795255f1?w=1200&h=800&fit=crop',
|
||||
'content': '''<div class="service-detail-content">
|
||||
<h2>Your Pets Are Family Too</h2>
|
||||
<p>We welcome your furry family members! Our pet-friendly accommodations and professional pet care services ensure your pets are as comfortable and well-cared for as you are during your stay.</p>
|
||||
|
||||
<h3>Pet Amenities</h3>
|
||||
<ul>
|
||||
<li>Comfortable pet beds and blankets</li>
|
||||
<li>Food and water bowls</li>
|
||||
<li>Complimentary treats and toys</li>
|
||||
<li>Pet waste bags and disposal</li>
|
||||
<li>Designated pet relief areas</li>
|
||||
</ul>
|
||||
|
||||
<h3>Pet Care Services</h3>
|
||||
<ul>
|
||||
<li><strong>Dog Walking:</strong> Regular walks and exercise</li>
|
||||
<li><strong>Pet Sitting:</strong> In-room care while you're away</li>
|
||||
<li><strong>Feeding Service:</strong> Scheduled feeding times</li>
|
||||
<li><strong>Grooming:</strong> Basic grooming and bathing</li>
|
||||
<li><strong>Veterinary Referrals:</strong> Local vet recommendations</li>
|
||||
</ul>
|
||||
|
||||
<h3>Pet Policy</h3>
|
||||
<p>Pets up to 50 lbs are welcome. A pet fee applies per night. Please inform us in advance about your pet's stay. We ask that pets be leashed in public areas and that owners clean up after their pets.</p>
|
||||
</div>''',
|
||||
'sections': json.dumps([
|
||||
{
|
||||
'type': 'hero',
|
||||
'title': 'A Home Away From Home for Your Pets',
|
||||
'content': 'Because your pets deserve the same luxury experience as you.',
|
||||
'image': 'https://images.unsplash.com/photo-1601758228041-f3b2795255f1?w=1200&h=600&fit=crop'
|
||||
},
|
||||
{
|
||||
'type': 'features',
|
||||
'title': 'Pet-Friendly Features',
|
||||
'features': [
|
||||
{'title': 'Pet Amenities', 'description': 'Beds, bowls, treats, and toys provided', 'icon': 'Heart'},
|
||||
{'title': 'Professional Care', 'description': 'Walking, feeding, and grooming services', 'icon': 'User'},
|
||||
{'title': 'Designated Areas', 'description': 'Pet relief areas and walking paths', 'icon': 'MapPin'},
|
||||
{'title': 'Vet Referrals', 'description': 'Local veterinary recommendations', 'icon': 'Stethoscope'}
|
||||
]
|
||||
}
|
||||
]),
|
||||
'meta_title': 'Pet Care & Accommodation - Pet-Friendly Hotel',
|
||||
'meta_description': 'Pet-friendly accommodations with professional pet care services. Walking, feeding, grooming, and pet-sitting available. Your pets are welcome!',
|
||||
'meta_keywords': 'pet care, pet friendly hotel, dog walking, pet services, pet accommodation',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Premium Wine & Spirits Collection',
|
||||
'description': 'Extensive collection of fine wines, premium spirits, and craft cocktails. Expert sommelier service and private wine tastings available.',
|
||||
'price': 0.00,
|
||||
'category': 'Dining',
|
||||
'slug': 'premium-wine-spirits-collection',
|
||||
'image': 'https://images.unsplash.com/photo-1514362545857-3bc16c4c7d1b?w=1200&h=800&fit=crop',
|
||||
'content': '''<div class="service-detail-content">
|
||||
<h2>Curated Excellence</h2>
|
||||
<p>Discover our extensive collection of fine wines, premium spirits, and artisanal cocktails. Our master sommelier has curated a selection that spans the globe, featuring rare vintages, limited editions, and exceptional spirits.</p>
|
||||
|
||||
<h3>Our Collection</h3>
|
||||
<ul>
|
||||
<li><strong>Fine Wines:</strong> Over 500 labels from renowned vineyards worldwide</li>
|
||||
<li><strong>Premium Spirits:</strong> Rare whiskeys, cognacs, and artisanal spirits</li>
|
||||
<li><strong>Craft Cocktails:</strong> Signature cocktails created by our expert mixologists</li>
|
||||
<li><strong>Champagne & Sparkling:</strong> Exclusive selection of premium bubbles</li>
|
||||
</ul>
|
||||
|
||||
<h3>Services</h3>
|
||||
<ul>
|
||||
<li><strong>Sommelier Service:</strong> Expert wine recommendations and pairings</li>
|
||||
<li><strong>Wine Tastings:</strong> Private tastings and educational sessions</li>
|
||||
<li><strong>Bar Service:</strong> Craft cocktails at our premium bars</li>
|
||||
<li><strong>Private Events:</strong> Wine dinners and tasting events</li>
|
||||
</ul>
|
||||
|
||||
<h3>Locations</h3>
|
||||
<p>Enjoy our collection at our signature bars, restaurants, or through our room service. Our sommelier is available for consultations and recommendations.</p>
|
||||
</div>''',
|
||||
'sections': json.dumps([
|
||||
{
|
||||
'type': 'hero',
|
||||
'title': 'A World of Flavors',
|
||||
'content': 'Curated by experts, enjoyed by connoisseurs.',
|
||||
'image': 'https://images.unsplash.com/photo-1514362545857-3bc16c4c7d1b?w=1200&h=600&fit=crop'
|
||||
},
|
||||
{
|
||||
'type': 'quote',
|
||||
'quote': 'An exceptional collection that rivals the finest establishments in the world.',
|
||||
'author': 'Wine Enthusiast Magazine'
|
||||
}
|
||||
]),
|
||||
'meta_title': 'Premium Wine & Spirits Collection - Fine Beverages',
|
||||
'meta_description': 'Extensive collection of fine wines, premium spirits, and craft cocktails. Expert sommelier service and private tastings available.',
|
||||
'meta_keywords': 'wine collection, premium spirits, sommelier, wine tasting, luxury bar, craft cocktails',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Private Event & Catering Services',
|
||||
'description': 'Professional event planning and catering services for weddings, corporate events, celebrations, and private gatherings. Make your special occasion unforgettable.',
|
||||
'price': 0.00,
|
||||
'category': 'Entertainment',
|
||||
'slug': 'private-event-catering-services',
|
||||
'image': 'https://images.unsplash.com/photo-1519167758481-83f550bb49b3?w=1200&h=800&fit=crop',
|
||||
'content': '''<div class="service-detail-content">
|
||||
<h2>Unforgettable Events</h2>
|
||||
<p>From intimate gatherings to grand celebrations, our professional event planning and catering team will ensure your special occasion is executed flawlessly. We handle every detail so you can enjoy your event.</p>
|
||||
|
||||
<h3>Event Types</h3>
|
||||
<ul>
|
||||
<li><strong>Weddings:</strong> Ceremonies, receptions, and rehearsal dinners</li>
|
||||
<li><strong>Corporate Events:</strong> Conferences, meetings, and team building</li>
|
||||
<li><strong>Celebrations:</strong> Birthdays, anniversaries, and milestones</li>
|
||||
<li><strong>Private Dinners:</strong> Intimate gatherings and special occasions</li>
|
||||
</ul>
|
||||
|
||||
<h3>Our Services</h3>
|
||||
<ul>
|
||||
<li>Complete event planning and coordination</li>
|
||||
<li>Customized menu design and catering</li>
|
||||
<li>Venue selection and setup</li>
|
||||
<li>Floral arrangements and decorations</li>
|
||||
<li>Entertainment coordination</li>
|
||||
<li>Photography and videography services</li>
|
||||
<li>Technical support and AV equipment</li>
|
||||
</ul>
|
||||
|
||||
<h3>Planning Process</h3>
|
||||
<p>Our event specialists will work with you from initial consultation through event execution. We offer flexible packages and can customize services to meet your specific needs and budget.</p>
|
||||
</div>''',
|
||||
'sections': json.dumps([
|
||||
{
|
||||
'type': 'hero',
|
||||
'title': 'Making Memories That Last',
|
||||
'content': 'Professional event planning and catering for your most important occasions.',
|
||||
'image': 'https://images.unsplash.com/photo-1519167758481-83f550bb49b3?w=1200&h=600&fit=crop'
|
||||
},
|
||||
{
|
||||
'type': 'cta',
|
||||
'title': 'Planning an Event?',
|
||||
'cta_text': 'Contact Our Event Team',
|
||||
'cta_link': '/contact',
|
||||
'content': 'Let us help you create an unforgettable experience.'
|
||||
}
|
||||
]),
|
||||
'meta_title': 'Private Event & Catering Services - Professional Planning',
|
||||
'meta_description': 'Professional event planning and catering for weddings, corporate events, and celebrations. Make your special occasion unforgettable.',
|
||||
'meta_keywords': 'event planning, catering, wedding planning, corporate events, private events, hotel events',
|
||||
'is_active': True
|
||||
}
|
||||
]
|
||||
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
|
||||
for service_data in services_data:
|
||||
# Check if service already exists by slug
|
||||
existing = db.query(Service).filter(Service.slug == service_data['slug']).first()
|
||||
|
||||
if existing:
|
||||
# Update existing service
|
||||
for key, value in service_data.items():
|
||||
if key != 'slug': # Don't update slug
|
||||
setattr(existing, key, value)
|
||||
updated_count += 1
|
||||
print(f' ✓ Updated service: {service_data["name"]}')
|
||||
else:
|
||||
# Create new service
|
||||
service = Service(**service_data)
|
||||
db.add(service)
|
||||
created_count += 1
|
||||
print(f' ✓ Created service: {service_data["name"]}')
|
||||
|
||||
db.commit()
|
||||
print(f'\n✓ Enterprise services seeded successfully!')
|
||||
print(f' - Created: {created_count}')
|
||||
print(f' - Updated: {updated_count}')
|
||||
print(f' - Total: {len(services_data)}\n')
|
||||
|
||||
def main():
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
print('=' * 80)
|
||||
print('SEEDING ENTERPRISE SERVICES')
|
||||
print('=' * 80)
|
||||
print()
|
||||
seed_enterprise_services(db)
|
||||
print('=' * 80)
|
||||
print('✓ All enterprise services data seeded successfully!')
|
||||
print('=' * 80)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f'\n✗ Error seeding services: {e}')
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, Integer, String, Numeric, Boolean, Text, DateTime
|
||||
from sqlalchemy import Column, Integer, String, Numeric, Boolean, Text, DateTime, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ...shared.config.database import Base
|
||||
@@ -10,6 +10,13 @@ class Service(Base):
|
||||
description = Column(Text, nullable=True)
|
||||
price = Column(Numeric(10, 2), nullable=False)
|
||||
category = Column(String(50), nullable=True)
|
||||
slug = Column(String(200), nullable=True, unique=True, index=True)
|
||||
image = Column(String(1000), nullable=True)
|
||||
content = Column(Text, nullable=True)
|
||||
sections = Column(Text, nullable=True) # JSON string for advanced content blocks
|
||||
meta_title = Column(String(500), nullable=True)
|
||||
meta_description = Column(Text, nullable=True)
|
||||
meta_keywords = Column(String(1000), nullable=True)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
Binary file not shown.
@@ -24,7 +24,24 @@ async def get_services(search: Optional[str]=Query(None), status_filter: Optiona
|
||||
services = query.order_by(Service.created_at.desc()).offset(offset).limit(limit).all()
|
||||
result = []
|
||||
for service in services:
|
||||
service_dict = {'id': service.id, 'name': service.name, 'description': service.description, 'price': float(service.price) if service.price else 0.0, 'category': service.category, 'is_active': service.is_active, 'created_at': service.created_at.isoformat() if service.created_at else None}
|
||||
service_dict = {
|
||||
'id': service.id,
|
||||
'name': service.name,
|
||||
'description': service.description,
|
||||
'price': float(service.price) if service.price else 0.0,
|
||||
'category': service.category,
|
||||
'slug': service.slug,
|
||||
'image': service.image,
|
||||
'content': service.content,
|
||||
'sections': service.sections,
|
||||
'meta_title': service.meta_title,
|
||||
'meta_description': service.meta_description,
|
||||
'meta_keywords': service.meta_keywords,
|
||||
'is_active': service.is_active,
|
||||
'status': 'active' if service.is_active else 'inactive',
|
||||
'unit': service.category, # For backward compatibility
|
||||
'created_at': service.created_at.isoformat() if service.created_at else None
|
||||
}
|
||||
result.append(service_dict)
|
||||
return {'status': 'success', 'data': {'services': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}}
|
||||
except Exception as e:
|
||||
@@ -36,7 +53,54 @@ async def get_service_by_id(id: int, db: Session=Depends(get_db)):
|
||||
service = db.query(Service).filter(Service.id == id).first()
|
||||
if not service:
|
||||
raise HTTPException(status_code=404, detail='Service not found')
|
||||
service_dict = {'id': service.id, 'name': service.name, 'description': service.description, 'price': float(service.price) if service.price else 0.0, 'category': service.category, 'is_active': service.is_active, 'created_at': service.created_at.isoformat() if service.created_at else None}
|
||||
service_dict = {
|
||||
'id': service.id,
|
||||
'name': service.name,
|
||||
'description': service.description,
|
||||
'price': float(service.price) if service.price else 0.0,
|
||||
'category': service.category,
|
||||
'slug': service.slug,
|
||||
'image': service.image,
|
||||
'content': service.content,
|
||||
'sections': service.sections,
|
||||
'meta_title': service.meta_title,
|
||||
'meta_description': service.meta_description,
|
||||
'meta_keywords': service.meta_keywords,
|
||||
'is_active': service.is_active,
|
||||
'status': 'active' if service.is_active else 'inactive',
|
||||
'unit': service.category, # For backward compatibility
|
||||
'created_at': service.created_at.isoformat() if service.created_at else None
|
||||
}
|
||||
return {'status': 'success', 'data': {'service': service_dict}}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/slug/{slug}')
|
||||
async def get_service_by_slug(slug: str, db: Session=Depends(get_db)):
|
||||
try:
|
||||
service = db.query(Service).filter(Service.slug == slug, Service.is_active == True).first()
|
||||
if not service:
|
||||
raise HTTPException(status_code=404, detail='Service not found')
|
||||
service_dict = {
|
||||
'id': service.id,
|
||||
'name': service.name,
|
||||
'description': service.description,
|
||||
'price': float(service.price) if service.price else 0.0,
|
||||
'category': service.category,
|
||||
'slug': service.slug,
|
||||
'image': service.image,
|
||||
'content': service.content,
|
||||
'sections': service.sections,
|
||||
'meta_title': service.meta_title,
|
||||
'meta_description': service.meta_description,
|
||||
'meta_keywords': service.meta_keywords,
|
||||
'is_active': service.is_active,
|
||||
'status': 'active' if service.is_active else 'inactive',
|
||||
'unit': service.category, # For backward compatibility
|
||||
'created_at': service.created_at.isoformat() if service.created_at else None
|
||||
}
|
||||
return {'status': 'success', 'data': {'service': service_dict}}
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -50,7 +114,39 @@ async def create_service(service_data: dict, current_user: User=Depends(authoriz
|
||||
existing = db.query(Service).filter(Service.name == name).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail='Service name already exists')
|
||||
service = Service(name=name, description=service_data.get('description'), price=float(service_data.get('price', 0)), category=service_data.get('category'), is_active=service_data.get('status') == 'active' if service_data.get('status') else True)
|
||||
|
||||
# Generate slug from name if not provided
|
||||
slug = service_data.get('slug')
|
||||
if not slug and name:
|
||||
import re
|
||||
slug = re.sub(r'[^a-z0-9-]', '', name.lower().replace(' ', '-'))
|
||||
# Ensure slug is unique
|
||||
counter = 1
|
||||
original_slug = slug
|
||||
while db.query(Service).filter(Service.slug == slug).first():
|
||||
slug = f"{original_slug}-{counter}"
|
||||
counter += 1
|
||||
|
||||
# Handle sections as JSON string
|
||||
sections = service_data.get('sections')
|
||||
if sections and isinstance(sections, (list, dict)):
|
||||
import json
|
||||
sections = json.dumps(sections)
|
||||
|
||||
service = Service(
|
||||
name=name,
|
||||
description=service_data.get('description'),
|
||||
price=float(service_data.get('price', 0)),
|
||||
category=service_data.get('category'),
|
||||
slug=slug,
|
||||
image=service_data.get('image'),
|
||||
content=service_data.get('content'),
|
||||
sections=sections,
|
||||
meta_title=service_data.get('meta_title'),
|
||||
meta_description=service_data.get('meta_description'),
|
||||
meta_keywords=service_data.get('meta_keywords'),
|
||||
is_active=service_data.get('status') == 'active' if service_data.get('status') else True
|
||||
)
|
||||
db.add(service)
|
||||
db.commit()
|
||||
db.refresh(service)
|
||||
@@ -72,6 +168,14 @@ async def update_service(id: int, service_data: dict, current_user: User=Depends
|
||||
existing = db.query(Service).filter(Service.name == name, Service.id != id).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail='Service name already exists')
|
||||
|
||||
# Check slug uniqueness if provided
|
||||
slug = service_data.get('slug')
|
||||
if slug and slug != service.slug:
|
||||
existing = db.query(Service).filter(Service.slug == slug, Service.id != id).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail='Service slug already exists')
|
||||
|
||||
if 'name' in service_data:
|
||||
service.name = service_data['name']
|
||||
if 'description' in service_data:
|
||||
@@ -80,6 +184,25 @@ async def update_service(id: int, service_data: dict, current_user: User=Depends
|
||||
service.price = float(service_data['price'])
|
||||
if 'category' in service_data:
|
||||
service.category = service_data['category']
|
||||
if 'slug' in service_data:
|
||||
service.slug = service_data['slug']
|
||||
if 'image' in service_data:
|
||||
service.image = service_data['image']
|
||||
if 'content' in service_data:
|
||||
service.content = service_data['content']
|
||||
if 'sections' in service_data:
|
||||
sections = service_data['sections']
|
||||
if sections and isinstance(sections, (list, dict)):
|
||||
import json
|
||||
service.sections = json.dumps(sections)
|
||||
else:
|
||||
service.sections = sections
|
||||
if 'meta_title' in service_data:
|
||||
service.meta_title = service_data['meta_title']
|
||||
if 'meta_description' in service_data:
|
||||
service.meta_description = service_data['meta_description']
|
||||
if 'meta_keywords' in service_data:
|
||||
service.meta_keywords = service_data['meta_keywords']
|
||||
if 'status' in service_data:
|
||||
service.is_active = service_data['status'] == 'active'
|
||||
db.commit()
|
||||
|
||||
@@ -76,6 +76,8 @@ const AccessibilityPage = lazy(() => import('./features/content/pages/Accessibil
|
||||
const FAQPage = lazy(() => import('./features/content/pages/FAQPage'));
|
||||
const BlogPage = lazy(() => import('./features/content/pages/BlogPage'));
|
||||
const BlogDetailPage = lazy(() => import('./features/content/pages/BlogDetailPage'));
|
||||
const ServicesPage = lazy(() => import('./features/content/pages/ServicesPage'));
|
||||
const ServiceDetailPage = lazy(() => import('./features/content/pages/ServiceDetailPage'));
|
||||
|
||||
const AdminDashboardPage = lazy(() => import('./pages/admin/DashboardPage'));
|
||||
const InvoiceManagementPage = lazy(() => import('./pages/admin/InvoiceManagementPage'));
|
||||
@@ -109,6 +111,7 @@ const GDPRManagementPage = lazy(() => import('./pages/admin/GDPRManagementPage')
|
||||
const WebhookManagementPage = lazy(() => import('./pages/admin/WebhookManagementPage'));
|
||||
const APIKeyManagementPage = lazy(() => import('./pages/admin/APIKeyManagementPage'));
|
||||
const BackupManagementPage = lazy(() => import('./pages/admin/BackupManagementPage'));
|
||||
const ServiceManagementPage = lazy(() => import('./pages/admin/ServiceManagementPage'));
|
||||
|
||||
const StaffDashboardPage = lazy(() => import('./pages/staff/DashboardPage'));
|
||||
const StaffBookingManagementPage = lazy(() => import('./pages/staff/BookingManagementPage'));
|
||||
@@ -361,6 +364,14 @@ function App() {
|
||||
path="blog/:slug"
|
||||
element={<BlogDetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="services"
|
||||
element={<ServicesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="services/:slug"
|
||||
element={<ServiceDetailPage />}
|
||||
/>
|
||||
|
||||
{}
|
||||
<Route
|
||||
@@ -681,6 +692,10 @@ function App() {
|
||||
path="backups"
|
||||
element={<BackupManagementPage />}
|
||||
/>
|
||||
<Route
|
||||
path="services"
|
||||
element={<ServiceManagementPage />}
|
||||
/>
|
||||
<Route
|
||||
path="profile"
|
||||
element={<AdminProfilePage />}
|
||||
|
||||
@@ -19,20 +19,26 @@ import {
|
||||
import bannerService from '../services/bannerService';
|
||||
import roomService from '../../rooms/services/roomService';
|
||||
import pageContentService from '../services/pageContentService';
|
||||
import serviceService from '../../hotel_services/services/serviceService';
|
||||
import { useFormatCurrency } from '../../payments/hooks/useFormatCurrency';
|
||||
import type { Banner } from '../services/bannerService';
|
||||
import type { Room } from '../../rooms/services/roomService';
|
||||
import type { PageContent } from '../services/pageContentService';
|
||||
import type { Service } from '../../hotel_services/services/serviceService';
|
||||
|
||||
const HomePage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const [banners, setBanners] = useState<Banner[]>([]);
|
||||
const [featuredRooms, setFeaturedRooms] = useState<Room[]>([]);
|
||||
const [newestRooms, setNewestRooms] = useState<Room[]>([]);
|
||||
const [pageContent, setPageContent] = useState<PageContent | null>(null);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [isLoadingBanners, setIsLoadingBanners] =
|
||||
useState(true);
|
||||
const [isLoadingRooms, setIsLoadingRooms] = useState(true);
|
||||
const [isLoadingNewest, setIsLoadingNewest] = useState(true);
|
||||
const [, setIsLoadingContent] = useState(true);
|
||||
const [isLoadingServices, setIsLoadingServices] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [apiError, setApiError] = useState(false);
|
||||
const [apiErrorMessage, setApiErrorMessage] = useState<string>('');
|
||||
@@ -95,6 +101,27 @@ const HomePage: React.FC = () => {
|
||||
}, [featuredRooms, newestRooms]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
fetchServices();
|
||||
}, []);
|
||||
|
||||
const fetchServices = async () => {
|
||||
try {
|
||||
setIsLoadingServices(true);
|
||||
const response = await serviceService.getServices({
|
||||
status: 'active',
|
||||
limit: 6, // Only fetch first 6 for homepage
|
||||
});
|
||||
if (response.success && response.data?.services) {
|
||||
setServices(response.data.services);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching services:', error);
|
||||
} finally {
|
||||
setIsLoadingServices(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPageContent = async () => {
|
||||
try {
|
||||
@@ -967,48 +994,64 @@ const HomePage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{}
|
||||
{pageContent?.luxury_services && pageContent.luxury_services.length > 0 && (
|
||||
{services.length > 0 && (
|
||||
<section className="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-16">
|
||||
<div className="text-center mb-8 md:mb-10 animate-fade-in">
|
||||
<div className="inline-block mb-3">
|
||||
<div className="h-0.5 w-16 md:w-20 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto rounded-full"></div>
|
||||
</div>
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-serif font-bold text-gray-900 tracking-tight mb-3 md:mb-4 px-4">
|
||||
{pageContent.luxury_services_section_title || 'Luxury Services'}
|
||||
{pageContent?.luxury_services_section_title || 'Luxury Services'}
|
||||
</h2>
|
||||
{pageContent.luxury_services_section_subtitle && (
|
||||
{pageContent?.luxury_services_section_subtitle && (
|
||||
<p className="text-sm sm:text-base md:text-lg text-gray-600 font-light tracking-wide max-w-2xl mx-auto mt-3 md:mt-4 px-4 leading-relaxed">
|
||||
{pageContent.luxury_services_section_subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 md:gap-6 px-4">
|
||||
{pageContent.luxury_services.map((service: any, index: number) => (
|
||||
<div key={index} className="relative bg-white rounded-lg md:rounded-xl p-5 md:p-6 group hover:shadow-xl hover:shadow-[#d4af37]/10 transition-all duration-300 animate-fade-in border border-gray-100/50 hover:border-[#d4af37]/25 hover:-translate-y-1" style={{ animationDelay: `${index * 0.1}s` }}>
|
||||
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-t-lg md:rounded-t-xl"></div>
|
||||
{service.image ? (
|
||||
<div className="w-full h-40 md:h-48 mb-4 md:mb-5 rounded-lg md:rounded-xl overflow-hidden shadow-lg group-hover:scale-105 transition-transform duration-300 border border-gray-100 group-hover:border-[#d4af37]/25">
|
||||
<img src={service.image} alt={service.title} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-14 h-14 md:w-16 md:h-16 bg-gradient-to-br from-[#d4af37]/15 via-[#f5d76e]/10 to-[#d4af37]/15 rounded-lg flex items-center justify-center mb-4 md:mb-5 mx-auto group-hover:scale-110 group-hover:shadow-lg group-hover:shadow-[#d4af37]/20 transition-all duration-300 border border-[#d4af37]/25 group-hover:border-[#d4af37]/40">
|
||||
{service.icon && (LucideIcons as any)[service.icon] ? (
|
||||
React.createElement((LucideIcons as any)[service.icon], {
|
||||
className: 'w-7 h-7 md:w-8 md:h-8 text-[#d4af37] drop-shadow-md'
|
||||
})
|
||||
) : (
|
||||
{services.slice(0, 6).map((service, index: number) => {
|
||||
const serviceSlug = service.slug || service.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
return (
|
||||
<Link
|
||||
key={service.id}
|
||||
to={`/services/${serviceSlug}`}
|
||||
className="relative bg-white rounded-lg md:rounded-xl p-5 md:p-6 group hover:shadow-xl hover:shadow-[#d4af37]/10 transition-all duration-300 animate-fade-in border border-gray-100/50 hover:border-[#d4af37]/25 hover:-translate-y-1 block"
|
||||
style={{ animationDelay: `${index * 0.1}s` }}
|
||||
>
|
||||
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-t-lg md:rounded-t-xl"></div>
|
||||
{service.image ? (
|
||||
<div className="w-full h-40 md:h-48 mb-4 md:mb-5 rounded-lg md:rounded-xl overflow-hidden shadow-lg group-hover:scale-105 transition-transform duration-300 border border-gray-100 group-hover:border-[#d4af37]/25">
|
||||
<img src={service.image} alt={service.name} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-14 h-14 md:w-16 md:h-16 bg-gradient-to-br from-[#d4af37]/15 via-[#f5d76e]/10 to-[#d4af37]/15 rounded-lg flex items-center justify-center mb-4 md:mb-5 mx-auto group-hover:scale-110 group-hover:shadow-lg group-hover:shadow-[#d4af37]/20 transition-all duration-300 border border-[#d4af37]/25 group-hover:border-[#d4af37]/40">
|
||||
<span className="text-2xl md:text-3xl">✨</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg md:text-xl font-serif font-semibold mb-2 md:mb-3 text-gray-900 group-hover:text-[#d4af37] transition-colors duration-300 tracking-tight">
|
||||
{service.title}
|
||||
</h3>
|
||||
<p className="text-sm md:text-base text-gray-600 leading-relaxed font-light tracking-wide">
|
||||
{service.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg md:text-xl font-serif font-semibold mb-2 md:mb-3 text-gray-900 group-hover:text-[#d4af37] transition-colors duration-300 tracking-tight">
|
||||
{service.name}
|
||||
</h3>
|
||||
<p className="text-sm md:text-base text-gray-600 leading-relaxed font-light tracking-wide">
|
||||
{service.description || 'Premium service for your comfort'}
|
||||
</p>
|
||||
{service.price && (
|
||||
<div className="mt-3 text-sm font-semibold text-[#d4af37]">
|
||||
Starting from {formatCurrency(service.price)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-center mt-8 md:mt-10">
|
||||
<Link
|
||||
to="/services"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] rounded-lg font-medium hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 shadow-lg shadow-[#d4af37]/30 hover:shadow-xl hover:shadow-[#d4af37]/40 hover:-translate-y-0.5"
|
||||
>
|
||||
<span>View All Services</span>
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
709
Frontend/src/features/content/pages/ServiceDetailPage.tsx
Normal file
709
Frontend/src/features/content/pages/ServiceDetailPage.tsx
Normal file
@@ -0,0 +1,709 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Share2, Tag, Star } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import pageContentService, { PageContent } from '../services/pageContentService';
|
||||
import serviceService from '../../hotel_services/services/serviceService';
|
||||
import Loading from '../../../shared/components/Loading';
|
||||
import { createSanitizedHtml } from '../../../shared/utils/htmlSanitizer';
|
||||
import { useFormatCurrency } from '../../payments/hooks/useFormatCurrency';
|
||||
|
||||
interface ServiceSection {
|
||||
type: 'hero' | 'text' | 'image' | 'gallery' | 'quote' | 'features' | 'cta' | 'video';
|
||||
title?: string;
|
||||
content?: string;
|
||||
image?: string;
|
||||
images?: string[];
|
||||
quote?: string;
|
||||
author?: string;
|
||||
features?: Array<{ title: string; description: string; icon?: string }>;
|
||||
cta_text?: string;
|
||||
cta_link?: string;
|
||||
video_url?: string;
|
||||
alignment?: 'left' | 'center' | 'right';
|
||||
background_color?: string;
|
||||
text_color?: string;
|
||||
is_visible?: boolean;
|
||||
}
|
||||
|
||||
interface ServiceDetail {
|
||||
id: string | number;
|
||||
title: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
content?: string;
|
||||
image?: string;
|
||||
icon?: string;
|
||||
price?: number;
|
||||
unit?: string;
|
||||
category?: string;
|
||||
type: 'luxury' | 'hotel';
|
||||
sections?: ServiceSection[];
|
||||
meta_title?: string;
|
||||
meta_description?: string;
|
||||
meta_keywords?: string;
|
||||
}
|
||||
|
||||
const ServiceDetailPage: React.FC = () => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const [service, setService] = useState<ServiceDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [relatedServices, setRelatedServices] = useState<ServiceDetail[]>([]);
|
||||
const [pageContent, setPageContent] = useState<PageContent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (slug) {
|
||||
fetchService();
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
const fetchService = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// First, try to fetch from hotel services by slug (primary source)
|
||||
try {
|
||||
const serviceResponse = await serviceService.getServiceBySlug(slug!);
|
||||
if (serviceResponse.success && serviceResponse.data?.service) {
|
||||
const service = serviceResponse.data.service;
|
||||
|
||||
// Parse sections if it's a string
|
||||
let sections: ServiceSection[] = [];
|
||||
if (service.sections) {
|
||||
if (typeof service.sections === 'string') {
|
||||
try {
|
||||
sections = JSON.parse(service.sections);
|
||||
} catch {
|
||||
sections = [];
|
||||
}
|
||||
} else if (Array.isArray(service.sections)) {
|
||||
sections = service.sections;
|
||||
}
|
||||
}
|
||||
|
||||
const serviceDetail: ServiceDetail = {
|
||||
id: service.id,
|
||||
title: service.name,
|
||||
slug: service.slug || slug!,
|
||||
description: service.description,
|
||||
content: service.content,
|
||||
image: service.image,
|
||||
category: service.category,
|
||||
type: 'hotel',
|
||||
price: service.price,
|
||||
unit: service.unit,
|
||||
sections: sections,
|
||||
meta_title: service.meta_title,
|
||||
meta_description: service.meta_description,
|
||||
meta_keywords: service.meta_keywords,
|
||||
};
|
||||
|
||||
setService(serviceDetail);
|
||||
|
||||
// Set meta tags
|
||||
if (serviceDetail.meta_title) {
|
||||
document.title = serviceDetail.meta_title;
|
||||
}
|
||||
if (serviceDetail.meta_description) {
|
||||
let metaDescription = document.querySelector('meta[name="description"]');
|
||||
if (!metaDescription) {
|
||||
metaDescription = document.createElement('meta');
|
||||
metaDescription.setAttribute('name', 'description');
|
||||
document.head.appendChild(metaDescription);
|
||||
}
|
||||
metaDescription.setAttribute('content', serviceDetail.meta_description);
|
||||
}
|
||||
|
||||
// Fetch related services
|
||||
await fetchRelatedServices(serviceDetail);
|
||||
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// If hotel service not found, fall back to luxury services
|
||||
console.log('Hotel service not found, trying luxury services...');
|
||||
}
|
||||
|
||||
// Fallback: Fetch page content for luxury services
|
||||
const contentResponse = await pageContentService.getHomeContent();
|
||||
if (contentResponse.status === 'success' && contentResponse.data?.page_content) {
|
||||
const content = contentResponse.data.page_content;
|
||||
|
||||
// Handle luxury_services
|
||||
if (typeof content.luxury_services === 'string') {
|
||||
try {
|
||||
content.luxury_services = JSON.parse(content.luxury_services);
|
||||
} catch {
|
||||
content.luxury_services = [];
|
||||
}
|
||||
} else if (!Array.isArray(content.luxury_services)) {
|
||||
content.luxury_services = content.luxury_services || [];
|
||||
}
|
||||
|
||||
setPageContent(content);
|
||||
|
||||
// Find service by slug
|
||||
const luxuryService = content.luxury_services?.find((s: any) => s.slug === slug);
|
||||
if (luxuryService) {
|
||||
const serviceDetail: ServiceDetail = {
|
||||
id: `luxury-${luxuryService.slug}`,
|
||||
title: luxuryService.title || 'Service',
|
||||
slug: luxuryService.slug || slug || '',
|
||||
description: luxuryService.description,
|
||||
content: luxuryService.content,
|
||||
image: luxuryService.image,
|
||||
icon: luxuryService.icon,
|
||||
category: luxuryService.category,
|
||||
type: 'luxury',
|
||||
sections: luxuryService.sections || [],
|
||||
meta_title: luxuryService.meta_title,
|
||||
meta_description: luxuryService.meta_description,
|
||||
meta_keywords: luxuryService.meta_keywords,
|
||||
};
|
||||
|
||||
setService(serviceDetail);
|
||||
|
||||
// Set meta tags
|
||||
if (serviceDetail.meta_title) {
|
||||
document.title = serviceDetail.meta_title;
|
||||
}
|
||||
if (serviceDetail.meta_description) {
|
||||
let metaDescription = document.querySelector('meta[name="description"]');
|
||||
if (!metaDescription) {
|
||||
metaDescription = document.createElement('meta');
|
||||
metaDescription.setAttribute('name', 'description');
|
||||
document.head.appendChild(metaDescription);
|
||||
}
|
||||
metaDescription.setAttribute('content', serviceDetail.meta_description);
|
||||
}
|
||||
|
||||
// Fetch related services
|
||||
await fetchRelatedServices(serviceDetail);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Service not found
|
||||
navigate('/services');
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching service:', error);
|
||||
navigate('/services');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRelatedServices = async (currentService: ServiceDetail) => {
|
||||
try {
|
||||
const services: ServiceDetail[] = [];
|
||||
|
||||
// Fetch hotel services first
|
||||
const servicesResponse = await serviceService.getServices({
|
||||
status: 'active',
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
if (servicesResponse.success && servicesResponse.data?.services) {
|
||||
servicesResponse.data.services.forEach((service: any) => {
|
||||
// Skip current service
|
||||
if (currentService.type === 'hotel' && service.id === currentService.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter by category if available
|
||||
if (currentService.category && service.category !== currentService.category) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serviceSlug = service.slug || service.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
services.push({
|
||||
id: service.id,
|
||||
title: service.name,
|
||||
slug: serviceSlug,
|
||||
description: service.description,
|
||||
image: service.image,
|
||||
category: service.category,
|
||||
type: 'hotel',
|
||||
price: service.price,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add luxury services as fallback
|
||||
if (pageContent?.luxury_services && Array.isArray(pageContent.luxury_services)) {
|
||||
pageContent.luxury_services.forEach((s: any, index: number) => {
|
||||
if (s.slug && s.slug !== currentService.slug) {
|
||||
if (!currentService.category || s.category === currentService.category) {
|
||||
// Check if already in services (by slug)
|
||||
const serviceSlug = s.slug || s.title?.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
const exists = services.some(serv => serv.slug === serviceSlug);
|
||||
if (!exists) {
|
||||
services.push({
|
||||
id: `luxury-${index}`,
|
||||
title: s.title || 'Service',
|
||||
slug: s.slug,
|
||||
description: s.description,
|
||||
image: s.image,
|
||||
icon: s.icon,
|
||||
category: s.category,
|
||||
type: 'luxury',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setRelatedServices(services.slice(0, 3));
|
||||
} catch (error) {
|
||||
console.error('Error fetching related services:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
if (navigator.share && service) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: service.title,
|
||||
text: service.description,
|
||||
url: window.location.href,
|
||||
});
|
||||
} catch (error) {
|
||||
// User cancelled or error occurred
|
||||
}
|
||||
} else {
|
||||
// Fallback: copy to clipboard
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
alert('Link copied to clipboard!');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (!service) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-black text-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">Service not found</h1>
|
||||
<Link to="/services" className="text-[#d4af37] hover:underline">
|
||||
Back to Services
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-full" style={{ width: '100vw', position: 'relative', left: '50%', right: '50%', marginLeft: '-50vw', marginRight: '-50vw', marginTop: '-1.5rem', marginBottom: '-1.5rem' }}>
|
||||
{/* Hero Section with Featured Image - Enhanced Luxury */}
|
||||
{service.image && (
|
||||
<div className="relative w-full h-[60vh] min-h-[500px] max-h-[800px] overflow-hidden">
|
||||
<div className="absolute inset-0">
|
||||
<img
|
||||
src={service.image}
|
||||
alt={service.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/60 to-black/30"></div>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/40 via-transparent to-black/40"></div>
|
||||
</div>
|
||||
|
||||
{/* Luxury Overlay Pattern */}
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-0 left-0 w-96 h-96 bg-[#d4af37] rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-0 right-0 w-96 h-96 bg-[#c9a227] rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
{/* Hero Content Overlay */}
|
||||
<div className="absolute inset-0 flex items-end">
|
||||
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20 pb-12 lg:pb-16">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{service.category && (
|
||||
<div className="flex flex-wrap gap-3 mb-6">
|
||||
<span className="inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-br from-[#d4af37]/20 to-[#c9a227]/10 backdrop-blur-md rounded-xl text-[#d4af37] text-sm font-semibold border border-[#d4af37]/40 shadow-xl">
|
||||
<Tag className="w-4 h-4" />
|
||||
{service.category}
|
||||
</span>
|
||||
{service.type === 'luxury' && (
|
||||
<span className="inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-br from-[#d4af37]/30 to-[#c9a227]/20 backdrop-blur-md rounded-xl text-[#0f0f0f] text-sm font-bold border border-[#d4af37]/50 shadow-xl">
|
||||
<Star className="w-4 h-4 fill-[#0f0f0f]" />
|
||||
Premium
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-serif font-bold text-white mb-6 leading-tight drop-shadow-2xl">
|
||||
{service.title}
|
||||
</h1>
|
||||
{service.description && (
|
||||
<p className="text-xl sm:text-2xl lg:text-3xl text-gray-200 font-light leading-relaxed max-w-4xl mb-8 drop-shadow-lg">
|
||||
{service.description}
|
||||
</p>
|
||||
)}
|
||||
{service.price !== undefined && (
|
||||
<div className="flex items-baseline gap-3 mb-8">
|
||||
<span className="text-4xl sm:text-5xl lg:text-6xl font-serif font-bold bg-gradient-to-r from-[#d4af37] via-[#f5d76e] to-[#d4af37] bg-clip-text text-transparent drop-shadow-lg">
|
||||
{formatCurrency(service.price)}
|
||||
</span>
|
||||
{service.unit && (
|
||||
<span className="text-xl sm:text-2xl text-gray-300 font-light">
|
||||
/ {service.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative Bottom Border */}
|
||||
<div className="absolute bottom-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-[#d4af37]/50 to-transparent"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content - Enhanced Luxury Layout */}
|
||||
<div className="w-full py-12 sm:py-16 lg:py-24">
|
||||
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Back Button - Only show if no hero image */}
|
||||
{!service.image && (
|
||||
<Link
|
||||
to="/services"
|
||||
className="inline-flex items-center gap-3 px-6 py-3 bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] border-2 border-[#d4af37]/20 rounded-xl text-gray-300 hover:text-[#d4af37] hover:border-[#d4af37]/50 transition-all duration-300 mb-12 group"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
|
||||
<span className="font-medium">Back to Services</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Service Header - Only if no hero image */}
|
||||
{!service.image && (
|
||||
<div className="mb-12">
|
||||
{service.category && (
|
||||
<div className="flex flex-wrap gap-3 mb-6">
|
||||
<Link
|
||||
to={`/services?category=${encodeURIComponent(service.category)}`}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-br from-[#d4af37]/15 to-[#d4af37]/5 text-[#d4af37] rounded-xl text-sm font-semibold border border-[#d4af37]/30 hover:border-[#d4af37]/50 transition-all"
|
||||
>
|
||||
<Tag className="w-4 h-4" />
|
||||
{service.category}
|
||||
</Link>
|
||||
{service.type === 'luxury' && (
|
||||
<span className="inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-br from-[#d4af37]/20 to-[#c9a227]/10 text-[#d4af37] rounded-xl text-sm font-bold border border-[#d4af37]/40">
|
||||
<Star className="w-4 h-4" />
|
||||
Premium
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-6 mb-6">
|
||||
{service.icon && (LucideIcons as any)[service.icon] && (
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="absolute inset-0 bg-[#d4af37]/20 rounded-full blur-3xl"></div>
|
||||
{React.createElement((LucideIcons as any)[service.icon], {
|
||||
className: 'w-20 h-20 sm:w-24 sm:h-24 text-[#d4af37] relative z-10 drop-shadow-2xl'
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-serif font-bold text-white mb-6 leading-tight">
|
||||
{service.title}
|
||||
</h1>
|
||||
{service.price !== undefined && (
|
||||
<div className="flex items-baseline gap-3 mb-6">
|
||||
<span className="text-4xl sm:text-5xl font-serif font-bold bg-gradient-to-r from-[#d4af37] to-[#f5d76e] bg-clip-text text-transparent">
|
||||
{formatCurrency(service.price)}
|
||||
</span>
|
||||
{service.unit && (
|
||||
<span className="text-xl text-gray-400 font-light">
|
||||
/ {service.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{service.description && (
|
||||
<p className="text-2xl text-gray-300 mb-8 font-light leading-relaxed max-w-4xl">
|
||||
{service.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-6 pt-8 border-t border-[#d4af37]/20">
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="flex items-center gap-3 px-6 py-3 bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] border-2 border-[#d4af37]/20 rounded-xl text-[#d4af37] hover:border-[#d4af37]/50 hover:bg-[#d4af37]/5 transition-all duration-300 group"
|
||||
>
|
||||
<Share2 className="w-5 h-5 group-hover:scale-110 transition-transform" />
|
||||
<span className="font-medium">Share</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Share Button for Hero Image Layout */}
|
||||
{service.image && (
|
||||
<div className="flex justify-end mb-12">
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="flex items-center gap-3 px-6 py-3 bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] border-2 border-[#d4af37]/20 rounded-xl text-[#d4af37] hover:border-[#d4af37]/50 hover:bg-[#d4af37]/5 transition-all duration-300 group"
|
||||
>
|
||||
<Share2 className="w-5 h-5 group-hover:scale-110 transition-transform" />
|
||||
<span className="font-medium">Share</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service Content - Enhanced Luxury Styling */}
|
||||
{service.content && (
|
||||
<article className="prose prose-invert prose-xl max-w-none mb-16
|
||||
prose-headings:text-white prose-headings:font-serif prose-headings:font-bold
|
||||
prose-h2:text-4xl prose-h2:mt-16 prose-h2:mb-8 prose-h2:border-b-2 prose-h2:border-[#d4af37]/30 prose-h2:pb-4 prose-h2:pt-2
|
||||
prose-h3:text-3xl prose-h3:mt-12 prose-h3:mb-6 prose-h3:text-[#d4af37]
|
||||
prose-h4:text-2xl prose-h4:mt-10 prose-h4:mb-5
|
||||
prose-p:text-gray-300 prose-p:font-light prose-p:leading-relaxed prose-p:mb-8 prose-p:text-lg
|
||||
prose-ul:text-gray-300 prose-ul:font-light prose-ul:my-8 prose-ul:text-lg
|
||||
prose-ol:text-gray-300 prose-ol:font-light prose-ol:my-8 prose-ol:text-lg
|
||||
prose-li:text-gray-300 prose-li:mb-4 prose-li:leading-relaxed
|
||||
prose-strong:text-[#d4af37] prose-strong:font-semibold
|
||||
prose-a:text-[#d4af37] prose-a:no-underline hover:prose-a:underline prose-a:font-medium
|
||||
prose-img:rounded-2xl prose-img:shadow-2xl prose-img:border-2 prose-img:border-[#d4af37]/20 prose-img:my-12
|
||||
prose-blockquote:border-l-4 prose-blockquote:border-[#d4af37] prose-blockquote:pl-6 prose-blockquote:italic prose-blockquote:text-gray-300
|
||||
[&_*]:text-gray-300 [&_h1]:text-white [&_h2]:text-white [&_h3]:text-[#d4af37] [&_h4]:text-white
|
||||
[&_strong]:text-[#d4af37] [&_b]:text-[#d4af37] [&_a]:text-[#d4af37]
|
||||
[&_ul]:space-y-3 [&_ol]:space-y-3"
|
||||
>
|
||||
<div
|
||||
className="bg-gradient-to-br from-[#1a1a1a]/50 to-[#0f0f0f]/50 rounded-3xl border-2 border-[#d4af37]/10 p-8 md:p-12 lg:p-16 backdrop-blur-sm"
|
||||
dangerouslySetInnerHTML={createSanitizedHtml(
|
||||
service.content || '<p>No content available.</p>'
|
||||
)}
|
||||
/>
|
||||
</article>
|
||||
)}
|
||||
|
||||
{/* Luxury Sections - Enhanced */}
|
||||
{service.sections && service.sections.length > 0 && (
|
||||
<div className="space-y-20 mb-16">
|
||||
{service.sections
|
||||
.filter((section) => section.is_visible !== false)
|
||||
.map((section, index) => (
|
||||
<div key={index}>
|
||||
{/* Hero Section - Enhanced */}
|
||||
{section.type === 'hero' && (
|
||||
<div className="relative rounded-3xl overflow-hidden border-2 border-[#d4af37]/30 shadow-2xl group">
|
||||
{section.image && (
|
||||
<div className="absolute inset-0">
|
||||
<img src={section.image} alt={section.title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-1000" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/95 via-black/70 to-black/40"></div>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/40 via-transparent to-black/40"></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-0 left-0 w-96 h-96 bg-[#d4af37] rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-0 right-0 w-96 h-96 bg-[#c9a227] rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
<div className="relative px-8 py-20 md:px-16 md:py-32 text-center">
|
||||
{section.title && (
|
||||
<h2 className="text-5xl md:text-6xl lg:text-7xl font-serif font-bold text-white mb-8 leading-tight drop-shadow-2xl">
|
||||
{section.title}
|
||||
</h2>
|
||||
)}
|
||||
{section.content && (
|
||||
<p className="text-2xl md:text-3xl text-gray-200 font-light leading-relaxed max-w-4xl mx-auto drop-shadow-lg">
|
||||
{section.content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-[#d4af37]/50 to-transparent"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text Section */}
|
||||
{section.type === 'text' && (
|
||||
<div className={`bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] rounded-3xl border-2 border-[#d4af37]/20 p-8 md:p-12 shadow-xl ${
|
||||
section.alignment === 'center' ? 'text-center' : section.alignment === 'right' ? 'text-right' : 'text-left'
|
||||
}`}>
|
||||
{section.title && (
|
||||
<h3 className="text-3xl md:text-4xl font-serif font-bold text-white mb-6">
|
||||
{section.title}
|
||||
</h3>
|
||||
)}
|
||||
{section.content && (
|
||||
<div
|
||||
className="text-gray-300 font-light leading-relaxed text-lg"
|
||||
dangerouslySetInnerHTML={createSanitizedHtml(section.content)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Section */}
|
||||
{section.type === 'image' && section.image && (
|
||||
<div className="rounded-3xl overflow-hidden border-2 border-[#d4af37]/20 shadow-2xl">
|
||||
<img src={section.image} alt={section.title || 'Service image'} className="w-full h-auto" />
|
||||
{section.title && (
|
||||
<div className="bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] px-6 py-4 border-t border-[#d4af37]/20">
|
||||
<p className="text-gray-400 text-sm font-light italic text-center">{section.title}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gallery Section */}
|
||||
{section.type === 'gallery' && section.images && section.images.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{section.images.map((img, imgIndex) => (
|
||||
<div key={imgIndex} className="rounded-2xl overflow-hidden border-2 border-[#d4af37]/20 shadow-xl group hover:border-[#d4af37]/50 transition-all">
|
||||
<img src={img} alt={`Gallery image ${imgIndex + 1}`} className="w-full h-64 object-cover group-hover:scale-110 transition-transform duration-500" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quote Section */}
|
||||
{section.type === 'quote' && (
|
||||
<div className="relative bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] rounded-3xl border-2 border-[#d4af37]/30 p-8 md:p-12 shadow-2xl">
|
||||
<div className="absolute top-6 left-6 text-6xl text-[#d4af37]/20 font-serif">"</div>
|
||||
{section.quote && (
|
||||
<blockquote className="text-2xl md:text-3xl font-serif font-light text-white italic mb-6 relative z-10 pl-8">
|
||||
{section.quote}
|
||||
</blockquote>
|
||||
)}
|
||||
{section.author && (
|
||||
<cite className="text-[#d4af37] text-lg font-medium not-italic block text-right">
|
||||
— {section.author}
|
||||
</cite>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Features Section - Enhanced */}
|
||||
{section.type === 'features' && section.features && (
|
||||
<div>
|
||||
{section.title && (
|
||||
<h3 className="text-4xl md:text-5xl font-serif font-bold text-white mb-12 text-center border-b-2 border-[#d4af37]/30 pb-6">
|
||||
{section.title}
|
||||
</h3>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{section.features.map((feature, featIndex) => {
|
||||
const IconComponent = feature.icon && (LucideIcons as any)[feature.icon]
|
||||
? (LucideIcons as any)[feature.icon]
|
||||
: null;
|
||||
return (
|
||||
<div key={featIndex} className="group relative bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#0a0a0a] rounded-3xl border-2 border-[#d4af37]/20 p-8 shadow-2xl hover:border-[#d4af37]/60 hover:shadow-[#d4af37]/20 transition-all duration-500 hover:-translate-y-2">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37]/0 to-[#d4af37]/0 group-hover:from-[#d4af37]/5 group-hover:to-transparent rounded-3xl transition-all duration-500"></div>
|
||||
<div className="relative z-10">
|
||||
{IconComponent && (
|
||||
<div className="mb-6 relative">
|
||||
<div className="absolute inset-0 bg-[#d4af37]/20 rounded-full blur-2xl group-hover:blur-3xl transition-all"></div>
|
||||
{React.createElement(IconComponent, {
|
||||
className: 'w-12 h-12 text-[#d4af37] relative z-10 group-hover:scale-110 transition-transform duration-300'
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<h4 className="text-2xl font-bold text-white mb-4 group-hover:text-[#d4af37] transition-colors">{feature.title}</h4>
|
||||
<p className="text-gray-300 font-light leading-relaxed text-base">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA Section */}
|
||||
{section.type === 'cta' && (
|
||||
<div className="relative bg-gradient-to-br from-[#d4af37]/10 via-[#c9a227]/5 to-[#d4af37]/10 rounded-3xl border-2 border-[#d4af37]/30 p-8 md:p-12 text-center shadow-2xl">
|
||||
{section.title && (
|
||||
<h3 className="text-3xl md:text-4xl font-serif font-bold text-white mb-4">
|
||||
{section.title}
|
||||
</h3>
|
||||
)}
|
||||
{section.content && (
|
||||
<p className="text-xl text-gray-300 font-light mb-8 max-w-2xl mx-auto">
|
||||
{section.content}
|
||||
</p>
|
||||
)}
|
||||
{section.cta_text && section.cta_link && (
|
||||
<a
|
||||
href={section.cta_link}
|
||||
className="inline-flex items-center gap-3 px-8 py-4 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] font-semibold rounded-xl hover:from-[#f5d76e] hover:to-[#d4af37] transition-all shadow-lg hover:shadow-xl hover:scale-105"
|
||||
>
|
||||
{section.cta_text}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video Section */}
|
||||
{section.type === 'video' && section.video_url && (
|
||||
<div className="rounded-3xl overflow-hidden border-2 border-[#d4af37]/20 shadow-2xl bg-black">
|
||||
<div className="aspect-video">
|
||||
<iframe
|
||||
src={section.video_url.replace('watch?v=', 'embed/').replace('vimeo.com/', 'player.vimeo.com/video/')}
|
||||
className="w-full h-full"
|
||||
allowFullScreen
|
||||
title="Service video"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Related Services */}
|
||||
{relatedServices.length > 0 && (
|
||||
<div className="mt-16 pt-12 border-t border-[#d4af37]/20">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-white mb-8">Related Services</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{relatedServices.map((relatedService) => (
|
||||
<Link
|
||||
key={relatedService.id}
|
||||
to={`/services/${relatedService.slug}`}
|
||||
className="group bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] rounded-xl border border-[#d4af37]/20 overflow-hidden hover:border-[#d4af37]/50 transition-all duration-300"
|
||||
>
|
||||
{relatedService.image && (
|
||||
<div className="relative h-40 overflow-hidden">
|
||||
<img
|
||||
src={relatedService.image}
|
||||
alt={relatedService.title}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-bold text-white mb-2 group-hover:text-[#d4af37] transition-colors line-clamp-2">
|
||||
{relatedService.title}
|
||||
</h3>
|
||||
{relatedService.description && (
|
||||
<p className="text-gray-400 text-sm line-clamp-2 font-light">
|
||||
{relatedService.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceDetailPage;
|
||||
|
||||
393
Frontend/src/features/content/pages/ServicesPage.tsx
Normal file
393
Frontend/src/features/content/pages/ServicesPage.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Search, Sparkles, ArrowRight, Tag, Award, Star } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import pageContentService, { PageContent } from '../services/pageContentService';
|
||||
import serviceService, { Service } from '../../hotel_services/services/serviceService';
|
||||
import Loading from '../../../shared/components/Loading';
|
||||
import { useFormatCurrency } from '../../payments/hooks/useFormatCurrency';
|
||||
|
||||
const ServicesPage: React.FC = () => {
|
||||
const [pageContent, setPageContent] = useState<PageContent | null>(null);
|
||||
const [hotelServices, setHotelServices] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [allCategories, setAllCategories] = useState<string[]>([]);
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch page content for luxury services
|
||||
const contentResponse = await pageContentService.getHomeContent();
|
||||
if (contentResponse.status === 'success' && contentResponse.data?.page_content) {
|
||||
const content = contentResponse.data.page_content;
|
||||
|
||||
// Handle luxury_services - can be string, array, or null/undefined
|
||||
if (typeof content.luxury_services === 'string') {
|
||||
try {
|
||||
content.luxury_services = JSON.parse(content.luxury_services);
|
||||
} catch {
|
||||
content.luxury_services = [];
|
||||
}
|
||||
} else if (!Array.isArray(content.luxury_services)) {
|
||||
content.luxury_services = content.luxury_services || [];
|
||||
}
|
||||
|
||||
setPageContent(content);
|
||||
|
||||
// Extract categories from luxury services
|
||||
const categories = new Set<string>();
|
||||
if (Array.isArray(content.luxury_services)) {
|
||||
content.luxury_services.forEach((service: any) => {
|
||||
if (service.category) {
|
||||
categories.add(service.category);
|
||||
}
|
||||
});
|
||||
}
|
||||
setAllCategories(Array.from(categories));
|
||||
}
|
||||
|
||||
// Fetch hotel services from API
|
||||
const servicesResponse = await serviceService.getServices({
|
||||
status: 'active',
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
if (servicesResponse.success && servicesResponse.data?.services) {
|
||||
setHotelServices(servicesResponse.data.services);
|
||||
|
||||
// Add categories from hotel services
|
||||
const hotelCategories = new Set(allCategories);
|
||||
servicesResponse.data.services.forEach((service: Service) => {
|
||||
// You can add category logic here if services have categories
|
||||
});
|
||||
setAllCategories(Array.from(hotelCategories));
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching services:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Combine hotel services (primary) with luxury services from page content (fallback)
|
||||
const allServices = React.useMemo(() => {
|
||||
const services: Array<{
|
||||
id: string | number;
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
icon?: string;
|
||||
price?: number;
|
||||
unit?: string;
|
||||
category?: string;
|
||||
type: 'luxury' | 'hotel';
|
||||
slug?: string;
|
||||
}> = [];
|
||||
|
||||
// Add hotel services first (primary source)
|
||||
hotelServices.forEach((service: Service) => {
|
||||
// Generate slug if not present
|
||||
const serviceSlug = service.slug || service.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
services.push({
|
||||
id: service.id,
|
||||
title: service.name,
|
||||
description: service.description || '',
|
||||
image: service.image,
|
||||
price: service.price,
|
||||
unit: service.unit,
|
||||
category: service.category || 'Services',
|
||||
type: 'hotel',
|
||||
slug: serviceSlug,
|
||||
});
|
||||
});
|
||||
|
||||
// Add luxury services from page content (only if not already in hotel services)
|
||||
if (pageContent?.luxury_services && Array.isArray(pageContent.luxury_services)) {
|
||||
pageContent.luxury_services.forEach((service: any, index: number) => {
|
||||
// Check if this service already exists in hotel services by slug
|
||||
const existingSlug = service.slug || service.title?.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
const existsInHotel = hotelServices.some((hs: Service) => {
|
||||
const hotelSlug = hs.slug || hs.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
return hotelSlug === existingSlug;
|
||||
});
|
||||
|
||||
// Only add if not already in hotel services
|
||||
if (!existsInHotel) {
|
||||
services.push({
|
||||
id: `luxury-${index}`,
|
||||
title: service.title || 'Service',
|
||||
description: service.description || '',
|
||||
image: service.image,
|
||||
icon: service.icon,
|
||||
category: service.category,
|
||||
type: 'luxury',
|
||||
slug: service.slug,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return services;
|
||||
}, [pageContent, hotelServices]);
|
||||
|
||||
// Filter services based on search and category
|
||||
const filteredServices = React.useMemo(() => {
|
||||
return allServices.filter((service) => {
|
||||
const matchesSearch =
|
||||
!searchTerm ||
|
||||
service.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
service.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesCategory = !selectedCategory || service.category === selectedCategory;
|
||||
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
}, [allServices, searchTerm, selectedCategory]);
|
||||
|
||||
if (loading && allServices.length === 0) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#0f0f0f] via-[#1a1a1a] to-[#0f0f0f] w-full" style={{ width: '100vw', position: 'relative', left: '50%', right: '50%', marginLeft: '-50vw', marginRight: '-50vw', marginTop: '-1.5rem', marginBottom: '-1.5rem' }}>
|
||||
{/* Hero Section */}
|
||||
<div className="w-full bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#1a1a1a] border-b border-[#d4af37]/10 pt-6 sm:pt-7 md:pt-8 overflow-hidden relative">
|
||||
{/* Background Effects */}
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-10 left-10 w-32 sm:w-48 h-32 sm:h-48 bg-[#d4af37] rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-10 right-10 w-40 sm:w-64 h-40 sm:h-64 bg-[#c9a227] rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-[#d4af37]/50 to-transparent"></div>
|
||||
|
||||
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20 py-4 sm:py-5 md:py-6 relative z-10">
|
||||
<div className="max-w-2xl mx-auto text-center px-2">
|
||||
<div className="flex justify-center mb-2 sm:mb-3 md:mb-4">
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37] via-[#f5d76e] to-[#c9a227] rounded-xl blur-lg opacity-40 group-hover:opacity-60 transition-opacity duration-500"></div>
|
||||
<div className="relative p-2 sm:p-2.5 md:p-3 bg-gradient-to-br from-[#1a1a1a] to-[#0a0a0a] rounded-lg border-2 border-[#d4af37]/40 backdrop-blur-sm shadow-xl shadow-[#d4af37]/20 group-hover:border-[#d4af37]/60 transition-all duration-300">
|
||||
<Award className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-[#d4af37] drop-shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl xs:text-3xl sm:text-4xl md:text-5xl font-serif font-semibold mb-2 sm:mb-3 tracking-tight leading-tight px-2">
|
||||
<span className="bg-gradient-to-r from-white via-[#d4af37] to-white bg-clip-text text-transparent">
|
||||
{pageContent?.luxury_services_section_title || 'Our Services'}
|
||||
</span>
|
||||
</h1>
|
||||
<div className="w-12 sm:w-16 md:w-20 h-0.5 bg-gradient-to-r from-transparent via-[#d4af37] to-transparent mx-auto mb-2 sm:mb-3"></div>
|
||||
<p className="text-sm sm:text-base md:text-lg text-gray-300 font-light leading-relaxed max-w-xl mx-auto tracking-wide px-2 sm:px-4">
|
||||
{pageContent?.luxury_services_section_subtitle || 'Discover our premium services designed to enhance your stay'}
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-center gap-2 text-[#d4af37]/60">
|
||||
<Sparkles className="w-4 h-4 animate-pulse" />
|
||||
<span className="text-xs sm:text-sm font-light tracking-wider uppercase">Premium Services</span>
|
||||
<Sparkles className="w-4 h-4 animate-pulse" style={{ animationDelay: '0.5s' }} />
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* Main Content - Full Width */}
|
||||
<div className="w-full py-12 sm:py-16 lg:py-20">
|
||||
<div className="w-full max-w-[1920px] mx-auto px-3 sm:px-4 md:px-6 lg:px-8 xl:px-12 2xl:px-16 3xl:px-20">
|
||||
{/* Search Section */}
|
||||
<div className="mb-12 sm:mb-16">
|
||||
<div className="flex flex-col lg:flex-row gap-6 mb-8">
|
||||
<div className="flex-1 relative group">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#d4af37]/10 to-transparent rounded-xl blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-5 top-1/2 transform -translate-y-1/2 text-[#d4af37] w-5 h-5 z-10" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search services..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-14 pr-5 py-4 bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] border border-[#d4af37]/20 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#d4af37]/50 focus:border-[#d4af37]/50 transition-all duration-300 backdrop-blur-sm font-light"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories Filter - Top Center */}
|
||||
{allCategories.length > 0 && (
|
||||
<div className="mb-12 flex justify-center">
|
||||
<div className="inline-flex flex-wrap items-center gap-3 bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#0a0a0a] rounded-2xl border-2 border-[#d4af37]/20 p-4 backdrop-blur-xl shadow-2xl">
|
||||
<button
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
className={`group relative px-6 py-3 rounded-xl text-sm font-medium transition-all duration-300 overflow-hidden ${
|
||||
selectedCategory === null
|
||||
? 'bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] shadow-lg shadow-[#d4af37]/40'
|
||||
: 'bg-gradient-to-br from-[#0f0f0f] to-[#0a0a0a] text-gray-300 border-2 border-[#d4af37]/20 hover:border-[#d4af37]/50 hover:text-[#d4af37]'
|
||||
}`}
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-2">
|
||||
<Tag className="w-4 h-4" />
|
||||
All Services
|
||||
</span>
|
||||
</button>
|
||||
{allCategories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={`group relative px-6 py-3 rounded-xl text-sm font-medium transition-all duration-300 overflow-hidden ${
|
||||
selectedCategory === category
|
||||
? 'bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-[#0f0f0f] shadow-lg shadow-[#d4af37]/40'
|
||||
: 'bg-gradient-to-br from-[#0f0f0f] to-[#0a0a0a] text-gray-300 border-2 border-[#d4af37]/20 hover:border-[#d4af37]/50 hover:text-[#d4af37]'
|
||||
}`}
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-2">
|
||||
<Tag className="w-4 h-4" />
|
||||
{category}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Services Section - Centered */}
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Results Count */}
|
||||
{!loading && filteredServices.length > 0 && (
|
||||
<div className="mb-8 text-gray-400 font-light text-sm text-center">
|
||||
Showing {filteredServices.length} of {allServices.length} services
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Services Grid - Luxury Design - Centered */}
|
||||
{filteredServices.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-[#d4af37]/10 mb-6">
|
||||
<Award className="w-10 h-10 text-[#d4af37]" />
|
||||
</div>
|
||||
<p className="text-gray-400 text-xl font-light">No services found</p>
|
||||
<p className="text-gray-500 text-sm mt-2">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 lg:gap-10 mb-12 max-w-7xl w-full">
|
||||
{filteredServices.map((service, index) => {
|
||||
// Use the slug from the service object, or generate a fallback from title
|
||||
const serviceSlug = service.slug || service.title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
|
||||
if (!serviceSlug) {
|
||||
console.warn('Service missing slug:', service);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={service.id}
|
||||
to={`/services/${serviceSlug}`}
|
||||
className="group relative bg-gradient-to-br from-[#1a1a1a] via-[#0f0f0f] to-[#0a0a0a] rounded-3xl border-2 border-[#d4af37]/20 overflow-hidden hover:border-[#d4af37]/60 transition-all duration-700 hover:shadow-2xl hover:shadow-[#d4af37]/30 hover:-translate-y-3 block"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
{/* Premium Glow Effects */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37]/0 via-[#d4af37]/0 to-[#d4af37]/0 group-hover:from-[#d4af37]/10 group-hover:via-[#d4af37]/5 group-hover:to-[#d4af37]/10 transition-all duration-700 rounded-3xl blur-2xl opacity-0 group-hover:opacity-100"></div>
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-[#d4af37]/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10">
|
||||
{service.image ? (
|
||||
<div className="relative h-72 sm:h-80 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent z-10"></div>
|
||||
<img
|
||||
src={service.image}
|
||||
alt={service.title}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-1000 ease-out"
|
||||
/>
|
||||
{/* Premium Badge Overlay */}
|
||||
{service.type === 'luxury' && (
|
||||
<div className="absolute top-6 left-6 z-20">
|
||||
<div className="bg-gradient-to-br from-[#d4af37]/20 to-[#c9a227]/10 backdrop-blur-md rounded-2xl px-4 py-2 border border-[#d4af37]/40 shadow-xl">
|
||||
<div className="flex items-center gap-2 text-[#d4af37]">
|
||||
<Star className="w-4 h-4" />
|
||||
<span className="text-sm font-semibold">Premium</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Luxury Corner Accent */}
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-[#d4af37]/20 to-transparent rounded-bl-full opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-48 sm:h-56 bg-gradient-to-br from-[#1a1a1a] to-[#0f0f0f] flex items-center justify-center p-8">
|
||||
{service.icon && (LucideIcons as any)[service.icon] ? (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-[#d4af37]/20 rounded-full blur-2xl"></div>
|
||||
{React.createElement((LucideIcons as any)[service.icon], {
|
||||
className: 'w-16 h-16 sm:w-20 sm:h-20 text-[#d4af37] relative z-10 drop-shadow-lg'
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-[#d4af37]/20 rounded-full blur-2xl"></div>
|
||||
<Award className="w-16 h-16 sm:w-20 sm:h-20 text-[#d4af37] relative z-10 drop-shadow-lg" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-8">
|
||||
{service.category && (
|
||||
<div className="flex flex-wrap gap-2 mb-5">
|
||||
<span className="inline-flex items-center gap-1.5 px-4 py-1.5 bg-gradient-to-br from-[#d4af37]/15 to-[#d4af37]/5 text-[#d4af37] rounded-full text-xs font-semibold border border-[#d4af37]/30 backdrop-blur-sm shadow-lg">
|
||||
<Tag className="w-3 h-3" />
|
||||
{service.category}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-2xl sm:text-3xl font-serif font-bold text-white mb-4 group-hover:text-[#d4af37] transition-colors duration-500 line-clamp-2 leading-tight tracking-tight">
|
||||
{service.title}
|
||||
</h2>
|
||||
{service.description && (
|
||||
<p className="text-gray-300 text-base mb-6 line-clamp-3 font-light leading-relaxed">
|
||||
{service.description}
|
||||
</p>
|
||||
)}
|
||||
{service.price !== undefined && (
|
||||
<div className="mb-6 pb-6 border-b border-[#d4af37]/20">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl sm:text-3xl font-serif font-semibold bg-gradient-to-r from-[#d4af37] to-[#f5d76e] bg-clip-text text-transparent">
|
||||
{formatCurrency(service.price)}
|
||||
</span>
|
||||
{service.unit && (
|
||||
<span className="text-sm text-gray-400 font-light">
|
||||
/ {service.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-[#d4af37]/10">
|
||||
<div className="flex items-center gap-3 text-[#d4af37] group-hover:gap-4 transition-all duration-300">
|
||||
<span className="text-sm font-semibold tracking-wide uppercase">Learn More</span>
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-2 transition-transform duration-300" />
|
||||
</div>
|
||||
<div className="w-12 h-0.5 bg-gradient-to-r from-[#d4af37] to-transparent group-hover:w-20 transition-all duration-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServicesPage;
|
||||
|
||||
@@ -74,7 +74,33 @@ export interface PageContent {
|
||||
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_services?: Array<{
|
||||
icon?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
slug?: string;
|
||||
category?: string;
|
||||
content?: string;
|
||||
sections?: Array<{
|
||||
type: 'hero' | 'text' | 'image' | 'gallery' | 'quote' | 'features' | 'cta' | 'video';
|
||||
title?: string;
|
||||
content?: string;
|
||||
image?: string;
|
||||
images?: string[];
|
||||
quote?: string;
|
||||
author?: string;
|
||||
features?: Array<{ title: string; description: string; icon?: string }>;
|
||||
cta_text?: string;
|
||||
cta_link?: string;
|
||||
video_url?: string;
|
||||
alignment?: 'left' | 'center' | 'right';
|
||||
is_visible?: boolean;
|
||||
}>;
|
||||
meta_title?: string;
|
||||
meta_description?: string;
|
||||
meta_keywords?: string;
|
||||
}>;
|
||||
luxury_experiences_section_title?: string;
|
||||
luxury_experiences_section_subtitle?: string;
|
||||
luxury_experiences?: Array<{ icon?: string; title: string; description: string; image?: string }>;
|
||||
@@ -173,7 +199,33 @@ export interface UpdatePageContentData {
|
||||
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_services?: Array<{
|
||||
icon?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
slug?: string;
|
||||
category?: string;
|
||||
content?: string;
|
||||
sections?: Array<{
|
||||
type: 'hero' | 'text' | 'image' | 'gallery' | 'quote' | 'features' | 'cta' | 'video';
|
||||
title?: string;
|
||||
content?: string;
|
||||
image?: string;
|
||||
images?: string[];
|
||||
quote?: string;
|
||||
author?: string;
|
||||
features?: Array<{ title: string; description: string; icon?: string }>;
|
||||
cta_text?: string;
|
||||
cta_link?: string;
|
||||
video_url?: string;
|
||||
alignment?: 'left' | 'center' | 'right';
|
||||
is_visible?: boolean;
|
||||
}>;
|
||||
meta_title?: string;
|
||||
meta_description?: string;
|
||||
meta_keywords?: string;
|
||||
}>;
|
||||
luxury_experiences_section_title?: string;
|
||||
luxury_experiences_section_subtitle?: string;
|
||||
luxury_experiences?: Array<{ icon?: string; title: string; description: string; image?: string }>;
|
||||
|
||||
@@ -1,12 +1,37 @@
|
||||
import apiClient from '../../../shared/services/apiClient';
|
||||
|
||||
export interface ServiceSection {
|
||||
type: 'hero' | 'text' | 'image' | 'gallery' | 'quote' | 'features' | 'cta' | 'video';
|
||||
title?: string;
|
||||
content?: string;
|
||||
image?: string;
|
||||
images?: string[];
|
||||
quote?: string;
|
||||
author?: string;
|
||||
features?: Array<{ title: string; description: string; icon?: string }>;
|
||||
cta_text?: string;
|
||||
cta_link?: string;
|
||||
video_url?: string;
|
||||
alignment?: 'left' | 'center' | 'right';
|
||||
is_visible?: boolean;
|
||||
}
|
||||
|
||||
export interface Service {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
price: number;
|
||||
unit?: string;
|
||||
category?: string;
|
||||
slug?: string;
|
||||
image?: string;
|
||||
content?: string;
|
||||
sections?: ServiceSection[] | string; // Can be array or JSON string
|
||||
meta_title?: string;
|
||||
meta_description?: string;
|
||||
meta_keywords?: string;
|
||||
status: 'active' | 'inactive';
|
||||
is_active?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
@@ -31,6 +56,14 @@ export interface CreateServiceData {
|
||||
description?: string;
|
||||
price: number;
|
||||
unit?: string;
|
||||
category?: string;
|
||||
slug?: string;
|
||||
image?: string;
|
||||
content?: string;
|
||||
sections?: ServiceSection[] | string;
|
||||
meta_title?: string;
|
||||
meta_description?: string;
|
||||
meta_keywords?: string;
|
||||
status?: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
@@ -39,6 +72,14 @@ export interface UpdateServiceData {
|
||||
description?: string;
|
||||
price?: number;
|
||||
unit?: string;
|
||||
category?: string;
|
||||
slug?: string;
|
||||
image?: string;
|
||||
content?: string;
|
||||
sections?: ServiceSection[] | string;
|
||||
meta_title?: string;
|
||||
meta_description?: string;
|
||||
meta_keywords?: string;
|
||||
status?: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
@@ -75,6 +116,18 @@ export const getServiceById = async (
|
||||
};
|
||||
};
|
||||
|
||||
export const getServiceBySlug = async (
|
||||
slug: string
|
||||
): Promise<{ success: boolean; data: { service: Service } }> => {
|
||||
const response = await apiClient.get(`/services/slug/${slug}`);
|
||||
const data = response.data;
|
||||
// Handle both 'status: success' and 'success: true' formats
|
||||
return {
|
||||
success: data.status === 'success' || data.success === true,
|
||||
data: data.data || {},
|
||||
};
|
||||
};
|
||||
|
||||
export const createService = async (
|
||||
data: CreateServiceData
|
||||
): Promise<{ success: boolean; data: { service: Service }; message: string }> => {
|
||||
@@ -131,6 +184,7 @@ export const useService = async (data: {
|
||||
export default {
|
||||
getServices,
|
||||
getServiceById,
|
||||
getServiceBySlug,
|
||||
createService,
|
||||
updateService,
|
||||
deleteService,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,11 +19,9 @@ import {
|
||||
Edit,
|
||||
Trash2,
|
||||
X,
|
||||
Calendar,
|
||||
Wrench
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
import bookingService, { Booking } from '../../features/bookings/services/bookingService';
|
||||
import serviceService, { Service } from '../../features/hotel_services/services/serviceService';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import CurrencyIcon from '../../shared/components/CurrencyIcon';
|
||||
@@ -32,7 +30,7 @@ import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurren
|
||||
import { parseDateLocal } from '../../shared/utils/format';
|
||||
import CreateBookingModal from '../../features/hotel_services/components/CreateBookingModal';
|
||||
|
||||
type ReceptionTab = 'overview' | 'check-in' | 'check-out' | 'bookings' | 'services';
|
||||
type ReceptionTab = 'overview' | 'check-in' | 'check-out' | 'bookings';
|
||||
|
||||
interface GuestInfo {
|
||||
name: string;
|
||||
@@ -90,28 +88,6 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
const [showCreateBookingModal, setShowCreateBookingModal] = useState(false);
|
||||
|
||||
|
||||
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [servicesLoading, setServicesLoading] = useState(true);
|
||||
const [showServiceModal, setShowServiceModal] = useState(false);
|
||||
const [editingService, setEditingService] = useState<Service | null>(null);
|
||||
const [serviceFilters, setServiceFilters] = useState({
|
||||
search: '',
|
||||
status: '',
|
||||
});
|
||||
const [serviceCurrentPage, setServiceCurrentPage] = useState(1);
|
||||
const [serviceTotalPages, setServiceTotalPages] = useState(1);
|
||||
const [serviceTotalItems, setServiceTotalItems] = useState(0);
|
||||
const serviceItemsPerPage = 5;
|
||||
const [serviceFormData, setServiceFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
price: 0,
|
||||
unit: 'time',
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
});
|
||||
|
||||
|
||||
const handleCheckInSearch = async () => {
|
||||
if (!checkInBookingNumber.trim()) {
|
||||
toast.error('Please enter booking number');
|
||||
@@ -406,107 +382,11 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
};
|
||||
|
||||
|
||||
const fetchServices = useCallback(async () => {
|
||||
try {
|
||||
setServicesLoading(true);
|
||||
const response = await serviceService.getServices({
|
||||
...serviceFilters,
|
||||
page: serviceCurrentPage,
|
||||
limit: serviceItemsPerPage,
|
||||
});
|
||||
setServices(response.data.services);
|
||||
if (response.data.pagination) {
|
||||
setServiceTotalPages(response.data.pagination.totalPages);
|
||||
setServiceTotalItems(response.data.pagination.total);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load services list');
|
||||
} finally {
|
||||
setServicesLoading(false);
|
||||
}
|
||||
}, [serviceFilters.search, serviceFilters.status, serviceCurrentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setServiceCurrentPage(1);
|
||||
}, [serviceFilters.search, serviceFilters.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'services') {
|
||||
fetchServices();
|
||||
}
|
||||
}, [activeTab, fetchServices]);
|
||||
|
||||
const handleServiceSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingService) {
|
||||
await serviceService.updateService(editingService.id, serviceFormData);
|
||||
toast.success('Service updated successfully');
|
||||
} else {
|
||||
await serviceService.createService(serviceFormData);
|
||||
toast.success('Service added successfully');
|
||||
}
|
||||
setShowServiceModal(false);
|
||||
resetServiceForm();
|
||||
fetchServices();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'An error occurred');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditService = (service: Service) => {
|
||||
setEditingService(service);
|
||||
setServiceFormData({
|
||||
name: service.name,
|
||||
description: service.description || '',
|
||||
price: service.price,
|
||||
unit: service.unit || 'time',
|
||||
status: service.status,
|
||||
});
|
||||
setShowServiceModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteService = async (id: number) => {
|
||||
if (!window.confirm('Are you sure you want to delete this service?')) return;
|
||||
|
||||
try {
|
||||
await serviceService.deleteService(id);
|
||||
toast.success('Service deleted successfully');
|
||||
fetchServices();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to delete service');
|
||||
}
|
||||
};
|
||||
|
||||
const resetServiceForm = () => {
|
||||
setEditingService(null);
|
||||
setServiceFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
price: 0,
|
||||
unit: 'time',
|
||||
status: 'active',
|
||||
});
|
||||
};
|
||||
|
||||
const getServiceStatusBadge = (status: string) => {
|
||||
return status === 'active' ? (
|
||||
<span className="px-4 py-1.5 rounded-full text-xs font-semibold border shadow-sm bg-gradient-to-r from-emerald-50 to-green-50 text-emerald-800 border-emerald-200">
|
||||
Active
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-4 py-1.5 rounded-full text-xs font-semibold border shadow-sm bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200">
|
||||
Inactive
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview' as ReceptionTab, label: 'Overview', icon: LogIn },
|
||||
{ id: 'check-in' as ReceptionTab, label: 'Check-in', icon: LogIn },
|
||||
{ id: 'check-out' as ReceptionTab, label: 'Check-out', icon: LogOut },
|
||||
{ id: 'bookings' as ReceptionTab, label: 'Bookings', icon: Calendar },
|
||||
{ id: 'services' as ReceptionTab, label: 'Services', icon: Wrench },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -677,38 +557,6 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-blue-50/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => setActiveTab('services')}
|
||||
className="group relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-purple-100/50 p-8 cursor-pointer transition-all duration-300 hover:shadow-2xl hover:scale-105 hover:border-purple-300/60 overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-purple-400/10 to-transparent rounded-bl-full"></div>
|
||||
<div className="relative space-y-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-purple-400 to-purple-600 rounded-xl blur-md opacity-50 group-hover:opacity-75 transition-opacity"></div>
|
||||
<div className="relative p-3.5 rounded-xl bg-gradient-to-br from-purple-500 to-purple-600 shadow-lg border border-purple-400/50 group-hover:scale-110 transition-transform">
|
||||
<Wrench className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 text-xl mb-1">Services</h3>
|
||||
<div className="h-1 w-12 bg-gradient-to-r from-purple-500 to-purple-600 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
Manage hotel services and amenities
|
||||
</p>
|
||||
<div className="pt-5 border-t border-gray-100">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500 font-medium">Manage Services</span>
|
||||
<ChevronRight className="w-5 h-5 text-purple-600 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-purple-50/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1878,248 +1726,6 @@ const ReceptionDashboardPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{}
|
||||
{activeTab === 'services' && (
|
||||
<div className="space-y-8">
|
||||
{servicesLoading && <Loading />}
|
||||
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-gradient-to-br from-purple-500/10 to-violet-500/10 border border-purple-200/40">
|
||||
<Wrench className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<h2 className="text-xl sm:text-2xl md:text-2xl font-extrabold text-gray-900">Service Management</h2>
|
||||
</div>
|
||||
<p className="text-gray-600 text-base max-w-2xl leading-relaxed">
|
||||
Manage hotel services and amenities
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetServiceForm();
|
||||
setShowServiceModal(true);
|
||||
}}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-purple-500 to-purple-600 text-white rounded-xl font-semibold hover:from-purple-600 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Add Service
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5 group-focus-within:text-purple-500 transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search services..."
|
||||
value={serviceFilters.search}
|
||||
onChange={(e) => setServiceFilters({ ...serviceFilters, search: e.target.value })}
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 placeholder-gray-400 font-medium shadow-sm hover:shadow-md"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={serviceFilters.status}
|
||||
onChange={(e) => setServiceFilters({ ...serviceFilters, status: e.target.value })}
|
||||
className="w-full px-4 py-3.5 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-gray-700 font-medium shadow-sm hover:shadow-md cursor-pointer"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="bg-white/90 backdrop-blur-xl rounded-xl sm:rounded-2xl shadow-xl border border-gray-200/50 overflow-hidden">
|
||||
<div className="overflow-x-auto -mx-2 sm:mx-0 px-2 sm:px-0">
|
||||
<table className="w-full min-w-[640px] sm:min-w-full">
|
||||
<thead>
|
||||
<tr className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900">
|
||||
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Service Name</th>
|
||||
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700 hidden sm:table-cell">Description</th>
|
||||
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Price</th>
|
||||
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Unit</th>
|
||||
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-left text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Status</th>
|
||||
<th className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 text-right text-xs font-bold text-amber-100 uppercase tracking-wider border-b border-slate-700">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-slate-100">
|
||||
{services.map((service) => (
|
||||
<tr
|
||||
key={service.id}
|
||||
className="hover:bg-gradient-to-r hover:from-purple-50/30 hover:to-violet-50/30 transition-all duration-200 group border-b border-slate-100"
|
||||
>
|
||||
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap">
|
||||
<div className="text-xs sm:text-sm font-semibold text-gray-900">{service.name}</div>
|
||||
<div className="text-xs text-gray-500 sm:hidden mt-0.5 truncate max-w-[150px]">{service.description}</div>
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 hidden sm:table-cell">
|
||||
<div className="text-xs sm:text-sm text-gray-700 max-w-xs truncate">{service.description}</div>
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap">
|
||||
<div className="text-xs sm:text-sm font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
|
||||
{formatCurrency(service.price)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap">
|
||||
<div className="text-xs sm:text-sm text-gray-600">{service.unit}</div>
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap">
|
||||
{getServiceStatusBadge(service.status)}
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 lg:px-8 py-3 sm:py-4 md:py-5 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end gap-1 sm:gap-2">
|
||||
<button
|
||||
onClick={() => handleEditService(service)}
|
||||
className="p-1.5 sm:p-2 rounded-lg text-blue-600 hover:text-blue-700 hover:bg-blue-50 transition-all duration-200 shadow-sm hover:shadow-md border border-blue-200 hover:border-blue-300"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteService(service.id)}
|
||||
className="p-1.5 sm:p-2 rounded-lg text-rose-600 hover:text-rose-700 hover:bg-rose-50 transition-all duration-200 shadow-sm hover:shadow-md border border-rose-200 hover:border-rose-300"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={serviceCurrentPage}
|
||||
totalPages={serviceTotalPages}
|
||||
onPageChange={setServiceCurrentPage}
|
||||
totalItems={serviceTotalItems}
|
||||
itemsPerPage={serviceItemsPerPage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{}
|
||||
{showServiceModal && (
|
||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-hidden border border-slate-200">
|
||||
{}
|
||||
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-6 py-5 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-lg sm:text-xl md:text-xl font-bold text-amber-100">
|
||||
{editingService ? 'Update Service' : 'Add New Service'}
|
||||
</h2>
|
||||
<p className="text-amber-200/80 text-sm font-light mt-1">
|
||||
{editingService ? 'Modify service information' : 'Create a new service'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowServiceModal(false);
|
||||
resetServiceForm();
|
||||
}}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-120px)]">
|
||||
<form onSubmit={handleServiceSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Service Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={serviceFormData.name}
|
||||
onChange={(e) => setServiceFormData({ ...serviceFormData, name: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={serviceFormData.description}
|
||||
onChange={(e) => setServiceFormData({ ...serviceFormData, description: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Price
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={serviceFormData.price}
|
||||
onChange={(e) => setServiceFormData({ ...serviceFormData, price: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
required
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Unit
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={serviceFormData.unit}
|
||||
onChange={(e) => setServiceFormData({ ...serviceFormData, unit: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
placeholder="e.g: time, hour, day..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={serviceFormData.status}
|
||||
onChange={(e) => setServiceFormData({ ...serviceFormData, status: e.target.value as any })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200 text-slate-700 font-medium shadow-sm cursor-pointer"
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-4 border-t border-slate-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowServiceModal(false);
|
||||
resetServiceForm();
|
||||
}}
|
||||
className="flex-1 px-6 py-3 border-2 border-slate-300 rounded-xl text-slate-700 font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-6 py-3 bg-gradient-to-r from-purple-500 to-purple-600 text-white rounded-xl font-semibold hover:from-purple-600 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
{editingService ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Booking Modal */}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Plus, Search, Edit, Trash2, X } from 'lucide-react';
|
||||
import { Plus, Search, Edit, Trash2, X, Upload, Image as ImageIcon } from 'lucide-react';
|
||||
import serviceService, { Service } from '../../features/hotel_services/services/serviceService';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import Pagination from '../../shared/components/Pagination';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
import IconPicker from '../../features/system/components/IconPicker';
|
||||
import ConfirmationDialog from '../../shared/components/ConfirmationDialog';
|
||||
|
||||
const ServiceManagementPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
@@ -26,8 +28,20 @@ const ServiceManagementPage: React.FC = () => {
|
||||
description: '',
|
||||
price: 0,
|
||||
unit: 'time',
|
||||
category: '',
|
||||
slug: '',
|
||||
image: '',
|
||||
content: '',
|
||||
sections: [] as any[],
|
||||
meta_title: '',
|
||||
meta_description: '',
|
||||
meta_keywords: '',
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
});
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; id: number | null }>({ show: false, id: null });
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
@@ -60,11 +74,18 @@ const ServiceManagementPage: React.FC = () => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
// Auto-generate slug if not provided
|
||||
const dataToSubmit = {
|
||||
...formData,
|
||||
slug: formData.slug || formData.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''),
|
||||
sections: formData.sections.length > 0 ? JSON.stringify(formData.sections) : null,
|
||||
};
|
||||
|
||||
if (editingService) {
|
||||
await serviceService.updateService(editingService.id, formData);
|
||||
await serviceService.updateService(editingService.id, dataToSubmit);
|
||||
toast.success('Service updated successfully');
|
||||
} else {
|
||||
await serviceService.createService(formData);
|
||||
await serviceService.createService(dataToSubmit);
|
||||
toast.success('Service added successfully');
|
||||
}
|
||||
setShowModal(false);
|
||||
@@ -77,25 +98,62 @@ const ServiceManagementPage: React.FC = () => {
|
||||
|
||||
const handleEdit = (service: Service) => {
|
||||
setEditingService(service);
|
||||
|
||||
// Parse sections if it's a string
|
||||
let sections: any[] = [];
|
||||
if (service.sections) {
|
||||
if (typeof service.sections === 'string') {
|
||||
try {
|
||||
sections = JSON.parse(service.sections);
|
||||
} catch {
|
||||
sections = [];
|
||||
}
|
||||
} else if (Array.isArray(service.sections)) {
|
||||
sections = service.sections;
|
||||
}
|
||||
}
|
||||
|
||||
setFormData({
|
||||
name: service.name,
|
||||
description: service.description || '',
|
||||
price: service.price,
|
||||
unit: service.unit || 'time',
|
||||
category: service.category || '',
|
||||
slug: service.slug || '',
|
||||
image: service.image || '',
|
||||
content: service.content || '',
|
||||
sections: sections,
|
||||
meta_title: service.meta_title || '',
|
||||
meta_description: service.meta_description || '',
|
||||
meta_keywords: service.meta_keywords || '',
|
||||
status: service.status,
|
||||
});
|
||||
|
||||
// Set image preview if image exists
|
||||
if (service.image) {
|
||||
setImagePreview(service.image.startsWith('http') ? service.image : `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000'}${service.image}`);
|
||||
} else {
|
||||
setImagePreview(null);
|
||||
}
|
||||
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!window.confirm('Are you sure you want to delete this service?')) return;
|
||||
const handleDeleteClick = (id: number) => {
|
||||
setDeleteConfirm({ show: true, id });
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirm.id) return;
|
||||
|
||||
try {
|
||||
await serviceService.deleteService(id);
|
||||
await serviceService.deleteService(deleteConfirm.id);
|
||||
toast.success('Service deleted successfully');
|
||||
setDeleteConfirm({ show: false, id: null });
|
||||
fetchServices();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to delete service');
|
||||
setDeleteConfirm({ show: false, id: null });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -106,8 +164,48 @@ const ServiceManagementPage: React.FC = () => {
|
||||
description: '',
|
||||
price: 0,
|
||||
unit: 'time',
|
||||
category: '',
|
||||
slug: '',
|
||||
image: '',
|
||||
content: '',
|
||||
sections: [],
|
||||
meta_title: '',
|
||||
meta_description: '',
|
||||
meta_keywords: '',
|
||||
status: 'active',
|
||||
});
|
||||
setImagePreview(null);
|
||||
setImageFile(null);
|
||||
};
|
||||
|
||||
const handleImageUpload = async (file: File) => {
|
||||
try {
|
||||
setUploadingImage(true);
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
// Use the page content image upload endpoint or create a service-specific one
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8000'}/api/page-content/upload-image`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'success' && data.data?.image_url) {
|
||||
setFormData(prev => ({ ...prev, image: data.data.image_url }));
|
||||
setImagePreview(data.data.image_url);
|
||||
toast.success('Image uploaded successfully');
|
||||
} else {
|
||||
throw new Error(data.message || 'Upload failed');
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to upload image');
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -216,7 +314,7 @@ const ServiceManagementPage: React.FC = () => {
|
||||
<Edit className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(service.id)}
|
||||
onClick={() => handleDeleteClick(service.id)}
|
||||
className="p-2 rounded-lg text-rose-600 hover:text-rose-700 hover:bg-rose-50 transition-all duration-200 shadow-sm hover:shadow-md border border-rose-200 hover:border-rose-300"
|
||||
title="Delete"
|
||||
>
|
||||
@@ -242,7 +340,7 @@ const ServiceManagementPage: React.FC = () => {
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-4 animate-fade-in">
|
||||
<div className="min-h-full flex items-start justify-center py-8">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-md my-8 max-h-[calc(100vh-4rem)] overflow-hidden border border-slate-200 animate-scale-in">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-4xl my-8 max-h-[calc(100vh-4rem)] overflow-hidden border border-slate-200 animate-scale-in">
|
||||
{}
|
||||
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-6 py-5 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -302,16 +400,228 @@ const ServiceManagementPage: React.FC = () => {
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Unit
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.unit}
|
||||
onChange={(e) => setFormData({ ...formData, unit: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
placeholder="e.g: time, hour, day..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Category
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
placeholder="e.g: Spa, Dining, Concierge..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Unit
|
||||
Slug (URL-friendly identifier)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.unit}
|
||||
onChange={(e) => setFormData({ ...formData, unit: e.target.value })}
|
||||
value={formData.slug}
|
||||
onChange={(e) => {
|
||||
const slug = e.target.value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
setFormData({ ...formData, slug });
|
||||
}}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
placeholder="e.g: time, hour, day..."
|
||||
placeholder="auto-generated-from-name"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Leave empty to auto-generate from name</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Image URL
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.image}
|
||||
onChange={(e) => setFormData({ ...formData, image: e.target.value })}
|
||||
className="flex-1 px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
handleImageUpload(file);
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
id="image-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="image-upload"
|
||||
className="px-4 py-3 bg-amber-500 text-white rounded-xl hover:bg-amber-600 transition-colors cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Upload
|
||||
</label>
|
||||
</div>
|
||||
{imagePreview && (
|
||||
<div className="mt-2">
|
||||
<img src={imagePreview} alt="Preview" className="w-32 h-32 object-cover rounded-lg" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Content (HTML)
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm font-mono text-sm"
|
||||
rows={6}
|
||||
placeholder="Full HTML content for service detail page..."
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Supports HTML formatting. This content will appear on the service detail page.</p>
|
||||
</div>
|
||||
|
||||
{/* Sections Editor */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider">
|
||||
Sections (Advanced Content)
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFormData({
|
||||
...formData,
|
||||
sections: [...formData.sections, { type: 'text', title: '', content: '', is_visible: true }]
|
||||
});
|
||||
}}
|
||||
className="text-xs px-3 py-1.5 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Add Section
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto p-3 bg-slate-50 rounded-xl border border-slate-200">
|
||||
{formData.sections.map((section: any, sectionIndex: number) => (
|
||||
<div key={sectionIndex} className="p-3 bg-white border border-slate-300 rounded-lg">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<select
|
||||
value={section?.type || 'text'}
|
||||
onChange={(e) => {
|
||||
const updatedSections = [...formData.sections];
|
||||
updatedSections[sectionIndex] = { ...updatedSections[sectionIndex], type: e.target.value };
|
||||
setFormData({ ...formData, sections: updatedSections });
|
||||
}}
|
||||
className="px-3 py-1.5 border border-slate-300 rounded text-sm"
|
||||
>
|
||||
<option value="text">Text</option>
|
||||
<option value="hero">Hero</option>
|
||||
<option value="image">Image</option>
|
||||
<option value="gallery">Gallery</option>
|
||||
<option value="quote">Quote</option>
|
||||
<option value="features">Features</option>
|
||||
<option value="cta">Call to Action</option>
|
||||
<option value="video">Video</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const updatedSections = formData.sections.filter((_, i) => i !== sectionIndex);
|
||||
setFormData({ ...formData, sections: updatedSections });
|
||||
}}
|
||||
className="text-red-600 hover:text-red-700 text-sm px-2 py-1"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={section?.title || ''}
|
||||
onChange={(e) => {
|
||||
const updatedSections = [...formData.sections];
|
||||
updatedSections[sectionIndex] = { ...updatedSections[sectionIndex], title: e.target.value };
|
||||
setFormData({ ...formData, sections: updatedSections });
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded mb-2 text-sm"
|
||||
placeholder="Section title"
|
||||
/>
|
||||
<textarea
|
||||
value={section?.content || ''}
|
||||
onChange={(e) => {
|
||||
const updatedSections = [...formData.sections];
|
||||
updatedSections[sectionIndex] = { ...updatedSections[sectionIndex], content: e.target.value };
|
||||
setFormData({ ...formData, sections: updatedSections });
|
||||
}}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded text-sm"
|
||||
placeholder="Section content (HTML supported)"
|
||||
/>
|
||||
{section?.type === 'image' && (
|
||||
<input
|
||||
type="url"
|
||||
value={section?.image || ''}
|
||||
onChange={(e) => {
|
||||
const updatedSections = [...formData.sections];
|
||||
updatedSections[sectionIndex] = { ...updatedSections[sectionIndex], image: e.target.value };
|
||||
setFormData({ ...formData, sections: updatedSections });
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded mt-2 text-sm"
|
||||
placeholder="Image URL"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{formData.sections.length === 0 && (
|
||||
<p className="text-xs text-slate-500 italic text-center py-4">No sections added. Click "Add Section" to create advanced content blocks.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
SEO Meta Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.meta_title}
|
||||
onChange={(e) => setFormData({ ...formData, meta_title: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
placeholder="Service Name - Luxury Hotel"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
SEO Meta Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.meta_description}
|
||||
onChange={(e) => setFormData({ ...formData, meta_description: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
rows={2}
|
||||
placeholder="Brief description for search engines..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
SEO Meta Keywords
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.meta_keywords}
|
||||
onChange={(e) => setFormData({ ...formData, meta_keywords: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
||||
placeholder="keyword1, keyword2, keyword3"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -348,6 +658,18 @@ const ServiceManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<ConfirmationDialog
|
||||
isOpen={deleteConfirm.show}
|
||||
onClose={() => setDeleteConfirm({ show: false, id: null })}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Service"
|
||||
message="Are you sure you want to delete this service? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -447,6 +447,15 @@ const RoomDetailPage: React.FC = () => {
|
||||
: (roomType?.amenities || [])
|
||||
}
|
||||
/>
|
||||
<div className="mt-6 pt-6 border-t border-[#d4af37]/20">
|
||||
<Link
|
||||
to="/services"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-[#d4af37]/20 to-[#c9a227]/10 hover:from-[#d4af37]/30 hover:to-[#d4af37]/20 text-[#d4af37] rounded-lg font-medium transition-all duration-300 border border-[#d4af37]/30 hover:border-[#d4af37]/50"
|
||||
>
|
||||
<Award className="w-4 h-4" />
|
||||
<span className="text-sm">View All Services</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ interface NavbarProps {
|
||||
export const navLinks = [
|
||||
{ to: '/', label: 'Home' },
|
||||
{ to: '/rooms', label: 'Rooms' },
|
||||
{ to: '/services', label: 'Services' },
|
||||
{ to: '/about', label: 'About' },
|
||||
{ to: '/contact', label: 'Contact' },
|
||||
{ to: '/blog', label: 'Blog' },
|
||||
|
||||
Reference in New Issue
Block a user