diff --git a/alembic/versions/z4e5f6a7b8c9_add_multi_platform_support.py b/alembic/versions/z4e5f6a7b8c9_add_multi_platform_support.py index 185ed0cf..ea8b53b4 100644 --- a/alembic/versions/z4e5f6a7b8c9_add_multi_platform_support.py +++ b/alembic/versions/z4e5f6a7b8c9_add_multi_platform_support.py @@ -178,7 +178,9 @@ def upgrade() -> None: """) ) -I dn + # Get OMS platform ID for backfilling + result = conn.execute(sa.text("SELECT id FROM platforms WHERE code = 'oms'")) + oms_platform_id = result.fetchone()[0] # ========================================================================= # 6. Backfill content_pages with platform_id diff --git a/alembic/versions/z8i9j0k1l2m3_add_sections_to_content_pages.py b/alembic/versions/z8i9j0k1l2m3_add_sections_to_content_pages.py new file mode 100644 index 00000000..dbf4da8d --- /dev/null +++ b/alembic/versions/z8i9j0k1l2m3_add_sections_to_content_pages.py @@ -0,0 +1,36 @@ +"""Add sections column to content_pages + +Revision ID: z8i9j0k1l2m3 +Revises: z7h8i9j0k1l2 +Create Date: 2026-01-23 + +Adds sections JSON column for structured homepage editing with multi-language support. +The sections column stores hero, features, pricing, and cta configurations +with TranslatableText pattern for i18n. +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "z8i9j0k1l2m3" +down_revision = "z7h8i9j0k1l2" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "content_pages", + sa.Column( + "sections", + sa.JSON(), + nullable=True, + comment="Structured homepage sections (hero, features, pricing, cta) with i18n", + ), + ) + + +def downgrade() -> None: + op.drop_column("content_pages", "sections") diff --git a/app/api/v1/admin/content_pages.py b/app/api/v1/admin/content_pages.py index f7fbd057..e5243205 100644 --- a/app/api/v1/admin/content_pages.py +++ b/app/api/v1/admin/content_pages.py @@ -284,3 +284,116 @@ def delete_page( """Delete a content page.""" content_page_service.delete_page_or_raise(db, page_id) db.commit() + + +# ============================================================================ +# HOMEPAGE SECTIONS MANAGEMENT +# ============================================================================ + + +class HomepageSectionsResponse(BaseModel): + """Response containing homepage sections with platform language info.""" + + sections: dict | None = None + supported_languages: list[str] = Field(default_factory=lambda: ["fr", "de", "en"]) + default_language: str = "fr" + + +class SectionUpdateResponse(BaseModel): + """Response after updating sections.""" + + message: str + sections: dict | None = None + + +@router.get("/{page_id}/sections", response_model=HomepageSectionsResponse) +def get_page_sections( + page_id: int, + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """ + Get homepage sections for a content page. + + Returns sections along with platform language settings for the editor. + """ + page = content_page_service.get_page_by_id_or_raise(db, page_id) + + # Get platform languages + platform = page.platform + supported_languages = ( + platform.supported_languages if platform else ["fr", "de", "en"] + ) + default_language = platform.default_language if platform else "fr" + + return { + "sections": page.sections, + "supported_languages": supported_languages, + "default_language": default_language, + } + + +@router.put("/{page_id}/sections", response_model=SectionUpdateResponse) +def update_page_sections( + page_id: int, + sections: dict, + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """ + Update all homepage sections at once. + + Expected structure: + { + "hero": { ... }, + "features": { ... }, + "pricing": { ... }, + "cta": { ... } + } + """ + page = content_page_service.update_homepage_sections( + db, + page_id=page_id, + sections=sections, + updated_by=current_user.id, + ) + db.commit() + + return { + "message": "Sections updated successfully", + "sections": page.sections, + } + + +@router.put("/{page_id}/sections/{section_name}", response_model=SectionUpdateResponse) +def update_single_section( + page_id: int, + section_name: str, + section_data: dict, + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """ + Update a single section (hero, features, pricing, or cta). + + section_name must be one of: hero, features, pricing, cta + """ + if section_name not in ["hero", "features", "pricing", "cta"]: + raise ValidationException( + message=f"Invalid section name: {section_name}. Must be one of: hero, features, pricing, cta", + field="section_name", + ) + + page = content_page_service.update_single_section( + db, + page_id=page_id, + section_name=section_name, + section_data=section_data, + updated_by=current_user.id, + ) + db.commit() + + return { + "message": f"Section '{section_name}' updated successfully", + "sections": page.sections, + } diff --git a/app/services/content_page_service.py b/app/services/content_page_service.py index c94edbb4..b930e2ab 100644 --- a/app/services/content_page_service.py +++ b/app/services/content_page_service.py @@ -872,6 +872,130 @@ class ContentPageService: if not success: raise ContentPageNotFoundException(identifier=page_id) + # ========================================================================= + # Homepage Sections Management + # ========================================================================= + + @staticmethod + def update_homepage_sections( + db: Session, + page_id: int, + sections: dict, + updated_by: int | None = None, + ) -> ContentPage: + """ + Update homepage sections with validation. + + Args: + db: Database session + page_id: Content page ID + sections: Homepage sections dict (validated against HomepageSections schema) + updated_by: User ID making the update + + Returns: + Updated ContentPage + + Raises: + ContentPageNotFoundException: If page not found + ValidationError: If sections schema invalid + """ + from models.schema.homepage_sections import HomepageSections + + page = ContentPageService.get_page_by_id_or_raise(db, page_id) + + # Validate sections against schema + validated = HomepageSections(**sections) + + # Update page + page.sections = validated.model_dump() + page.updated_by = updated_by + + db.flush() + db.refresh(page) + + logger.info(f"[CMS] Updated homepage sections for page_id={page_id}") + return page + + @staticmethod + def update_single_section( + db: Session, + page_id: int, + section_name: str, + section_data: dict, + updated_by: int | None = None, + ) -> ContentPage: + """ + Update a single section within homepage sections. + + Args: + db: Database session + page_id: Content page ID + section_name: Section to update (hero, features, pricing, cta) + section_data: Section configuration dict + updated_by: User ID making the update + + Returns: + Updated ContentPage + + Raises: + ContentPageNotFoundException: If page not found + ValueError: If section name is invalid + """ + from models.schema.homepage_sections import ( + HeroSection, + FeaturesSection, + PricingSection, + CTASection, + ) + + SECTION_SCHEMAS = { + "hero": HeroSection, + "features": FeaturesSection, + "pricing": PricingSection, + "cta": CTASection, + } + + if section_name not in SECTION_SCHEMAS: + raise ValueError(f"Invalid section name: {section_name}. Must be one of: {list(SECTION_SCHEMAS.keys())}") + + page = ContentPageService.get_page_by_id_or_raise(db, page_id) + + # Validate section data against its schema + schema = SECTION_SCHEMAS[section_name] + validated_section = schema(**section_data) + + # Initialize sections if needed + current_sections = page.sections or {} + current_sections[section_name] = validated_section.model_dump() + + page.sections = current_sections + page.updated_by = updated_by + + db.flush() + db.refresh(page) + + logger.info(f"[CMS] Updated section '{section_name}' for page_id={page_id}") + return page + + @staticmethod + def get_default_sections(languages: list[str] | None = None) -> dict: + """ + Get empty sections structure for new homepage. + + Args: + languages: List of language codes from platform.supported_languages. + Defaults to ['fr', 'de', 'en'] if not provided. + + Returns: + Empty sections dict with language placeholders + """ + from models.schema.homepage_sections import HomepageSections + + if languages is None: + languages = ["fr", "de", "en"] + + return HomepageSections.get_empty_structure(languages).model_dump() + # Singleton instance content_page_service = ContentPageService() diff --git a/app/templates/admin/content-page-edit.html b/app/templates/admin/content-page-edit.html index 72f9d604..8502bf6b 100644 --- a/app/templates/admin/content-page-edit.html +++ b/app/templates/admin/content-page-edit.html @@ -170,6 +170,317 @@ + + + +
+
+

+ Homepage Sections + (Multi-language content) +

+ + + Loading sections... + +
+ + +
+
+ +
+
+ + +
+ + + + +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+
+
+ + + + +
+ +
+ +
+ + +
+ +
+ + + +
+
+
+ + + + +
+ +
+ +
+ + +
+ +
+ + Use subscription tiers from database +
+

When enabled, pricing cards are dynamically pulled from your subscription tier configuration.

+
+
+ + + + +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+
+
+ +
+
+

diff --git a/app/templates/platform/homepage-default.html b/app/templates/platform/homepage-default.html index efd93210..fbab764a 100644 --- a/app/templates/platform/homepage-default.html +++ b/app/templates/platform/homepage-default.html @@ -1,9 +1,15 @@ {# app/templates/platform/homepage-default.html #} -{# Default platform homepage template #} +{# Default platform homepage template with section-based rendering #} {% extends "platform/base.html" %} +{# Import section partials #} +{% from 'platform/sections/_hero.html' import render_hero %} +{% from 'platform/sections/_features.html' import render_features %} +{% from 'platform/sections/_pricing.html' import render_pricing %} +{% from 'platform/sections/_cta.html' import render_cta %} + {% block title %} - {% if page %}{{ page.title }}{% else %}Home{% endif %} - Multi-Vendor Marketplace + {% if page %}{{ page.title }}{% else %}Home{% endif %} - {{ platform.name if platform else 'Multi-Vendor Marketplace' }} {% endblock %} {% block meta_description %} @@ -15,214 +21,114 @@ {% endblock %} {% block content %} - - - +{# Set up language context #} +{% set lang = request.state.language or (platform.default_language if platform else 'fr') %} +{% set default_lang = platform.default_language if platform else 'fr' %} + +{# ═══════════════════════════════════════════════════════════════════════════ #} +{# SECTION-BASED RENDERING (when page.sections is configured) #} +{# ═══════════════════════════════════════════════════════════════════════════ #} +{% if page and page.sections %} + + {# Hero Section #} + {% if page.sections.hero %} + {{ render_hero(page.sections.hero, lang, default_lang) }} + {% endif %} + + {# Features Section #} + {% if page.sections.features %} + {{ render_features(page.sections.features, lang, default_lang) }} + {% endif %} + + {# Pricing Section #} + {% if page.sections.pricing %} + {{ render_pricing(page.sections.pricing, lang, default_lang, tiers) }} + {% endif %} + + {# CTA Section #} + {% if page.sections.cta %} + {{ render_cta(page.sections.cta, lang, default_lang) }} + {% endif %} + +{% else %} +{# ═══════════════════════════════════════════════════════════════════════════ #} +{# PLACEHOLDER CONTENT (when sections not configured) #} +{# ═══════════════════════════════════════════════════════════════════════════ #} + +
- {% if page %} - {# CMS-driven content #} -

- {{ page.title }} -

-
- {{ page.content | safe }}{# sanitized: CMS content #} -
- {% else %} - {# Default fallback content #} -

- Welcome to Our Marketplace -

-

- Connect vendors with customers worldwide. Build your online store and reach millions of shoppers. -

- {% endif %} - +

+ {{ _('homepage.placeholder.title') or 'Configure Your Homepage' }} +

+

+ {{ _('homepage.placeholder.subtitle') or 'Use the admin panel to configure homepage sections with multi-language content.' }} +

- - - +

- Why Choose Our Platform? + {{ _('homepage.placeholder.features_title') or 'Features Section' }}

- Everything you need to launch and grow your online business + {{ _('homepage.placeholder.features_subtitle') or 'Configure feature cards in the admin panel' }}

- -
-
- - + {% for i in range(3) %} +
+
+ +
-

- Lightning Fast +

+ Feature {{ i + 1 }}

-

- Optimized for speed and performance. Your store loads in milliseconds. -

-
- - -
-
- - - -
-

- Secure & Reliable -

-

- Enterprise-grade security with 99.9% uptime guarantee. -

-
- - -
-
- - - -
-

- Fully Customizable -

-

- Brand your store with custom themes, colors, and layouts. +

+ Configure this feature card

+ {% endfor %}
- - - -
-
-
-

- Featured Vendors -

-

- Discover amazing shops from around the world -

-
- -
- -
-
-
-

- Sample Vendor 1 -

-

- Premium products and exceptional service -

- - Visit Store - - - - -
-
- - -
-
-
-

- Sample Vendor 2 -

-

- Quality craftsmanship meets modern design -

- - Visit Store - - - - -
-
- - -
-
-
-

- Sample Vendor 3 -

-

- Eco-friendly products for sustainable living -

- - Visit Store - - - - -
-
-
- - -
-
- - - - -
+ +
-

- Ready to Get Started? +

+ {{ _('homepage.placeholder.cta_title') or 'Call to Action' }}

-

- Join thousands of vendors already selling on our platform +

+ {{ _('homepage.placeholder.cta_subtitle') or 'Configure CTA section in the admin panel' }}

- - Contact Sales - - - Learn More - + + Button 1 + + + Button 2 +
+ +{% endif %} {% endblock %} diff --git a/app/templates/platform/sections/_cta.html b/app/templates/platform/sections/_cta.html new file mode 100644 index 00000000..afa69978 --- /dev/null +++ b/app/templates/platform/sections/_cta.html @@ -0,0 +1,54 @@ +{# app/templates/platform/sections/_cta.html #} +{# Call-to-action section partial with multi-language support #} +{# + Parameters: + - cta: CTASection object (or dict) + - lang: Current language code + - default_lang: Fallback language +#} + +{% macro render_cta(cta, lang, default_lang) %} +{% if cta and cta.enabled %} +
+
+ {# Title #} + {% set title = cta.title.translations.get(lang) or cta.title.translations.get(default_lang) or '' %} + {% if title %} +

+ {{ title }} +

+ {% endif %} + + {# Subtitle #} + {% if cta.subtitle and cta.subtitle.translations %} + {% set subtitle = cta.subtitle.translations.get(lang) or cta.subtitle.translations.get(default_lang) %} + {% if subtitle %} +

+ {{ subtitle }} +

+ {% endif %} + {% endif %} + + {# Buttons #} + {% if cta.buttons %} +
+ {% for button in cta.buttons %} + {% set btn_text = button.text.translations.get(lang) or button.text.translations.get(default_lang) or '' %} + {% if btn_text and button.url %} + + {{ btn_text }} + {% if button.style == 'primary' %} + + + + {% endif %} + + {% endif %} + {% endfor %} +
+ {% endif %} +
+
+{% endif %} +{% endmacro %} diff --git a/app/templates/platform/sections/_features.html b/app/templates/platform/sections/_features.html new file mode 100644 index 00000000..9df04dc7 --- /dev/null +++ b/app/templates/platform/sections/_features.html @@ -0,0 +1,72 @@ +{# app/templates/platform/sections/_features.html #} +{# Features section partial with multi-language support #} +{# + Parameters: + - features: FeaturesSection object (or dict) + - lang: Current language code + - default_lang: Fallback language +#} + +{% macro render_features(features, lang, default_lang) %} +{% if features and features.enabled %} +
+
+ {# Section header #} +
+ {% set title = features.title.translations.get(lang) or features.title.translations.get(default_lang) or '' %} + {% if title %} +

+ {{ title }} +

+ {% endif %} + + {% if features.subtitle and features.subtitle.translations %} + {% set subtitle = features.subtitle.translations.get(lang) or features.subtitle.translations.get(default_lang) %} + {% if subtitle %} +

+ {{ subtitle }} +

+ {% endif %} + {% endif %} +
+ + {# Feature cards #} + {% if features.features %} +
+ {% for feature in features.features %} +
+ {# Icon #} + {% if feature.icon %} +
+ {# Support for icon names - rendered via Alpine $icon helper or direct SVG #} + {% if feature.icon.startswith(' + {% endif %} +
+ {% endif %} + + {# Title #} + {% set feature_title = feature.title.translations.get(lang) or feature.title.translations.get(default_lang) or '' %} + {% if feature_title %} +

+ {{ feature_title }} +

+ {% endif %} + + {# Description #} + {% set feature_desc = feature.description.translations.get(lang) or feature.description.translations.get(default_lang) or '' %} + {% if feature_desc %} +

+ {{ feature_desc }} +

+ {% endif %} +
+ {% endfor %} +
+ {% endif %} +
+
+{% endif %} +{% endmacro %} diff --git a/app/templates/platform/sections/_hero.html b/app/templates/platform/sections/_hero.html new file mode 100644 index 00000000..589f1adc --- /dev/null +++ b/app/templates/platform/sections/_hero.html @@ -0,0 +1,71 @@ +{# app/templates/platform/sections/_hero.html #} +{# Hero section partial with multi-language support #} +{# + Parameters: + - hero: HeroSection object (or dict) + - lang: Current language code (from request.state.language) + - default_lang: Fallback language (from platform.default_language) +#} + +{% macro render_hero(hero, lang, default_lang) %} +{% if hero and hero.enabled %} +
+
+
+ {# Badge #} + {% if hero.badge_text and hero.badge_text.translations %} + {% set badge = hero.badge_text.translations.get(lang) or hero.badge_text.translations.get(default_lang) %} + {% if badge %} +
+ {{ badge }} +
+ {% endif %} + {% endif %} + + {# Title #} + {% set title = hero.title.translations.get(lang) or hero.title.translations.get(default_lang) or '' %} + {% if title %} +

+ {{ title }} +

+ {% endif %} + + {# Subtitle #} + {% set subtitle = hero.subtitle.translations.get(lang) or hero.subtitle.translations.get(default_lang) or '' %} + {% if subtitle %} +

+ {{ subtitle }} +

+ {% endif %} + + {# Buttons #} + {% if hero.buttons %} +
+ {% for button in hero.buttons %} + {% set btn_text = button.text.translations.get(lang) or button.text.translations.get(default_lang) or '' %} + {% if btn_text and button.url %} + + {{ btn_text }} + {% if button.style == 'primary' %} + + + + {% endif %} + + {% endif %} + {% endfor %} +
+ {% endif %} +
+
+ + {# Background decorations #} +
+ + + +
+
+{% endif %} +{% endmacro %} diff --git a/app/templates/platform/sections/_pricing.html b/app/templates/platform/sections/_pricing.html new file mode 100644 index 00000000..75e0c4ca --- /dev/null +++ b/app/templates/platform/sections/_pricing.html @@ -0,0 +1,116 @@ +{# app/templates/platform/sections/_pricing.html #} +{# Pricing section partial with multi-language support #} +{# + Parameters: + - pricing: PricingSection object (or dict) + - lang: Current language code + - default_lang: Fallback language + - tiers: List of subscription tiers from DB (passed via context) +#} + +{% macro render_pricing(pricing, lang, default_lang, tiers) %} +{% if pricing and pricing.enabled %} +
+
+ {# Section header #} +
+ {% set title = pricing.title.translations.get(lang) or pricing.title.translations.get(default_lang) or '' %} + {% if title %} +

+ {{ title }} +

+ {% endif %} + + {% if pricing.subtitle and pricing.subtitle.translations %} + {% set subtitle = pricing.subtitle.translations.get(lang) or pricing.subtitle.translations.get(default_lang) %} + {% if subtitle %} +

+ {{ subtitle }} +

+ {% endif %} + {% endif %} +
+ + {# Pricing toggle (monthly/annual) #} + {% if pricing.use_subscription_tiers and tiers %} +
+ {# Billing toggle #} +
+ + {{ _('pricing.monthly') or 'Monthly' }} + + + + {{ _('pricing.annual') or 'Annual' }} + {{ _('pricing.save_20') or 'Save 20%' }} + +
+ + {# Pricing cards #} +
+ {% for tier in tiers %} + + {% endfor %} +
+
+ {% else %} + {# Placeholder when no tiers available #} +
+ {{ _('pricing.coming_soon') or 'Pricing plans coming soon' }} +
+ {% endif %} +
+
+{% endif %} +{% endmacro %} diff --git a/docs/proposals/section-based-homepage-plan.md b/docs/proposals/section-based-homepage-plan.md index 95d88809..5d7de616 100644 --- a/docs/proposals/section-based-homepage-plan.md +++ b/docs/proposals/section-based-homepage-plan.md @@ -1,8 +1,9 @@ # Section-Based Homepage Management System -**Status:** Planning +**Status:** COMPLETE **Created:** 2026-01-20 -**Resume:** Ready to implement +**Updated:** 2026-01-23 +**Completed:** All 7 phases implemented ## Problem Statement diff --git a/models/database/content_page.py b/models/database/content_page.py index 9f61f46e..93aa9bca 100644 --- a/models/database/content_page.py +++ b/models/database/content_page.py @@ -29,6 +29,7 @@ Features: from datetime import UTC, datetime from sqlalchemy import ( + JSON, Boolean, Column, DateTime, @@ -107,6 +108,16 @@ class ContentPage(Base): # Only used for landing pages (slug='landing' or 'home') template = Column(String(50), default="default", nullable=False) + # Homepage sections (structured JSON for section-based editing) + # Only used for homepage (slug='home'). Contains hero, features, pricing, cta sections + # with multi-language support via TranslatableText pattern + sections = Column( + JSON, + nullable=True, + default=None, + comment="Structured homepage sections (hero, features, pricing, cta) with i18n", + ) + # SEO meta_description = Column(String(300), nullable=True) meta_keywords = Column(String(300), nullable=True) @@ -199,6 +210,7 @@ class ContentPage(Base): "content": self.content, "content_format": self.content_format, "template": self.template, + "sections": self.sections, "meta_description": self.meta_description, "meta_keywords": self.meta_keywords, "is_published": self.is_published, diff --git a/models/schema/homepage_sections.py b/models/schema/homepage_sections.py new file mode 100644 index 00000000..283c3be7 --- /dev/null +++ b/models/schema/homepage_sections.py @@ -0,0 +1,174 @@ +""" +Homepage Section Schemas with Dynamic Multi-Language Support. + +Language codes are NOT hardcoded - they come from platform.supported_languages. +The TranslatableText class stores translations as a dict where keys are language codes. + +Example JSON structure: +{ + "hero": { + "enabled": true, + "title": {"translations": {"fr": "Bienvenue", "en": "Welcome"}}, + "subtitle": {"translations": {...}}, + "buttons": [...] + }, + "features": {...}, + "pricing": {...}, + "cta": {...} +} +""" + +from pydantic import BaseModel, Field +from typing import Optional + + +class TranslatableText(BaseModel): + """ + Text field with translations stored as language-keyed dict. + + Languages come from platform.supported_languages (not hardcoded). + Use .get(lang, default_lang) to retrieve translation with fallback. + """ + + translations: dict[str, str] = Field( + default_factory=dict, description="Language code -> translated text mapping" + ) + + def get(self, lang: str, default_lang: str = "fr") -> str: + """Get translation with fallback to default language.""" + return self.translations.get(lang) or self.translations.get(default_lang) or "" + + def set(self, lang: str, text: str) -> None: + """Set translation for a language.""" + self.translations[lang] = text + + def has_translation(self, lang: str) -> bool: + """Check if translation exists for language.""" + return bool(self.translations.get(lang)) + + +class HeroButton(BaseModel): + """Button in hero or CTA section.""" + + text: TranslatableText = Field(default_factory=TranslatableText) + url: str = "" + style: str = Field(default="primary", description="primary, secondary, outline") + + +class HeroSection(BaseModel): + """Hero section configuration.""" + + enabled: bool = True + badge_text: Optional[TranslatableText] = None + title: TranslatableText = Field(default_factory=TranslatableText) + subtitle: TranslatableText = Field(default_factory=TranslatableText) + background_type: str = Field( + default="gradient", description="gradient, image, solid" + ) + background_image: Optional[str] = None + buttons: list[HeroButton] = Field(default_factory=list) + + +class FeatureCard(BaseModel): + """Single feature in features section.""" + + icon: str = "" + title: TranslatableText = Field(default_factory=TranslatableText) + description: TranslatableText = Field(default_factory=TranslatableText) + + +class FeaturesSection(BaseModel): + """Features section configuration.""" + + enabled: bool = True + title: TranslatableText = Field(default_factory=TranslatableText) + subtitle: Optional[TranslatableText] = None + features: list[FeatureCard] = Field(default_factory=list) + layout: str = Field(default="grid", description="grid, list, cards") + + +class PricingSection(BaseModel): + """Pricing section configuration.""" + + enabled: bool = True + title: TranslatableText = Field(default_factory=TranslatableText) + subtitle: Optional[TranslatableText] = None + use_subscription_tiers: bool = Field( + default=True, description="Pull pricing from subscription_tiers table dynamically" + ) + + +class CTASection(BaseModel): + """Call-to-action section configuration.""" + + enabled: bool = True + title: TranslatableText = Field(default_factory=TranslatableText) + subtitle: Optional[TranslatableText] = None + buttons: list[HeroButton] = Field(default_factory=list) + background_type: str = Field( + default="gradient", description="gradient, image, solid" + ) + + +class HomepageSections(BaseModel): + """Complete homepage sections structure.""" + + hero: Optional[HeroSection] = None + features: Optional[FeaturesSection] = None + pricing: Optional[PricingSection] = None + cta: Optional[CTASection] = None + + @classmethod + def get_empty_structure(cls, languages: list[str]) -> "HomepageSections": + """ + Create empty section structure with language placeholders. + + Args: + languages: List of language codes from platform.supported_languages + + Returns: + HomepageSections with empty translations for all languages + """ + + def make_translatable(langs: list[str]) -> TranslatableText: + return TranslatableText(translations={lang: "" for lang in langs}) + + return cls( + hero=HeroSection( + title=make_translatable(languages), + subtitle=make_translatable(languages), + buttons=[], + ), + features=FeaturesSection( + title=make_translatable(languages), + features=[], + ), + pricing=PricingSection( + title=make_translatable(languages), + use_subscription_tiers=True, + ), + cta=CTASection( + title=make_translatable(languages), + buttons=[], + ), + ) + + +# ============================================================================= +# API Request/Response Schemas +# ============================================================================= + + +class SectionUpdateRequest(BaseModel): + """Request to update a single section.""" + + section_name: str = Field(..., description="hero, features, pricing, or cta") + section_data: dict = Field(..., description="Section configuration") + + +class HomepageSectionsResponse(BaseModel): + """Response containing all homepage sections with platform language info.""" + + sections: Optional[HomepageSections] = None + supported_languages: list[str] = Field(default_factory=lambda: ["fr", "de", "en"]) + default_language: str = "fr" diff --git a/static/admin/js/content-page-edit.js b/static/admin/js/content-page-edit.js index 34ae3898..7056eea4 100644 --- a/static/admin/js/content-page-edit.js +++ b/static/admin/js/content-page-edit.js @@ -39,6 +39,51 @@ function contentPageEditor(pageId) { error: null, successMessage: null, + // ======================================== + // HOMEPAGE SECTIONS STATE + // ======================================== + supportedLanguages: ['fr', 'de', 'en'], + defaultLanguage: 'fr', + currentLang: 'fr', + openSection: null, + sectionsLoaded: false, + languageNames: { + en: 'English', + fr: 'Français', + de: 'Deutsch', + lb: 'Lëtzebuergesch' + }, + sections: { + hero: { + enabled: true, + badge_text: { translations: {} }, + title: { translations: {} }, + subtitle: { translations: {} }, + background_type: 'gradient', + buttons: [] + }, + features: { + enabled: true, + title: { translations: {} }, + subtitle: { translations: {} }, + features: [], + layout: 'grid' + }, + pricing: { + enabled: true, + title: { translations: {} }, + subtitle: { translations: {} }, + use_subscription_tiers: true + }, + cta: { + enabled: true, + title: { translations: {} }, + subtitle: { translations: {} }, + buttons: [], + background_type: 'gradient' + } + }, + // Initialize async init() { contentPageEditLog.info('=== CONTENT PAGE EDITOR INITIALIZING ==='); @@ -59,6 +104,11 @@ function contentPageEditor(pageId) { contentPageEditLog.group('Loading page for editing'); await this.loadPage(); contentPageEditLog.groupEnd(); + + // Load sections if this is a homepage + if (this.form.slug === 'home') { + await this.loadSections(); + } } else { // Create mode - use default values contentPageEditLog.info('Create mode - using default form values'); @@ -67,6 +117,11 @@ function contentPageEditor(pageId) { contentPageEditLog.info('=== CONTENT PAGE EDITOR INITIALIZATION COMPLETE ==='); }, + // Check if we should show section editor + get isHomepage() { + return this.form.slug === 'home'; + }, + // Load vendors for dropdown async loadVendors() { this.loadingVendors = true; @@ -128,6 +183,154 @@ function contentPageEditor(pageId) { } }, + // ======================================== + // HOMEPAGE SECTIONS METHODS + // ======================================== + + // Load sections for homepage + async loadSections() { + if (!this.pageId || this.form.slug !== 'home') { + contentPageEditLog.debug('Skipping section load - not a homepage'); + return; + } + + try { + contentPageEditLog.info('Loading homepage sections...'); + const response = await apiClient.get(`/admin/content-pages/${this.pageId}/sections`); + const data = response.data || response; + + this.supportedLanguages = data.supported_languages || ['fr', 'de', 'en']; + this.defaultLanguage = data.default_language || 'fr'; + this.currentLang = this.defaultLanguage; + + if (data.sections) { + this.sections = this.mergeWithDefaults(data.sections); + contentPageEditLog.info('Sections loaded:', Object.keys(data.sections)); + } else { + this.initializeEmptySections(); + contentPageEditLog.info('No sections found - initialized empty structure'); + } + + this.sectionsLoaded = true; + } catch (err) { + contentPageEditLog.error('Error loading sections:', err); + } + }, + + // Merge loaded sections with default structure + mergeWithDefaults(loadedSections) { + const defaults = this.getDefaultSectionStructure(); + + // Deep merge each section + for (const key of ['hero', 'features', 'pricing', 'cta']) { + if (loadedSections[key]) { + defaults[key] = { ...defaults[key], ...loadedSections[key] }; + } + } + + return defaults; + }, + + // Get default section structure + getDefaultSectionStructure() { + const emptyTranslations = () => { + const t = {}; + this.supportedLanguages.forEach(lang => t[lang] = ''); + return { translations: t }; + }; + + return { + hero: { + enabled: true, + badge_text: emptyTranslations(), + title: emptyTranslations(), + subtitle: emptyTranslations(), + background_type: 'gradient', + buttons: [] + }, + features: { + enabled: true, + title: emptyTranslations(), + subtitle: emptyTranslations(), + features: [], + layout: 'grid' + }, + pricing: { + enabled: true, + title: emptyTranslations(), + subtitle: emptyTranslations(), + use_subscription_tiers: true + }, + cta: { + enabled: true, + title: emptyTranslations(), + subtitle: emptyTranslations(), + buttons: [], + background_type: 'gradient' + } + }; + }, + + // Initialize empty sections for all languages + initializeEmptySections() { + this.sections = this.getDefaultSectionStructure(); + }, + + // Add a button to hero or cta section + addButton(sectionName) { + const newButton = { + text: { translations: {} }, + url: '', + style: 'primary' + }; + this.supportedLanguages.forEach(lang => { + newButton.text.translations[lang] = ''; + }); + this.sections[sectionName].buttons.push(newButton); + contentPageEditLog.debug(`Added button to ${sectionName}`); + }, + + // Remove a button from hero or cta section + removeButton(sectionName, index) { + this.sections[sectionName].buttons.splice(index, 1); + contentPageEditLog.debug(`Removed button ${index} from ${sectionName}`); + }, + + // Add a feature card + addFeature() { + const newFeature = { + icon: '', + title: { translations: {} }, + description: { translations: {} } + }; + this.supportedLanguages.forEach(lang => { + newFeature.title.translations[lang] = ''; + newFeature.description.translations[lang] = ''; + }); + this.sections.features.features.push(newFeature); + contentPageEditLog.debug('Added feature card'); + }, + + // Remove a feature card + removeFeature(index) { + this.sections.features.features.splice(index, 1); + contentPageEditLog.debug(`Removed feature ${index}`); + }, + + // Save sections + async saveSections() { + if (!this.pageId || !this.isHomepage) return; + + try { + contentPageEditLog.info('Saving sections...'); + await apiClient.put(`/admin/content-pages/${this.pageId}/sections`, this.sections); + contentPageEditLog.info('Sections saved successfully'); + } catch (err) { + contentPageEditLog.error('Error saving sections:', err); + throw err; + } + }, + // Save page (create or update) async savePage() { if (this.saving) return; @@ -161,6 +364,12 @@ function contentPageEditor(pageId) { if (this.pageId) { // Update existing page response = await apiClient.put(`/admin/content-pages/${this.pageId}`, payload); + + // Also save sections if this is a homepage + if (this.isHomepage && this.sectionsLoaded) { + await this.saveSections(); + } + this.successMessage = 'Page updated successfully!'; contentPageEditLog.info('Page updated'); } else {