feat(i18n): add multilingual platform descriptions and HostWizard demo data
Some checks failed
CI / ruff (push) Successful in 11s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled

- Add description_translations JSON column to Platform model + migration
- Add language tabs to platform admin edit form for multilingual descriptions
- Update API schemas to include description_translations in request/response
- Translate pricing section UI labels via _t() macro (monthly/annual/CTA/etc.)
- Add Luxembourgish (lb) support to all platforms (OMS, Main, Loyalty, Hosting)
- Seed description_translations, contact emails, and social links for all platforms
- Add LuxWeb Agency demo merchant with hosting stores, team, and content pages
- Fix language code typo: lu → lb in platform-edit.js availableLanguages
- Fix store content pages to use correct primary platform instead of hardcoded OMS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 23:38:52 +01:00
parent 2268f32f51
commit 820ab1aaa4
8 changed files with 327 additions and 32 deletions

View File

@@ -11,7 +11,7 @@
#}
{# Helper macro: resolve a TranslatableText field with fallback #}
{% macro _t(field, fallback='') %}
{% macro _t(field, lang, default_lang, fallback='') %}
{%- if field and field.translations -%}
{{ field.translations.get(lang) or field.translations.get(default_lang) or fallback }}
{%- else -%}
@@ -25,12 +25,12 @@
<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 = _t(pricing.title, 'Simple, Transparent Pricing') %}
{% set title = _t(pricing.title, lang, default_lang, 'Simple, Transparent Pricing') %}
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ title }}
</h2>
{% set subtitle = _t(pricing.subtitle) %}
{% set subtitle = _t(pricing.subtitle, lang, default_lang) %}
{% if subtitle|trim %}
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{{ subtitle }}
@@ -40,13 +40,13 @@
{# Pricing toggle (monthly/annual) #}
{% if pricing.use_subscription_tiers and tiers %}
{% set monthly_label = _t(pricing.monthly_label, 'Monthly') %}
{% set annual_label = _t(pricing.annual_label, 'Annual') %}
{% set save_text = _t(pricing.save_text, 'Save 2 months!') %}
{% set popular_badge = _t(pricing.popular_badge, 'Most Popular') %}
{% set cta_text = _t(pricing.cta_text, 'Start Free Trial') %}
{% set per_month = _t(pricing.per_month_label, '/month') %}
{% set per_year = _t(pricing.per_year_label, '/year') %}
{% set monthly_label = _t(pricing.monthly_label, lang, default_lang, 'Monthly') %}
{% set annual_label = _t(pricing.annual_label, lang, default_lang, 'Annual') %}
{% set save_text = _t(pricing.save_text, lang, default_lang, 'Save 2 months!') %}
{% set popular_badge = _t(pricing.popular_badge, lang, default_lang, 'Most Popular') %}
{% set cta_text = _t(pricing.cta_text, lang, default_lang, 'Start Free Trial') %}
{% set per_month = _t(pricing.per_month_label, lang, default_lang, '/month') %}
{% set per_year = _t(pricing.per_year_label, lang, default_lang, '/year') %}
<div x-data="{ annual: false }" class="space-y-8">
{# Billing toggle #}
@@ -128,7 +128,7 @@
</div>
{% else %}
{# Placeholder when no tiers available #}
{% set coming_soon = _t(pricing.coming_soon_text, 'Pricing plans coming soon') %}
{% set coming_soon = _t(pricing.coming_soon_text, lang, default_lang, 'Pricing plans coming soon') %}
<div class="text-center text-gray-500 dark:text-gray-400 py-8">
{{ coming_soon }}
</div>

View File

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

View File

@@ -71,6 +71,13 @@ class Platform(Base, TimestampMixin):
comment="Platform description for admin/marketing purposes",
)
description_translations = Column(
JSON,
nullable=True,
default=None,
comment="Language-keyed description dict for multi-language support",
)
# ========================================================================
# Domain Routing
# ========================================================================
@@ -226,6 +233,17 @@ class Platform(Base, TimestampMixin):
# Properties
# ========================================================================
def get_translated_description(self, lang: str, default_lang: str = "fr") -> str:
"""Return description in the requested language with fallback."""
if self.description_translations:
return (
self.description_translations.get(lang)
or self.description_translations.get(default_lang)
or self.description
or ""
)
return self.description or ""
@property
def base_url(self) -> str:
"""Get the base URL for this platform (for link generation)."""

View File

@@ -41,6 +41,7 @@ class PlatformResponse(BaseModel):
code: str
name: str
description: str | None = None
description_translations: dict[str, str] | None = None
domain: str | None = None
path_prefix: str | None = None
logo: str | None = None
@@ -76,6 +77,7 @@ class PlatformUpdateRequest(BaseModel):
name: str | None = None
description: str | None = None
description_translations: dict[str, str] | None = None
domain: str | None = None
path_prefix: str | None = None
logo: str | None = None
@@ -115,6 +117,7 @@ def _build_platform_response(db: Session, platform) -> PlatformResponse:
code=platform.code,
name=platform.name,
description=platform.description,
description_translations=platform.description_translations,
domain=platform.domain,
path_prefix=platform.path_prefix,
logo=platform.logo,

View File

@@ -22,10 +22,20 @@ function platformEdit() {
success: null,
platformCode: null,
// Language editing
currentLang: 'fr',
languageNames: {
fr: 'Fran\u00e7ais',
de: 'Deutsch',
en: 'English',
lb: 'L\u00ebtzebuergesch',
},
// Form data
formData: {
name: '',
description: '',
description_translations: {},
domain: '',
path_prefix: '',
logo: '',
@@ -46,7 +56,7 @@ function platformEdit() {
{ code: 'fr', name: 'French' },
{ code: 'de', name: 'German' },
{ code: 'en', name: 'English' },
{ code: 'lu', name: 'Luxembourgish' },
{ code: 'lb', name: 'Luxembourgish' },
],
// Lifecycle
@@ -92,23 +102,34 @@ function platformEdit() {
const response = await apiClient.get(`/admin/platforms/${this.platformCode}`);
this.platform = response;
// Build description_translations with empty strings for all supported languages
const langs = response.supported_languages || ['fr', 'de', 'en'];
const descTranslations = {};
for (const lang of langs) {
descTranslations[lang] = (response.description_translations && response.description_translations[lang]) || '';
}
// Populate form data
this.formData = {
name: response.name || '',
description: response.description || '',
description_translations: descTranslations,
domain: response.domain || '',
path_prefix: response.path_prefix || '',
logo: response.logo || '',
logo_dark: response.logo_dark || '',
favicon: response.favicon || '',
default_language: response.default_language || 'fr',
supported_languages: response.supported_languages || ['fr', 'de', 'en'],
supported_languages: langs,
is_active: response.is_active ?? true,
is_public: response.is_public ?? true,
theme_config: response.theme_config || {},
settings: response.settings || {},
};
// Set current language tab to default language
this.currentLang = response.default_language || 'fr';
platformEditLog.info(`Loaded platform: ${this.platformCode}`);
} catch (err) {
platformEditLog.error('Error loading platform:', err);
@@ -126,9 +147,14 @@ function platformEdit() {
try {
// Build update payload (only changed fields)
// Sync base description from default language translation
const defaultLang = this.formData.default_language || 'fr';
const baseDesc = this.formData.description_translations[defaultLang] || this.formData.description;
const payload = {
name: this.formData.name,
description: this.formData.description || null,
description: baseDesc || null,
description_translations: this.formData.description_translations,
domain: this.formData.domain || null,
path_prefix: this.formData.path_prefix || null,
logo: this.formData.logo || null,
@@ -199,9 +225,17 @@ function platformEdit() {
// Don't allow removing the last language
if (this.formData.supported_languages.length > 1) {
this.formData.supported_languages.splice(index, 1);
// Switch tab if the removed language was active
if (this.currentLang === code) {
this.currentLang = this.formData.supported_languages[0];
}
}
} else {
this.formData.supported_languages.push(code);
// Initialize empty translation for new language
if (!this.formData.description_translations[code]) {
this.formData.description_translations[code] = '';
}
}
},

View File

@@ -130,19 +130,41 @@
<span x-show="errors.name" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="errors.name"></span>
</label>
<!-- Description -->
<label class="block mb-4 text-sm">
<!-- Description (multilingual) -->
<div class="block mb-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
Description
</span>
<!-- Language Tabs -->
<div class="mt-2 border-b border-gray-200 dark:border-gray-700">
<nav class="flex -mb-px space-x-2">
<template x-for="lang in formData.supported_languages" :key="lang">
<button
type="button"
@click="currentLang = lang"
:class="currentLang === lang
? 'border-purple-500 text-purple-600 dark:text-purple-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
class="py-2 px-3 border-b-2 font-medium text-xs transition-colors"
>
<span x-text="languageNames[lang] || lang.toUpperCase()"></span>
<span x-show="lang === formData.default_language" class="ml-1 text-xs text-gray-400">(default)</span>
</button>
</template>
</nav>
</div>
<!-- Per-language textarea -->
<textarea
x-model="formData.description"
x-model="formData.description_translations[currentLang]"
rows="3"
maxlength="500"
:disabled="saving"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-textarea"
:placeholder="'Description in ' + (languageNames[currentLang] || currentLang)"
class="block w-full mt-2 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-textarea"
></textarea>
</label>
</div>
<!-- Default Language -->
<label class="block mb-4 text-sm">