feat(cms): CMS-driven homepages, products section, placeholder resolution
Some checks failed
CI / ruff (push) Successful in 11s
CI / pytest (push) Failing after 51m41s
CI / validate (push) Successful in 26s
CI / dependency-scanning (push) Successful in 32s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

- Add ProductCard/ProductsSection schema and _products.html section macro
- Rewrite seed script with 3-platform homepage sections (wizard, OMS, loyalty),
  platform marketing pages, and store defaults with {{store_name}} placeholders
- Add resolve_placeholders() to ContentPageService for store default pages
- Fix SQLAlchemy filter bugs: replace Python `is None` with `.is_(None)` across
  all ContentPageService query methods (was silently breaking all platform page lookups)
- Remove hardcoded orion fallback and delete homepage-orion.html
- Add placeholder hint box with click-to-copy in admin content page editor
- Export ProductCard/ProductsSection from cms schemas __init__

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 12:12:20 +01:00
parent ef9ea29643
commit adbecd360b
11 changed files with 1132 additions and 916 deletions

View File

@@ -164,46 +164,13 @@ async def homepage(
logger.info(f"[HOMEPAGE] Rendering CMS homepage with template: {template_path}") logger.info(f"[HOMEPAGE] Rendering CMS homepage with template: {template_path}")
return templates.TemplateResponse(template_path, context) return templates.TemplateResponse(template_path, context)
# Fallback: Default orion homepage (no CMS content) # Fallback: Default homepage template with placeholder content
logger.info("[HOMEPAGE] No CMS homepage found, using default orion template") logger.info("[HOMEPAGE] No CMS homepage found, using default template with placeholders")
context = get_platform_context(request, db) context = get_platform_context(request, db)
context["tiers"] = _get_tiers_data(db) context["tiers"] = _get_tiers_data(db)
# Add-ons (hardcoded for now, will come from DB)
context["addons"] = [
{
"code": "domain",
"name": "Custom Domain",
"description": "Use your own domain (mydomain.com)",
"price": 15,
"billing_period": "year",
"icon": "globe",
},
{
"code": "ssl_premium",
"name": "Premium SSL",
"description": "EV certificate for trust badges",
"price": 49,
"billing_period": "year",
"icon": "shield-check",
},
{
"code": "email",
"name": "Email Package",
"description": "Professional email addresses",
"price": 5,
"billing_period": "month",
"icon": "mail",
"options": [
{"quantity": 5, "price": 5},
{"quantity": 10, "price": 9},
{"quantity": 25, "price": 19},
],
},
]
return templates.TemplateResponse( return templates.TemplateResponse(
"cms/platform/homepage-orion.html", "cms/platform/homepage-default.html",
context, context,
) )

View File

@@ -101,9 +101,17 @@ async def generic_content_page(
}, },
) )
# Resolve placeholders in store default pages ({{store_name}}, etc.)
page_content = page.content
if page.is_store_default and store:
page_content = content_page_service.resolve_placeholders(page.content, store)
context = get_storefront_context(request, db=db, page=page)
context["page_content"] = page_content
return templates.TemplateResponse( return templates.TemplateResponse(
"cms/storefront/content-page.html", "cms/storefront/content-page.html",
get_storefront_context(request, db=db, page=page), context,
) )

View File

