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:
2026-01-23 14:31:23 +01:00
parent 3d3b8cae22
commit dca52d004e
14 changed files with 1377 additions and 176 deletions

View File

@@ -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,
}

View File

@@ -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()

View File

@@ -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">

View File

@@ -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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}