From e1988fe37a2b7e277fd56882573e456359f085e9 Mon Sep 17 00:00:00 2001 From: Iliyan Angelov Date: Fri, 5 Dec 2025 01:50:38 +0200 Subject: [PATCH] update --- .../page_content_routes.cpython-312.pyc | Bin 42834 -> 44251 bytes .../src/content/routes/page_content_routes.py | 23 +- .../__pycache__/page_content.cpython-312.pyc | Bin 7914 -> 7894 bytes Backend/src/content/schemas/page_content.py | 2 +- .../content/components/PartnersCarousel.tsx | 286 +++++++++++++ .../src/features/content/pages/HomePage.tsx | 56 +-- .../src/pages/admin/PageContentDashboard.tsx | 389 +++++++++++++----- 7 files changed, 623 insertions(+), 133 deletions(-) create mode 100644 Frontend/src/features/content/components/PartnersCarousel.tsx 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 ed328601274ef6b9750a5dd51c23297ebca5a9be..8fee1c2068cd5b5528887d6d5a8d4671e0eb03d4 100644 GIT binary patch delta 5437 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 delta 4367 zcmZ`+eQZ2)zY{01V~5}n^1*i!2q|g#CbaZ(M#3??7m{Gd=K7gWLqZY) zg%${xZB=0`Y^9`y6_~4Pnx?HEYqhOiOWRfIT!cW{ADgCD(01CiP1DY~j$eXRUX=5G z_nhB7_uO;uefRi_KWjeuP*ePVQBl4C&+Czf-jZEo#SYRvRzksj)*hhFgFnHCpKR8=lhQJadE ztEgQ?52RT`9`f+8w2lkV5;|w`CzmId(Q~i}ovWzY4!k5W@5uOayr%9@PUEsWQ!IbCrG^h>Ug9W1rPTa7-`#_mKoj1l;CbO$J?9<_#u9FK!T+Ry&8q=Q7+^5#l%fCZbY z$U!#L>?1#5mzp<{arRHpTwtXwi}XLi&Oc>OwbakSxljy)#!>7?ft!d9g78}?W`e$m z;w-z`Vr`fJn9l3&4a>n;Na>;DXu5z(VD^$AL=H@_@>2gQWHWiqGg2DwU(2^ zY;9|$_!cPYQ~j-#I^s^9SpH?P6DO7~jA4c**NH~3S7qvC=hKA!eWRx_itU{s{F|wQ zm~Rn11f|H7x_+K7_zo!_*o(?O6nx@IL6(1;0y!zoF$7n@UA9KechCd0A7don50Q*b#40 zulNbr`%{0T!$wWJDb>HfiIB(Hc+yvxgzx+=fzC}D4$WboC+8Gdl``P+rw>lvOmmYV z`^7n6NE!yobE_1Lls`#bQlB(j)@E8nc8$amOLOAT9d<&}>FtDnJ%{oS;nd~btn8EOIzFlj9 zx2M3u#)pfT<`J`oH@%`2{H{j7#KvL+wyc@s!NC&I4)*oz!b~gs@v(gDsN;$|Mz81q z)R}AQWYH z=Jxxd#wJ8Y-0a{Xr|5ycT|6&UKqbW%Jvj6NytB%l4Vh_%s)>(n8Je2jmZ55GHL%8t zRTgGsteE@O&H}u1wFQH#fdRsNfZ3q*l&E}W9QGM65?Eh97|0q6=De1@Im{Q0Y1$z1m62q)G9abGTR9}>j|FeQ1)8rf?Zb`z)uQ6eGSlf}QEVmu07TbXFB9CD?pmXfd4BXV`bB8$3JnOt*80XGbup39X{|@N$ zQZDx;cAaxy=G;eE*HO-03EWq6xv%m}t^(5)&R)q>$b&bch7SPWDSu__38+_J= zJNykizYxwZ_&J_&%&Hy?(=i)tzd|JaYLeuGLCsFw>b|Knoke}W$$l6f?kAEUtvd%P zJYba8OBr6)k-A(+DchVQY(-VG8=5pv^z7w-Z9h*VTk1YZNU5ug~MF)zND4s;I76twg zqK~3@2E}F+TTpCA(TZXzie)J9cHcyFem%v~&0*ztGOuxdhi1G5>F?R`v8p;cb^G^F z{ANG+BKwGaG`3^`Ui@iMN`4lAw{A5Qlpr;%v&9rQU5yTDwEuOZingj4Xa7<^$q|1Q(fV z!kl2I&zP-uzyv>2h4`>3Q$h%LbuRYHGlSa=c`eVbJ=$@~db7lK!(KCC{;Xu~ZL{sJ zAe5~T??Gj|LIk`@T%UP8Bi^)o#;dRRUe`^y{$yWt)9t&Ie<6Qj;ivAV7sSuaj*rd0 zo0hU0uDXeB*A{+eX}((mBYbO0ef0gt5|+LwFT>RzB3%_^WJUFsTH)>5`5@n^*jxaw zTYfa$s-4+YroFZFq0%m!_O?xg#_cjK82t0tYcFp#@sjL$oc0?iuCj(pF1P{LU#cP| z7QIyQ^bAzv8mCR6gwj(ViAr)fUZ03VnP*0|c~Wy~{8- zOP&D-5{$uMe<&gcgS_g4-viMEmXN2=!zc=@V0gXHYvi;p7KN%3&mMgbL|V5s8V%D= z5Ncxyh2rwiKcF~`;xLGR8<#?miLl&8zlJ8Poe=NT32bD-pSm`2R|}z)Rge1QK0Fw-;w 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 2fecd58bde9d0259952834e6e1b37e2cb40d9007..94dd9de0c941f1d08f90ac39849d728e440a5a06 100644 GIT binary patch delta 53 zcmaE5d(D>jG%qg~0}zyp8)d%P$Scpt^owh=9^+ItM)u9;*!S}>F5TQGR>(5hUrJ`P Is`OE20J8oNPXGV_ delta 89 zcmV-f0H*)eJ?cFT%MA?*00000$J{7s_puEh0RjXIvn~OZ29t>c9JAF2t_}gOvxF9L v1SLU5fKHG|m{p)Ns064~up_W@uqdfs(I`C7BRtU^J<$|C(*#Ac5F5q=RHz@x 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" /> -