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:
@@ -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"
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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' %}
|
||||
|
||||
{# ═══════════════════════════════════════════════════════════════════════════ #}
|
||||
|
||||
@@ -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) %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
34
app/templates_config.py
Normal 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
|
||||
4
main.py
4
main.py
@@ -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)
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user