fix: resolve homepage sections editor and rendering issues

- Fix sections editor not showing by converting isHomepage getter to property
- Add Alpine Collapse plugin for accordion animations
- Fix Quill editor content not syncing after page load
- Add platform dropdown to content page edit form
- Create shared templates config (app/templates_config.py) with i18n globals
  to make _() translation function available in Jinja2 macros
- Fix pricing template field names (monthly_price → price_monthly)
- Fix translation key (pricing.save_20 → pricing.save_months)
- Add tiers context to CMS homepage route for pricing section
- Fix architecture validation issues (language defaults, inline SVGs → $icon)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-23 20:03:13 +01:00
parent 8662fcd6da
commit 7e39bb0564
10 changed files with 161 additions and 34 deletions

View File

@@ -11,7 +11,7 @@ from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.templates_config import templates
from sqlalchemy.orm import Session
from app.core.config import settings
@@ -24,8 +24,8 @@ logger = logging.getLogger(__name__)
# Get the templates directory
BASE_DIR = Path(__file__).resolve().parent.parent.parent
TEMPLATES_DIR = BASE_DIR / "app" / "templates"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
# TEMPLATES_DIR moved to app.templates_config
# templates imported from app.templates_config
def get_platform_context(request: Request, db: Session) -> dict:
@@ -171,6 +171,25 @@ async def homepage(
context["page"] = cms_homepage
context["platform"] = platform
# Include subscription tiers for pricing section
from models.database.subscription import TIER_LIMITS, TierCode
tiers = []
for tier_code, limits in TIER_LIMITS.items():
tiers.append({
"code": tier_code.value,
"name": limits["name"],
"price_monthly": limits["price_monthly_cents"] / 100,
"price_annual": (limits["price_annual_cents"] / 100) if limits.get("price_annual_cents") else None,
"orders_per_month": limits.get("orders_per_month"),
"products_limit": limits.get("products_limit"),
"team_members": limits.get("team_members"),
"features": limits.get("features", []),
"is_popular": tier_code == TierCode.PROFESSIONAL,
"is_enterprise": tier_code == TierCode.ENTERPRISE,
})
context["tiers"] = tiers
template_name = cms_homepage.template or "default"
template_path = f"platform/homepage-{template_name}.html"

View File

@@ -130,7 +130,10 @@
<!-- 7. SEVENTH: Vendor Selector (depends on Tom Select and API Client) -->
<script src="{{ url_for('static', path='shared/js/vendor-selector.js') }}"></script>
<!-- 8. EIGHTH: Alpine.js v3 with CDN fallback (with defer) -->
<!-- 8a. Alpine.js Collapse Plugin (must load before Alpine) -->
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.13.3/dist/cdn.min.js"></script>
<!-- 8b. Alpine.js v3 with CDN fallback (with defer) -->
<script>
(function() {
var script = document.createElement('script');

View File

@@ -91,24 +91,43 @@
</p>
</div>
<!-- Vendor ID (Platform vs Vendor-specific) -->
<!-- Platform Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Page Type
Platform <span class="text-red-500">*</span>
</label>
<select
x-model="form.platform_id"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
:disabled="loadingPlatforms"
>
<template x-for="plat in (platforms || [])" :key="plat.id">
<option :value="plat.id" x-text="plat.name"></option>
</template>
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Which platform this page belongs to (e.g., OMS, Loyalty+)
</p>
</div>
<!-- Vendor Override (optional) -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Vendor Override
</label>
<select
x-model="form.vendor_id"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
:disabled="loadingVendors"
>
<option :value="null">Platform Default</option>
<option :value="null">None (Platform Default)</option>
<template x-for="vendor in (vendors || [])" :key="vendor.id">
<option :value="vendor.id" x-text="vendor.name"></option>
</template>
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<span x-show="!form.vendor_id">Platform defaults are shown to all vendors</span>
<span x-show="form.vendor_id">This page will only be visible for the selected vendor</span>
<span x-show="!form.vendor_id">This is a platform-wide default page</span>
<span x-show="form.vendor_id">This page overrides the default for selected vendor only</span>
</p>
</div>
</div>
@@ -222,9 +241,7 @@
<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>
<span :class="openSection === 'hero' ? 'rotate-180' : ''" class="transition-transform" x-html="$icon('chevron-down', 'w-5 h-5 text-gray-400')"></span>
</div>
</button>
<div x-show="openSection === 'hero'" x-collapse class="p-4 space-y-4 border-t border-gray-200 dark:border-gray-700">
@@ -307,9 +324,7 @@
<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>
<span :class="openSection === 'features' ? 'rotate-180' : ''" class="transition-transform" x-html="$icon('chevron-down', 'w-5 h-5 text-gray-400')"></span>
</div>
</button>
<div x-show="openSection === 'features'" x-collapse class="p-4 space-y-4 border-t border-gray-200 dark:border-gray-700">
@@ -378,9 +393,7 @@
<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>
<span :class="openSection === 'pricing' ? 'rotate-180' : ''" class="transition-transform" x-html="$icon('chevron-down', 'w-5 h-5 text-gray-400')"></span>
</div>
</button>
<div x-show="openSection === 'pricing'" x-collapse class="p-4 space-y-4 border-t border-gray-200 dark:border-gray-700">
@@ -418,9 +431,7 @@
<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>
<span :class="openSection === 'cta' ? 'rotate-180' : ''" class="transition-transform" x-html="$icon('chevron-down', 'w-5 h-5 text-gray-400')"></span>
</div>
</button>
<div x-show="openSection === 'cta'" x-collapse class="p-4 space-y-4 border-t border-gray-200 dark:border-gray-700">

View File

@@ -22,7 +22,7 @@
{% block content %}
{# Set up language context #}
{% set lang = request.state.language or (platform.default_language if platform else 'fr') %}
{% set lang = request.state.language|default("fr") or (platform.default_language if platform else 'fr') %}
{% set default_lang = platform.default_language if platform else 'fr' %}
{# ═══════════════════════════════════════════════════════════════════════════ #}

View File

@@ -3,8 +3,8 @@
{#
Parameters:
- hero: HeroSection object (or dict)
- lang: Current language code (from request.state.language)
- default_lang: Fallback language (from platform.default_language)
- lang: Current language code (passed from parent template)
- default_lang: Fallback language (passed from parent template)
#}
{% macro render_hero(hero, lang, default_lang) %}

View File

@@ -47,7 +47,7 @@
</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 class="text-green-500 text-sm ml-1">{{ _('pricing.save_months') or 'Save 2 months!' }}</span>
</span>
</div>
@@ -74,8 +74,8 @@
{# 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 }}
x-text="annual ? '{{ tier.price_annual or (tier.price_monthly * 10)|int }}' : '{{ tier.price_monthly }}'">
{{ tier.price_monthly }}
</span>
<span class="text-gray-500 dark:text-gray-400">/{{ _('pricing.month') or 'mo' }}</span>
</div>

View File

@@ -272,6 +272,9 @@
}
});
// Store quill instance on container for external access
container.__quill = quill;
// Find the Alpine component scope
const wrapper = container.closest('[x-data]');
if (wrapper && wrapper._x_dataStack) {

34
app/templates_config.py Normal file
View File

@@ -0,0 +1,34 @@
# app/templates_config.py
"""
Shared Jinja2 templates configuration.
All route modules should import `templates` from here to ensure
consistent globals (like translation function) are available.
"""
from pathlib import Path
from fastapi.templating import Jinja2Templates
from app.utils.i18n import (
LANGUAGE_FLAGS,
LANGUAGE_NAMES,
SUPPORTED_LANGUAGES,
DEFAULT_LANGUAGE,
create_translation_context,
)
# Templates directory
TEMPLATES_DIR = Path(__file__).parent / "templates"
# Create shared templates instance
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
# Add translation function to Jinja2 environment globals
# This makes _() available in all templates AND macros
_default_translator = create_translation_context(DEFAULT_LANGUAGE)
templates.env.globals["_"] = _default_translator
templates.env.globals["t"] = _default_translator # Alias
templates.env.globals["SUPPORTED_LANGUAGES"] = SUPPORTED_LANGUAGES
templates.env.globals["DEFAULT_LANGUAGE"] = DEFAULT_LANGUAGE
templates.env.globals["LANGUAGE_NAMES"] = LANGUAGE_NAMES
templates.env.globals["LANGUAGE_FLAGS"] = LANGUAGE_FLAGS

View File

@@ -88,8 +88,8 @@ app = FastAPI(
lifespan=lifespan,
)
# Configure Jinja2 Templates
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
# Configure Jinja2 Templates (shared instance with i18n globals)
from app.templates_config import templates # noqa: F401 - re-exported for compatibility
# Setup custom exception handlers (unified approach)
setup_exception_handlers(app)

View File

@@ -30,10 +30,13 @@ function contentPageEditor(pageId) {
show_in_footer: true,
show_in_legal: false,
display_order: 0,
platform_id: null,
vendor_id: null
},
platforms: [],
vendors: [],
loading: false,
loadingPlatforms: false,
loadingVendors: false,
saving: false,
error: null,
@@ -96,8 +99,8 @@ function contentPageEditor(pageId) {
}
window._contentPageEditInitialized = true;
// Load vendors for dropdown
await this.loadVendors();
// Load platforms and vendors for dropdowns
await Promise.all([this.loadPlatforms(), this.loadVendors()]);
if (this.pageId) {
// Edit mode - load existing page
@@ -117,9 +120,34 @@ function contentPageEditor(pageId) {
contentPageEditLog.info('=== CONTENT PAGE EDITOR INITIALIZATION COMPLETE ===');
},
// Check if we should show section editor
get isHomepage() {
return this.form.slug === 'home';
// Check if we should show section editor (property, not getter for Alpine compatibility)
isHomepage: false,
// Update isHomepage when slug changes
updateIsHomepage() {
this.isHomepage = this.form.slug === 'home';
},
// Load platforms for dropdown
async loadPlatforms() {
this.loadingPlatforms = true;
try {
contentPageEditLog.info('Loading platforms...');
const response = await apiClient.get('/admin/platforms?is_active=true');
const data = response.data || response;
this.platforms = data.platforms || data.items || data || [];
contentPageEditLog.info(`Loaded ${this.platforms.length} platforms`);
// Set default platform if not editing and no platform selected
if (!this.pageId && !this.form.platform_id && this.platforms.length > 0) {
this.form.platform_id = this.platforms[0].id;
}
} catch (err) {
contentPageEditLog.error('Error loading platforms:', err);
this.platforms = [];
} finally {
this.loadingPlatforms = false;
}
},
// Load vendors for dropdown
@@ -170,11 +198,19 @@ function contentPageEditor(pageId) {
show_in_footer: page.show_in_footer !== undefined ? page.show_in_footer : true,
show_in_legal: page.show_in_legal || false,
display_order: page.display_order || 0,
platform_id: page.platform_id,
vendor_id: page.vendor_id
};
contentPageEditLog.info('Page loaded successfully');
// Update computed properties after loading
this.updateIsHomepage();
// Re-initialize Quill editor content after page data is loaded
// (Quill may have initialized before loadPage completed)
this.syncQuillContent();
} catch (err) {
contentPageEditLog.error('Error loading page:', err);
this.error = err.message || 'Failed to load page';
@@ -183,6 +219,26 @@ function contentPageEditor(pageId) {
}
},
// Sync Quill editor content after page data loads
// Quill may initialize before loadPage completes, leaving editor empty
syncQuillContent(retries = 5) {
const quillContainer = document.getElementById('content-editor');
if (!quillContainer || !quillContainer.__quill) {
// Quill not ready yet, retry
if (retries > 0) {
setTimeout(() => this.syncQuillContent(retries - 1), 100);
}
return;
}
const quill = quillContainer.__quill;
if (this.form.content && quill.root.innerHTML !== this.form.content) {
quill.root.innerHTML = this.form.content;
contentPageEditLog.debug('Synced Quill content after page load');
}
},
// ========================================
// HOMEPAGE SECTIONS METHODS
// ========================================
@@ -355,6 +411,7 @@ function contentPageEditor(pageId) {
show_in_footer: this.form.show_in_footer,
show_in_legal: this.form.show_in_legal,
display_order: this.form.display_order,
platform_id: this.form.platform_id,
vendor_id: this.form.vendor_id
};