diff --git a/Backend/src/content/routes/__pycache__/page_content_routes.cpython-312.pyc b/Backend/src/content/routes/__pycache__/page_content_routes.cpython-312.pyc index ed328601..8fee1c20 100644 Binary files a/Backend/src/content/routes/__pycache__/page_content_routes.cpython-312.pyc and b/Backend/src/content/routes/__pycache__/page_content_routes.cpython-312.pyc differ diff --git a/Backend/src/content/routes/page_content_routes.py b/Backend/src/content/routes/page_content_routes.py index b3b6f542..47e881fc 100644 --- a/Backend/src/content/routes/page_content_routes.py +++ b/Backend/src/content/routes/page_content_routes.py @@ -7,6 +7,7 @@ import json import os import aiofiles import uuid +from pydantic import ValidationError from ...shared.config.database import get_db from ...shared.config.logging_config import get_logger from ...security.middleware.auth import get_current_user, authorize_roles @@ -298,9 +299,20 @@ async def update_page_content(page_type: PageType, page_data: PageContentUpdateR for key, value in update_dict.items(): if hasattr(existing_content, key): # Convert dict/list to JSON string for JSON fields - if key in ['contact_info', 'social_links', 'footer_links', 'badges', 'values', 'features', 'amenities', 'testimonials', 'gallery_images', 'stats', 'luxury_features', 'luxury_gallery', 'luxury_testimonials', 'luxury_services', 'luxury_experiences', 'awards', 'partners', 'team', 'timeline', 'achievements'] and value is not None: + json_fields = ['contact_info', 'social_links', 'footer_links', 'badges', 'values', 'features', 'amenities', 'testimonials', 'gallery_images', 'stats', 'luxury_features', 'luxury_gallery', 'luxury_testimonials', 'luxury_services', 'luxury_experiences', 'awards', 'partners', 'team', 'timeline', 'achievements'] + if key in json_fields and value is not None: if isinstance(value, (dict, list)): value = json.dumps(value) + elif isinstance(value, str): + # Already a JSON string, validate it + try: + json.loads(value) + except json.JSONDecodeError: + logger.warning(f'Invalid JSON string for field {key}, skipping') + continue + # Handle empty arrays - set to None to avoid storing "[]" + if isinstance(value, list) and len(value) == 0: + value = None if value is not None: setattr(existing_content, key, value) existing_content.updated_at = datetime.utcnow() @@ -308,6 +320,15 @@ async def update_page_content(page_type: PageType, page_data: PageContentUpdateR db.refresh(existing_content) content_dict = {'id': existing_content.id, 'page_type': existing_content.page_type.value, 'title': existing_content.title, 'subtitle': existing_content.subtitle, 'description': existing_content.description, 'content': existing_content.content, 'meta_title': existing_content.meta_title, 'meta_description': existing_content.meta_description, 'meta_keywords': existing_content.meta_keywords, 'og_title': existing_content.og_title, 'og_description': existing_content.og_description, 'og_image': existing_content.og_image, 'canonical_url': existing_content.canonical_url, 'contact_info': json.loads(existing_content.contact_info) if existing_content.contact_info else None, 'map_url': existing_content.map_url, 'social_links': json.loads(existing_content.social_links) if existing_content.social_links else None, 'footer_links': json.loads(existing_content.footer_links) if existing_content.footer_links else None, 'badges': json.loads(existing_content.badges) if existing_content.badges else None, 'copyright_text': existing_content.copyright_text, 'hero_title': existing_content.hero_title, 'hero_subtitle': existing_content.hero_subtitle, 'hero_image': existing_content.hero_image, 'story_content': existing_content.story_content, 'values': json.loads(existing_content.values) if existing_content.values else None, 'features': json.loads(existing_content.features) if existing_content.features else None, 'about_hero_image': existing_content.about_hero_image, 'mission': existing_content.mission, 'vision': existing_content.vision, 'team': json.loads(existing_content.team) if existing_content.team else None, 'timeline': json.loads(existing_content.timeline) if existing_content.timeline else None, 'achievements': json.loads(existing_content.achievements) if existing_content.achievements else None, 'amenities_section_title': existing_content.amenities_section_title, 'amenities_section_subtitle': existing_content.amenities_section_subtitle, 'amenities': json.loads(existing_content.amenities) if existing_content.amenities else None, 'testimonials_section_title': existing_content.testimonials_section_title, 'testimonials_section_subtitle': existing_content.testimonials_section_subtitle, 'testimonials': json.loads(existing_content.testimonials) if existing_content.testimonials else None, 'gallery_section_title': existing_content.gallery_section_title, 'gallery_section_subtitle': existing_content.gallery_section_subtitle, 'gallery_images': json.loads(existing_content.gallery_images) if existing_content.gallery_images else None, 'luxury_section_title': existing_content.luxury_section_title, 'luxury_section_subtitle': existing_content.luxury_section_subtitle, 'luxury_section_image': existing_content.luxury_section_image, 'luxury_features': json.loads(existing_content.luxury_features) if existing_content.luxury_features else None, 'luxury_gallery_section_title': existing_content.luxury_gallery_section_title, 'luxury_gallery_section_subtitle': existing_content.luxury_gallery_section_subtitle, 'luxury_gallery': json.loads(existing_content.luxury_gallery) if existing_content.luxury_gallery else None, 'luxury_testimonials_section_title': existing_content.luxury_testimonials_section_title, 'luxury_testimonials_section_subtitle': existing_content.luxury_testimonials_section_subtitle, 'luxury_testimonials': json.loads(existing_content.luxury_testimonials) if existing_content.luxury_testimonials else None, 'about_preview_title': existing_content.about_preview_title, 'about_preview_subtitle': existing_content.about_preview_subtitle, 'about_preview_content': existing_content.about_preview_content, 'about_preview_image': existing_content.about_preview_image, 'stats': json.loads(existing_content.stats) if existing_content.stats else None, 'luxury_services_section_title': existing_content.luxury_services_section_title, 'luxury_services_section_subtitle': existing_content.luxury_services_section_subtitle, 'luxury_services': json.loads(existing_content.luxury_services) if existing_content.luxury_services else None, 'luxury_experiences_section_title': existing_content.luxury_experiences_section_title, 'luxury_experiences_section_subtitle': existing_content.luxury_experiences_section_subtitle, 'luxury_experiences': json.loads(existing_content.luxury_experiences) if existing_content.luxury_experiences else None, 'awards_section_title': existing_content.awards_section_title, 'awards_section_subtitle': existing_content.awards_section_subtitle, 'awards': json.loads(existing_content.awards) if existing_content.awards else None, 'cta_title': existing_content.cta_title, 'cta_subtitle': existing_content.cta_subtitle, 'cta_button_text': existing_content.cta_button_text, 'cta_button_link': existing_content.cta_button_link, 'cta_image': existing_content.cta_image, 'partners_section_title': existing_content.partners_section_title, 'partners_section_subtitle': existing_content.partners_section_subtitle, 'partners': json.loads(existing_content.partners) if existing_content.partners else None, 'is_active': existing_content.is_active, 'updated_at': existing_content.updated_at.isoformat() if existing_content.updated_at else None} return {'status': 'success', 'message': 'Page content updated successfully', 'data': {'page_content': content_dict}} + except ValidationError as e: + db.rollback() + error_messages = [] + for error in e.errors(): + field = '.'.join(str(loc) for loc in error['loc']) + error_messages.append(f"{field}: {error['msg']}") + error_detail = 'Validation error: ' + '; '.join(error_messages) + logger.error(f'Validation error updating page content ({page_type}): {error_detail}', exc_info=True) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_detail) except HTTPException: raise except Exception as e: diff --git a/Backend/src/content/schemas/__pycache__/page_content.cpython-312.pyc b/Backend/src/content/schemas/__pycache__/page_content.cpython-312.pyc index 2fecd58b..94dd9de0 100644 Binary files a/Backend/src/content/schemas/__pycache__/page_content.cpython-312.pyc and b/Backend/src/content/schemas/__pycache__/page_content.cpython-312.pyc differ diff --git a/Backend/src/content/schemas/page_content.py b/Backend/src/content/schemas/page_content.py index c8f46f29..e58507cb 100644 --- a/Backend/src/content/schemas/page_content.py +++ b/Backend/src/content/schemas/page_content.py @@ -51,7 +51,7 @@ class PageContentUpdateRequest(BaseModel): luxury_features: Optional[Union[str, List[Dict[str, Any]]]] = None luxury_gallery_section_title: Optional[str] = Field(None, max_length=500) luxury_gallery_section_subtitle: Optional[str] = Field(None, max_length=1000) - luxury_gallery: Optional[Union[str, List[Dict[str, Any]]]] = None + luxury_gallery: Optional[Union[str, List[str]]] = None luxury_testimonials_section_title: Optional[str] = Field(None, max_length=500) luxury_testimonials_section_subtitle: Optional[str] = Field(None, max_length=1000) luxury_testimonials: Optional[Union[str, List[Dict[str, Any]]]] = None diff --git a/Frontend/src/features/content/components/PartnersCarousel.tsx b/Frontend/src/features/content/components/PartnersCarousel.tsx new file mode 100644 index 00000000..afa7acc9 --- /dev/null +++ b/Frontend/src/features/content/components/PartnersCarousel.tsx @@ -0,0 +1,286 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; + +interface Partner { + name?: string; + logo?: string; + link?: string; +} + +interface PartnersCarouselProps { + partners: Partner[]; + autoSlideInterval?: number; + showNavigation?: boolean; + itemsPerView?: number; +} + +const PartnersCarousel: React.FC = ({ + partners, + autoSlideInterval = 5000, + showNavigation = true, + itemsPerView = 6, +}) => { + const [currentIndex, setCurrentIndex] = useState(0); + const [windowWidth, setWindowWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1920); + + // Handle window resize + useEffect(() => { + if (typeof window === 'undefined') return; + + const handleResize = () => { + setWindowWidth(window.innerWidth); + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + // Calculate responsive items per view + const responsiveItemsPerView = useMemo(() => { + if (windowWidth < 640) return 2; // sm: 2 items + if (windowWidth < 768) return 3; // md: 3 items + if (windowWidth < 1024) return 4; // lg: 4 items + return itemsPerView; // xl: 6 items + }, [windowWidth, itemsPerView]); + + // Calculate max index (how many slides we can have) + const maxIndex = Math.max(0, partners.length - responsiveItemsPerView); + const canNavigate = partners.length > responsiveItemsPerView; + const allItemsFit = partners.length <= responsiveItemsPerView; + + // Auto-slide functionality + useEffect(() => { + if (!canNavigate) return; + + const interval = setInterval(() => { + setCurrentIndex((prev) => { + if (prev >= maxIndex) { + return 0; // Loop back to start + } + return prev + 1; + }); + }, autoSlideInterval); + + return () => clearInterval(interval); + }, [canNavigate, maxIndex, autoSlideInterval]); + + // Reset index when it exceeds max + useEffect(() => { + if (currentIndex > maxIndex) { + setCurrentIndex(0); + } + }, [maxIndex, currentIndex]); + + const goToPrevious = () => { + if (!canNavigate) return; + setCurrentIndex((prev) => (prev === 0 ? maxIndex : prev - 1)); + }; + + const goToNext = () => { + if (!canNavigate) return; + setCurrentIndex((prev) => (prev >= maxIndex ? 0 : prev + 1)); + }; + + const goToSlide = (index: number) => { + if (index < 0 || index > maxIndex) return; + setCurrentIndex(index); + }; + + if (partners.length === 0) { + return null; + } + + const itemWidth = 100 / responsiveItemsPerView; + const translateX = allItemsFit ? 0 : -(currentIndex * itemWidth); + + return ( +
+ {/* Carousel Container */} +
+ {allItemsFit ? ( + // Centered layout when all items fit - show only logos without containers +
+ {partners.map((partner, index) => { + return ( +
+ {partner.link ? ( + + {partner.logo ? ( + {partner.name { + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + }} + /> + ) : ( + + {partner.name || `Partner ${index + 1}`} + + )} + + ) : ( + <> + {partner.logo ? ( + {partner.name { + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + }} + /> + ) : ( + + {partner.name || `Partner ${index + 1}`} + + )} + + )} +
+ ); + })} +
+ ) : ( + // Carousel layout when items don't all fit +
+ {partners.map((partner, index) => ( +
+ {partner.link ? ( + + {partner.logo ? ( + {partner.name { + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + }} + /> + ) : ( + + {partner.name || `Partner ${index + 1}`} + + )} + + ) : ( + <> + {partner.logo ? ( + {partner.name { + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + }} + /> + ) : ( + + {partner.name || `Partner ${index + 1}`} + + )} + + )} +
+ ))} +
+ )} +
+ + {/* Navigation Buttons */} + {showNavigation && canNavigate && ( + <> + + + + + )} + + {/* Dots Indicator */} + {canNavigate && maxIndex > 0 && ( +
+ {Array.from({ length: maxIndex + 1 }).map((_, index) => ( +
+ )} +
+ ); +}; + +export default PartnersCarousel; diff --git a/Frontend/src/features/content/pages/HomePage.tsx b/Frontend/src/features/content/pages/HomePage.tsx index b35a4156..f0136ebe 100644 --- a/Frontend/src/features/content/pages/HomePage.tsx +++ b/Frontend/src/features/content/pages/HomePage.tsx @@ -16,6 +16,7 @@ import { RoomCarousel, SearchRoomForm, } from '../../rooms/components'; +import PartnersCarousel from '../components/PartnersCarousel'; import bannerService from '../services/bannerService'; import roomService from '../../rooms/services/roomService'; import pageContentService from '../services/pageContentService'; @@ -1194,43 +1195,28 @@ const HomePage: React.FC = () => { {} {pageContent?.partners && pageContent.partners.length > 0 && ( -
-
-
-
-
-

- {pageContent.partners_section_title || 'Our Partners'} -

- {pageContent.partners_section_subtitle && ( -

- {pageContent.partners_section_subtitle} -

- )} -
-
- {pageContent.partners.map((partner: any, index: number) => ( -
- {partner.link ? ( - - {partner.logo ? ( - {partner.name} - ) : ( - {partner.name} - )} - - ) : ( - <> - {partner.logo ? ( - {partner.name} - ) : ( - {partner.name} - )} - - )} +
+
+
+
+
- ))} +

+ {pageContent.partners_section_title || 'Our Partners'} +

+ {pageContent.partners_section_subtitle && ( +

+ {pageContent.partners_section_subtitle} +

+ )} +
+
)} diff --git a/Frontend/src/pages/admin/PageContentDashboard.tsx b/Frontend/src/pages/admin/PageContentDashboard.tsx index 310157ef..914c5893 100644 --- a/Frontend/src/pages/admin/PageContentDashboard.tsx +++ b/Frontend/src/pages/admin/PageContentDashboard.tsx @@ -100,7 +100,7 @@ const PageContentDashboard: React.FC = () => { search: '', status: '', }); - const [serviceCurrentPage, setServiceCurrentPage] = useState(1); + const [serviceCurrentPage, setServiceCurrentPage] = useState(1); const [serviceTotalPages, setServiceTotalPages] = useState(1); const [serviceTotalItems, setServiceTotalItems] = useState(0); const serviceItemsPerPage = 10; @@ -400,18 +400,144 @@ const PageContentDashboard: React.FC = () => { setSaving(true); // Remove contact_info for contact and footer pages since it's now managed centrally const { contact_info, luxury_services, ...dataToSave } = data; + + // Clean up data: remove undefined values and ensure proper types + const cleanData: any = {}; + + // List of fields that should be strings (not objects/arrays) + const stringFields = [ + 'title', 'subtitle', 'description', 'content', 'meta_title', 'meta_description', 'meta_keywords', + 'og_title', 'og_description', 'og_image', 'canonical_url', 'hero_title', 'hero_subtitle', 'hero_image', + 'story_content', 'about_hero_image', 'mission', 'vision', + 'amenities_section_title', 'amenities_section_subtitle', + 'testimonials_section_title', 'testimonials_section_subtitle', + 'gallery_section_title', 'gallery_section_subtitle', + 'luxury_section_title', 'luxury_section_subtitle', 'luxury_section_image', + 'luxury_gallery_section_title', 'luxury_gallery_section_subtitle', + 'luxury_testimonials_section_title', 'luxury_testimonials_section_subtitle', + 'about_preview_title', 'about_preview_subtitle', 'about_preview_content', 'about_preview_image', + 'luxury_services_section_title', 'luxury_services_section_subtitle', + 'luxury_experiences_section_title', 'luxury_experiences_section_subtitle', + 'awards_section_title', 'awards_section_subtitle', + 'cta_title', 'cta_subtitle', 'cta_button_text', 'cta_button_link', 'cta_image', + 'partners_section_title', 'partners_section_subtitle', + 'copyright_text', 'map_url' + ]; + + Object.keys(dataToSave).forEach((key) => { + const value = (dataToSave as any)[key]; + + // Skip undefined values + if (value === undefined) return; + + // Handle string fields - ensure they're strings, skip empty/null values + if (stringFields.includes(key)) { + if (typeof value === 'string' && value.trim() !== '') { + cleanData[key] = value.trim(); + } else { + // Skip empty strings and null values - don't send them + return; + } + return; + } + + // Handle empty strings - skip them (don't send empty values) + if (value === '' || value === null) { + return; + } + + // Handle arrays - only send non-empty arrays + if (Array.isArray(value)) { + if (value.length > 0) { + cleanData[key] = value; + } + // Skip empty arrays - don't send them + return; + } + + // Handle objects - keep as is (for contact_info, social_links, etc.) + if (typeof value === 'object' && value !== null) { + cleanData[key] = value; + return; + } + + // Handle other types - ensure they're valid + if (typeof value === 'number' || typeof value === 'boolean') { + cleanData[key] = value; + return; + } + + // Skip any other unexpected types + console.warn(`Skipping field ${key} with unexpected type: ${typeof value}`, value); + }); + + // Log the data being sent for debugging + console.log(`Saving ${pageType} page content:`, { + fieldCount: Object.keys(cleanData).length, + fields: Object.keys(cleanData), + sampleData: Object.entries(cleanData).slice(0, 5).reduce((acc, [key, value]) => { + acc[key] = Array.isArray(value) ? `Array[${value.length}]` : typeof value === 'object' ? 'Object' : value; + return acc; + }, {} as any) + }); + if (pageType === 'contact' || pageType === 'footer') { - await pageContentService.updatePageContent(pageType, dataToSave); + await pageContentService.updatePageContent(pageType, cleanData); } else if (pageType === 'home') { // For home page, exclude luxury_services (services are managed in Service Management) - await pageContentService.updatePageContent(pageType, dataToSave); + await pageContentService.updatePageContent(pageType, cleanData); } else { - await pageContentService.updatePageContent(pageType, data); + await pageContentService.updatePageContent(pageType, cleanData); } toast.success(`${pageType.charAt(0).toUpperCase() + pageType.slice(1)} content saved successfully`); await fetchAllPageContents(); } catch (error: any) { - toast.error(error.response?.data?.message || `Failed to save ${pageType} content`); + console.error('Error saving page content:', error); + + // Log detailed validation errors + if (error.response?.data?.errors) { + console.error('Full validation errors:', JSON.stringify(error.response.data.errors, null, 2)); + const fieldErrors = error.response.data.errors.map((err: any, index: number) => { + const field = Array.isArray(err.loc) ? err.loc.join('.') : (err.loc || err.field || `error_${index}`); + const msg = err.msg || err.message || 'Unknown error'; + const type = err.type || 'validation_error'; + return { field, msg, type, full: err }; + }); + console.error('Parsed field errors:', fieldErrors); + } + + // Get error message - check multiple possible error formats + let errorMessage = error.response?.data?.detail || error.response?.data?.message; + + // Format validation errors for display - handle different error formats + if (error.response?.data?.errors && Array.isArray(error.response.data.errors) && error.response.data.errors.length > 0) { + const errorList = error.response.data.errors.map((err: any, index: number) => { + const field = Array.isArray(err.loc) ? err.loc.join('.') : (err.loc || err.field || `Field ${index + 1}`); + const msg = err.msg || err.message || 'Invalid value'; + return `${field}: ${msg}`; + }).join('\n'); + errorMessage = `Validation errors:\n${errorList}`; + + // Also log to console for debugging + console.error('Validation error details:', errorList); + } + + // Handle FastAPI validation error format + if (error.response?.data?.detail && Array.isArray(error.response.data.detail)) { + const errorList = error.response.data.detail.map((err: any, index: number) => { + const field = Array.isArray(err.loc) ? err.loc.join('.') : (err.loc || `Field ${index + 1}`); + const msg = err.msg || 'Invalid value'; + return `${field}: ${msg}`; + }).join('\n'); + errorMessage = `Validation errors:\n${errorList}`; + console.error('FastAPI validation errors:', errorList); + } + + if (!errorMessage) { + errorMessage = `Failed to save ${pageType} content`; + } + + toast.error(errorMessage); } finally { setSaving(false); } @@ -1006,8 +1132,8 @@ const PageContentDashboard: React.FC = () => { className="flex-1 px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200" placeholder="https://example.com/hero-image.jpg or upload" /> -
- setHomeData({ ...homeData, og_image: e.target.value })} - className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200" - /> +
+ setHomeData({ ...homeData, og_image: e.target.value })} + className="flex-1 px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200" + placeholder="https://example.com/og-image.jpg or upload" + /> + +
+ {homeData.og_image && ( +
+ OG Image preview +
+ )}
@@ -1178,8 +1333,9 @@ const PageContentDashboard: React.FC = () => { className="flex-1 px-4 py-2 border-2 border-gray-200 rounded-lg" placeholder="URL or upload" /> -