@@ -31,6 +31,8 @@ from app.modules.cms.schemas.homepage_sections import (
HomepageSections, HomepageSections,
HomepageSectionsResponse, HomepageSectionsResponse,
PricingSection, PricingSection,
ProductCard,
ProductsSection,
# API schemas # API schemas
SectionUpdateRequest, SectionUpdateRequest,
# Translatable text # Translatable text
@@ -92,6 +94,8 @@ __all__ = [
"HeroSection", "HeroSection",
"FeatureCard", "FeatureCard",
"FeaturesSection", "FeaturesSection",
"ProductCard",
"ProductsSection",
"PricingSection", "PricingSection",
"CTASection", "CTASection",
"HomepageSections", "HomepageSections",

View File

@@ -77,6 +77,25 @@ class FeatureCard(BaseModel):
description: TranslatableText = Field(default_factory=TranslatableText) description: TranslatableText = Field(default_factory=TranslatableText)
class ProductCard(BaseModel):
"""Single product/offering card in products section."""
icon: str = ""
title: TranslatableText = Field(default_factory=TranslatableText)
description: TranslatableText = Field(default_factory=TranslatableText)
url: str = ""
badge: TranslatableText | None = None
class ProductsSection(BaseModel):
"""Product/offering showcase section (e.g. wizard.lu multi-product landing)."""
enabled: bool = True
title: TranslatableText = Field(default_factory=TranslatableText)
subtitle: TranslatableText | None = None
products: list[ProductCard] = Field(default_factory=list)
class FeaturesSection(BaseModel): class FeaturesSection(BaseModel):
"""Features section configuration.""" """Features section configuration."""
@@ -114,6 +133,7 @@ class HomepageSections(BaseModel):
"""Complete homepage sections structure.""" """Complete homepage sections structure."""
hero: HeroSection | None = None hero: HeroSection | None = None
products: ProductsSection | None = None
features: FeaturesSection | None = None features: FeaturesSection | None = None
pricing: PricingSection | None = None pricing: PricingSection | None = None
cta: CTASection | None = None cta: CTASection | None = None
@@ -139,6 +159,10 @@ class HomepageSections(BaseModel):
subtitle=make_translatable(languages), subtitle=make_translatable(languages),
buttons=[], buttons=[],
), ),
products=ProductsSection(
title=make_translatable(languages),
products=[],
),
features=FeaturesSection( features=FeaturesSection(
title=make_translatable(languages), title=make_translatable(languages),
features=[], features=[],
@@ -162,7 +186,7 @@ class HomepageSections(BaseModel):
class SectionUpdateRequest(BaseModel): class SectionUpdateRequest(BaseModel):
"""Request to update a single section.""" """Request to update a single section."""
section_name: str = Field(..., description="hero, features, pricing, or cta") section_name: str = Field(..., description="hero, products, features, pricing, or cta")
section_data: dict = Field(..., description="Section configuration") section_data: dict = Field(..., description="Section configuration")

View File

@@ -142,8 +142,8 @@ class ContentPageService:
db.query(ContentPage) db.query(ContentPage)
.filter( .filter(
and_( and_(
ContentPage.store_id is None, ContentPage.store_id.is_(None),
ContentPage.is_platform_page == False, ContentPage.is_platform_page.is_(False),
*base_filters, *base_filters,
) )
) )
@@ -182,12 +182,12 @@ class ContentPageService:
filters = [ filters = [
ContentPage.platform_id == platform_id, ContentPage.platform_id == platform_id,
ContentPage.slug == slug, ContentPage.slug == slug,
ContentPage.store_id is None, ContentPage.store_id.is_(None),
ContentPage.is_platform_page == True, ContentPage.is_platform_page.is_(True),
] ]
if not include_unpublished: if not include_unpublished:
filters.append(ContentPage.is_published == True) filters.append(ContentPage.is_published.is_(True))
page = db.query(ContentPage).filter(and_(*filters)).first() page = db.query(ContentPage).filter(and_(*filters)).first()
@@ -255,8 +255,8 @@ class ContentPageService:
db.query(ContentPage) db.query(ContentPage)
.filter( .filter(
and_( and_(
ContentPage.store_id is None, ContentPage.store_id.is_(None),
ContentPage.is_platform_page == False, ContentPage.is_platform_page.is_(False),
*base_filters, *base_filters,
) )
) )
@@ -298,12 +298,12 @@ class ContentPageService:
""" """
filters = [ filters = [
ContentPage.platform_id == platform_id, ContentPage.platform_id == platform_id,
ContentPage.store_id is None, ContentPage.store_id.is_(None),
ContentPage.is_platform_page == True, ContentPage.is_platform_page.is_(True),
] ]
if not include_unpublished: if not include_unpublished:
filters.append(ContentPage.is_published == True) filters.append(ContentPage.is_published.is_(True))
if footer_only: if footer_only:
filters.append(ContentPage.show_in_footer == True) filters.append(ContentPage.show_in_footer == True)
@@ -377,12 +377,12 @@ class ContentPageService:
""" """
filters = [ filters = [
ContentPage.platform_id == platform_id, ContentPage.platform_id == platform_id,
ContentPage.store_id is None, ContentPage.store_id.is_(None),
ContentPage.is_platform_page == False, ContentPage.is_platform_page.is_(False),
] ]
if not include_unpublished: if not include_unpublished:
filters.append(ContentPage.is_published == True) filters.append(ContentPage.is_published.is_(True))
return ( return (
db.query(ContentPage) db.query(ContentPage)
@@ -845,13 +845,13 @@ class ContentPageService:
filters.append(ContentPage.is_published == True) filters.append(ContentPage.is_published == True)
if page_tier == "platform": if page_tier == "platform":
filters.append(ContentPage.is_platform_page == True) filters.append(ContentPage.is_platform_page.is_(True))
filters.append(ContentPage.store_id is None) filters.append(ContentPage.store_id.is_(None))
elif page_tier == "store_default": elif page_tier == "store_default":
filters.append(ContentPage.is_platform_page == False) filters.append(ContentPage.is_platform_page.is_(False))
filters.append(ContentPage.store_id is None) filters.append(ContentPage.store_id.is_(None))
elif page_tier == "store_override": elif page_tier == "store_override":
filters.append(ContentPage.store_id is not None) filters.append(ContentPage.store_id.isnot(None))
return ( return (
db.query(ContentPage) db.query(ContentPage)
@@ -958,6 +958,34 @@ class ContentPageService:
if not success: if not success:
raise ContentPageNotFoundException(identifier=page_id) raise ContentPageNotFoundException(identifier=page_id)
# =========================================================================
# Placeholder Resolution (for store default pages)
# =========================================================================
@staticmethod
def resolve_placeholders(content: str, store) -> str:
"""
Replace {{store_name}}, {{store_email}}, {{store_phone}} placeholders
in store default page content with actual store values.
Args:
content: HTML content with placeholders
store: Store object with name, contact_email, phone attributes
Returns:
Content with placeholders replaced
"""
if not content or not store:
return content or ""
replacements = {
"{{store_name}}": store.name or "Our Store",
"{{store_email}}": getattr(store, "contact_email", "") or "",
"{{store_phone}}": getattr(store, "phone", "") or "",
}
for placeholder, value in replacements.items():
content = content.replace(placeholder, value)
return content
# ========================================================================= # =========================================================================
# Homepage Sections Management # Homepage Sections Management
# ========================================================================= # =========================================================================
@@ -1032,10 +1060,12 @@ class ContentPageService:
FeaturesSection, FeaturesSection,
HeroSection, HeroSection,
PricingSection, PricingSection,
ProductsSection,
) )
SECTION_SCHEMAS = { SECTION_SCHEMAS = {
"hero": HeroSection, "hero": HeroSection,
"products": ProductsSection,
"features": FeaturesSection, "features": FeaturesSection,
"pricing": PricingSection, "pricing": PricingSection,
"cta": CTASection, "cta": CTASection,

View File

@@ -187,6 +187,35 @@
</p> </p>
</div> </div>
</div> </div>
{# Available Placeholders (for store default pages) #}
{% set placeholders = [
('store_name', "The store's display name"),
('store_email', "The store's contact email"),
('store_phone', "The store's phone number"),
] %}
<div x-show="!form.store_id || form.store_id === 'null'" x-cloak
class="mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<h4 class="text-sm font-semibold text-blue-800 dark:text-blue-300 mb-2 flex items-center">
<span x-html="$icon('information-circle', 'w-4 h-4 mr-1.5')"></span>
Available Placeholders
</h4>
<p class="text-xs text-blue-700 dark:text-blue-400 mb-2">
Use these placeholders in store default pages. They will be automatically replaced with the store's actual information when displayed.
</p>
<div class="flex flex-wrap gap-2">
{% for name, description in placeholders %}
<code class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-800/40 text-blue-800 dark:text-blue-300 rounded cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-800/60 transition-colors"
@click="navigator.clipboard.writeText('{% raw %}{{{% endraw %}{{ name }}{% raw %}}}{% endraw %}')"
title="{{ description }} — click to copy">
{% raw %}{{{% endraw %}{{ name }}{% raw %}}}{% endraw %}
</code>
{% endfor %}
</div>
<p class="text-xs text-blue-600 dark:text-blue-500 mt-2">
Click a placeholder to copy it to your clipboard.
</p>
</div>
</div> </div>
<!-- ══════════════════════════════════════════════════════════════════ --> <!-- ══════════════════════════════════════════════════════════════════ -->

View File

@@ -4,6 +4,7 @@
{# Import section partials #} {# Import section partials #}
{% from 'cms/platform/sections/_hero.html' import render_hero %} {% from 'cms/platform/sections/_hero.html' import render_hero %}
{% from 'cms/platform/sections/_products.html' import render_products %}
{% from 'cms/platform/sections/_features.html' import render_features %} {% from 'cms/platform/sections/_features.html' import render_features %}
{% from 'cms/platform/sections/_pricing.html' import render_pricing %} {% from 'cms/platform/sections/_pricing.html' import render_pricing %}
{% from 'cms/platform/sections/_cta.html' import render_cta %} {% from 'cms/platform/sections/_cta.html' import render_cta %}
@@ -35,6 +36,11 @@
{{ render_hero(page.sections.hero, lang, default_lang) }} {{ render_hero(page.sections.hero, lang, default_lang) }}
{% endif %} {% endif %}
{# Products Section #}
{% if page.sections.products %}
{{ render_products(page.sections.products, lang, default_lang) }}
{% endif %}
{# Features Section #} {# Features Section #}
{% if page.sections.features %} {% if page.sections.features %}
{{ render_features(page.sections.features, lang, default_lang) }} {{ render_features(page.sections.features, lang, default_lang) }}

View File

@@ -1,427 +0,0 @@
{# app/templates/platform/homepage-orion.html #}
{# Orion Marketing Homepage - Letzshop OMS Platform #}
{% extends "platform/base.html" %}
{% from 'shared/macros/inputs.html' import toggle_switch %}
{% block title %}Orion - Order Management for Letzshop Sellers{% endblock %}
{% block meta_description %}Lightweight OMS for Letzshop stores. Manage orders, inventory, and invoicing. Start your 30-day free trial today.{% endblock %}
{% block content %}
<div x-data="homepageData()" class="bg-gray-50 dark:bg-gray-900">
{# =========================================================================
HERO SECTION
========================================================================= #}
<section class="relative overflow-hidden">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 lg:py-24">
<div class="text-center">
{# Badge #}
<div class="inline-flex items-center px-4 py-2 bg-indigo-100 dark:bg-indigo-900/30 rounded-full text-indigo-700 dark:text-indigo-300 text-sm font-medium mb-6">
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
{{ _("cms.platform.hero.badge", trial_days=trial_days) }}
</div>
{# Headline #}
<h1 class="text-4xl md:text-5xl lg:text-6xl font-extrabold text-gray-900 dark:text-white leading-tight mb-6">
{{ _("cms.platform.hero.title") }}
</h1>
{# Subheadline #}
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-3xl mx-auto mb-10">
{{ _("cms.platform.hero.subtitle") }}
</p>
{# CTA Buttons #}
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/signup"
class="inline-flex items-center justify-center px-8 py-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl shadow-lg shadow-indigo-500/30 transition-all hover:scale-105">
{{ _("cms.platform.hero.cta_trial") }}
<svg class="w-5 h-5 ml-2" 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"/>
</svg>
</a>
<a href="#find-shop"
class="inline-flex items-center justify-center px-8 py-4 bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-semibold rounded-xl border-2 border-gray-200 dark:border-gray-700 hover:border-indigo-500 transition-all">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
{{ _("cms.platform.hero.cta_find_shop") }}
</a>
</div>
</div>
</div>
{# Background Decoration #}
<div class="absolute inset-0 -z-10 overflow-hidden">
<div class="absolute -top-1/2 -right-1/4 w-96 h-96 bg-indigo-200 dark:bg-indigo-900/20 rounded-full blur-3xl opacity-50"></div>
<div class="absolute -bottom-1/2 -left-1/4 w-96 h-96 bg-purple-200 dark:bg-purple-900/20 rounded-full blur-3xl opacity-50"></div>
</div>
</section>
{# =========================================================================
PRICING SECTION
========================================================================= #}
<section id="pricing" 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">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ _("cms.platform.pricing.title") }}
</h2>
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{{ _("cms.platform.pricing.subtitle", trial_days=trial_days) }}
</p>
{# Billing Toggle #}
<div class="flex justify-center mt-8">
{{ toggle_switch(
model='annual',
left_label=_("cms.platform.pricing.monthly"),
right_label=_("cms.platform.pricing.annual"),
right_badge=_("cms.platform.pricing.save_months")
) }}
</div>
</div>
{# Pricing Cards Grid #}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{% for tier in tiers %}
<div class="relative bg-gray-50 dark:bg-gray-900 rounded-2xl p-6 border-2 transition-all hover:shadow-xl
{% if tier.is_popular %}border-indigo-500 shadow-lg{% else %}border-gray-200 dark:border-gray-700{% endif %}">
{# Popular Badge #}
{% if tier.is_popular %}
<div class="absolute -top-3 left-1/2 -translate-x-1/2">
<span class="bg-indigo-600 text-white text-xs font-bold px-3 py-1 rounded-full">
{{ _("cms.platform.pricing.most_popular") }}
</span>
</div>
{% endif %}
{# Tier Name #}
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">{{ tier.name }}</h3>
{# Price #}
<div class="mb-6">
<template x-if="!annual">
<div>
<span class="text-4xl font-extrabold text-gray-900 dark:text-white">{{ tier.price_monthly|int }}€</span>
<span class="text-gray-500 dark:text-gray-400">{{ _("cms.platform.pricing.per_month") }}</span>
</div>
</template>
<template x-if="annual">
<div>
{% if tier.price_annual %}
<span class="text-4xl font-extrabold text-gray-900 dark:text-white">{{ (tier.price_annual / 12)|round(0)|int }}€</span>
<span class="text-gray-500 dark:text-gray-400">{{ _("cms.platform.pricing.per_month") }}</span>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ tier.price_annual|int }}€ {{ _("cms.platform.pricing.per_year") }}
</div>
{% else %}
<span class="text-2xl font-bold text-gray-900 dark:text-white">{{ _("cms.platform.pricing.custom") }}</span>
{% endif %}
</div>
</template>
</div>
{# Features List - Show all features, grey out unavailable #}
<ul class="space-y-2 mb-8 text-sm">
{# Orders #}
<li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{% if tier.orders_per_month %}{{ _("cms.platform.pricing.orders_per_month", count=tier.orders_per_month) }}{% else %}{{ _("cms.platform.pricing.unlimited_orders") }}{% endif %}
</li>
{# Products #}
<li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{% if tier.products_limit %}{{ _("cms.platform.pricing.products_limit", count=tier.products_limit) }}{% else %}{{ _("cms.platform.pricing.unlimited_products") }}{% endif %}
</li>
{# Team Members #}
<li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{% if tier.team_members %}{{ _("cms.platform.pricing.team_members", count=tier.team_members) }}{% else %}{{ _("cms.platform.pricing.unlimited_team") }}{% endif %}
</li>
{# Letzshop Sync - always included #}
<li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{{ _("cms.platform.pricing.letzshop_sync") }}
</li>
{# EU VAT Invoicing #}
<li class="flex items-center {% if 'invoice_eu_vat' in tier.features %}text-gray-700 dark:text-gray-300{% else %}text-gray-400 dark:text-gray-600{% endif %}">
{% if 'invoice_eu_vat' in tier.features %}
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{% else %}
<svg class="w-4 h-4 text-gray-300 dark:text-gray-600 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
{% endif %}
{{ _("cms.platform.pricing.eu_vat_invoicing") }}
</li>
{# Analytics Dashboard #}
<li class="flex items-center {% if 'analytics_dashboard' in tier.features %}text-gray-700 dark:text-gray-300{% else %}text-gray-400 dark:text-gray-600{% endif %}">
{% if 'analytics_dashboard' in tier.features %}
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{% else %}
<svg class="w-4 h-4 text-gray-300 dark:text-gray-600 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
{% endif %}
{{ _("cms.platform.pricing.analytics_dashboard") }}
</li>
{# API Access #}
<li class="flex items-center {% if 'api_access' in tier.features %}text-gray-700 dark:text-gray-300{% else %}text-gray-400 dark:text-gray-600{% endif %}">
{% if 'api_access' in tier.features %}
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{% else %}
<svg class="w-4 h-4 text-gray-300 dark:text-gray-600 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
{% endif %}
{{ _("cms.platform.pricing.api_access") }}
</li>
{# Multi-channel Integration - Enterprise only #}
<li class="flex items-center {% if tier.is_enterprise %}text-gray-700 dark:text-gray-300{% else %}text-gray-400 dark:text-gray-600{% endif %}">
{% if tier.is_enterprise %}
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{% else %}
<svg class="w-4 h-4 text-gray-300 dark:text-gray-600 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
{% endif %}
{{ _("cms.platform.pricing.multi_channel") }}
</li>
</ul>
{# CTA Button #}
{% if tier.is_enterprise %}
<a href="mailto:sales@orion.lu?subject=Enterprise%20Plan%20Inquiry"
class="block w-full py-3 px-4 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white font-semibold rounded-xl text-center hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
{{ _("cms.platform.pricing.contact_sales") }}
</a>
{% else %}
<a href="/signup?tier={{ tier.code }}"
:href="'/signup?tier={{ tier.code }}&annual=' + annual"
class="block w-full py-3 px-4 font-semibold rounded-xl text-center transition-colors
{% if tier.is_popular %}bg-indigo-600 hover:bg-indigo-700 text-white{% else %}bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-200 dark:hover:bg-indigo-900/50{% endif %}">
{{ _("cms.platform.pricing.start_trial") }}
</a>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</section>
{# =========================================================================
ADD-ONS SECTION
========================================================================= #}
<section id="addons" 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">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ _("cms.platform.addons.title") }}
</h2>
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{{ _("cms.platform.addons.subtitle") }}
</p>
</div>
{# Add-ons Grid #}
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
{% for addon in addons %}
<div class="bg-white dark:bg-gray-800 rounded-2xl p-8 border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-shadow">
{# Icon #}
<div class="w-14 h-14 bg-indigo-100 dark:bg-indigo-900/30 rounded-xl flex items-center justify-center mb-6">
{% if addon.icon == 'globe' %}
<svg class="w-7 h-7 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
</svg>
{% elif addon.icon == 'shield-check' %}
<svg class="w-7 h-7 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
{% elif addon.icon == 'mail' %}
<svg class="w-7 h-7 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
{% endif %}
</div>
{# Name & Description #}
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">{{ addon.name }}</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">{{ addon.description }}</p>
{# Price #}
<div class="flex items-baseline">
<span class="text-2xl font-bold text-gray-900 dark:text-white">{{ addon.price }}€</span>
<span class="text-gray-500 dark:text-gray-400 ml-1">/{{ addon.billing_period }}</span>
</div>
{# Options for email packages #}
{% if addon.options %}
<div class="mt-4 space-y-2">
{% for opt in addon.options %}
<div class="text-sm text-gray-600 dark:text-gray-400">
{{ opt.quantity }} addresses: {{ opt.price }}€/month
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</section>
{# =========================================================================
LETZSHOP STORE FINDER
========================================================================= #}
<section id="find-shop" class="py-16 lg:py-24 bg-white dark:bg-gray-800">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
{# Section Header #}
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ _("cms.platform.find_shop.title") }}
</h2>
<p class="text-lg text-gray-600 dark:text-gray-400">
{{ _("cms.platform.find_shop.subtitle") }}
</p>
</div>
{# Search Form #}
<div class="bg-gray-50 dark:bg-gray-900 rounded-2xl p-8 border border-gray-200 dark:border-gray-700">
<div class="flex flex-col sm:flex-row gap-4">
<input
type="text"
x-model="shopUrl"
placeholder="{{ _('cms.platform.find_shop.placeholder') }}"
class="flex-1 px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
<button
@click="lookupStore()"
:disabled="loading"
class="px-8 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition-colors disabled:opacity-50 flex items-center justify-center">
<template x-if="loading">
<svg class="animate-spin w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
</template>
{{ _("cms.platform.find_shop.button") }}
</button>
</div>
{# Result #}
<template x-if="storeResult">
<div class="mt-6 p-6 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<template x-if="storeResult.found">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="storeResult.store.name"></h3>
<a :href="storeResult.store.letzshop_url" target="_blank" class="text-sm text-indigo-600 dark:text-indigo-400 hover:underline" x-text="storeResult.store.letzshop_url"></a>
</div>
<template x-if="!storeResult.store.is_claimed">
<a :href="'/signup?letzshop=' + storeResult.store.slug"
class="px-6 py-2 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition-colors">
{{ _("cms.platform.find_shop.claim_shop") }}
</a>
</template>
<template x-if="storeResult.store.is_claimed">
<span class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-lg">
{{ _("cms.platform.find_shop.already_claimed") }}
</span>
</template>
</div>
</template>
<template x-if="!storeResult.found">
<div class="text-center text-gray-600 dark:text-gray-400">
<p x-text="storeResult.error || 'Shop not found. Please check your URL and try again.'"></p>
</div>
</template>
</div>
</template>
{# Help Text #}
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400 text-center">
{{ _("cms.platform.find_shop.no_account") }} <a href="https://letzshop.lu" target="_blank" class="text-indigo-600 dark:text-indigo-400 hover:underline">{{ _("cms.platform.find_shop.signup_letzshop") }}</a>{{ _("cms.platform.find_shop.then_connect") }}
</p>
</div>
</div>
</section>
{# =========================================================================
FINAL CTA SECTION
========================================================================= #}
<section class="py-16 lg:py-24 bg-gradient-to-r from-indigo-600 to-purple-600">
<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-white mb-6">
{{ _("cms.platform.cta.title") }}
</h2>
<p class="text-xl text-indigo-100 mb-10">
{{ _("cms.platform.cta.subtitle", trial_days=trial_days) }}
</p>
<a href="/signup"
class="inline-flex items-center px-10 py-4 bg-white text-indigo-600 font-bold rounded-xl shadow-lg hover:shadow-xl transition-all hover:scale-105">
{{ _("cms.platform.cta.button") }}
<svg class="w-5 h-5 ml-2" 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"/>
</svg>
</a>
</div>
</section>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function homepageData() {
return {
annual: false,
shopUrl: '',
storeResult: null,
loading: false,
async lookupStore() {
if (!this.shopUrl.trim()) return;
this.loading = true;
this.storeResult = null;
try {
const response = await fetch('/api/v1/platform/letzshop-stores/lookup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: this.shopUrl })
});
this.storeResult = await response.json();
} catch (error) {
console.error('Lookup error:', error);
this.storeResult = { found: false, error: 'Failed to lookup. Please try again.' };
} finally {
this.loading = false;
}
}
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,91 @@
{# app/templates/platform/sections/_products.html #}
{# Products/offerings section for multi-product platforms (e.g. wizard.lu) #}
{#
Parameters:
- products: ProductsSection object (or dict)
- lang: Current language code
- default_lang: Fallback language
#}
{% macro render_products(products, lang, default_lang) %}
{% if products and products.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 = products.title.translations.get(lang) or products.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 products.subtitle and products.subtitle.translations %}
{% set subtitle = products.subtitle.translations.get(lang) or products.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>
{# Product cards #}
{% if products.products %}
<div class="grid grid-cols-1 md:grid-cols-{{ [products.products|length, 3]|min }} gap-8">
{% for product in products.products %}
<div class="relative bg-gray-50 dark:bg-gray-700 rounded-2xl p-8 border-2 border-gray-200 dark:border-gray-600 hover:border-indigo-500 dark:hover:border-indigo-400 transition-all hover:shadow-xl group">
{# Badge #}
{% if product.badge and product.badge.translations %}
{% set badge_text = product.badge.translations.get(lang) or product.badge.translations.get(default_lang) %}
{% if badge_text %}
<div class="absolute -top-3 right-4">
<span class="bg-indigo-600 text-white text-xs font-bold px-3 py-1 rounded-full">
{{ badge_text }}
</span>
</div>
{% endif %}
{% endif %}
{# Icon #}
{% if product.icon %}
<div class="w-14 h-14 mb-6 rounded-xl gradient-primary flex items-center justify-center">
<span x-html="typeof $icon !== 'undefined' ? $icon('{{ product.icon }}', 'w-7 h-7 text-white') : ''"></span>
</div>
{% endif %}
{# Title #}
{% set product_title = product.title.translations.get(lang) or product.title.translations.get(default_lang) or '' %}
{% if product_title %}
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
{{ product_title }}
</h3>
{% endif %}
{# Description #}
{% set product_desc = product.description.translations.get(lang) or product.description.translations.get(default_lang) or '' %}
{% if product_desc %}
<p class="text-gray-600 dark:text-gray-400 mb-6">
{{ product_desc }}
</p>
{% endif %}
{# CTA Link #}
{% if product.url %}
<a href="{{ product.url }}"
class="inline-flex items-center text-indigo-600 dark:text-indigo-400 font-semibold hover:text-indigo-800 dark:hover:text-indigo-300 transition-colors group-hover:translate-x-1 transform transition-transform">
{% set link_text = product_title or 'Learn More' %}
Learn More
<svg class="w-4 h-4 ml-2" 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"/>
</svg>
</a>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
</section>
{% endif %}
{% endmacro %}

View File

@@ -42,17 +42,16 @@
{% endif %} {% endif %}
</div> </div>
{# Content #} {# Content — use page_content (with resolved placeholders) when available #}
{% set content = page_content if page_content is defined and page_content else page.content %}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
<div class="prose prose-lg dark:prose-invert max-w-none"> <div class="prose prose-lg dark:prose-invert max-w-none">
{% if page.content_format == 'markdown' %} {% if page.content_format == 'markdown' %}
{# Markdown content - future enhancement: render with markdown library #}
<div class="markdown-content"> <div class="markdown-content">
{{ page.content | safe }}{# sanitized: CMS content #} {{ content | safe }}{# sanitized: CMS content #}
</div> </div>
{% else %} {% else %}
{# HTML content (default) #} {{ content | safe }}{# sanitized: CMS content #}
{{ page.content | safe }}{# sanitized: CMS content #}
{% endif %} {% endif %}
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff