feat: implement section-based homepage management system
Add structured JSON sections to ContentPage for multi-language homepage editing:
Database:
- Add `sections` JSON column to content_pages table
- Migration z8i9j0k1l2m3 adds the column
Schema:
- New models/schema/homepage_sections.py with Pydantic schemas
- TranslatableText for language-keyed translations
- HeroSection, FeaturesSection, PricingSection, CTASection
Templates:
- New section partials in app/templates/platform/sections/
- Updated homepage-default.html to render sections dynamically
- Fallback to placeholder content when sections not configured
Service:
- update_homepage_sections() - validate and save all sections
- update_single_section() - update individual section
- get_default_sections() - empty structure for new homepages
API:
- GET /{page_id}/sections - get sections with platform languages
- PUT /{page_id}/sections - update all sections
- PUT /{page_id}/sections/{section_name} - update single section
Admin UI:
- Section editor appears when editing homepage (slug='home')
- Language tabs from platform.supported_languages
- Accordion sections for Hero, Features, Pricing, CTA
- Button/feature card repeaters with add/remove
Also fixes broken line 181 in z4e5f6a7b8c9 migration.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
174
models/schema/homepage_sections.py
Normal file
174
models/schema/homepage_sections.py
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user