feat(cms): CMS-driven homepages, products section, placeholder resolution
Some checks failed
Some checks failed
- 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:
@@ -164,46 +164,13 @@ async def homepage(
|
||||
logger.info(f"[HOMEPAGE] Rendering CMS homepage with template: {template_path}")
|
||||
return templates.TemplateResponse(template_path, context)
|
||||
|
||||
# Fallback: Default orion homepage (no CMS content)
|
||||
logger.info("[HOMEPAGE] No CMS homepage found, using default orion template")
|
||||
# Fallback: Default homepage template with placeholder content
|
||||
logger.info("[HOMEPAGE] No CMS homepage found, using default template with placeholders")
|
||||
context = get_platform_context(request, 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(
|
||||
"cms/platform/homepage-orion.html",
|
||||
"cms/platform/homepage-default.html",
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
@@ -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(
|
||||
"cms/storefront/content-page.html",
|
||||
get_storefront_context(request, db=db, page=page),
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ from app.modules.cms.schemas.homepage_sections import (
|
||||
HomepageSections,
|
||||
HomepageSectionsResponse,
|
||||
PricingSection,
|
||||
ProductCard,
|
||||
ProductsSection,
|
||||
# API schemas
|
||||
SectionUpdateRequest,
|
||||
# Translatable text
|
||||
@@ -92,6 +94,8 @@ __all__ = [
|
||||
"HeroSection",
|
||||
"FeatureCard",
|
||||
"FeaturesSection",
|
||||
"ProductCard",
|
||||
"ProductsSection",
|
||||
"PricingSection",
|
||||
"CTASection",
|
||||
"HomepageSections",
|
||||
|
||||
@@ -77,6 +77,25 @@ class FeatureCard(BaseModel):
|
||||
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):
|
||||
"""Features section configuration."""
|
||||
|
||||
@@ -114,6 +133,7 @@ class HomepageSections(BaseModel):
|
||||
"""Complete homepage sections structure."""
|
||||
|
||||
hero: HeroSection | None = None
|
||||
products: ProductsSection | None = None
|
||||
features: FeaturesSection | None = None
|
||||
pricing: PricingSection | None = None
|
||||
cta: CTASection | None = None
|
||||
@@ -139,6 +159,10 @@ class HomepageSections(BaseModel):
|
||||
subtitle=make_translatable(languages),
|
||||
buttons=[],
|
||||
),
|
||||
products=ProductsSection(
|
||||
title=make_translatable(languages),
|
||||
products=[],
|
||||
),
|
||||
features=FeaturesSection(
|
||||
title=make_translatable(languages),
|
||||
features=[],
|
||||
@@ -162,7 +186,7 @@ class HomepageSections(BaseModel):
|
||||
class SectionUpdateRequest(BaseModel):
|
||||
"""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")
|
||||
|
||||
|
||||
|
||||
@@ -142,8 +142,8 @@ class ContentPageService:
|
||||
db.query(ContentPage)
|
||||
.filter(
|
||||
and_(
|
||||
ContentPage.store_id is None,
|
||||
ContentPage.is_platform_page == False,
|
||||
ContentPage.store_id.is_(None),
|
||||
ContentPage.is_platform_page.is_(False),
|
||||
*base_filters,
|
||||
)
|
||||
)
|
||||
@@ -182,12 +182,12 @@ class ContentPageService:
|
||||
filters = [
|
||||
ContentPage.platform_id == platform_id,
|
||||
ContentPage.slug == slug,
|
||||
ContentPage.store_id is None,
|
||||
ContentPage.is_platform_page == True,
|
||||
ContentPage.store_id.is_(None),
|
||||
ContentPage.is_platform_page.is_(True),
|
||||
]
|
||||
|
||||
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()
|
||||
|
||||
@@ -255,8 +255,8 @@ class ContentPageService:
|
||||
db.query(ContentPage)
|
||||
.filter(
|
||||
and_(
|
||||
ContentPage.store_id is None,
|
||||
ContentPage.is_platform_page == False,
|
||||
ContentPage.store_id.is_(None),
|
||||
ContentPage.is_platform_page.is_(False),
|
||||
*base_filters,
|
||||
)
|
||||
)
|
||||
@@ -298,12 +298,12 @@ class ContentPageService:
|
||||
"""
|
||||
filters = [
|
||||
ContentPage.platform_id == platform_id,
|
||||
ContentPage.store_id is None,
|
||||
ContentPage.is_platform_page == True,
|
||||
ContentPage.store_id.is_(None),
|
||||
ContentPage.is_platform_page.is_(True),
|
||||
]
|
||||
|
||||
if not include_unpublished:
|
||||
filters.append(ContentPage.is_published == True)
|
||||
filters.append(ContentPage.is_published.is_(True))
|
||||
|
||||
if footer_only:
|
||||
filters.append(ContentPage.show_in_footer == True)
|
||||
@@ -377,12 +377,12 @@ class ContentPageService:
|
||||
"""
|
||||
filters = [
|
||||
ContentPage.platform_id == platform_id,
|
||||
ContentPage.store_id is None,
|
||||
ContentPage.is_platform_page == False,
|
||||
ContentPage.store_id.is_(None),
|
||||
ContentPage.is_platform_page.is_(False),
|
||||
]
|
||||
|
||||
if not include_unpublished:
|
||||
filters.append(ContentPage.is_published == True)
|
||||
filters.append(ContentPage.is_published.is_(True))
|
||||
|
||||
return (
|
||||
db.query(ContentPage)
|
||||
@@ -845,13 +845,13 @@ class ContentPageService:
|
||||
filters.append(ContentPage.is_published == True)
|
||||
|
||||
if page_tier == "platform":
|
||||
filters.append(ContentPage.is_platform_page == True)
|
||||
filters.append(ContentPage.store_id is None)
|
||||
filters.append(ContentPage.is_platform_page.is_(True))
|
||||
filters.append(ContentPage.store_id.is_(None))
|
||||
elif page_tier == "store_default":
|
||||
filters.append(ContentPage.is_platform_page == False)
|
||||
filters.append(ContentPage.store_id is None)
|
||||
filters.append(ContentPage.is_platform_page.is_(False))
|
||||
filters.append(ContentPage.store_id.is_(None))
|
||||
elif page_tier == "store_override":
|
||||
filters.append(ContentPage.store_id is not None)
|
||||
filters.append(ContentPage.store_id.isnot(None))
|
||||
|
||||
return (
|
||||
db.query(ContentPage)
|
||||
@@ -958,6 +958,34 @@ class ContentPageService:
|
||||
if not success:
|
||||
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
|
||||
# =========================================================================
|
||||
@@ -1032,10 +1060,12 @@ class ContentPageService:
|
||||
FeaturesSection,
|
||||
HeroSection,
|
||||
PricingSection,
|
||||
ProductsSection,
|
||||
)
|
||||
|
||||
SECTION_SCHEMAS = {
|
||||
"hero": HeroSection,
|
||||
"products": ProductsSection,
|
||||
"features": FeaturesSection,
|
||||
"pricing": PricingSection,
|
||||
"cta": CTASection,
|
||||
|
||||
@@ -187,6 +187,35 @@
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════ -->
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
{# Import section partials #}
|
||||
{% 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/_pricing.html' import render_pricing %}
|
||||
{% from 'cms/platform/sections/_cta.html' import render_cta %}
|
||||
@@ -35,6 +36,11 @@
|
||||
{{ render_hero(page.sections.hero, lang, default_lang) }}
|
||||
{% endif %}
|
||||
|
||||
{# Products Section #}
|
||||
{% if page.sections.products %}
|
||||
{{ render_products(page.sections.products, lang, default_lang) }}
|
||||
{% endif %}
|
||||
|
||||
{# Features Section #}
|
||||
{% if page.sections.features %}
|
||||
{{ render_features(page.sections.features, lang, default_lang) }}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -42,17 +42,16 @@
|
||||
{% endif %}
|
||||
</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="prose prose-lg dark:prose-invert max-w-none">
|
||||
{% if page.content_format == 'markdown' %}
|
||||
{# Markdown content - future enhancement: render with markdown library #}
|
||||
<div class="markdown-content">
|
||||
{{ page.content | safe }}{# sanitized: CMS content #}
|
||||
{{ content | safe }}{# sanitized: CMS content #}
|
||||
</div>
|
||||
{% else %}
|
||||
{# HTML content (default) #}
|
||||
{{ page.content | safe }}{# sanitized: CMS content #}
|
||||
{{ content | safe }}{# sanitized: CMS content #}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user