From d2b05441fc7505da9a0d547fe86bfb766e004c2b Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sat, 13 Dec 2025 22:36:09 +0100 Subject: [PATCH] feat: add multi-language (i18n) support for vendor dashboard and storefront MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 3 + ...8_add_language_settings_to_vendor_user_.py | 84 +++ app/api/main.py | 8 + app/api/v1/shared/language.py | 179 ++++++ .../shared/macros/language_selector.html | 329 +++++++++++ app/templates/shop/base.html | 46 ++ app/templates/vendor/base.html | 3 + app/templates/vendor/letzshop.html | 153 +++++ app/templates/vendor/partials/header.html | 64 ++ app/utils/i18n.py | 409 +++++++++++++ docs/architecture/language-i18n.md | 551 ++++++++++++++++++ .../migration/language-i18n-implementation.md | 319 ++++++++++ docs/guides/letzshop-marketplace-api.md | 121 +++- main.py | 20 +- middleware/language.py | 186 ++++++ mkdocs.yml | 6 +- models/database/customer.py | 4 + models/database/user.py | 4 + models/database/vendor.py | 17 + models/schema/auth.py | 7 + models/schema/customer.py | 12 +- models/schema/vendor.py | 34 ++ static/admin/js/marketplace-product-detail.js | 28 +- static/locales/de.json | 475 +++++++++++++++ static/locales/en.json | 475 +++++++++++++++ static/locales/fr.json | 475 +++++++++++++++ static/locales/lb.json | 475 +++++++++++++++ static/shop/js/shop-layout.js | 46 ++ static/vendor/js/init-alpine.js | 48 +- static/vendor/js/letzshop.js | 67 +++ 30 files changed, 4615 insertions(+), 33 deletions(-) create mode 100644 alembic/versions/fcfdc02d5138_add_language_settings_to_vendor_user_.py create mode 100644 app/api/v1/shared/language.py create mode 100644 app/templates/shared/macros/language_selector.html create mode 100644 app/utils/i18n.py create mode 100644 docs/architecture/language-i18n.md create mode 100644 docs/development/migration/language-i18n-implementation.md create mode 100644 middleware/language.py create mode 100644 static/locales/de.json create mode 100644 static/locales/en.json create mode 100644 static/locales/fr.json create mode 100644 static/locales/lb.json diff --git a/.gitignore b/.gitignore index 518809c7..db97ada3 100644 --- a/.gitignore +++ b/.gitignore @@ -176,3 +176,6 @@ yarn-error.log* package-lock.json tailadmin-free-tailwind-dashboard-template/ static/shared/css/tailwind.css + +# Export files +wizamart_letzshop_export_*.csv diff --git a/alembic/versions/fcfdc02d5138_add_language_settings_to_vendor_user_.py b/alembic/versions/fcfdc02d5138_add_language_settings_to_vendor_user_.py new file mode 100644 index 00000000..34d7fcfb --- /dev/null +++ b/alembic/versions/fcfdc02d5138_add_language_settings_to_vendor_user_.py @@ -0,0 +1,84 @@ +"""add_language_settings_to_vendor_user_customer + +Revision ID: fcfdc02d5138 +Revises: b412e0b49c2e +Create Date: 2025-12-13 20:08:27.120863 + +This migration adds language preference fields to support multi-language UI: +- Vendor: default_language, dashboard_language, storefront_language +- User: preferred_language +- Customer: preferred_language + +Supported languages: en (English), fr (French), de (German), lb (Luxembourgish) +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'fcfdc02d5138' +down_revision: Union[str, None] = 'b412e0b49c2e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ======================================================================== + # Vendor language settings + # ======================================================================== + # default_language: Default language for vendor content (products, etc.) + op.add_column( + 'vendors', + sa.Column('default_language', sa.String(5), nullable=False, server_default='fr') + ) + # dashboard_language: Language for vendor team dashboard UI + op.add_column( + 'vendors', + sa.Column('dashboard_language', sa.String(5), nullable=False, server_default='fr') + ) + # storefront_language: Default language for customer-facing shop + op.add_column( + 'vendors', + sa.Column('storefront_language', sa.String(5), nullable=False, server_default='fr') + ) + # storefront_languages: JSON array of enabled languages for storefront + # Allows vendors to enable/disable specific languages + op.add_column( + 'vendors', + sa.Column( + 'storefront_languages', + sa.JSON, + nullable=False, + server_default='["fr", "de", "en"]' + ) + ) + + # ======================================================================== + # User language preference + # ======================================================================== + # preferred_language: User's preferred UI language (NULL = use context default) + op.add_column( + 'users', + sa.Column('preferred_language', sa.String(5), nullable=True) + ) + + # ======================================================================== + # Customer language preference + # ======================================================================== + # preferred_language: Customer's preferred language (NULL = use storefront default) + op.add_column( + 'customers', + sa.Column('preferred_language', sa.String(5), nullable=True) + ) + + +def downgrade() -> None: + # Remove columns in reverse order + op.drop_column('customers', 'preferred_language') + op.drop_column('users', 'preferred_language') + op.drop_column('vendors', 'storefront_languages') + op.drop_column('vendors', 'storefront_language') + op.drop_column('vendors', 'dashboard_language') + op.drop_column('vendors', 'default_language') diff --git a/app/api/main.py b/app/api/main.py index 1f1c43be..865d33b3 100644 --- a/app/api/main.py +++ b/app/api/main.py @@ -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"]) diff --git a/app/api/v1/shared/language.py b/app/api/v1/shared/language.py new file mode 100644 index 00000000..57e565b2 --- /dev/null +++ b/app/api/v1/shared/language.py @@ -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.", + ) diff --git a/app/templates/shared/macros/language_selector.html b/app/templates/shared/macros/language_selector.html new file mode 100644 index 00000000..b49e8419 --- /dev/null +++ b/app/templates/shared/macros/language_selector.html @@ -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 #} +
+ + +
+ +
+
+{% 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 #} +
+ + +
+ +
+
+{% 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 #} +
+ +
+{% 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'} +] %} +
+ {# Default Content Language #} +
+ +

+ Language used as fallback when content is not available in the requested language. +

+ +
+ + {# Dashboard Language #} +
+ +

+ Default language for your vendor dashboard (team members can override in their profile). +

+ +
+ + {# Storefront Default Language #} +
+ +

+ Default language shown to customers when they first visit your store. +

+ +
+ + {# Enabled Storefront Languages #} +
+ +

+ Languages available to customers in the language selector. +

+
+ +
+ +
+
+{% endmacro %} diff --git a/app/templates/shop/base.html b/app/templates/shop/base.html index f9f38f81..72be6bff 100644 --- a/app/templates/shop/base.html +++ b/app/templates/shop/base.html @@ -40,6 +40,9 @@ {# Tailwind CSS v4 (built locally via standalone CLI) #} + {# Flag Icons for Language Selector #} + + {# Base Shop Styles #} @@ -130,6 +133,49 @@ + {# Language Selector #} + {% set enabled_langs = vendor.storefront_languages if vendor and vendor.storefront_languages else ['fr', 'de', 'en'] %} + {% if enabled_langs|length > 1 %} +
+ +
+ +
+
+ {% endif %} + {# Account #} diff --git a/app/templates/vendor/base.html b/app/templates/vendor/base.html index 34b3c896..1dc326b5 100644 --- a/app/templates/vendor/base.html +++ b/app/templates/vendor/base.html @@ -13,6 +13,9 @@ + + + + + + + + + + +
+ +

+ Export products that are currently marked as inactive +

+
+ + +
+ +
+ + + + +
+
+

+ CSV Format Information +

+ +
+
+

File Format

+
    +
  • Tab-separated values (TSV)
  • +
  • UTF-8 encoding
  • +
  • Google Shopping compatible
  • +
+
+ +
+

Included Fields

+
+ id + title + description + price + image_link + availability + brand + gtin + +30 more +
+
+ +
+

How to Upload

+
    +
  1. Download the CSV file
  2. +
  3. Log in to your Letzshop merchant portal
  4. +
  5. Navigate to Products > Import
  6. +
  7. Upload the CSV file
  8. +
+
+
+ +
+
+ +
+

Translation Fallback

+

If a product doesn't have a translation in the selected language, the system will use English, then fall back to any available translation.

+
+
+
+
+
+ + +
diff --git a/app/templates/vendor/partials/header.html b/app/templates/vendor/partials/header.html index 13089a48..df3daea1 100644 --- a/app/templates/vendor/partials/header.html +++ b/app/templates/vendor/partials/header.html @@ -36,6 +36,70 @@ + +
  • + +
    + +
    +
  • +