fix(i18n): translate pricing tiers, features, and content pages
Some checks failed
CI / ruff (push) Successful in 11s
CI / pytest (push) Failing after 49m22s
CI / validate (push) Successful in 26s
CI / dependency-scanning (push) Successful in 28s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

Add name_translations JSON column to SubscriptionTier for multi-language
tier names. Pre-resolve tier names and build dynamic feature lists from
module providers in route handlers. Fix Jinja2 macro scoping by importing
pricing partial with context. Backfill content_translations for all 43
content pages across 4 platforms (en/fr/de/lb).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 07:48:15 +01:00
parent 8b147f53c6
commit 3c7e4458af
9 changed files with 1544 additions and 33 deletions

View File

@@ -34,6 +34,9 @@ def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any
""" """
from app.core.config import settings from app.core.config import settings
from app.modules.billing.models import SubscriptionTier, TierCode from app.modules.billing.models import SubscriptionTier, TierCode
from app.modules.billing.services.feature_aggregator import feature_aggregator
language = getattr(request.state, "language", "fr") or "fr"
tiers_db = ( tiers_db = (
db.query(SubscriptionTier) db.query(SubscriptionTier)
@@ -48,14 +51,28 @@ def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any
tiers = [] tiers = []
for tier in tiers_db: for tier in tiers_db:
feature_codes = sorted(tier.get_feature_codes()) feature_codes = sorted(tier.get_feature_codes())
# Build features list from declarations for template rendering
features = []
for code in feature_codes:
decl = feature_aggregator.get_declaration(code)
if decl:
features.append({
"code": code,
"name_key": decl.name_key,
"limit": tier.get_limit_for_feature(code),
"is_quantitative": decl.feature_type.value == "quantitative",
})
tiers.append({ tiers.append({
"code": tier.code, "code": tier.code,
"name": tier.name, "name": tier.get_translated_name(language),
"price_monthly": tier.price_monthly_cents / 100, "price_monthly": tier.price_monthly_cents / 100,
"price_annual": (tier.price_annual_cents / 100) "price_annual": (tier.price_annual_cents / 100)
if tier.price_annual_cents if tier.price_annual_cents
else None, else None,
"feature_codes": feature_codes, "feature_codes": feature_codes,
"features": features,
"products_limit": tier.get_limit_for_feature("products_limit"), "products_limit": tier.get_limit_for_feature("products_limit"),
"orders_per_month": tier.get_limit_for_feature("orders_per_month"), "orders_per_month": tier.get_limit_for_feature("orders_per_month"),
"team_members": tier.get_limit_for_feature("team_members"), "team_members": tier.get_limit_for_feature("team_members"),

View File

@@ -0,0 +1,31 @@
"""add name_translations to subscription_tiers
Revision ID: billing_002
Revises: hosting_001
Create Date: 2026-03-03
"""
import sqlalchemy as sa
from alembic import op
revision = "billing_002"
down_revision = "hosting_001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"subscription_tiers",
sa.Column(
"name_translations",
sa.JSON(),
nullable=True,
comment="Language-keyed name dict for multi-language support",
),
)
def downgrade() -> None:
op.drop_column("subscription_tiers", "name_translations")

View File

@@ -100,6 +100,12 @@ class SubscriptionTier(Base, TimestampMixin):
code = Column(String(30), nullable=False, index=True) code = Column(String(30), nullable=False, index=True)
name = Column(String(100), nullable=False) name = Column(String(100), nullable=False)
name_translations = Column(
JSON,
nullable=True,
default=None,
comment="Language-keyed name dict for multi-language support",
)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
# Pricing (in cents for precision) # Pricing (in cents for precision)
@@ -154,6 +160,16 @@ class SubscriptionTier(Base, TimestampMixin):
"""Check if this tier includes a specific feature.""" """Check if this tier includes a specific feature."""
return feature_code in self.get_feature_codes() return feature_code in self.get_feature_codes()
def get_translated_name(self, lang: str, default_lang: str = "fr") -> str:
"""Get name in the given language, falling back to default_lang then self.name."""
if self.name_translations:
return (
self.name_translations.get(lang)
or self.name_translations.get(default_lang)
or self.name
)
return self.name
# ============================================================================ # ============================================================================
# AddOnProduct - Purchasable add-ons # AddOnProduct - Purchasable add-ons

View File

@@ -66,32 +66,23 @@
</template> </template>
</div> </div>
{# Features list (dynamic from module providers) #}
{% if tier.features %}
<ul class="space-y-3 mb-8 text-sm"> <ul class="space-y-3 mb-8 text-sm">
{% for feat in tier.features %}
<li class="flex items-center text-gray-700 dark:text-gray-300"> <li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-4 h-4 text-green-500 mr-2" 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"/> <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> </svg>
{% if tier.orders_per_month %}{{ _("cms.platform.pricing.orders_per_month", count=tier.orders_per_month) }}{% else %}{{ _("cms.platform.pricing.unlimited_orders") }}{% endif %} {% if feat.is_quantitative and feat.limit %}
</li> {{ feat.limit }} {{ _(feat.name_key) }}
<li class="flex items-center text-gray-700 dark:text-gray-300"> {% else %}
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20"> {{ _(feat.name_key) }}
<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"/> {% endif %}
</svg>
{% if tier.products_limit %}{{ _("cms.platform.pricing.products_limit", count=tier.products_limit) }}{% else %}{{ _("cms.platform.pricing.unlimited_products") }}{% endif %}
</li>
<li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2" 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>
<li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2" 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> </li>
{% endfor %}
</ul> </ul>
{% endif %}
{% if tier.is_enterprise %} {% if tier.is_enterprise %}
<a href="/contact" class="block w-full py-3 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white font-semibold rounded-xl text-center hover:bg-gray-300 transition-colors"> <a href="/contact" class="block w-full py-3 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white font-semibold rounded-xl text-center hover:bg-gray-300 transition-colors">

View File

@@ -28,9 +28,12 @@ ROUTE_CONFIG = {
} }
def _get_tiers_data(db: Session, platform_id: int | None = None) -> list[dict]: def _get_tiers_data(
db: Session, platform_id: int | None = None, lang: str = "fr",
) -> list[dict]:
"""Build tier data for display in templates from database.""" """Build tier data for display in templates from database."""
from app.modules.billing.models import SubscriptionTier, TierCode from app.modules.billing.models import SubscriptionTier, TierCode
from app.modules.billing.services.feature_aggregator import feature_aggregator
filters = [ filters = [
SubscriptionTier.is_active.is_(True), SubscriptionTier.is_active.is_(True),
@@ -49,12 +52,26 @@ def _get_tiers_data(db: Session, platform_id: int | None = None) -> list[dict]:
tiers = [] tiers = []
for tier in tiers_db: for tier in tiers_db:
feature_codes = sorted(tier.get_feature_codes()) feature_codes = sorted(tier.get_feature_codes())
# Build features list from declarations for template rendering
features = []
for code in feature_codes:
decl = feature_aggregator.get_declaration(code)
if decl:
features.append({
"code": code,
"name_key": decl.name_key,
"limit": tier.get_limit_for_feature(code),
"is_quantitative": decl.feature_type.value == "quantitative",
})
tiers.append({ tiers.append({
"code": tier.code, "code": tier.code,
"name": tier.name, "name": tier.get_translated_name(lang),
"price_monthly": tier.price_monthly_cents / 100, "price_monthly": tier.price_monthly_cents / 100,
"price_annual": (tier.price_annual_cents / 100) if tier.price_annual_cents else None, "price_annual": (tier.price_annual_cents / 100) if tier.price_annual_cents else None,
"feature_codes": feature_codes, "feature_codes": feature_codes,
"features": features,
"products_limit": tier.get_limit_for_feature("products_limit"), "products_limit": tier.get_limit_for_feature("products_limit"),
"orders_per_month": tier.get_limit_for_feature("orders_per_month"), "orders_per_month": tier.get_limit_for_feature("orders_per_month"),
"team_members": tier.get_limit_for_feature("team_members"), "team_members": tier.get_limit_for_feature("team_members"),
@@ -156,11 +173,13 @@ async def homepage(
db, platform_id=platform_id, slug="home", include_unpublished=False db, platform_id=platform_id, slug="home", include_unpublished=False
) )
language = getattr(request.state, "language", "fr") or "fr"
if cms_homepage: if cms_homepage:
# Use CMS-based homepage with template selection # Use CMS-based homepage with template selection
context = get_platform_context(request, db) context = get_platform_context(request, db)
context["page"] = cms_homepage context["page"] = cms_homepage
context["tiers"] = _get_tiers_data(db, platform_id=platform_id) context["tiers"] = _get_tiers_data(db, platform_id=platform_id, lang=language)
template_name = cms_homepage.template or "default" template_name = cms_homepage.template or "default"
template_path = f"cms/platform/homepage-{template_name}.html" template_path = f"cms/platform/homepage-{template_name}.html"
@@ -171,7 +190,7 @@ async def homepage(
# Fallback: Default homepage template with placeholder content # Fallback: Default homepage template with placeholder content
logger.info("[HOMEPAGE] No CMS homepage found, using default template with placeholders") 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, platform_id=platform_id) context["tiers"] = _get_tiers_data(db, platform_id=platform_id, lang=language)
return templates.TemplateResponse( return templates.TemplateResponse(
"cms/platform/homepage-default.html", "cms/platform/homepage-default.html",

View File

@@ -6,7 +6,7 @@
{% 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/_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 with context %}
{% from 'cms/platform/sections/_cta.html' import render_cta %} {% from 'cms/platform/sections/_cta.html' import render_cta %}
{% block title %} {% block title %}

View File

@@ -2,10 +2,12 @@
{# Pricing section partial with multi-language support #} {# Pricing section partial with multi-language support #}
{# {#
Parameters: Parameters:
- pricing: PricingSection object (or dict) - pricing: PricingSection object (or dict) from CMS sections JSON
- lang: Current language code - lang: Current language code
- default_lang: Fallback language - default_lang: Fallback language
- tiers: List of subscription tiers from DB (passed via context) - tiers: List of subscription tiers from DB (passed via context)
Requires: import with context (for _() locale function)
#} #}
{# Helper macro: resolve a TranslatableText field with fallback #} {# Helper macro: resolve a TranslatableText field with fallback #}
@@ -80,9 +82,6 @@
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2"> <h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">
{{ tier.name }} {{ tier.name }}
</h3> </h3>
<p class="text-gray-500 dark:text-gray-400 text-sm mb-4">
{{ tier.description or '' }}
</p>
{# Price #} {# Price #}
<div class="mb-6"> <div class="mb-6">
@@ -102,15 +101,23 @@
</a> </a>
</div> </div>
{# Features list #} {# Features list (dynamic from module providers) #}
{% if tier.features %} {% if tier.features %}
<ul class="mt-8 space-y-3"> <ul class="mt-8 space-y-3">
{% for feature in tier.features %} {% for feat in tier.features %}
<li class="flex items-start"> <li class="flex items-start">
<svg class="w-5 h-5 text-green-500 mr-3 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 text-green-500 mr-3 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg> </svg>
<span class="text-gray-600 dark:text-gray-400 text-sm">{{ feature }}</span> <span class="text-gray-600 dark:text-gray-400 text-sm">
{% if feat.is_quantitative and feat.limit %}
{{ feat.limit }} {{ _(feat.name_key) }}
{% elif feat.is_quantitative %}
{{ _(feat.name_key) }}
{% else %}
{{ _(feat.name_key) }}
{% endif %}
</span>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

File diff suppressed because it is too large Load Diff

View File

@@ -55,6 +55,7 @@ for _mod in [
"app.modules.orders.models", "app.modules.orders.models",
"app.modules.marketplace.models", "app.modules.marketplace.models",
"app.modules.cms.models", "app.modules.cms.models",
"app.modules.prospecting.models",
"app.modules.hosting.models", "app.modules.hosting.models",
]: ]:
with contextlib.suppress(ImportError): with contextlib.suppress(ImportError):
@@ -472,6 +473,10 @@ def create_subscription_tiers(db: Session, platform: Platform) -> int:
{ {
"code": "essential", "code": "essential",
"name": "Essential", "name": "Essential",
"name_translations": {
"en": "Essential", "fr": "Essentiel",
"de": "Basis", "lb": "Basis",
},
"price_monthly_cents": 2900, "price_monthly_cents": 2900,
"price_annual_cents": 29000, "price_annual_cents": 29000,
"is_public": True, "is_public": True,
@@ -480,6 +485,10 @@ def create_subscription_tiers(db: Session, platform: Platform) -> int:
{ {
"code": "professional", "code": "professional",
"name": "Professional", "name": "Professional",
"name_translations": {
"en": "Professional", "fr": "Professionnel",
"de": "Professionell", "lb": "Professionell",
},
"price_monthly_cents": 7900, "price_monthly_cents": 7900,
"price_annual_cents": 79000, "price_annual_cents": 79000,
"is_public": True, "is_public": True,
@@ -488,6 +497,10 @@ def create_subscription_tiers(db: Session, platform: Platform) -> int:
{ {
"code": "business", "code": "business",
"name": "Business", "name": "Business",
"name_translations": {
"en": "Business", "fr": "Business",
"de": "Business", "lb": "Business",
},
"price_monthly_cents": 14900, "price_monthly_cents": 14900,
"price_annual_cents": 149000, "price_annual_cents": 149000,
"is_public": True, "is_public": True,
@@ -496,6 +509,10 @@ def create_subscription_tiers(db: Session, platform: Platform) -> int:
{ {
"code": "enterprise", "code": "enterprise",
"name": "Enterprise", "name": "Enterprise",
"name_translations": {
"en": "Enterprise", "fr": "Entreprise",
"de": "Enterprise", "lb": "Enterprise",
},
"price_monthly_cents": 29900, "price_monthly_cents": 29900,
"price_annual_cents": None, "price_annual_cents": None,
"is_public": False, "is_public": False,
@@ -513,13 +530,19 @@ def create_subscription_tiers(db: Session, platform: Platform) -> int:
).scalar_one_or_none() ).scalar_one_or_none()
if existing: if existing:
print_warning(f"Tier already exists: {existing.name} ({existing.code}) for {platform.name}") # Backfill name_translations if missing
if existing.name_translations is None and tdef.get("name_translations"):
existing.name_translations = tdef["name_translations"]
print_success(f"Backfilled name_translations: {existing.name} ({existing.code}) for {platform.name}")
else:
print_warning(f"Tier already exists: {existing.name} ({existing.code}) for {platform.name}")
continue continue
tier = SubscriptionTier( tier = SubscriptionTier(
platform_id=platform.id, platform_id=platform.id,
code=tdef["code"], code=tdef["code"],
name=tdef["name"], name=tdef["name"],
name_translations=tdef.get("name_translations"),
price_monthly_cents=tdef["price_monthly_cents"], price_monthly_cents=tdef["price_monthly_cents"],
price_annual_cents=tdef["price_annual_cents"], price_annual_cents=tdef["price_annual_cents"],
is_public=tdef["is_public"], is_public=tdef["is_public"],