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 import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from app.templates_config import templates
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import settings from app.core.config import settings
@@ -24,8 +24,8 @@ logger = logging.getLogger(__name__)
# Get the templates directory # Get the templates directory
BASE_DIR = Path(__file__).resolve().parent.parent.parent BASE_DIR = Path(__file__).resolve().parent.parent.parent
TEMPLATES_DIR = BASE_DIR / "app" / "templates" # TEMPLATES_DIR moved to app.templates_config
templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) # templates imported from app.templates_config
def get_platform_context(request: Request, db: Session) -> dict: def get_platform_context(request: Request, db: Session) -> dict:
@@ -171,6 +171,25 @@ async def homepage(
context["page"] = cms_homepage context["page"] = cms_homepage
context["platform"] = platform 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_name = cms_homepage.template or "default"
template_path = f"platform/homepage-{template_name}.html" 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) --> <!-- 7. SEVENTH: Vendor Selector (depends on Tom Select and API Client) -->
<script src="{{ url_for('static', path='shared/js/vendor-selector.js') }}"></script> <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> <script>
(function() { (function() {
var script = document.createElement('script'); var script = document.createElement('script');

View File

@@ -91,24 +91,43 @@
</p> </p>
</div> </div>
<!-- Vendor ID (Platform vs Vendor-specific) --> <!-- Platform Selection -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <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> </label>
<select <select
x-model="form.vendor_id" 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" 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" :disabled="loadingVendors"
> >
<option :value="null">Platform Default</option> <option :value="null">None (Platform Default)</option>
<template x-for="vendor in (vendors || [])" :key="vendor.id"> <template x-for="vendor in (vendors || [])" :key="vendor.id">
<option :value="vendor.id" x-text="vendor.name"></option> <option :value="vendor.id" x-text="vendor.name"></option>
</template> </template>
</select> </select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <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 is a platform-wide default page</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 page overrides the default for selected vendor only</span>
</p> </p>
</div> </div>
</div> </div>
@@ -222,9 +241,7 @@
<input type="checkbox" x-model="sections.hero.enabled" class="w-4 h-4 text-purple-600 rounded"> <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> <span class="ml-2 text-sm text-gray-500">Enabled</span>
</label> </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"> <span :class="openSection === 'hero' ? 'rotate-180' : ''" class="transition-transform" x-html="$icon('chevron-down', 'w-5 h-5 text-gray-400')"></span>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div> </div>
</button> </button>
<div x-show="openSection === 'hero'" x-collapse class="p-4 space-y-4 border-t border-gray-200 dark:border-gray-700"> <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"> <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> <span class="ml-2 text-sm text-gray-500">Enabled</span>
</label> </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"> <span :class="openSection === 'features' ? 'rotate-180' : ''" class="transition-transform" x-html="$icon('chevron-down', 'w-5 h-5 text-gray-400')"></span>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div> </div>
</button> </button>
<div x-show="openSection === 'features'" x-collapse class="p-4 space-y-4 border-t border-gray-200 dark:border-gray-700"> <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"> <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> <span class="ml-2 text-sm text-gray-500">Enabled</span>
</label> </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"> <span :class="openSection === 'pricing' ? 'rotate-180' : ''" class="transition-transform" x-html="$icon('chevron-down', 'w-5 h-5 text-gray-400')"></span>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div> </div>
</button> </button>
<div x-show="openSection === 'pricing'" x-collapse class="p-4 space-y-4 border-t border-gray-200 dark:border-gray-700"> <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"> <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> <span class="ml-2 text-sm text-gray-500">Enabled</span>
</label> </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"> <span :class="openSection === 'cta' ? 'rotate-180' : ''" class="transition-transform" x-html="$icon('chevron-down', 'w-5 h-5 text-gray-400')"></span>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div> </div>
</button> </button>
<div x-show="openSection === 'cta'" x-collapse class="p-4 space-y-4 border-t border-gray-200 dark:border-gray-700"> <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 %} {% block content %}
{# Set up language context #} {# 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' %} {% set default_lang = platform.default_language if platform else 'fr' %}
{# ═══════════════════════════════════════════════════════════════════════════ #} {# ═══════════════════════════════════════════════════════════════════════════ #}

View File

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

View File

@@ -47,7 +47,7 @@
</button> </button>
<span :class="!annual ? 'text-gray-400' : 'text-gray-900 dark:text-white font-semibold'"> <span :class="!annual ? 'text-gray-400' : 'text-gray-900 dark:text-white font-semibold'">
{{ _('pricing.annual') or 'Annual' }} {{ _('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> </span>
</div> </div>
@@ -74,8 +74,8 @@
{# Price #} {# Price #}
<div class="mb-6"> <div class="mb-6">
<span class="text-4xl font-extrabold text-gray-900 dark:text-white" <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 }}'"> x-text="annual ? '{{ tier.price_annual or (tier.price_monthly * 10)|int }}' : '{{ tier.price_monthly }}'">
{{ tier.monthly_price }} {{ tier.price_monthly }}
</span> </span>
<span class="text-gray-500 dark:text-gray-400">/{{ _('pricing.month') or 'mo' }}</span> <span class="text-gray-500 dark:text-gray-400">/{{ _('pricing.month') or 'mo' }}</span>
</div> </div>

View File

@@ -272,6 +272,9 @@
} }
}); });
// Store quill instance on container for external access
container.__quill = quill;
// Find the Alpine component scope // Find the Alpine component scope
const wrapper = container.closest('[x-data]'); const wrapper = container.closest('[x-data]');
if (wrapper && wrapper._x_dataStack) { 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, lifespan=lifespan,
) )
# Configure Jinja2 Templates # Configure Jinja2 Templates (shared instance with i18n globals)
templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) from app.templates_config import templates # noqa: F401 - re-exported for compatibility
# Setup custom exception handlers (unified approach) # Setup custom exception handlers (unified approach)
setup_exception_handlers(app) setup_exception_handlers(app)

