feat: add multi-language (i18n) support for vendor dashboard and storefront
- Add database fields for language preferences: - Vendor: dashboard_language, storefront_language, storefront_languages - User: preferred_language - Customer: preferred_language - Add language middleware for request-level language detection: - Cookie-based persistence - Browser Accept-Language fallback - Vendor storefront language constraints - Add language API endpoints (/api/v1/language/*): - POST /set - Set language preference - GET /current - Get current language info - GET /list - List available languages - DELETE /clear - Clear preference - Add i18n utilities (app/utils/i18n.py): - JSON-based translation loading - Jinja2 template integration - Language resolution helpers - Add reusable language selector macros for templates - Add languageSelector() Alpine.js component - Add translation files (en, fr, de, lb) in static/locales/ - Add architecture rules documentation for language implementation - Update marketplace-product-detail.js to use native language names 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ This module provides:
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import admin, shop, vendor
|
||||
from app.api.v1.shared import language
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -34,3 +35,10 @@ api_router.include_router(vendor.router, prefix="/v1/vendor", tags=["vendor"])
|
||||
# ============================================================================
|
||||
|
||||
api_router.include_router(shop.router, prefix="/v1/shop", tags=["shop"])
|
||||
|
||||
# ============================================================================
|
||||
# SHARED ROUTES (Cross-context utilities)
|
||||
# Prefix: /api/v1
|
||||
# ============================================================================
|
||||
|
||||
api_router.include_router(language.router, prefix="/v1", tags=["language"])
|
||||
|
||||
179
app/api/v1/shared/language.py
Normal file
179
app/api/v1/shared/language.py
Normal file
@@ -0,0 +1,179 @@
|
||||
# app/api/v1/shared/language.py
|
||||
"""
|
||||
Language API endpoints for setting user/customer language preferences.
|
||||
|
||||
These endpoints handle:
|
||||
- Setting language preference via cookie
|
||||
- Getting current language info
|
||||
- Listing available languages
|
||||
"""
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.utils.i18n import (
|
||||
DEFAULT_LANGUAGE,
|
||||
LANGUAGE_FLAGS,
|
||||
LANGUAGE_NAMES,
|
||||
LANGUAGE_NAMES_EN,
|
||||
SUPPORTED_LANGUAGES,
|
||||
get_language_info,
|
||||
)
|
||||
from middleware.language import LANGUAGE_COOKIE_NAME, set_language_cookie
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/language", tags=["language"])
|
||||
|
||||
|
||||
class SetLanguageRequest(BaseModel):
|
||||
"""Request body for setting language preference."""
|
||||
|
||||
language: str = Field(
|
||||
...,
|
||||
description="Language code (en, fr, de, lb)",
|
||||
min_length=2,
|
||||
max_length=5,
|
||||
)
|
||||
|
||||
|
||||
class SetLanguageResponse(BaseModel):
|
||||
"""Response after setting language preference."""
|
||||
|
||||
success: bool
|
||||
language: str
|
||||
message: str
|
||||
|
||||
|
||||
class LanguageInfo(BaseModel):
|
||||
"""Information about a single language."""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
name_en: str
|
||||
flag: str
|
||||
|
||||
|
||||
class LanguageListResponse(BaseModel):
|
||||
"""Response listing all available languages."""
|
||||
|
||||
languages: list[LanguageInfo]
|
||||
current: str
|
||||
default: str
|
||||
|
||||
|
||||
class CurrentLanguageResponse(BaseModel):
|
||||
"""Response with current language information."""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
name_en: str
|
||||
flag: str
|
||||
source: str # Where the language was determined from (cookie, browser, default)
|
||||
|
||||
|
||||
# public - Language preference can be set without authentication
|
||||
@router.post("/set", response_model=SetLanguageResponse)
|
||||
async def set_language(
|
||||
request: Request,
|
||||
response: Response,
|
||||
body: SetLanguageRequest,
|
||||
) -> SetLanguageResponse:
|
||||
"""
|
||||
Set the user's language preference.
|
||||
|
||||
This sets a cookie that will be used for subsequent requests.
|
||||
The page should be reloaded after calling this endpoint.
|
||||
"""
|
||||
language = body.language.lower()
|
||||
|
||||
if language not in SUPPORTED_LANGUAGES:
|
||||
return SetLanguageResponse(
|
||||
success=False,
|
||||
language=language,
|
||||
message=f"Unsupported language: {language}. Supported: {', '.join(SUPPORTED_LANGUAGES)}",
|
||||
)
|
||||
|
||||
# Set language cookie
|
||||
set_language_cookie(response, language)
|
||||
|
||||
logger.info(f"Language preference set to: {language}")
|
||||
|
||||
return SetLanguageResponse(
|
||||
success=True,
|
||||
language=language,
|
||||
message=f"Language set to {LANGUAGE_NAMES.get(language, language)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/current", response_model=CurrentLanguageResponse)
|
||||
async def get_current_language(request: Request) -> CurrentLanguageResponse:
|
||||
"""
|
||||
Get the current language for this request.
|
||||
|
||||
Returns information about the detected language and where it came from.
|
||||
"""
|
||||
# Get language from request state (set by middleware)
|
||||
language = getattr(request.state, "language", DEFAULT_LANGUAGE)
|
||||
language_info = getattr(request.state, "language_info", {})
|
||||
|
||||
# Determine source
|
||||
source = "default"
|
||||
if language_info.get("cookie"):
|
||||
source = "cookie"
|
||||
elif language_info.get("browser"):
|
||||
source = "browser"
|
||||
|
||||
info = get_language_info(language)
|
||||
|
||||
return CurrentLanguageResponse(
|
||||
code=info["code"],
|
||||
name=info["name"],
|
||||
name_en=info["name_en"],
|
||||
flag=info["flag"],
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/list", response_model=LanguageListResponse)
|
||||
async def list_languages(request: Request) -> LanguageListResponse:
|
||||
"""
|
||||
List all available languages.
|
||||
|
||||
Returns all supported languages with their display names.
|
||||
"""
|
||||
current = getattr(request.state, "language", DEFAULT_LANGUAGE)
|
||||
|
||||
languages = [
|
||||
LanguageInfo(
|
||||
code=code,
|
||||
name=LANGUAGE_NAMES.get(code, code),
|
||||
name_en=LANGUAGE_NAMES_EN.get(code, code),
|
||||
flag=LANGUAGE_FLAGS.get(code, ""),
|
||||
)
|
||||
for code in SUPPORTED_LANGUAGES
|
||||
]
|
||||
|
||||
return LanguageListResponse(
|
||||
languages=languages,
|
||||
current=current,
|
||||
default=DEFAULT_LANGUAGE,
|
||||
)
|
||||
|
||||
|
||||
# public - Language preference clearing doesn't require authentication
|
||||
@router.delete("/clear")
|
||||
async def clear_language(response: Response) -> SetLanguageResponse:
|
||||
"""
|
||||
Clear the language preference cookie.
|
||||
|
||||
After clearing, the language will be determined from browser settings or defaults.
|
||||
"""
|
||||
response.delete_cookie(key=LANGUAGE_COOKIE_NAME)
|
||||
|
||||
return SetLanguageResponse(
|
||||
success=True,
|
||||
language=DEFAULT_LANGUAGE,
|
||||
message="Language preference cleared. Using browser/default language.",
|
||||
)
|
||||
329
app/templates/shared/macros/language_selector.html
Normal file
329
app/templates/shared/macros/language_selector.html
Normal file
@@ -0,0 +1,329 @@
|
||||
{#
|
||||
Language Selector Macros
|
||||
========================
|
||||
Reusable language selector components for vendor dashboard and storefront.
|
||||
|
||||
Usage:
|
||||
{% from 'shared/macros/language_selector.html' import language_selector, language_selector_compact %}
|
||||
|
||||
{# Full language selector with labels #}
|
||||
{{ language_selector(current_language='fr', enabled_languages=['fr', 'de', 'en'], position='right') }}
|
||||
|
||||
{# Compact selector (flag only) #}
|
||||
{{ language_selector_compact(current_language='fr', enabled_languages=['fr', 'de', 'en']) }}
|
||||
#}
|
||||
|
||||
|
||||
{#
|
||||
Language configuration - matches app/utils/i18n.py
|
||||
Uses native language names per LANG-005 architecture rule
|
||||
#}
|
||||
{% set LANGUAGE_NAMES = {
|
||||
'en': 'English',
|
||||
'fr': 'Français',
|
||||
'de': 'Deutsch',
|
||||
'lb': 'Lëtzebuergesch'
|
||||
} %}
|
||||
|
||||
{% set LANGUAGE_NATIVE = {
|
||||
'en': 'English',
|
||||
'fr': 'Français',
|
||||
'de': 'Deutsch',
|
||||
'lb': 'Lëtzebuergesch'
|
||||
} %}
|
||||
|
||||
{% set LANGUAGE_FLAGS = {
|
||||
'en': 'gb',
|
||||
'fr': 'fr',
|
||||
'de': 'de',
|
||||
'lb': 'lu'
|
||||
} %}
|
||||
|
||||
|
||||
{#
|
||||
Language Selector (Full)
|
||||
========================
|
||||
A dropdown language selector showing flag and language name.
|
||||
|
||||
Parameters:
|
||||
- current_language: Current language code (default: 'fr')
|
||||
- enabled_languages: List of enabled language codes (default: all)
|
||||
- position: 'left' | 'right' (default: 'right')
|
||||
- context: 'vendor' | 'shop' | 'admin' (affects API endpoint)
|
||||
- show_label: Show language name next to flag (default: true)
|
||||
#}
|
||||
{% macro language_selector(current_language='fr', enabled_languages=none, position='right', context='shop', show_label=true) %}
|
||||
{% set langs = enabled_languages or ['en', 'fr', 'de', 'lb'] %}
|
||||
{% set current = current_language if current_language in langs else langs[0] %}
|
||||
{% set positions = {'left': 'left-0', 'right': 'right-0'} %}
|
||||
{# Uses languageSelector() function per LANG-002 architecture rule #}
|
||||
<div
|
||||
x-data="languageSelector('{{ current }}', {{ langs | tojson | safe }})"
|
||||
class="relative inline-block"
|
||||
>
|
||||
<button
|
||||
@click="isLangOpen = !isLangOpen"
|
||||
@click.outside="isLangOpen = false"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 transition-colors"
|
||||
:class="{ 'ring-2 ring-purple-500 ring-offset-2': isLangOpen }"
|
||||
>
|
||||
<span class="fi" :class="'fi-' + languageFlags[currentLang]"></span>
|
||||
{% if show_label %}
|
||||
<span x-text="languageNames[currentLang]" class="hidden sm:inline"></span>
|
||||
{% endif %}
|
||||
<span x-html="$icon('chevron-down', 'w-4 h-4')" :class="{ 'rotate-180': isLangOpen }" class="transition-transform"></span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
x-show="isLangOpen"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
x-transition:enter-end="transform opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
class="absolute z-50 mt-2 w-44 {{ positions[position] }} bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1"
|
||||
>
|
||||
<template x-for="lang in languages" :key="lang">
|
||||
<button
|
||||
@click="setLanguage(lang)"
|
||||
type="button"
|
||||
class="flex items-center gap-3 w-full px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="currentLang === lang
|
||||
? 'bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'"
|
||||
>
|
||||
<span class="fi" :class="'fi-' + languageFlags[lang]"></span>
|
||||
<span x-text="languageNames[lang]"></span>
|
||||
<span x-show="currentLang === lang" x-html="$icon('check', 'w-4 h-4 ml-auto')" class="text-purple-600 dark:text-purple-400"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Language Selector (Compact)
|
||||
===========================
|
||||
A compact language selector showing only flag icon.
|
||||
Good for headers with limited space.
|
||||
|
||||
Parameters:
|
||||
- current_language: Current language code (default: 'fr')
|
||||
- enabled_languages: List of enabled language codes (default: all)
|
||||
- position: 'left' | 'right' (default: 'right')
|
||||
#}
|
||||
{% macro language_selector_compact(current_language='fr', enabled_languages=none, position='right') %}
|
||||
{% set langs = enabled_languages or ['en', 'fr', 'de', 'lb'] %}
|
||||
{% set current = current_language if current_language in langs else langs[0] %}
|
||||
{% set positions = {'left': 'left-0', 'right': 'right-0'} %}
|
||||
{# Uses languageSelector() function per LANG-002 architecture rule #}
|
||||
<div
|
||||
x-data="languageSelector('{{ current }}', {{ langs | tojson | safe }})"
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
@click="isLangOpen = !isLangOpen"
|
||||
@click.outside="isLangOpen = false"
|
||||
type="button"
|
||||
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors focus:outline-none"
|
||||
:class="{ 'text-gray-700 dark:text-white bg-gray-100 dark:bg-gray-700': isLangOpen }"
|
||||
title="Change language"
|
||||
>
|
||||
<span class="fi text-lg" :class="'fi-' + languageFlags[currentLang]"></span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
x-show="isLangOpen"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
x-transition:enter-end="transform opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
class="absolute z-50 mt-1 w-44 {{ positions[position] }} bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1"
|
||||
>
|
||||
<template x-for="lang in languages" :key="lang">
|
||||
<button
|
||||
@click="setLanguage(lang)"
|
||||
type="button"
|
||||
class="flex items-center gap-3 w-full px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="currentLang === lang
|
||||
? 'bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'"
|
||||
>
|
||||
<span class="fi" :class="'fi-' + languageFlags[lang]"></span>
|
||||
<span x-text="languageNames[lang]"></span>
|
||||
<span x-show="currentLang === lang" x-html="$icon('check', 'w-4 h-4 ml-auto')" class="text-purple-600 dark:text-purple-400"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Language Toggle (Simple)
|
||||
========================
|
||||
A simple language toggle for when only 2 languages are enabled.
|
||||
Shows both flags side by side.
|
||||
|
||||
Parameters:
|
||||
- current_language: Current language code
|
||||
- enabled_languages: List of exactly 2 language codes
|
||||
#}
|
||||
{% macro language_toggle(current_language='fr', enabled_languages=['fr', 'de']) %}
|
||||
{% set langs = enabled_languages[:2] if enabled_languages else ['fr', 'de'] %}
|
||||
{% set current = current_language if current_language in langs else langs[0] %}
|
||||
{# Uses languageSelector() function per LANG-002 architecture rule #}
|
||||
<div
|
||||
x-data="languageSelector('{{ current }}', {{ langs | tojson | safe }})"
|
||||
class="inline-flex items-center gap-1 p-1 bg-gray-100 dark:bg-gray-700 rounded-lg"
|
||||
>
|
||||
<template x-for="lang in languages" :key="lang">
|
||||
<button
|
||||
@click="setLanguage(lang)"
|
||||
type="button"
|
||||
class="p-1.5 rounded transition-all"
|
||||
:class="currentLang === lang
|
||||
? 'bg-white dark:bg-gray-600 shadow-sm'
|
||||
: 'hover:bg-gray-200 dark:hover:bg-gray-600'"
|
||||
:title="lang.toUpperCase()"
|
||||
>
|
||||
<span class="fi" :class="'fi-' + languageFlags[lang]"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{#
|
||||
Language Settings Form
|
||||
======================
|
||||
A form for vendor/admin settings page to configure language preferences.
|
||||
|
||||
Parameters:
|
||||
- current_settings: Dict with current vendor language settings
|
||||
- form_id: Form ID for submission
|
||||
#}
|
||||
{% macro language_settings_form(current_settings=none, form_id='language-settings-form') %}
|
||||
{% set settings = current_settings or {} %}
|
||||
{# Native language names per LANG-005 #}
|
||||
{% set all_languages = [
|
||||
{'code': 'en', 'name': 'English'},
|
||||
{'code': 'fr', 'name': 'Français'},
|
||||
{'code': 'de', 'name': 'Deutsch'},
|
||||
{'code': 'lb', 'name': 'Lëtzebuergesch'}
|
||||
] %}
|
||||
<div
|
||||
x-data="{
|
||||
defaultLanguage: '{{ settings.get('default_language', 'fr') }}',
|
||||
dashboardLanguage: '{{ settings.get('dashboard_language', 'fr') }}',
|
||||
storefrontLanguage: '{{ settings.get('storefront_language', 'fr') }}',
|
||||
storefrontLanguages: {{ settings.get('storefront_languages', ['fr', 'de', 'en']) | tojson }},
|
||||
allLanguages: {{ all_languages | tojson }},
|
||||
toggleStorefrontLanguage(code) {
|
||||
const index = this.storefrontLanguages.indexOf(code);
|
||||
if (index > -1) {
|
||||
// Don't allow removing if it's the only one or if it's the default
|
||||
if (this.storefrontLanguages.length > 1 && code !== this.storefrontLanguage) {
|
||||
this.storefrontLanguages.splice(index, 1);
|
||||
}
|
||||
} else {
|
||||
this.storefrontLanguages.push(code);
|
||||
}
|
||||
},
|
||||
isStorefrontLanguageEnabled(code) {
|
||||
return this.storefrontLanguages.includes(code);
|
||||
}
|
||||
}"
|
||||
class="space-y-6"
|
||||
>
|
||||
{# Default Content Language #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Default Content Language
|
||||
</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
Language used as fallback when content is not available in the requested language.
|
||||
</p>
|
||||
<select
|
||||
x-model="defaultLanguage"
|
||||
name="default_language"
|
||||
class="w-full px-4 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<template x-for="lang in allLanguages" :key="lang.code">
|
||||
<option :value="lang.code" x-text="lang.name" :selected="lang.code === defaultLanguage"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Dashboard Language #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Dashboard Language
|
||||
</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
Default language for your vendor dashboard (team members can override in their profile).
|
||||
</p>
|
||||
<select
|
||||
x-model="dashboardLanguage"
|
||||
name="dashboard_language"
|
||||
class="w-full px-4 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<template x-for="lang in allLanguages" :key="lang.code">
|
||||
<option :value="lang.code" x-text="lang.name" :selected="lang.code === dashboardLanguage"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Storefront Default Language #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Storefront Default Language
|
||||
</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
Default language shown to customers when they first visit your store.
|
||||
</p>
|
||||
<select
|
||||
x-model="storefrontLanguage"
|
||||
name="storefront_language"
|
||||
class="w-full px-4 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<template x-for="lang in allLanguages.filter(l => storefrontLanguages.includes(l.code))" :key="lang.code">
|
||||
<option :value="lang.code" x-text="lang.name" :selected="lang.code === storefrontLanguage"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Enabled Storefront Languages #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Enabled Storefront Languages
|
||||
</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
Languages available to customers in the language selector.
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<template x-for="lang in allLanguages" :key="lang.code">
|
||||
<label class="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="lang.code"
|
||||
:checked="isStorefrontLanguageEnabled(lang.code)"
|
||||
@change="toggleStorefrontLanguage(lang.code)"
|
||||
:disabled="lang.code === storefrontLanguage"
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500 disabled:opacity-50"
|
||||
>
|
||||
<span class="fi" :class="'fi-' + (lang.code === 'en' ? 'gb' : lang.code === 'lb' ? 'lu' : lang.code)"></span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="lang.name"></span>
|
||||
<span x-show="lang.code === storefrontLanguage" class="text-xs text-purple-600 dark:text-purple-400">(default)</span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
<input type="hidden" name="storefront_languages" :value="JSON.stringify(storefrontLanguages)">
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -40,6 +40,9 @@
|
||||
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
|
||||
|
||||
{# Flag Icons for Language Selector #}
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css" />
|
||||
|
||||
{# Base Shop Styles #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/shop.css') }}">
|
||||
|
||||
@@ -130,6 +133,49 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{# Language Selector #}
|
||||
{% set enabled_langs = vendor.storefront_languages if vendor and vendor.storefront_languages else ['fr', 'de', 'en'] %}
|
||||
{% if enabled_langs|length > 1 %}
|
||||
<div class="relative" x-data="languageSelector('{{ request.state.language|default("fr") }}', {{ enabled_langs|tojson|safe }})">
|
||||
<button
|
||||
@click="isLangOpen = !isLangOpen"
|
||||
@click.outside="isLangOpen = false"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
aria-label="Change language"
|
||||
>
|
||||
<span class="fi text-lg" :class="'fi-' + languageFlags[currentLang]"></span>
|
||||
</button>
|
||||
<div
|
||||
x-show="isLangOpen"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
x-transition:enter-end="transform opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
class="absolute right-0 w-40 mt-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50"
|
||||
>
|
||||
<template x-for="lang in languages" :key="lang">
|
||||
<button
|
||||
@click="setLanguage(lang)"
|
||||
type="button"
|
||||
class="flex items-center gap-3 w-full px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="currentLang === lang
|
||||
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'"
|
||||
>
|
||||
<span class="fi" :class="'fi-' + languageFlags[lang]"></span>
|
||||
<span x-text="languageNames[lang]"></span>
|
||||
<svg x-show="currentLang === lang" class="w-4 h-4 ml-auto" style="color: var(--color-primary)" 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>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Account #}
|
||||
<a href="{{ base_url }}shop/account" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
3
app/templates/vendor/base.html
vendored
3
app/templates/vendor/base.html
vendored
@@ -13,6 +13,9 @@
|
||||
<!-- Tailwind CSS v4 (built locally via standalone CLI) -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='vendor/css/tailwind.output.css') }}" />
|
||||
|
||||
<!-- Flag Icons for Language Selector -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css" />
|
||||
|
||||
<!-- Alpine Cloak -->
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
153
app/templates/vendor/letzshop.html
vendored
153
app/templates/vendor/letzshop.html
vendored
@@ -79,6 +79,16 @@
|
||||
<span x-show="orders.length > 0" class="ml-2 px-2 py-0.5 text-xs bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-300 rounded-full" x-text="totalOrders"></span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'export'"
|
||||
:class="activeTab === 'export' ? 'border-purple-600 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'"
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<span x-html="$icon('upload', 'w-4 h-4 mr-2')"></span>
|
||||
Export
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'settings'"
|
||||
:class="activeTab === 'settings' ? 'border-purple-600 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'"
|
||||
@@ -294,6 +304,149 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Tab -->
|
||||
<div x-show="activeTab === 'export'" x-transition>
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<!-- Export Card -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Export Products to Letzshop
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Generate a Letzshop-compatible CSV file from your product catalog.
|
||||
The file uses Google Shopping feed format and includes all required fields.
|
||||
</p>
|
||||
|
||||
<!-- Language Selection -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Export Language
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
Select the language for product titles and descriptions
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
@click="exportLanguage = 'fr'"
|
||||
:class="exportLanguage === 'fr'
|
||||
? 'bg-purple-100 dark:bg-purple-900/30 border-purple-500 text-purple-700 dark:text-purple-300'
|
||||
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<span class="fi fi-fr"></span>
|
||||
Francais
|
||||
</button>
|
||||
<button
|
||||
@click="exportLanguage = 'de'"
|
||||
:class="exportLanguage === 'de'
|
||||
? 'bg-purple-100 dark:bg-purple-900/30 border-purple-500 text-purple-700 dark:text-purple-300'
|
||||
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<span class="fi fi-de"></span>
|
||||
Deutsch
|
||||
</button>
|
||||
<button
|
||||
@click="exportLanguage = 'en'"
|
||||
:class="exportLanguage === 'en'
|
||||
? 'bg-purple-100 dark:bg-purple-900/30 border-purple-500 text-purple-700 dark:text-purple-300'
|
||||
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<span class="fi fi-gb"></span>
|
||||
English
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Include Inactive -->
|
||||
<div class="mb-6">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="exportIncludeInactive"
|
||||
class="form-checkbox h-5 w-5 text-purple-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:ring-purple-500"
|
||||
/>
|
||||
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-400">Include inactive products</span>
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-8">
|
||||
Export products that are currently marked as inactive
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Download Button -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
@click="downloadExport()"
|
||||
:disabled="exporting"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!exporting" x-html="$icon('download', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="exporting" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="exporting ? 'Generating...' : 'Download CSV'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Info Card -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
CSV Format Information
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-2">File Format</h4>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li>Tab-separated values (TSV)</li>
|
||||
<li>UTF-8 encoding</li>
|
||||
<li>Google Shopping compatible</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-2">Included Fields</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded">id</span>
|
||||
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded">title</span>
|
||||
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded">description</span>
|
||||
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded">price</span>
|
||||
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded">image_link</span>
|
||||
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded">availability</span>
|
||||
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded">brand</span>
|
||||
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded">gtin</span>
|
||||
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded">+30 more</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-2">How to Upload</h4>
|
||||
<ol class="list-decimal list-inside space-y-1">
|
||||
<li>Download the CSV file</li>
|
||||
<li>Log in to your Letzshop merchant portal</li>
|
||||
<li>Navigate to Products > Import</li>
|
||||
<li>Upload the CSV file</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div class="flex">
|
||||
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-500 mr-2 flex-shrink-0')"></span>
|
||||
<div class="text-sm text-blue-700 dark:text-blue-300">
|
||||
<p class="font-medium">Translation Fallback</p>
|
||||
<p class="mt-1">If a product doesn't have a translation in the selected language, the system will use English, then fall back to any available translation.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div x-show="activeTab === 'settings'" x-transition>
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
|
||||
64
app/templates/vendor/partials/header.html
vendored
64
app/templates/vendor/partials/header.html
vendored
@@ -36,6 +36,70 @@
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<!-- Language selector -->
|
||||
<li class="relative" x-data="{
|
||||
isLangOpen: false,
|
||||
currentLang: '{{ request.state.language|default("fr") }}',
|
||||
languages: ['en', 'fr', 'de', 'lb'],
|
||||
languageNames: { 'en': 'English', 'fr': 'Francais', 'de': 'Deutsch', 'lb': 'Letzebuerg' },
|
||||
languageFlags: { 'en': 'gb', 'fr': 'fr', 'de': 'de', 'lb': 'lu' },
|
||||
async setLanguage(lang) {
|
||||
if (lang === this.currentLang) {
|
||||
this.isLangOpen = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/v1/language/set', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ language: lang })
|
||||
});
|
||||
if (response.ok) {
|
||||
this.currentLang = lang;
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set language:', error);
|
||||
}
|
||||
this.isLangOpen = false;
|
||||
}
|
||||
}">
|
||||
<button
|
||||
@click="isLangOpen = !isLangOpen"
|
||||
@click.outside="isLangOpen = false"
|
||||
class="p-1 rounded-md focus:outline-none focus:shadow-outline-purple"
|
||||
aria-label="Change language"
|
||||
>
|
||||
<span class="fi text-lg" :class="'fi-' + languageFlags[currentLang]"></span>
|
||||
</button>
|
||||
<div
|
||||
x-show="isLangOpen"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
x-transition:enter-end="transform opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
class="absolute right-0 w-44 mt-2 bg-white dark:bg-gray-700 rounded-lg shadow-lg border border-gray-100 dark:border-gray-600 py-1 z-50"
|
||||
>
|
||||
<template x-for="lang in languages" :key="lang">
|
||||
<button
|
||||
@click="setLanguage(lang)"
|
||||
type="button"
|
||||
class="flex items-center gap-3 w-full px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="currentLang === lang
|
||||
? 'bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600'"
|
||||
>
|
||||
<span class="fi" :class="'fi-' + languageFlags[lang]"></span>
|
||||
<span x-text="languageNames[lang]"></span>
|
||||
<span x-show="currentLang === lang" x-html="$icon('check', 'w-4 h-4 ml-auto')" class="text-purple-600 dark:text-purple-400"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- Notifications menu -->
|
||||
<li class="relative">
|
||||
<button class="relative align-middle rounded-md focus:outline-none focus:shadow-outline-purple"
|
||||
|
||||
409
app/utils/i18n.py
Normal file
409
app/utils/i18n.py
Normal file
@@ -0,0 +1,409 @@
|
||||
# app/utils/i18n.py
|
||||
"""
|
||||
Internationalization (i18n) utilities for multi-language support.
|
||||
|
||||
This module provides:
|
||||
- Language constants and configuration
|
||||
- JSON-based translation loading
|
||||
- Translation helper functions
|
||||
- Jinja2 template integration
|
||||
|
||||
Supported languages:
|
||||
- en: English
|
||||
- fr: French (default for Luxembourg)
|
||||
- de: German
|
||||
- lb: Luxembourgish
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# Language Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Supported language codes
|
||||
SUPPORTED_LANGUAGES = ["en", "fr", "de", "lb"]
|
||||
|
||||
# Default language (Luxembourg context)
|
||||
DEFAULT_LANGUAGE = "fr"
|
||||
|
||||
# Language display names (in their native language)
|
||||
LANGUAGE_NAMES = {
|
||||
"en": "English",
|
||||
"fr": "Français",
|
||||
"de": "Deutsch",
|
||||
"lb": "Lëtzebuergesch",
|
||||
}
|
||||
|
||||
# Language display names in English (for admin)
|
||||
LANGUAGE_NAMES_EN = {
|
||||
"en": "English",
|
||||
"fr": "French",
|
||||
"de": "German",
|
||||
"lb": "Luxembourgish",
|
||||
}
|
||||
|
||||
# Flag emoji for language selector (optional)
|
||||
LANGUAGE_FLAGS = {
|
||||
"en": "🇬🇧",
|
||||
"fr": "🇫🇷",
|
||||
"de": "🇩🇪",
|
||||
"lb": "🇱🇺",
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Translation Storage
|
||||
# ============================================================================
|
||||
|
||||
# Path to locale files
|
||||
LOCALES_PATH = Path(__file__).parent.parent.parent / "static" / "locales"
|
||||
|
||||
# In-memory cache for loaded translations
|
||||
_translations: dict[str, dict] = {}
|
||||
|
||||
|
||||
def get_locales_path() -> Path:
|
||||
"""Get the path to locale files."""
|
||||
return LOCALES_PATH
|
||||
|
||||
|
||||
@lru_cache(maxsize=10)
|
||||
def load_translations(language: str) -> dict:
|
||||
"""
|
||||
Load translations for a specific language from JSON file.
|
||||
|
||||
Args:
|
||||
language: Language code (en, fr, de, lb)
|
||||
|
||||
Returns:
|
||||
Dictionary of translations, empty dict if file not found
|
||||
"""
|
||||
if language not in SUPPORTED_LANGUAGES:
|
||||
logger.warning(f"Unsupported language requested: {language}")
|
||||
language = DEFAULT_LANGUAGE
|
||||
|
||||
locale_file = LOCALES_PATH / f"{language}.json"
|
||||
|
||||
if not locale_file.exists():
|
||||
logger.warning(f"Translation file not found: {locale_file}")
|
||||
# Fall back to default language
|
||||
if language != DEFAULT_LANGUAGE:
|
||||
return load_translations(DEFAULT_LANGUAGE)
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(locale_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Invalid JSON in translation file {locale_file}: {e}")
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading translation file {locale_file}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def clear_translation_cache():
|
||||
"""Clear the translation cache (useful for development/testing)."""
|
||||
load_translations.cache_clear()
|
||||
_translations.clear()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Translation Functions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def get_nested_value(data: dict, key_path: str, default: str = None) -> str:
|
||||
"""
|
||||
Get a nested value from a dictionary using dot notation.
|
||||
|
||||
Args:
|
||||
data: Dictionary to search
|
||||
key_path: Dot-separated path (e.g., "common.save")
|
||||
default: Default value if key not found
|
||||
|
||||
Returns:
|
||||
Value at the key path, or default if not found
|
||||
"""
|
||||
keys = key_path.split(".")
|
||||
value = data
|
||||
|
||||
for key in keys:
|
||||
if isinstance(value, dict) and key in value:
|
||||
value = value[key]
|
||||
else:
|
||||
return default if default is not None else key_path
|
||||
|
||||
return value if isinstance(value, str) else (default if default is not None else key_path)
|
||||
|
||||
|
||||
def translate(
|
||||
key: str,
|
||||
language: str = None,
|
||||
default: str = None,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""
|
||||
Translate a key to the specified language.
|
||||
|
||||
Args:
|
||||
key: Translation key (dot notation, e.g., "common.save")
|
||||
language: Target language code (defaults to DEFAULT_LANGUAGE)
|
||||
default: Default value if translation not found
|
||||
**kwargs: Variables for string interpolation
|
||||
|
||||
Returns:
|
||||
Translated string with variables interpolated
|
||||
|
||||
Example:
|
||||
translate("common.welcome", language="fr", name="John")
|
||||
# Returns: "Bienvenue, John!" (if translation is "Bienvenue, {name}!")
|
||||
"""
|
||||
if language is None:
|
||||
language = DEFAULT_LANGUAGE
|
||||
|
||||
translations = load_translations(language)
|
||||
text = get_nested_value(translations, key, default)
|
||||
|
||||
# If translation not found and we're not already in default language, try default
|
||||
if text == key and language != DEFAULT_LANGUAGE:
|
||||
translations = load_translations(DEFAULT_LANGUAGE)
|
||||
text = get_nested_value(translations, key, default)
|
||||
|
||||
# Interpolate variables
|
||||
if kwargs and isinstance(text, str):
|
||||
try:
|
||||
text = text.format(**kwargs)
|
||||
except KeyError as e:
|
||||
logger.warning(f"Missing interpolation variable in translation '{key}': {e}")
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def t(key: str, language: str = None, **kwargs: Any) -> str:
|
||||
"""
|
||||
Shorthand alias for translate().
|
||||
|
||||
Example:
|
||||
t("common.save", "fr") # Returns French translation
|
||||
"""
|
||||
return translate(key, language, **kwargs)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Jinja2 Integration
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TranslationContext:
|
||||
"""
|
||||
Translation context for use in Jinja2 templates.
|
||||
|
||||
Provides a callable interface for the _() function in templates.
|
||||
"""
|
||||
|
||||
def __init__(self, language: str = None):
|
||||
"""Initialize with a specific language."""
|
||||
self.language = language or DEFAULT_LANGUAGE
|
||||
|
||||
def __call__(self, key: str, **kwargs: Any) -> str:
|
||||
"""Translate a key."""
|
||||
return translate(key, self.language, **kwargs)
|
||||
|
||||
def set_language(self, language: str):
|
||||
"""Change the current language."""
|
||||
if language in SUPPORTED_LANGUAGES:
|
||||
self.language = language
|
||||
else:
|
||||
logger.warning(f"Attempted to set unsupported language: {language}")
|
||||
|
||||
|
||||
def create_translation_context(language: str = None) -> TranslationContext:
|
||||
"""Create a translation context for templates."""
|
||||
return TranslationContext(language)
|
||||
|
||||
|
||||
def get_jinja2_globals(language: str = None) -> dict:
|
||||
"""
|
||||
Get globals to add to Jinja2 environment for i18n support.
|
||||
|
||||
Returns:
|
||||
Dictionary of globals for Jinja2 templates
|
||||
"""
|
||||
ctx = create_translation_context(language)
|
||||
|
||||
return {
|
||||
"_": ctx, # Main translation function: {{ _("common.save") }}
|
||||
"t": ctx, # Alias: {{ t("common.save") }}
|
||||
"SUPPORTED_LANGUAGES": SUPPORTED_LANGUAGES,
|
||||
"DEFAULT_LANGUAGE": DEFAULT_LANGUAGE,
|
||||
"LANGUAGE_NAMES": LANGUAGE_NAMES,
|
||||
"LANGUAGE_FLAGS": LANGUAGE_FLAGS,
|
||||
"current_language": language or DEFAULT_LANGUAGE,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Language Resolution Helpers
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def resolve_vendor_dashboard_language(
|
||||
user_preferred: str | None,
|
||||
vendor_dashboard: str | None,
|
||||
) -> str:
|
||||
"""
|
||||
Resolve language for vendor dashboard.
|
||||
|
||||
Priority:
|
||||
1. User's preferred_language (if set)
|
||||
2. Vendor's dashboard_language
|
||||
3. System default (fr)
|
||||
"""
|
||||
if user_preferred and user_preferred in SUPPORTED_LANGUAGES:
|
||||
return user_preferred
|
||||
|
||||
if vendor_dashboard and vendor_dashboard in SUPPORTED_LANGUAGES:
|
||||
return vendor_dashboard
|
||||
|
||||
return DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def resolve_storefront_language(
|
||||
customer_preferred: str | None,
|
||||
session_language: str | None,
|
||||
vendor_storefront: str | None,
|
||||
browser_language: str | None,
|
||||
enabled_languages: list[str] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Resolve language for storefront.
|
||||
|
||||
Priority:
|
||||
1. Customer's preferred_language (if logged in and set)
|
||||
2. Session/cookie language
|
||||
3. Vendor's storefront_language
|
||||
4. Browser Accept-Language header
|
||||
5. System default (fr)
|
||||
|
||||
Args:
|
||||
customer_preferred: Customer's saved preference
|
||||
session_language: Language from session/cookie
|
||||
vendor_storefront: Vendor's default storefront language
|
||||
browser_language: Primary language from Accept-Language header
|
||||
enabled_languages: List of languages enabled for this storefront
|
||||
"""
|
||||
candidates = [
|
||||
customer_preferred,
|
||||
session_language,
|
||||
vendor_storefront,
|
||||
browser_language,
|
||||
DEFAULT_LANGUAGE,
|
||||
]
|
||||
|
||||
# Filter to enabled languages if specified
|
||||
if enabled_languages:
|
||||
enabled_set = set(enabled_languages)
|
||||
for lang in candidates:
|
||||
if lang and lang in SUPPORTED_LANGUAGES and lang in enabled_set:
|
||||
return lang
|
||||
else:
|
||||
for lang in candidates:
|
||||
if lang and lang in SUPPORTED_LANGUAGES:
|
||||
return lang
|
||||
|
||||
return DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def parse_accept_language(accept_language: str | None) -> str | None:
|
||||
"""
|
||||
Parse Accept-Language header and return the best supported language.
|
||||
|
||||
Args:
|
||||
accept_language: Accept-Language header value
|
||||
|
||||
Returns:
|
||||
Best matching supported language code, or None
|
||||
"""
|
||||
if not accept_language:
|
||||
return None
|
||||
|
||||
# Parse header: "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7"
|
||||
languages = []
|
||||
for part in accept_language.split(","):
|
||||
part = part.strip()
|
||||
if ";q=" in part:
|
||||
lang, q = part.split(";q=")
|
||||
try:
|
||||
quality = float(q)
|
||||
except ValueError:
|
||||
quality = 0.0
|
||||
else:
|
||||
lang = part
|
||||
quality = 1.0
|
||||
|
||||
# Extract base language code (e.g., "fr-FR" -> "fr")
|
||||
lang_code = lang.split("-")[0].lower()
|
||||
languages.append((lang_code, quality))
|
||||
|
||||
# Sort by quality (highest first)
|
||||
languages.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
# Return first supported language
|
||||
for lang_code, _ in languages:
|
||||
if lang_code in SUPPORTED_LANGUAGES:
|
||||
return lang_code
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Utility Functions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def get_language_choices() -> list[tuple[str, str]]:
|
||||
"""
|
||||
Get language choices for form select fields.
|
||||
|
||||
Returns:
|
||||
List of (code, name) tuples
|
||||
"""
|
||||
return [(code, LANGUAGE_NAMES[code]) for code in SUPPORTED_LANGUAGES]
|
||||
|
||||
|
||||
def get_language_info(language: str) -> dict:
|
||||
"""
|
||||
Get full information about a language.
|
||||
|
||||
Args:
|
||||
language: Language code
|
||||
|
||||
Returns:
|
||||
Dictionary with code, name, name_en, flag
|
||||
"""
|
||||
if language not in SUPPORTED_LANGUAGES:
|
||||
language = DEFAULT_LANGUAGE
|
||||
|
||||
return {
|
||||
"code": language,
|
||||
"name": LANGUAGE_NAMES.get(language, language),
|
||||
"name_en": LANGUAGE_NAMES_EN.get(language, language),
|
||||
"flag": LANGUAGE_FLAGS.get(language, "🌐"),
|
||||
}
|
||||
|
||||
|
||||
def is_rtl_language(language: str) -> bool:
|
||||
"""
|
||||
Check if a language is right-to-left.
|
||||
|
||||
Currently all supported languages are LTR.
|
||||
"""
|
||||
# RTL languages (not currently supported, but ready for future)
|
||||
rtl_languages = ["ar", "he", "fa"]
|
||||
return language in rtl_languages
|
||||
Reference in New Issue
Block a user