fix(i18n): translate pricing tiers, features, and content pages
Some checks failed
Some checks failed
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:
@@ -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.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 = (
|
||||
db.query(SubscriptionTier)
|
||||
@@ -48,14 +51,28 @@ def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any
|
||||
tiers = []
|
||||
for tier in tiers_db:
|
||||
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({
|
||||
"code": tier.code,
|
||||
"name": tier.name,
|
||||
"name": tier.get_translated_name(language),
|
||||
"price_monthly": tier.price_monthly_cents / 100,
|
||||
"price_annual": (tier.price_annual_cents / 100)
|
||||
if tier.price_annual_cents
|
||||
else None,
|
||||
"feature_codes": feature_codes,
|
||||
"features": features,
|
||||
"products_limit": tier.get_limit_for_feature("products_limit"),
|
||||
"orders_per_month": tier.get_limit_for_feature("orders_per_month"),
|
||||
"team_members": tier.get_limit_for_feature("team_members"),
|
||||
|
||||
@@ -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")
|
||||
@@ -100,6 +100,12 @@ class SubscriptionTier(Base, TimestampMixin):
|
||||
|
||||
code = Column(String(30), nullable=False, index=True)
|
||||
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)
|
||||
|
||||
# Pricing (in cents for precision)
|
||||
@@ -154,6 +160,16 @@ class SubscriptionTier(Base, TimestampMixin):
|
||||
"""Check if this tier includes a specific feature."""
|
||||
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
|
||||
|
||||
@@ -66,32 +66,23 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# Features list (dynamic from module providers) #}
|
||||
{% if tier.features %}
|
||||
<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">
|
||||
<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.orders_per_month %}{{ _("cms.platform.pricing.orders_per_month", count=tier.orders_per_month) }}{% else %}{{ _("cms.platform.pricing.unlimited_orders") }}{% 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.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") }}
|
||||
{% if feat.is_quantitative and feat.limit %}
|
||||
{{ feat.limit }} {{ _(feat.name_key) }}
|
||||
{% else %}
|
||||
{{ _(feat.name_key) }}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% 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">
|
||||
|
||||
@@ -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."""
|
||||
from app.modules.billing.models import SubscriptionTier, TierCode
|
||||
from app.modules.billing.services.feature_aggregator import feature_aggregator
|
||||
|
||||
filters = [
|
||||
SubscriptionTier.is_active.is_(True),
|
||||
@@ -49,12 +52,26 @@ def _get_tiers_data(db: Session, platform_id: int | None = None) -> list[dict]:
|
||||
tiers = []
|
||||
for tier in tiers_db:
|
||||
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({
|
||||
"code": tier.code,
|
||||
"name": tier.name,
|
||||
"name": tier.get_translated_name(lang),
|
||||
"price_monthly": tier.price_monthly_cents / 100,
|
||||
"price_annual": (tier.price_annual_cents / 100) if tier.price_annual_cents else None,
|
||||
"feature_codes": feature_codes,
|
||||
"features": features,
|
||||
"products_limit": tier.get_limit_for_feature("products_limit"),
|
||||
"orders_per_month": tier.get_limit_for_feature("orders_per_month"),
|
||||
"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
|
||||
)
|
||||
|
||||
language = getattr(request.state, "language", "fr") or "fr"
|
||||
|
||||
if cms_homepage:
|
||||
# Use CMS-based homepage with template selection
|
||||
context = get_platform_context(request, db)
|
||||
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_path = f"cms/platform/homepage-{template_name}.html"
|
||||
@@ -171,7 +190,7 @@ async def homepage(
|
||||
# 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, platform_id=platform_id)
|
||||
context["tiers"] = _get_tiers_data(db, platform_id=platform_id, lang=language)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"cms/platform/homepage-default.html",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% 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/_pricing.html' import render_pricing with context %}
|
||||
{% from 'cms/platform/sections/_cta.html' import render_cta %}
|
||||
|
||||
{% block title %}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
{# Pricing section partial with multi-language support #}
|
||||
{#
|
||||
Parameters:
|
||||
- pricing: PricingSection object (or dict)
|
||||
- pricing: PricingSection object (or dict) from CMS sections JSON
|
||||
- lang: Current language code
|
||||
- default_lang: Fallback language
|
||||
- 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 #}
|
||||
@@ -80,9 +82,6 @@
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{{ tier.name }}
|
||||
</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm mb-4">
|
||||
{{ tier.description or '' }}
|
||||
</p>
|
||||
|
||||
{# Price #}
|
||||
<div class="mb-6">
|
||||
@@ -102,15 +101,23 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Features list #}
|
||||
{# Features list (dynamic from module providers) #}
|
||||
{% if tier.features %}
|
||||
<ul class="mt-8 space-y-3">
|
||||
{% for feature in tier.features %}
|
||||
{% for feat in tier.features %}
|
||||
<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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</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>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user