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:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -170,6 +170,317 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════ -->
|
||||
<!-- HOMEPAGE SECTIONS EDITOR (only for slug='home') -->
|
||||
<!-- ══════════════════════════════════════════════════════════════════ -->
|
||||
<div x-show="isHomepage" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Homepage Sections
|
||||
<span class="text-sm font-normal text-gray-500 ml-2">(Multi-language content)</span>
|
||||
</h3>
|
||||
<span x-show="!sectionsLoaded" class="text-sm text-gray-500">
|
||||
<span x-html="$icon('spinner', 'w-4 h-4 inline mr-1')"></span>
|
||||
Loading sections...
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Language Tabs -->
|
||||
<div class="mb-6" x-show="sectionsLoaded">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav class="flex -mb-px space-x-4">
|
||||
<template x-for="lang in supportedLanguages" :key="lang">
|
||||
<button
|
||||
type="button"
|
||||
@click="currentLang = lang"
|
||||
:class="currentLang === lang ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
|
||||
class="py-2 px-4 border-b-2 font-medium text-sm transition-colors"
|
||||
>
|
||||
<span x-text="languageNames[lang] || lang.toUpperCase()"></span>
|
||||
<span x-show="lang === defaultLanguage" class="ml-1 text-xs text-gray-400">(default)</span>
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Accordions -->
|
||||
<div class="space-y-4" x-show="sectionsLoaded">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<!-- HERO SECTION -->
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
@click="openSection = openSection === 'hero' ? null : 'hero'"
|
||||
class="w-full flex items-center justify-between p-4 text-left bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span class="font-medium text-gray-900 dark:text-white">Hero Section</span>
|
||||
<div class="flex items-center space-x-3">
|
||||
<label class="flex items-center" @click.stop>
|
||||
<input type="checkbox" x-model="sections.hero.enabled" class="w-4 h-4 text-purple-600 rounded">
|
||||
<span class="ml-2 text-sm text-gray-500">Enabled</span>
|
||||
</label>
|
||||
<svg :class="openSection === 'hero' ? 'rotate-180' : ''" class="w-5 h-5 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div x-show="openSection === 'hero'" x-collapse class="p-4 space-y-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<!-- Badge Text -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Badge Text</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="sections.hero.badge_text.translations[currentLang]"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
:placeholder="'Badge text in ' + languageNames[currentLang]"
|
||||
>
|
||||
</div>
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Title <span class="text-red-500">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="sections.hero.title.translations[currentLang]"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
:placeholder="'Hero title in ' + languageNames[currentLang]"
|
||||
>
|
||||
</div>
|
||||
<!-- Subtitle -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Subtitle</label>
|
||||
<textarea
|
||||
x-model="sections.hero.subtitle.translations[currentLang]"
|
||||
rows="2"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
:placeholder="'Hero subtitle in ' + languageNames[currentLang]"
|
||||
></textarea>
|
||||
</div>
|
||||
<!-- Buttons -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Buttons</label>
|
||||
<template x-for="(button, idx) in sections.hero.buttons" :key="idx">
|
||||
<div class="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
x-model="button.text.translations[currentLang]"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-sm"
|
||||
:placeholder="'Button text'"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
x-model="button.url"
|
||||
class="w-32 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-sm"
|
||||
placeholder="/signup"
|
||||
>
|
||||
<select x-model="button.style" class="w-28 px-2 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-sm">
|
||||
<option value="primary">Primary</option>
|
||||
<option value="secondary">Secondary</option>
|
||||
<option value="outline">Outline</option>
|
||||
</select>
|
||||
<button type="button" @click="removeButton('hero', idx)" class="px-3 py-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg">
|
||||
<span x-html="$icon('trash', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<button type="button" @click="addButton('hero')" class="text-sm text-purple-600 hover:text-purple-700 font-medium">
|
||||
+ Add Button
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<!-- FEATURES SECTION -->
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
@click="openSection = openSection === 'features' ? null : 'features'"
|
||||
class="w-full flex items-center justify-between p-4 text-left bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span class="font-medium text-gray-900 dark:text-white">Features Section</span>
|
||||
<div class="flex items-center space-x-3">
|
||||
<label class="flex items-center" @click.stop>
|
||||
<input type="checkbox" x-model="sections.features.enabled" class="w-4 h-4 text-purple-600 rounded">
|
||||
<span class="ml-2 text-sm text-gray-500">Enabled</span>
|
||||
</label>
|
||||
<svg :class="openSection === 'features' ? 'rotate-180' : ''" class="w-5 h-5 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div x-show="openSection === 'features'" x-collapse class="p-4 space-y-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<!-- Section Title -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Section Title</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="sections.features.title.translations[currentLang]"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
:placeholder="'Features title in ' + languageNames[currentLang]"
|
||||
>
|
||||
</div>
|
||||
<!-- Feature Cards -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Feature Cards</label>
|
||||
<template x-for="(feature, idx) in sections.features.features" :key="idx">
|
||||
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">Feature <span x-text="idx + 1"></span></span>
|
||||
<button type="button" @click="removeFeature(idx)" class="text-red-500 hover:text-red-700">
|
||||
<span x-html="$icon('trash', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
x-model="feature.icon"
|
||||
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-sm"
|
||||
placeholder="Icon name (e.g., bolt)"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
x-model="feature.title.translations[currentLang]"
|
||||
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-sm"
|
||||
:placeholder="'Title'"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
x-model="feature.description.translations[currentLang]"
|
||||
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-sm"
|
||||
:placeholder="'Description'"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<button type="button" @click="addFeature()" class="text-sm text-purple-600 hover:text-purple-700 font-medium">
|
||||
+ Add Feature Card
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<!-- PRICING SECTION -->
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
@click="openSection = openSection === 'pricing' ? null : 'pricing'"
|
||||
class="w-full flex items-center justify-between p-4 text-left bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span class="font-medium text-gray-900 dark:text-white">Pricing Section</span>
|
||||
<div class="flex items-center space-x-3">
|
||||
<label class="flex items-center" @click.stop>
|
||||
<input type="checkbox" x-model="sections.pricing.enabled" class="w-4 h-4 text-purple-600 rounded">
|
||||
<span class="ml-2 text-sm text-gray-500">Enabled</span>
|
||||
</label>
|
||||
<svg :class="openSection === 'pricing' ? 'rotate-180' : ''" class="w-5 h-5 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div x-show="openSection === 'pricing'" x-collapse class="p-4 space-y-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<!-- Section Title -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Section Title</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="sections.pricing.title.translations[currentLang]"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
:placeholder="'Pricing title in ' + languageNames[currentLang]"
|
||||
>
|
||||
</div>
|
||||
<!-- Use Subscription Tiers -->
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" x-model="sections.pricing.use_subscription_tiers" class="w-4 h-4 text-purple-600 rounded">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Use subscription tiers from database</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">When enabled, pricing cards are dynamically pulled from your subscription tier configuration.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<!-- CTA SECTION -->
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
@click="openSection = openSection === 'cta' ? null : 'cta'"
|
||||
class="w-full flex items-center justify-between p-4 text-left bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span class="font-medium text-gray-900 dark:text-white">Call to Action Section</span>
|
||||
<div class="flex items-center space-x-3">
|
||||
<label class="flex items-center" @click.stop>
|
||||
<input type="checkbox" x-model="sections.cta.enabled" class="w-4 h-4 text-purple-600 rounded">
|
||||
<span class="ml-2 text-sm text-gray-500">Enabled</span>
|
||||
</label>
|
||||
<svg :class="openSection === 'cta' ? 'rotate-180' : ''" class="w-5 h-5 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div x-show="openSection === 'cta'" x-collapse class="p-4 space-y-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="sections.cta.title.translations[currentLang]"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
:placeholder="'CTA title in ' + languageNames[currentLang]"
|
||||
>
|
||||
</div>
|
||||
<!-- Subtitle -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Subtitle</label>
|
||||
<textarea
|
||||
x-model="sections.cta.subtitle.translations[currentLang]"
|
||||
rows="2"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
:placeholder="'CTA subtitle in ' + languageNames[currentLang]"
|
||||
></textarea>
|
||||
</div>
|
||||
<!-- Buttons -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Buttons</label>
|
||||
<template x-for="(button, idx) in sections.cta.buttons" :key="idx">
|
||||
<div class="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
x-model="button.text.translations[currentLang]"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-sm"
|
||||
:placeholder="'Button text'"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
x-model="button.url"
|
||||
class="w-32 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-sm"
|
||||
placeholder="/signup"
|
||||
>
|
||||
<select x-model="button.style" class="w-28 px-2 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 text-sm">
|
||||
<option value="primary">Primary</option>
|
||||
<option value="secondary">Secondary</option>
|
||||
<option value="outline">Outline</option>
|
||||
</select>
|
||||
<button type="button" @click="removeButton('cta', idx)" class="px-3 py-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg">
|
||||
<span x-html="$icon('trash', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<button type="button" @click="addButton('cta')" class="text-sm text-purple-600 hover:text-purple-700 font-medium">
|
||||
+ Add Button
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SEO Settings -->
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
|
||||
@@ -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 %}
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- HERO SECTION -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
{# 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) #}
|
||||
{# ═══════════════════════════════════════════════════════════════════════════ #}
|
||||
|
||||
<!-- HERO SECTION -->
|
||||
<section class="gradient-primary text-white py-20">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center">
|
||||
{% if page %}
|
||||
{# CMS-driven content #}
|
||||
<h1 class="text-4xl md:text-6xl font-bold mb-6">
|
||||
{{ page.title }}
|
||||
</h1>
|
||||
<div class="text-xl md:text-2xl mb-8 opacity-90 max-w-3xl mx-auto">
|
||||
{{ page.content | safe }}{# sanitized: CMS content #}
|
||||
</div>
|
||||
{% else %}
|
||||
{# Default fallback content #}
|
||||
<h1 class="text-4xl md:text-6xl font-bold mb-6">
|
||||
Welcome to Our Marketplace
|
||||
</h1>
|
||||
<p class="text-xl md:text-2xl mb-8 opacity-90 max-w-3xl mx-auto">
|
||||
Connect vendors with customers worldwide. Build your online store and reach millions of shoppers.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<h1 class="text-4xl md:text-6xl font-bold mb-6">
|
||||
{{ _('homepage.placeholder.title') or 'Configure Your Homepage' }}
|
||||
</h1>
|
||||
<p class="text-xl md:text-2xl mb-8 opacity-90 max-w-3xl mx-auto">
|
||||
{{ _('homepage.placeholder.subtitle') or 'Use the admin panel to configure homepage sections with multi-language content.' }}
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<a href="/vendors"
|
||||
class="btn-primary inline-flex items-center space-x-2">
|
||||
<span>Browse Vendors</span>
|
||||
<a href="/admin/content-pages"
|
||||
class="bg-white text-gray-900 px-8 py-4 rounded-xl font-semibold hover:bg-gray-100 transition inline-flex items-center space-x-2">
|
||||
<span>{{ _('homepage.placeholder.configure_btn') or 'Configure Homepage' }}</span>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="/contact"
|
||||
class="bg-white text-gray-900 px-6 py-3 rounded-lg font-semibold hover:bg-gray-100 transition">
|
||||
Get Started
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- FEATURES SECTION -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- FEATURES SECTION (Placeholder) -->
|
||||
<section class="py-16 bg-white dark:bg-gray-800">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Why Choose Our Platform?
|
||||
{{ _('homepage.placeholder.features_title') or 'Features Section' }}
|
||||
</h2>
|
||||
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
Everything you need to launch and grow your online business
|
||||
{{ _('homepage.placeholder.features_subtitle') or 'Configure feature cards in the admin panel' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<!-- Feature 1 -->
|
||||
<div class="card-hover bg-gray-50 dark:bg-gray-700 rounded-xl p-8 text-center">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-full gradient-primary flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
{% for i in range(3) %}
|
||||
<div class="bg-gray-50 dark:bg-gray-700 rounded-xl p-8 text-center">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Lightning Fast
|
||||
<h3 class="text-xl font-semibold text-gray-400 mb-3">
|
||||
Feature {{ i + 1 }}
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Optimized for speed and performance. Your store loads in milliseconds.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 2 -->
|
||||
<div class="card-hover bg-gray-50 dark:bg-gray-700 rounded-xl p-8 text-center">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-full gradient-primary flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Secure & Reliable
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Enterprise-grade security with 99.9% uptime guarantee.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 3 -->
|
||||
<div class="card-hover bg-gray-50 dark:bg-gray-700 rounded-xl p-8 text-center">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-full gradient-primary flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Fully Customizable
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Brand your store with custom themes, colors, and layouts.
|
||||
<p class="text-gray-400">
|
||||
Configure this feature card
|
||||
</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- FEATURED VENDORS SECTION -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<section class="py-16 bg-gray-50 dark:bg-gray-900">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Featured Vendors
|
||||
</h2>
|
||||
<p class="text-lg text-gray-600 dark:text-gray-400">
|
||||
Discover amazing shops from around the world
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<!-- Vendor Card 1 - Placeholder -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm hover:shadow-lg transition-shadow overflow-hidden">
|
||||
<div class="h-48 bg-gradient-to-r from-purple-400 to-pink-400"></div>
|
||||
<div class="p-6">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Sample Vendor 1
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Premium products and exceptional service
|
||||
</p>
|
||||
<a href="/vendors/vendor1/shop"
|
||||
class="text-primary hover:underline font-medium inline-flex items-center">
|
||||
Visit Store
|
||||
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vendor Card 2 - Placeholder -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm hover:shadow-lg transition-shadow overflow-hidden">
|
||||
<div class="h-48 bg-gradient-to-r from-blue-400 to-cyan-400"></div>
|
||||
<div class="p-6">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Sample Vendor 2
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Quality craftsmanship meets modern design
|
||||
</p>
|
||||
<a href="/vendors/vendor2/shop"
|
||||
class="text-primary hover:underline font-medium inline-flex items-center">
|
||||
Visit Store
|
||||
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vendor Card 3 - Placeholder -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm hover:shadow-lg transition-shadow overflow-hidden">
|
||||
<div class="h-48 bg-gradient-to-r from-green-400 to-teal-400"></div>
|
||||
<div class="p-6">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Sample Vendor 3
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Eco-friendly products for sustainable living
|
||||
</p>
|
||||
<a href="/vendors/vendor3/shop"
|
||||
class="text-primary hover:underline font-medium inline-flex items-center">
|
||||
Visit Store
|
||||
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-12">
|
||||
<a href="/vendors" class="btn-primary inline-block">
|
||||
View All Vendors
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- CTA SECTION -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<section class="py-16 bg-white dark:bg-gray-800">
|
||||
<!-- CTA SECTION (Placeholder) -->
|
||||
<section class="py-16 bg-gray-100 dark:bg-gray-900">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Ready to Get Started?
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-400 mb-4">
|
||||
{{ _('homepage.placeholder.cta_title') or 'Call to Action' }}
|
||||
</h2>
|
||||
<p class="text-lg text-gray-600 dark:text-gray-400 mb-8">
|
||||
Join thousands of vendors already selling on our platform
|
||||
<p class="text-lg text-gray-400 mb-8">
|
||||
{{ _('homepage.placeholder.cta_subtitle') or 'Configure CTA section in the admin panel' }}
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a href="/contact" class="btn-primary">
|
||||
Contact Sales
|
||||
</a>
|
||||
<a href="/about"
|
||||
class="bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white px-6 py-3 rounded-lg font-semibold hover:bg-gray-200 dark:hover:bg-gray-600 transition">
|
||||
Learn More
|
||||
</a>
|
||||
<span class="bg-gray-300 text-gray-500 px-6 py-3 rounded-lg font-semibold">
|
||||
Button 1
|
||||
</span>
|
||||
<span class="bg-gray-200 text-gray-500 px-6 py-3 rounded-lg font-semibold">
|
||||
Button 2
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
54
app/templates/platform/sections/_cta.html
Normal file
54
app/templates/platform/sections/_cta.html
Normal file
@@ -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 %}
|
||||
<section class="py-16 lg:py-24 {% if cta.background_type == 'gradient' %}bg-gradient-to-r from-indigo-600 to-purple-600{% else %}bg-indigo-600{% endif %}">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
{# Title #}
|
||||
{% set title = cta.title.translations.get(lang) or cta.title.translations.get(default_lang) or '' %}
|
||||
{% if title %}
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-white mb-6">
|
||||
{{ title }}
|
||||
</h2>
|
||||
{% 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 %}
|
||||
<p class="text-xl text-indigo-100 mb-10 max-w-2xl mx-auto">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# Buttons #}
|
||||
{% if cta.buttons %}
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
{% 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 %}
|
||||
<a href="{{ button.url }}"
|
||||
class="{% if button.style == 'primary' %}bg-white text-indigo-600 hover:bg-gray-100{% elif button.style == 'secondary' %}bg-indigo-500 text-white hover:bg-indigo-400{% else %}border-2 border-white text-white hover:bg-white/10{% endif %} px-10 py-4 rounded-xl font-bold transition inline-flex items-center space-x-2">
|
||||
<span>{{ btn_text }}</span>
|
||||
{% if button.style == 'primary' %}
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
72
app/templates/platform/sections/_features.html
Normal file
72
app/templates/platform/sections/_features.html
Normal file
@@ -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 class="py-16 lg:py-24 bg-white dark:bg-gray-800">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{# Section header #}
|
||||
<div class="text-center mb-12">
|
||||
{% set title = features.title.translations.get(lang) or features.title.translations.get(default_lang) or '' %}
|
||||
{% if title %}
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{{ title }}
|
||||
</h2>
|
||||
{% endif %}
|
||||
|
||||
{% if features.subtitle and features.subtitle.translations %}
|
||||
{% set subtitle = features.subtitle.translations.get(lang) or features.subtitle.translations.get(default_lang) %}
|
||||
{% if subtitle %}
|
||||
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Feature cards #}
|
||||
{% if features.features %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-{{ [features.features|length, 4]|min }} gap-8">
|
||||
{% for feature in features.features %}
|
||||
<div class="card-hover bg-gray-50 dark:bg-gray-700 rounded-xl p-8 text-center">
|
||||
{# Icon #}
|
||||
{% if feature.icon %}
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-full gradient-primary flex items-center justify-center">
|
||||
{# Support for icon names - rendered via Alpine $icon helper or direct SVG #}
|
||||
{% if feature.icon.startswith('<svg') %}
|
||||
{{ feature.icon | safe }}
|
||||
{% else %}
|
||||
<span x-html="typeof $icon !== 'undefined' ? $icon('{{ feature.icon }}', 'w-8 h-8 text-white') : ''"></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Title #}
|
||||
{% set feature_title = feature.title.translations.get(lang) or feature.title.translations.get(default_lang) or '' %}
|
||||
{% if feature_title %}
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
{{ feature_title }}
|
||||
</h3>
|
||||
{% endif %}
|
||||
|
||||
{# Description #}
|
||||
{% set feature_desc = feature.description.translations.get(lang) or feature.description.translations.get(default_lang) or '' %}
|
||||
{% if feature_desc %}
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
{{ feature_desc }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
71
app/templates/platform/sections/_hero.html
Normal file
71
app/templates/platform/sections/_hero.html
Normal file
@@ -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 %}
|
||||
<section class="gradient-primary text-white py-20 relative overflow-hidden">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center">
|
||||
{# 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 %}
|
||||
<div class="inline-flex items-center px-4 py-2 bg-white/20 backdrop-blur-sm rounded-full text-white text-sm font-medium mb-6">
|
||||
{{ badge }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# Title #}
|
||||
{% set title = hero.title.translations.get(lang) or hero.title.translations.get(default_lang) or '' %}
|
||||
{% if title %}
|
||||
<h1 class="text-4xl md:text-5xl lg:text-6xl font-extrabold leading-tight mb-6">
|
||||
{{ title }}
|
||||
</h1>
|
||||
{% endif %}
|
||||
|
||||
{# Subtitle #}
|
||||
{% set subtitle = hero.subtitle.translations.get(lang) or hero.subtitle.translations.get(default_lang) or '' %}
|
||||
{% if subtitle %}
|
||||
<p class="text-xl md:text-2xl mb-10 opacity-90 max-w-3xl mx-auto">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{# Buttons #}
|
||||
{% if hero.buttons %}
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
{% 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 %}
|
||||
<a href="{{ button.url }}"
|
||||
class="{% if button.style == 'primary' %}bg-white text-gray-900 hover:bg-gray-100{% elif button.style == 'secondary' %}bg-white/20 text-white hover:bg-white/30{% else %}border-2 border-white text-white hover:bg-white/10{% endif %} px-8 py-4 rounded-xl font-semibold transition inline-flex items-center space-x-2">
|
||||
<span>{{ btn_text }}</span>
|
||||
{% if button.style == 'primary' %}
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Background decorations #}
|
||||
<div class="absolute top-0 right-0 w-1/3 h-full opacity-10">
|
||||
<svg viewBox="0 0 200 200" class="w-full h-full">
|
||||
<circle cx="100" cy="100" r="80" fill="white"/>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
116
app/templates/platform/sections/_pricing.html
Normal file
116
app/templates/platform/sections/_pricing.html
Normal file
@@ -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 id="pricing" class="py-16 lg:py-24 bg-gray-50 dark:bg-gray-900">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{# Section header #}
|
||||
<div class="text-center mb-12">
|
||||
{% set title = pricing.title.translations.get(lang) or pricing.title.translations.get(default_lang) or '' %}
|
||||
{% if title %}
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{{ title }}
|
||||
</h2>
|
||||
{% endif %}
|
||||
|
||||
{% if pricing.subtitle and pricing.subtitle.translations %}
|
||||
{% set subtitle = pricing.subtitle.translations.get(lang) or pricing.subtitle.translations.get(default_lang) %}
|
||||
{% if subtitle %}
|
||||
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Pricing toggle (monthly/annual) #}
|
||||
{% if pricing.use_subscription_tiers and tiers %}
|
||||
<div x-data="{ annual: false }" class="space-y-8">
|
||||
{# Billing toggle #}
|
||||
<div class="flex justify-center items-center space-x-4">
|
||||
<span :class="annual ? 'text-gray-400' : 'text-gray-900 dark:text-white font-semibold'">
|
||||
{{ _('pricing.monthly') or 'Monthly' }}
|
||||
</span>
|
||||
<button @click="annual = !annual"
|
||||
class="relative w-14 h-7 bg-gray-200 dark:bg-gray-700 rounded-full transition-colors"
|
||||
:class="annual && 'bg-indigo-600 dark:bg-indigo-500'">
|
||||
<span class="absolute top-1 left-1 w-5 h-5 bg-white rounded-full shadow transition-transform"
|
||||
:class="annual && 'translate-x-7'"></span>
|
||||
</button>
|
||||
<span :class="!annual ? 'text-gray-400' : 'text-gray-900 dark:text-white font-semibold'">
|
||||
{{ _('pricing.annual') or 'Annual' }}
|
||||
<span class="text-green-500 text-sm ml-1">{{ _('pricing.save_20') or 'Save 20%' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{# Pricing cards #}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-{{ [tiers|length, 4]|min }} gap-6">
|
||||
{% for tier in tiers %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-sm hover:shadow-lg transition-shadow p-8 {% if tier.is_popular %}ring-2 ring-indigo-500 relative{% endif %}">
|
||||
{% if tier.is_popular %}
|
||||
<div class="absolute -top-4 left-1/2 -translate-x-1/2">
|
||||
<span class="bg-indigo-500 text-white text-sm font-semibold px-4 py-1 rounded-full">
|
||||
{{ _('pricing.most_popular') or 'Most Popular' }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="text-center">
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{{ tier.name }}
|
||||
</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm mb-4">
|
||||
{{ tier.description or '' }}
|
||||
</p>
|
||||
|
||||
{# Price #}
|
||||
<div class="mb-6">
|
||||
<span class="text-4xl font-extrabold text-gray-900 dark:text-white"
|
||||
x-text="annual ? '{{ tier.annual_price or (tier.monthly_price * 10)|int }}' : '{{ tier.monthly_price }}'">
|
||||
{{ tier.monthly_price }}
|
||||
</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">/{{ _('pricing.month') or 'mo' }}</span>
|
||||
</div>
|
||||
|
||||
{# CTA button #}
|
||||
<a href="/signup?tier={{ tier.code }}"
|
||||
class="block w-full py-3 px-6 rounded-xl font-semibold transition {% if tier.is_popular %}bg-indigo-600 text-white hover:bg-indigo-700{% else %}bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-600{% endif %}">
|
||||
{{ _('pricing.get_started') or 'Get Started' }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Features list #}
|
||||
{% if tier.features %}
|
||||
<ul class="mt-8 space-y-3">
|
||||
{% for feature in tier.features %}
|
||||
<li class="flex items-start">
|
||||
<svg class="w-5 h-5 text-green-500 mr-3 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
<span class="text-gray-600 dark:text-gray-400 text-sm">{{ feature }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{# Placeholder when no tiers available #}
|
||||
<div class="text-center text-gray-500 dark:text-gray-400 py-8">
|
||||
{{ _('pricing.coming_soon') or 'Pricing plans coming soon' }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user