feat(i18n): add multilingual platform descriptions and HostWizard demo data
Some checks failed
Some checks failed
- 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:
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user