View File

@@ -30,10 +30,13 @@ function contentPageEditor(pageId) {
show_in_footer: true, show_in_footer: true,
show_in_legal: false, show_in_legal: false,
display_order: 0, display_order: 0,
platform_id: null,
vendor_id: null vendor_id: null
}, },
platforms: [],
vendors: [], vendors: [],
loading: false, loading: false,
loadingPlatforms: false,
loadingVendors: false, loadingVendors: false,
saving: false, saving: false,
error: null, error: null,
@@ -96,8 +99,8 @@ function contentPageEditor(pageId) {
} }
window._contentPageEditInitialized = true; window._contentPageEditInitialized = true;
// Load vendors for dropdown // Load platforms and vendors for dropdowns
await this.loadVendors(); await Promise.all([this.loadPlatforms(), this.loadVendors()]);
if (this.pageId) { if (this.pageId) {
// Edit mode - load existing page // Edit mode - load existing page
@@ -117,9 +120,34 @@ function contentPageEditor(pageId) {
contentPageEditLog.info('=== CONTENT PAGE EDITOR INITIALIZATION COMPLETE ==='); contentPageEditLog.info('=== CONTENT PAGE EDITOR INITIALIZATION COMPLETE ===');
}, },
// Check if we should show section editor // Check if we should show section editor (property, not getter for Alpine compatibility)
get isHomepage() { isHomepage: false,
return this.form.slug === 'home';
// 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 // 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_footer: page.show_in_footer !== undefined ? page.show_in_footer : true,
show_in_legal: page.show_in_legal || false, show_in_legal: page.show_in_legal || false,
display_order: page.display_order || 0, display_order: page.display_order || 0,
platform_id: page.platform_id,
vendor_id: page.vendor_id vendor_id: page.vendor_id
}; };
contentPageEditLog.info('Page loaded successfully'); 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) { } catch (err) {
contentPageEditLog.error('Error loading page:', err); contentPageEditLog.error('Error loading page:', err);
this.error = err.message || 'Failed to load page'; 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 // HOMEPAGE SECTIONS METHODS
// ======================================== // ========================================
@@ -355,6 +411,7 @@ function contentPageEditor(pageId) {
show_in_footer: this.form.show_in_footer, show_in_footer: this.form.show_in_footer,
show_in_legal: this.form.show_in_legal, show_in_legal: this.form.show_in_legal,
display_order: this.form.display_order, display_order: this.form.display_order,
platform_id: this.form.platform_id,
vendor_id: this.form.vendor_id vendor_id: this.form.vendor_id
}; };