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:
2025-12-13 22:36:09 +01:00
parent d21cd366dc
commit d2b05441fc
30 changed files with 4615 additions and 33 deletions

3
.gitignore vendored
View File

@@ -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

View File

@@ -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')

View File

@@ -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"])

View 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.",
)

View 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 %}

View File

@@ -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">

View File

@@ -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; }

View File

@@ -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">

View File

@@ -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
View 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

View File

@@ -0,0 +1,551 @@
# Language & Internationalization (i18n) Architecture
This document defines **strict rules** for implementing language support across the Wizamart platform.
> **IMPORTANT:** These rules are mandatory. Violations will cause runtime errors, inconsistent UX, or security issues.
---
## Table of Contents
1. [Supported Languages](#supported-languages)
2. [Language Context Flow](#language-context-flow)
3. [Database Schema Rules](#database-schema-rules)
4. [Frontend Rules](#frontend-rules)
5. [API Rules](#api-rules)
6. [Template Rules](#template-rules)
7. [JavaScript Rules](#javascript-rules)
8. [Translation File Rules](#translation-file-rules)
---
## Supported Languages
| Code | Language | Flag Code | Notes |
|------|----------|-----------|-------|
| `en` | English | `gb` | Fallback language |
| `fr` | French | `fr` | Default for Luxembourg |
| `de` | German | `de` | Second official language |
| `lb` | Luxembourgish | `lu` | Native language |
**Rule LANG-001: Only Use Supported Language Codes**
```python
# ✅ GOOD: Use supported codes
SUPPORTED_LANGUAGES = ["en", "fr", "de", "lb"]
# ❌ BAD: Invalid codes
language = "english" # ❌ Use "en"
language = "french" # ❌ Use "fr"
language = "lux" # ❌ Use "lb"
```
---
## Language Context Flow
### Resolution Priority
Language is resolved in this order (highest to lowest priority):
1. **URL parameter** (`?lang=fr`)
2. **Cookie** (`wizamart_language`)
3. **User preference** (database: `preferred_language`)
4. **Vendor default** (database: `storefront_language` or `dashboard_language`)
5. **Accept-Language header** (browser)
6. **Platform default** (`fr`)
### Vendor Dashboard vs Storefront
| Context | Language Source | Database Field |
|---------|-----------------|----------------|
| Vendor Dashboard | Vendor's `dashboard_language` | `vendors.dashboard_language` |
| Customer Storefront | Vendor's `storefront_language` | `vendors.storefront_language` |
| Admin Panel | User's `preferred_language` | `users.preferred_language` |
---
## Database Schema Rules
### Rule DB-001: Vendor Language Fields Are Required
**Vendors MUST have these language columns with defaults:**
```python
# ✅ GOOD: All language fields with defaults
class Vendor(Base):
default_language = Column(String(5), nullable=False, default="fr")
dashboard_language = Column(String(5), nullable=False, default="fr")
storefront_language = Column(String(5), nullable=False, default="fr")
storefront_languages = Column(JSON, nullable=False, default=["fr", "de", "en"])
```
```python
# ❌ BAD: Nullable language fields
class Vendor(Base):
default_language = Column(String(5), nullable=True) # ❌ Must have default
```
### Rule DB-002: User/Customer Preferred Language Is Optional
```python
# ✅ GOOD: Optional with fallback logic
class User(Base):
preferred_language = Column(String(5), nullable=True) # Falls back to vendor/platform default
class Customer(Base):
preferred_language = Column(String(5), nullable=True) # Falls back to storefront_language
```
### Rule DB-003: Pydantic Schemas Must Handle Missing Language Fields
```python
# ✅ GOOD: Optional in response with defaults
class VendorResponse(BaseModel):
default_language: str = "fr"
dashboard_language: str = "fr"
storefront_language: str = "fr"
storefront_languages: list[str] = ["fr", "de", "en"]
# ❌ BAD: Required without defaults (breaks backward compatibility)
class VendorResponse(BaseModel):
default_language: str # ❌ Will fail if DB doesn't have value
```
---
## Frontend Rules
### Rule FE-001: NEVER Use Inline Complex Alpine.js Data for Language Selector
**Move complex JavaScript objects to functions. Inline x-data with Jinja breaks JSON serialization.**
```html
<!-- ❌ BAD: Inline complex object with Jinja variable -->
<div x-data="{
isLangOpen: false,
currentLang: '{{ request.state.language }}',
languages: {{ enabled_langs }}, <!-- ❌ Jinja outputs Python list, not JSON -->
async setLanguage(lang) {
// Complex function here
}
}">
```
```html
<!-- ✅ GOOD: Use function with tojson|safe filter -->
<div x-data="languageSelector('{{ request.state.language|default("fr") }}', {{ enabled_langs|tojson|safe }})">
```
```javascript
// ✅ GOOD: Define function in JavaScript file
function languageSelector(currentLang, enabledLanguages) {
return {
isLangOpen: false,
currentLang: currentLang || 'fr',
languages: enabledLanguages || ['fr', 'de', 'en'],
languageNames: {
'en': 'English',
'fr': 'Français',
'de': 'Deutsch',
'lb': 'Lëtzebuergesch'
},
languageFlags: {
'en': 'gb',
'fr': 'fr',
'de': 'de',
'lb': 'lu'
},
async setLanguage(lang) {
// Implementation
}
};
}
```
### Rule FE-002: Always Use tojson|safe for Python Lists in JavaScript
```html
<!-- ❌ BAD: Raw Jinja output -->
<div x-data="{ languages: {{ vendor.storefront_languages }} }">
<!-- Outputs: ['fr', 'de'] - Python syntax, invalid JavaScript -->
<!-- ❌ BAD: tojson without safe -->
<div x-data="{ languages: {{ vendor.storefront_languages|tojson }} }">
<!-- May escape quotes to &quot; in HTML context -->
<!-- ✅ GOOD: tojson with safe -->
<div x-data="{ languages: {{ vendor.storefront_languages|tojson|safe }} }">
<!-- Outputs: ["fr", "de"] - Valid JSON/JavaScript -->
```
### Rule FE-003: Language Selector Must Be In Shared JavaScript
**Language selector function MUST be defined in:**
- `static/shop/js/shop-layout.js` for storefront
- `static/vendor/js/init-alpine.js` for vendor dashboard
```javascript
// ✅ GOOD: Reusable function with consistent implementation
function languageSelector(currentLang, enabledLanguages) {
return {
isLangOpen: false,
currentLang: currentLang || 'fr',
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
languageNames: {
'en': 'English',
'fr': 'Français',
'de': 'Deutsch',
'lb': 'Lëtzebuergesch'
},
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;
}
};
}
window.languageSelector = languageSelector;
```
### Rule FE-004: Storefront Must Respect Vendor's Enabled Languages
```html
<!-- ✅ GOOD: Only show languages enabled by vendor -->
{% set enabled_langs = vendor.storefront_languages if vendor and vendor.storefront_languages else ['fr', 'de', 'en'] %}
{% if enabled_langs|length > 1 %}
<div x-data="languageSelector('{{ request.state.language|default("fr") }}', {{ enabled_langs|tojson|safe }})">
<!-- Language selector UI -->
</div>
{% endif %}
```
```html
<!-- ❌ BAD: Hardcoded language list ignoring vendor settings -->
<div x-data="languageSelector('fr', ['en', 'fr', 'de', 'lb'])">
<!-- Shows all languages regardless of vendor config -->
</div>
```
### Rule FE-005: Vendor Dashboard Shows All Languages
```html
<!-- ✅ GOOD: Vendor dashboard always shows all 4 languages -->
<div x-data="languageSelector('{{ request.state.language|default("fr") }}', ['en', 'fr', 'de', 'lb'])">
```
---
## API Rules
### Rule API-001: Language Endpoint Must Set Cookie
```python
# ✅ GOOD: Set both cookie and return JSON
@router.post("/language/set")
async def set_language(request: LanguageSetRequest, response: Response):
if request.language not in SUPPORTED_LANGUAGES:
raise HTTPException(status_code=400, detail="Unsupported language")
response.set_cookie(
key="wizamart_language",
value=request.language,
max_age=365 * 24 * 60 * 60, # 1 year
httponly=True,
samesite="lax"
)
return {"success": True, "language": request.language}
```
### Rule API-002: Validate Language Codes
```python
# ✅ GOOD: Strict validation
SUPPORTED_LANGUAGES = {"en", "fr", "de", "lb"}
class LanguageSetRequest(BaseModel):
language: str = Field(..., pattern="^(en|fr|de|lb)$")
# ❌ BAD: No validation
class LanguageSetRequest(BaseModel):
language: str # ❌ Accepts any string
```
---
## Template Rules
### Rule TPL-001: Always Provide Language Default
```html
<!-- ✅ GOOD: Default value -->
{{ request.state.language|default("fr") }}
<!-- ❌ BAD: No default -->
{{ request.state.language }} <!-- ❌ May be None -->
```
### Rule TPL-002: Check Language Array Length Before Rendering Selector
```html
<!-- ✅ GOOD: Only show if multiple languages -->
{% if enabled_langs|length > 1 %}
<!-- Language selector -->
{% endif %}
<!-- ❌ BAD: Always show even with single language -->
<!-- Language selector shown with only 1 option -->
```
### Rule TPL-003: Use Consistent Flag Icon Classes
```html
<!-- ✅ GOOD: Use flag-icons library consistently -->
<span class="fi fi-{{ flag_code }}"></span>
<!-- Flag codes mapping -->
<!-- en → gb (Great Britain) -->
<!-- fr → fr (France) -->
<!-- de → de (Germany) -->
<!-- lb → lu (Luxembourg) -->
```
---
## JavaScript Rules
### Rule JS-001: Language Names Must Use Native Language
```javascript
// ✅ GOOD: Native language names
languageNames: {
'en': 'English',
'fr': 'Français', // ✅ Not "French"
'de': 'Deutsch', // ✅ Not "German"
'lb': 'Lëtzebuergesch' // ✅ Not "Luxembourgish"
}
// ❌ BAD: English names
languageNames: {
'en': 'English',
'fr': 'French', // ❌ Should be "Français"
'de': 'German', // ❌ Should be "Deutsch"
'lb': 'Luxembourgish' // ❌ Should be "Lëtzebuergesch"
}
```
### Rule JS-002: Flag Codes Must Map Correctly
```javascript
// ✅ GOOD: Correct flag mappings
languageFlags: {
'en': 'gb', // ✅ Great Britain flag for English
'fr': 'fr', // ✅ France flag
'de': 'de', // ✅ Germany flag
'lb': 'lu' // ✅ Luxembourg flag
}
// ❌ BAD: Incorrect mappings
languageFlags: {
'en': 'us', // ❌ US flag is incorrect for general English
'en': 'en', // ❌ 'en' is not a valid flag code
'lb': 'lb' // ❌ 'lb' is not a valid flag code
}
```
### Rule JS-003: Language API Must Use Correct Endpoint
```javascript
// ✅ GOOD: Correct endpoint
fetch('/api/v1/language/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language: lang })
});
// ❌ BAD: Wrong endpoint or method
fetch('/api/language/set', ...); // ❌ Missing /v1
fetch('/api/v1/language', ...); // ❌ Missing /set
fetch('/api/v1/language/set', { method: 'GET' }); // ❌ Should be POST
```
---
## Translation File Rules
### Rule TRANS-001: All Translation Keys Must Exist in All Files
```
static/locales/
├── en.json # Must have ALL keys
├── fr.json # Must have ALL keys
├── de.json # Must have ALL keys
└── lb.json # Must have ALL keys
```
### Rule TRANS-002: Translation Files Must Be Valid JSON
```json
// ✅ GOOD: Valid JSON
{
"common": {
"save": "Save",
"cancel": "Cancel"
}
}
// ❌ BAD: Trailing comma
{
"common": {
"save": "Save",
"cancel": "Cancel", // ❌ Trailing comma
}
}
```
### Rule TRANS-003: Use Nested Structure for Organization
```json
// ✅ GOOD: Organized by section
{
"common": {
"save": "Sauvegarder",
"cancel": "Annuler"
},
"vendor": {
"dashboard": {
"title": "Tableau de bord"
}
},
"shop": {
"cart": {
"empty": "Votre panier est vide"
}
}
}
// ❌ BAD: Flat structure
{
"save": "Sauvegarder",
"cancel": "Annuler",
"dashboard_title": "Tableau de bord",
"cart_empty": "Votre panier est vide"
}
```
---
## Quick Reference
### Language Selector Implementation Checklist
- [ ] Function defined in appropriate JS file (`shop-layout.js` or `init-alpine.js`)
- [ ] Function exported to `window.languageSelector`
- [ ] Uses `tojson|safe` filter for language array
- [ ] Provides default values for both parameters
- [ ] Uses native language names (Français, Deutsch, Lëtzebuergesch)
- [ ] Uses correct flag codes (en→gb, fr→fr, de→de, lb→lu)
- [ ] Calls `/api/v1/language/set` with POST method
- [ ] Reloads page after successful language change
- [ ] Hides selector if only one language enabled (storefront)
- [ ] Shows all languages (vendor dashboard)
### Database Column Defaults
| Table | Column | Type | Default | Nullable |
|-------|--------|------|---------|----------|
| vendors | default_language | VARCHAR(5) | 'fr' | NO |
| vendors | dashboard_language | VARCHAR(5) | 'fr' | NO |
| vendors | storefront_language | VARCHAR(5) | 'fr' | NO |
| vendors | storefront_languages | JSON | ["fr","de","en"] | NO |
| users | preferred_language | VARCHAR(5) | NULL | YES |
| customers | preferred_language | VARCHAR(5) | NULL | YES |
### Files Requiring Language Support
| File | Type | Notes |
|------|------|-------|
| `static/shop/js/shop-layout.js` | JS | `languageSelector()` function |
| `static/vendor/js/init-alpine.js` | JS | `languageSelector()` function |
| `app/templates/shop/base.html` | Template | Storefront language selector |
| `app/templates/vendor/partials/header.html` | Template | Dashboard language selector |
| `app/api/v1/shared/language.py` | API | Language endpoints |
| `middleware/language.py` | Middleware | Language detection |
| `static/locales/*.json` | JSON | Translation files |
---
## Common Violations and Fixes
### Violation: Alpine.js Expression Error
**Symptom:**
```
Alpine Expression Error: expected expression, got '}'
```
**Cause:** Inline x-data with Jinja template variable not properly escaped.
**Fix:** Move to function-based approach with `tojson|safe`.
### Violation: languageFlags is not defined
**Symptom:**
```
Uncaught ReferenceError: languageFlags is not defined
```
**Cause:** `languageSelector` function not loaded or not exported.
**Fix:** Ensure function is defined in JS file and exported to `window.languageSelector`.
### Violation: Wrong flag displayed
**Symptom:** US flag shown for English, or no flag for Luxembourgish.
**Cause:** Incorrect flag code mapping.
**Fix:** Use correct mappings: `en→gb`, `fr→fr`, `de→de`, `lb→lu`.
---
## Validation
Add these checks to CI/CD pipeline:
```bash
# Validate translation files are valid JSON
python -c "import json; json.load(open('static/locales/en.json'))"
python -c "import json; json.load(open('static/locales/fr.json'))"
python -c "import json; json.load(open('static/locales/de.json'))"
python -c "import json; json.load(open('static/locales/lb.json'))"
# Check all translation keys exist in all files
python scripts/validate_translations.py
```
---
**Remember:** Language implementation errors cause immediate user-facing issues. Follow these rules strictly.

View File

@@ -0,0 +1,319 @@
# Language & Internationalization (i18n) Implementation
## Overview
This document describes the implementation of multi-language support for the Wizamart platform. The system supports four languages (English, French, German, Luxembourgish) with flexible configuration at vendor, user, and customer levels.
## Supported Languages
| Code | Language | Native Name | Flag |
|------|----------|-------------|------|
| `en` | English | English | GB |
| `fr` | French | Francais | FR |
| `de` | German | Deutsch | DE |
| `lb` | Luxembourgish | Letzebuerg | LU |
**Default Language**: French (`fr`) - reflecting the Luxembourg market context.
## Database Changes
### Migration: `fcfdc02d5138_add_language_settings_to_vendor_user_customer`
#### Vendors Table
New columns added to `vendors`:
```sql
ALTER TABLE vendors ADD COLUMN default_language VARCHAR(5) NOT NULL DEFAULT 'fr';
ALTER TABLE vendors ADD COLUMN dashboard_language VARCHAR(5) NOT NULL DEFAULT 'fr';
ALTER TABLE vendors ADD COLUMN storefront_language VARCHAR(5) NOT NULL DEFAULT 'fr';
ALTER TABLE vendors ADD COLUMN storefront_languages JSON NOT NULL DEFAULT '["fr", "de", "en"]';
```
| Column | Type | Description |
|--------|------|-------------|
| `default_language` | VARCHAR(5) | Fallback language for content when translation unavailable |
| `dashboard_language` | VARCHAR(5) | Default UI language for vendor dashboard |
| `storefront_language` | VARCHAR(5) | Default language for customer-facing shop |
| `storefront_languages` | JSON | Array of enabled languages for storefront selector |
#### Users Table
```sql
ALTER TABLE users ADD COLUMN preferred_language VARCHAR(5) NULL;
```
| Column | Type | Description |
|--------|------|-------------|
| `preferred_language` | VARCHAR(5) | User's preferred dashboard language (overrides vendor setting) |
#### Customers Table
```sql
ALTER TABLE customers ADD COLUMN preferred_language VARCHAR(5) NULL;
```
| Column | Type | Description |
|--------|------|-------------|
| `preferred_language` | VARCHAR(5) | Customer's preferred shop language (overrides vendor setting) |
## Architecture
### Language Resolution Flow
#### Vendor Dashboard
```
User preferred_language
|
v (if not set)
Vendor dashboard_language
|
v (if not set)
System DEFAULT_LANGUAGE (fr)
```
#### Storefront
```
Customer preferred_language
|
v (if not set)
Session/Cookie language
|
v (if not set)
Vendor storefront_language
|
v (if not set)
Browser Accept-Language header
|
v (if not supported)
System DEFAULT_LANGUAGE (fr)
```
### Files Created/Modified
#### New Files
| File | Description |
|------|-------------|
| `app/utils/i18n.py` | Core i18n utilities, translation loading, language resolution |
| `middleware/language.py` | Language detection middleware |
| `app/api/v1/shared/language.py` | Language API endpoints |
| `app/templates/shared/macros/language_selector.html` | UI components for language selection |
| `static/locales/en.json` | English translations |
| `static/locales/fr.json` | French translations |
| `static/locales/de.json` | German translations |
| `static/locales/lb.json` | Luxembourgish translations |
#### Modified Files
| File | Changes |
|------|---------|
| `models/database/vendor.py` | Added language settings columns |
| `models/database/user.py` | Added `preferred_language` column |
| `models/database/customer.py` | Added `preferred_language` column |
| `models/schema/vendor.py` | Added language fields to Pydantic schemas |
| `models/schema/auth.py` | Added `preferred_language` to user schemas |
| `models/schema/customer.py` | Added `preferred_language` to customer schemas |
| `main.py` | Registered LanguageMiddleware |
| `app/api/main.py` | Registered language API router |
## API Endpoints
### Language API (`/api/v1/language`)
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/language/set` | POST | Set language preference (cookie) |
| `/api/v1/language/current` | GET | Get current language info |
| `/api/v1/language/list` | GET | List all available languages |
| `/api/v1/language/clear` | DELETE | Clear language preference |
### Request/Response Examples
#### Set Language
```http
POST /api/v1/language/set
Content-Type: application/json
{
"language": "de"
}
```
Response:
```json
{
"success": true,
"language": "de",
"message": "Language set to Deutsch"
}
```
## Translation Files
Translation files are stored in `static/locales/{lang}.json` with the following structure:
```json
{
"common": {
"save": "Save",
"cancel": "Cancel"
},
"auth": {
"login": "Login",
"logout": "Logout"
},
"nav": {
"dashboard": "Dashboard",
"products": "Products"
}
}
```
### Key Naming Convention
- Use dot notation for nested keys: `common.save`, `auth.login`
- Use snake_case for key names: `product_name`, `order_status`
- Group by feature/section: `products.add_product`, `orders.confirm_order`
## Template Integration
### Using Translations in Jinja2
```jinja2
{# Import the translation function #}
{% from 'shared/macros/language_selector.html' import language_selector %}
{# Use translation function #}
{{ _('common.save') }}
{# With interpolation #}
{{ _('welcome.message', name=user.name) }}
{# Language selector component #}
{{ language_selector(
current_language=request.state.language,
enabled_languages=vendor.storefront_languages
) }}
```
### Request State Variables
The LanguageMiddleware sets these on `request.state`:
| Variable | Type | Description |
|----------|------|-------------|
| `language` | str | Resolved language code |
| `language_info` | dict | Additional info (source, cookie value, browser value) |
## Middleware Order
The LanguageMiddleware must run **after** ContextMiddleware (to know the context type) and **before** ThemeContextMiddleware.
Execution order (request flow):
1. LoggingMiddleware
2. VendorContextMiddleware
3. ContextMiddleware
4. **LanguageMiddleware** <-- Detects language
5. ThemeContextMiddleware
6. FastAPI Router
## UI Components
### Full Language Selector
```jinja2
{{ language_selector(
current_language='fr',
enabled_languages=['fr', 'de', 'en'],
position='right',
show_label=true
) }}
```
### Compact Selector (Flag Only)
```jinja2
{{ language_selector_compact(
current_language='fr',
enabled_languages=['fr', 'de', 'en']
) }}
```
### Language Settings Form
For vendor settings pages:
```jinja2
{{ language_settings_form(
current_settings={
'default_language': vendor.default_language,
'dashboard_language': vendor.dashboard_language,
'storefront_language': vendor.storefront_language,
'storefront_languages': vendor.storefront_languages
}
) }}
```
## Flag Icons
The language selector uses [flag-icons](https://flagicons.lipis.dev/) CSS library. Ensure this is included in your base template:
```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css">
```
Usage: `<span class="fi fi-fr"></span>` for French flag.
## Testing
### Manual Testing Checklist
- [ ] Language cookie is set when selecting language
- [ ] Page reloads with correct language after selection
- [ ] Vendor dashboard respects user's preferred_language
- [ ] Storefront respects customer's preferred_language
- [ ] Browser language detection works (clear cookie, use browser with different language)
- [ ] Fallback to default language works for unsupported languages
- [ ] Language selector shows only enabled languages on storefront
### API Testing
```bash
# Set language
curl -X POST http://localhost:8000/api/v1/language/set \
-H "Content-Type: application/json" \
-d '{"language": "de"}'
# Get current language
curl http://localhost:8000/api/v1/language/current
# List languages
curl http://localhost:8000/api/v1/language/list
```
## Future Enhancements
1. **Admin Language Support**: Currently admin is English-only. The system is designed to easily add admin language support later.
2. **Translation Management UI**: Add a UI for vendors to manage their own translations (product descriptions, category names, etc.).
3. **RTL Language Support**: The `is_rtl_language()` function is ready for future RTL language support (Arabic, Hebrew, etc.).
4. **Auto-Translation**: Integration with translation APIs for automatic content translation.
## Rollback
To rollback this migration:
```bash
alembic downgrade -1
```
This will remove:
- `default_language`, `dashboard_language`, `storefront_language`, `storefront_languages` from `vendors`
- `preferred_language` from `users`
- `preferred_language` from `customers`

View File

@@ -1,17 +1,118 @@
# Letzshop Development Documentation
# Letzshop Marketplace Integration
## Introduction
Welcome to the Letzshop Development page. Here youll find details on:
This guide covers the Wizamart platform's integration with the Letzshop marketplace, including:
- GraphQL API
- Authentication
- Playground
- Deprecations
- Order management
- Event system
- Data structures [1](https://letzshop.lu/en/dev)
- **Product Export**: Generate Letzshop-compatible CSV files from your product catalog
- **Order Import**: Fetch and manage orders from Letzshop
- **Fulfillment Sync**: Confirm/reject orders, set tracking numbers
- **GraphQL API Reference**: Direct API access for advanced use cases
---
## Product Export
### Overview
The Wizamart platform can export your products to Letzshop-compatible CSV format (Google Shopping feed format). This allows you to:
- Upload your product catalog to Letzshop marketplace
- Generate feeds in multiple languages (English, French, German)
- Include all required Letzshop fields automatically
### API Endpoints
#### Vendor Export
```http
GET /api/v1/vendor/letzshop/export?language=fr
Authorization: Bearer <vendor_token>
```
**Parameters:**
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `language` | string | `en` | Language for title/description (`en`, `fr`, `de`) |
| `include_inactive` | bool | `false` | Include inactive products |
**Response:** CSV file download (`vendor_code_letzshop_export.csv`)
#### Admin Export
```http
GET /api/v1/admin/letzshop/export?vendor_id=1&language=fr
Authorization: Bearer <admin_token>
```
**Additional Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `vendor_id` | int | Required. Vendor ID to export |
### CSV Format
The export generates a tab-separated CSV file with these columns:
| Column | Description | Example |
|--------|-------------|---------|
| `id` | Product SKU | `PROD-001` |
| `title` | Product title (localized) | `Wireless Headphones` |
| `description` | Product description (localized) | `High-quality...` |
| `link` | Product URL | `https://shop.example.com/product/123` |
| `image_link` | Main product image | `https://cdn.example.com/img.jpg` |
| `additional_image_link` | Additional images (comma-separated) | `img2.jpg,img3.jpg` |
| `availability` | Stock status | `in stock` / `out of stock` |
| `price` | Regular price with currency | `49.99 EUR` |
| `sale_price` | Sale price with currency | `39.99 EUR` |
| `brand` | Brand name | `TechBrand` |
| `gtin` | Global Trade Item Number | `0012345678901` |
| `mpn` | Manufacturer Part Number | `TB-WH-001` |
| `google_product_category` | Google category ID | `Electronics > Audio` |
| `condition` | Product condition | `new` / `used` / `refurbished` |
| `atalanda:tax_rate` | Luxembourg VAT rate | `17` |
### Multi-Language Support
Products are exported with localized content based on the `language` parameter:
```bash
# French export
curl -H "Authorization: Bearer $TOKEN" \
"https://api.example.com/api/v1/vendor/letzshop/export?language=fr"
# German export
curl -H "Authorization: Bearer $TOKEN" \
"https://api.example.com/api/v1/vendor/letzshop/export?language=de"
# English export (default)
curl -H "Authorization: Bearer $TOKEN" \
"https://api.example.com/api/v1/vendor/letzshop/export?language=en"
```
If a translation is not available for the requested language, the system falls back to English, then to any available translation.
### Using the Export
1. **Navigate to Letzshop** in your vendor dashboard
2. **Click the Export tab**
3. **Select your language** (French, German, or English)
4. **Click "Download CSV"**
5. **Upload to Letzshop** via their merchant portal
---
## Order Integration
For details on order import and fulfillment, see [Letzshop Order Integration](./letzshop-order-integration.md).
---
## Letzshop GraphQL API Reference
The following sections document the Letzshop GraphQL API for direct integration.
---

20
main.py
View File

@@ -39,6 +39,7 @@ from app.exceptions.handler import setup_exception_handlers
# Import page routers
from app.routes import admin_pages, shop_pages, vendor_pages
from middleware.context import ContextMiddleware
from middleware.language import LanguageMiddleware
from middleware.logging import LoggingMiddleware
from middleware.theme_context import ThemeContextMiddleware
@@ -89,13 +90,15 @@ app.add_middleware(
# Desired execution order:
# 1. VendorContextMiddleware (detect vendor, extract clean_path)
# 2. ContextMiddleware (detect context using clean_path)
# 3. ThemeContextMiddleware (load theme)
# 4. LoggingMiddleware (log all requests)
# 3. LanguageMiddleware (detect language based on context)
# 4. ThemeContextMiddleware (load theme)
# 5. LoggingMiddleware (log all requests)
#
# Therefore we add them in REVERSE:
# - Add ThemeContextMiddleware FIRST (runs LAST in request)
# - Add ContextMiddleware SECOND
# - Add VendorContextMiddleware THIRD
# - Add LanguageMiddleware SECOND (runs after context)
# - Add ContextMiddleware THIRD
# - Add VendorContextMiddleware FOURTH
# - Add LoggingMiddleware LAST (runs FIRST for timing)
# ============================================================================
@@ -111,6 +114,10 @@ app.add_middleware(LoggingMiddleware)
logger.info("Adding ThemeContextMiddleware (detects and loads theme)")
app.add_middleware(ThemeContextMiddleware)
# Add language middleware (detects language after context is determined)
logger.info("Adding LanguageMiddleware (detects language based on context)")
app.add_middleware(LanguageMiddleware)
# Add context detection middleware (runs after vendor context extraction)
logger.info("Adding ContextMiddleware (detects context type using clean_path)")
app.add_middleware(ContextMiddleware)
@@ -125,8 +132,9 @@ logger.info(" Execution order (request →):")
logger.info(" 1. LoggingMiddleware (timing)")
logger.info(" 2. VendorContextMiddleware (vendor detection)")
logger.info(" 3. ContextMiddleware (context detection)")
logger.info(" 4. ThemeContextMiddleware (theme loading)")
logger.info(" 5. FastAPI Router")
logger.info(" 4. LanguageMiddleware (language detection)")
logger.info(" 5. ThemeContextMiddleware (theme loading)")
logger.info(" 6. FastAPI Router")
logger.info("=" * 80)
# ========================================

186
middleware/language.py Normal file
View File

@@ -0,0 +1,186 @@
# middleware/language.py
"""
Language detection middleware for multi-language support.
This middleware detects the appropriate language for each request based on:
- User/Customer preferences (from JWT token)
- Session/cookie language
- Vendor settings
- Browser Accept-Language header
- System default
The resolved language is stored in request.state.language for use in templates.
"""
import logging
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from app.utils.i18n import (
DEFAULT_LANGUAGE,
SUPPORTED_LANGUAGES,
parse_accept_language,
resolve_storefront_language,
resolve_vendor_dashboard_language,
)
logger = logging.getLogger(__name__)
# Cookie name for language preference
LANGUAGE_COOKIE_NAME = "lang"
class LanguageMiddleware(BaseHTTPMiddleware):
"""
Middleware to detect and set the request language.
Sets request.state.language based on context:
- Admin: Always English (for now)
- Vendor dashboard: User preference → Vendor dashboard_language → default
- Storefront: Customer preference → Cookie → Vendor storefront_language → browser → default
- API: Accept-Language header → default
"""
async def dispatch(self, request: Request, call_next) -> Response:
"""Process the request and set language."""
# Get context type from previous middleware
context_type = getattr(request.state, "context_type", None)
context_value = context_type.value if context_type else None
# Get vendor from previous middleware (if available)
vendor = getattr(request.state, "vendor", None)
# Get language from cookie
cookie_language = request.cookies.get(LANGUAGE_COOKIE_NAME)
# Get browser language from Accept-Language header
accept_language = request.headers.get("accept-language")
browser_language = parse_accept_language(accept_language)
# Resolve language based on context
if context_value == "admin":
# Admin dashboard: English only (for now)
# TODO: Implement admin language support later
language = "en"
elif context_value == "vendor_dashboard":
# Vendor dashboard
user_preferred = self._get_user_language_from_token(request)
vendor_dashboard = vendor.dashboard_language if vendor else None
language = resolve_vendor_dashboard_language(
user_preferred=user_preferred,
vendor_dashboard=vendor_dashboard,
)
elif context_value == "shop":
# Storefront
customer_preferred = self._get_customer_language_from_token(request)
vendor_storefront = vendor.storefront_language if vendor else None
enabled_languages = vendor.storefront_languages if vendor else None
language = resolve_storefront_language(
customer_preferred=customer_preferred,
session_language=cookie_language,
vendor_storefront=vendor_storefront,
browser_language=browser_language,
enabled_languages=enabled_languages,
)
elif context_value == "api":
# API requests: Use Accept-Language or cookie
language = cookie_language or browser_language or DEFAULT_LANGUAGE
else:
# Fallback: Use cookie, browser, or default
language = cookie_language or browser_language or DEFAULT_LANGUAGE
# Validate language is supported
if language not in SUPPORTED_LANGUAGES:
language = DEFAULT_LANGUAGE
# Store language in request state
request.state.language = language
# Also store related info for templates
request.state.language_info = {
"code": language,
"cookie": cookie_language,
"browser": browser_language,
"context": context_value,
}
# Log language detection for debugging
logger.debug(
f"Language detected: {language} "
f"(context={context_value}, cookie={cookie_language}, browser={browser_language})"
)
# Process request
response = await call_next(request)
return response
def _get_user_language_from_token(self, request: Request) -> str | None:
"""
Extract user's preferred_language from JWT token.
This requires the auth middleware to have run first and stored
user info in request.state.
"""
# Check if user info is in request state (set by auth dependency)
current_user = getattr(request.state, "current_user", None)
if current_user and hasattr(current_user, "preferred_language"):
return current_user.preferred_language
return None
def _get_customer_language_from_token(self, request: Request) -> str | None:
"""
Extract customer's preferred_language from JWT token.
This requires the shop auth middleware to have run first.
"""
# Check if customer info is in request state
current_customer = getattr(request.state, "current_customer", None)
if current_customer and hasattr(current_customer, "preferred_language"):
return current_customer.preferred_language
return None
def set_language_cookie(response: Response, language: str) -> Response:
"""
Helper function to set the language cookie on a response.
Args:
response: Response object to modify
language: Language code to set
Returns:
Modified response with language cookie
"""
if language in SUPPORTED_LANGUAGES:
response.set_cookie(
key=LANGUAGE_COOKIE_NAME,
value=language,
max_age=60 * 60 * 24 * 365, # 1 year
httponly=False, # Accessible to JavaScript
samesite="lax",
)
return response
def delete_language_cookie(response: Response) -> Response:
"""
Helper function to delete the language cookie.
Args:
response: Response object to modify
Returns:
Modified response with cookie deleted
"""
response.delete_cookie(key=LANGUAGE_COOKIE_NAME)
return response

View File

@@ -31,6 +31,7 @@ nav:
- Overview: architecture/overview.md
- Marketplace Integration: architecture/marketplace-integration.md
- Architecture Patterns: architecture/architecture-patterns.md
- Language & i18n: architecture/language-i18n.md
- Company-Vendor Management: architecture/company-vendor-management.md
- Multi-Tenant System: architecture/multi-tenant.md
- Middleware Stack: architecture/middleware.md
@@ -124,6 +125,7 @@ nav:
- Quick Summary: development/customer-auth-summary.md
- Migrations:
- Database Migrations: development/migration/database-migrations.md
- Language & i18n Implementation: development/migration/language-i18n-implementation.md
- Tailwind CSS Migration: development/migration/tailwind-migration-plan.md
- Makefile Refactoring: development/migration/makefile-refactoring-complete.md
- SVC-006 Migration Plan: development/migration/svc-006-migration-plan.md
@@ -186,7 +188,9 @@ nav:
- Shop Setup: guides/shop-setup.md
- CSV Import: guides/csv-import.md
- Marketplace Integration: guides/marketplace-integration.md
- Letzshop Order Integration: guides/letzshop-order-integration.md
- Letzshop:
- Order Integration: guides/letzshop-order-integration.md
- Marketplace API: guides/letzshop-marketplace-api.md
# ============================================
# TROUBLESHOOTING

View File

@@ -37,6 +37,10 @@ class Customer(Base, TimestampMixin):
total_spent = Column(Numeric(10, 2), default=0)
is_active = Column(Boolean, default=True, nullable=False)
# Language preference (NULL = use vendor storefront_language default)
# Supported: en, fr, de, lb
preferred_language = Column(String(5), nullable=True)
# Relationships
vendor = relationship("Vendor", back_populates="customers")
addresses = relationship("CustomerAddress", back_populates="customer")

View File

@@ -46,6 +46,10 @@ class User(Base, TimestampMixin):
is_email_verified = Column(Boolean, default=False, nullable=False)
last_login = Column(DateTime, nullable=True)
# Language preference (NULL = use context default: vendor dashboard_language or system default)
# Supported: en, fr, de, lb
preferred_language = Column(String(5), nullable=True)
# Relationships
marketplace_import_jobs = relationship(
"MarketplaceImportJob", back_populates="user"

View File

@@ -74,6 +74,23 @@ class Vendor(Base, TimestampMixin):
business_address = Column(Text, nullable=True) # Override company business address
tax_number = Column(String(100), nullable=True) # Override company tax number
# ========================================================================
# Language Settings
# ========================================================================
# Supported languages: en, fr, de, lb (Luxembourgish)
default_language = Column(
String(5), nullable=False, default="fr"
) # Default language for vendor content (products, emails, etc.)
dashboard_language = Column(
String(5), nullable=False, default="fr"
) # Language for vendor team dashboard UI
storefront_language = Column(
String(5), nullable=False, default="fr"
) # Default language for customer-facing storefront
storefront_languages = Column(
JSON, nullable=False, default=["fr", "de", "en"]
) # Array of enabled languages for storefront language selector
# ========================================================================
# Relationships
# ========================================================================

View File

@@ -25,6 +25,7 @@ class UserResponse(BaseModel):
username: str
role: str
is_active: bool
preferred_language: str | None = None
last_login: datetime | None = None
created_at: datetime
updated_at: datetime
@@ -58,6 +59,9 @@ class UserUpdate(BaseModel):
role: str | None = Field(None, pattern="^(admin|vendor)$")
is_active: bool | None = None
is_email_verified: bool | None = None
preferred_language: str | None = Field(
None, description="Preferred language (en, fr, de, lb)"
)
@field_validator("username")
@classmethod
@@ -78,6 +82,9 @@ class UserCreate(BaseModel):
first_name: str | None = Field(None, max_length=100)
last_name: str | None = Field(None, max_length=100)
role: str = Field(default="vendor", pattern="^(admin|vendor)$")
preferred_language: str | None = Field(
None, description="Preferred language (en, fr, de, lb)"
)
@field_validator("username")
@classmethod

View File

@@ -24,6 +24,9 @@ class CustomerRegister(BaseModel):
last_name: str = Field(..., min_length=1, max_length=100)
phone: str | None = Field(None, max_length=50)
marketing_consent: bool = Field(default=False)
preferred_language: str | None = Field(
None, description="Preferred language (en, fr, de, lb)"
)
@field_validator("email")
@classmethod
@@ -52,6 +55,9 @@ class CustomerUpdate(BaseModel):
last_name: str | None = Field(None, min_length=1, max_length=100)
phone: str | None = Field(None, max_length=50)
marketing_consent: bool | None = None
preferred_language: str | None = Field(
None, description="Preferred language (en, fr, de, lb)"
)
@field_validator("email")
@classmethod
@@ -76,6 +82,7 @@ class CustomerResponse(BaseModel):
phone: str | None
customer_number: str
marketing_consent: bool
preferred_language: str | None
last_order_date: datetime | None
total_orders: int
total_spent: Decimal
@@ -169,7 +176,9 @@ class CustomerPreferencesUpdate(BaseModel):
"""Schema for updating customer preferences."""
marketing_consent: bool | None = None
language: str | None = Field(None, max_length=10)
preferred_language: str | None = Field(
None, description="Preferred language (en, fr, de, lb)"
)
currency: str | None = Field(None, max_length=3)
notification_preferences: dict[str, bool] | None = None
@@ -206,6 +215,7 @@ class CustomerDetailResponse(BaseModel):
phone: str | None = None
customer_number: str | None = None
marketing_consent: bool | None = None
preferred_language: str | None = None
last_order_date: datetime | None = None
total_orders: int | None = None
total_spent: Decimal | None = None

View File

@@ -59,6 +59,20 @@ class VendorCreate(BaseModel):
business_address: str | None = Field(None, description="Override company business address")
tax_number: str | None = Field(None, description="Override company tax number")
# Language Settings
default_language: str | None = Field(
"fr", description="Default language for content (en, fr, de, lb)"
)
dashboard_language: str | None = Field(
"fr", description="Vendor dashboard UI language"
)
storefront_language: str | None = Field(
"fr", description="Default storefront language for customers"
)
storefront_languages: list[str] | None = Field(
default=["fr", "de", "en"], description="Enabled languages for storefront"
)
@field_validator("subdomain")
@classmethod
def validate_subdomain(cls, v):
@@ -110,6 +124,20 @@ class VendorUpdate(BaseModel):
None, description="If true, reset all contact fields to inherit from company"
)
# Language Settings
default_language: str | None = Field(
None, description="Default language for content (en, fr, de, lb)"
)
dashboard_language: str | None = Field(
None, description="Vendor dashboard UI language"
)
storefront_language: str | None = Field(
None, description="Default storefront language for customers"
)
storefront_languages: list[str] | None = Field(
None, description="Enabled languages for storefront"
)
@field_validator("subdomain")
@classmethod
def subdomain_lowercase(cls, v):
@@ -148,6 +176,12 @@ class VendorResponse(BaseModel):
is_active: bool
is_verified: bool
# Language Settings (optional with defaults for backward compatibility)
default_language: str = "fr"
dashboard_language: str = "fr"
storefront_language: str = "fr"
storefront_languages: list[str] = ["fr", "de", "en"]
# Timestamps
created_at: datetime
updated_at: datetime

View File

@@ -179,24 +179,24 @@ function adminMarketplaceProductDetail() {
},
/**
* Get full language name from ISO code
* Get full language name from ISO code (native names for Luxembourg languages)
*/
getLanguageName(code) {
const languages = {
'en': 'English',
'de': 'German',
'fr': 'French',
'lb': 'Luxembourgish',
'es': 'Spanish',
'it': 'Italian',
'nl': 'Dutch',
'pt': 'Portuguese',
'pl': 'Polish',
'cs': 'Czech',
'da': 'Danish',
'sv': 'Swedish',
'fi': 'Finnish',
'no': 'Norwegian',
'de': 'Deutsch',
'fr': 'Français',
'lb': 'Lëtzebuergesch',
'es': 'Español',
'it': 'Italiano',
'nl': 'Nederlands',
'pt': 'Português',
'pl': 'Polski',
'cs': 'Čeština',
'da': 'Dansk',
'sv': 'Svenska',
'fi': 'Suomi',
'no': 'Norsk',
'hu': 'Hungarian',
'ro': 'Romanian',
'bg': 'Bulgarian',

475
static/locales/de.json Normal file
View File

@@ -0,0 +1,475 @@
{
"common": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten",
"create": "Erstellen",
"update": "Aktualisieren",
"add": "Hinzufügen",
"remove": "Entfernen",
"close": "Schließen",
"back": "Zurück",
"next": "Weiter",
"previous": "Zurück",
"submit": "Absenden",
"confirm": "Bestätigen",
"yes": "Ja",
"no": "Nein",
"ok": "OK",
"done": "Fertig",
"loading": "Laden...",
"saving": "Speichern...",
"processing": "Verarbeiten...",
"searching": "Suchen...",
"refresh": "Aktualisieren",
"retry": "Erneut versuchen",
"view": "Ansehen",
"view_details": "Details ansehen",
"view_all": "Alle anzeigen",
"show_more": "Mehr anzeigen",
"show_less": "Weniger anzeigen",
"search": "Suchen",
"filter": "Filtern",
"sort": "Sortieren",
"export": "Exportieren",
"import": "Importieren",
"download": "Herunterladen",
"upload": "Hochladen",
"select": "Auswählen",
"select_all": "Alle auswählen",
"deselect_all": "Auswahl aufheben",
"actions": "Aktionen",
"status": "Status",
"date": "Datum",
"time": "Zeit",
"name": "Name",
"email": "E-Mail",
"phone": "Telefon",
"address": "Adresse",
"description": "Beschreibung",
"notes": "Notizen",
"total": "Gesamt",
"amount": "Betrag",
"quantity": "Menge",
"price": "Preis",
"items": "Artikel",
"id": "ID",
"type": "Typ",
"category": "Kategorie",
"tags": "Tags",
"active": "Aktiv",
"inactive": "Inaktiv",
"enabled": "Aktiviert",
"disabled": "Deaktiviert",
"pending": "Ausstehend",
"completed": "Abgeschlossen",
"failed": "Fehlgeschlagen",
"success": "Erfolg",
"error": "Fehler",
"warning": "Warnung",
"info": "Info",
"all": "Alle",
"none": "Keine",
"other": "Andere",
"unknown": "Unbekannt",
"not_available": "N/V",
"required": "Erforderlich",
"optional": "Optional",
"language": "Sprache",
"settings": "Einstellungen",
"help": "Hilfe",
"support": "Support",
"contact": "Kontakt",
"about": "Über",
"privacy": "Datenschutz",
"terms": "AGB",
"copyright": "Urheberrecht"
},
"auth": {
"sign_in": "Anmelden",
"sign_out": "Abmelden",
"sign_up": "Registrieren",
"login": "Anmelden",
"logout": "Abmelden",
"register": "Registrieren",
"forgot_password": "Passwort vergessen?",
"reset_password": "Passwort zurücksetzen",
"change_password": "Passwort ändern",
"username": "Benutzername",
"password": "Passwort",
"confirm_password": "Passwort bestätigen",
"current_password": "Aktuelles Passwort",
"new_password": "Neues Passwort",
"remember_me": "Angemeldet bleiben",
"email_placeholder": "E-Mail eingeben",
"username_placeholder": "Benutzername eingeben",
"password_placeholder": "Passwort eingeben",
"login_success": "Anmeldung erfolgreich",
"login_failed": "Anmeldung fehlgeschlagen",
"logout_success": "Sie wurden abgemeldet",
"invalid_credentials": "Ungültiger Benutzername oder Passwort",
"session_expired": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
"account_locked": "Ihr Konto wurde gesperrt",
"account_inactive": "Ihr Konto ist inaktiv"
},
"nav": {
"dashboard": "Dashboard",
"products": "Produkte",
"orders": "Bestellungen",
"customers": "Kunden",
"inventory": "Inventar",
"analytics": "Analysen",
"reports": "Berichte",
"settings": "Einstellungen",
"profile": "Profil",
"team": "Team",
"marketplace": "Marktplatz",
"integrations": "Integrationen",
"notifications": "Benachrichtigungen",
"help": "Hilfe",
"home": "Startseite",
"shop": "Shop",
"cart": "Warenkorb",
"checkout": "Kasse",
"account": "Konto",
"wishlist": "Wunschliste"
},
"dashboard": {
"title": "Dashboard",
"welcome": "Willkommen zurück",
"overview": "Übersicht",
"quick_stats": "Schnellstatistiken",
"recent_activity": "Letzte Aktivitäten",
"total_products": "Produkte gesamt",
"total_orders": "Bestellungen gesamt",
"total_customers": "Kunden gesamt",
"total_revenue": "Gesamtumsatz",
"active_products": "Aktive Produkte",
"pending_orders": "Ausstehende Bestellungen",
"new_customers": "Neue Kunden",
"today": "Heute",
"this_week": "Diese Woche",
"this_month": "Dieser Monat",
"this_year": "Dieses Jahr",
"error_loading": "Fehler beim Laden des Dashboards",
"no_data": "Keine Daten verfügbar"
},
"products": {
"title": "Produkte",
"product": "Produkt",
"add_product": "Produkt hinzufügen",
"edit_product": "Produkt bearbeiten",
"delete_product": "Produkt löschen",
"product_name": "Produktname",
"product_code": "Produktcode",
"sku": "SKU",
"price": "Preis",
"sale_price": "Verkaufspreis",
"cost": "Kosten",
"stock": "Lagerbestand",
"in_stock": "Auf Lager",
"out_of_stock": "Nicht auf Lager",
"low_stock": "Geringer Bestand",
"availability": "Verfügbarkeit",
"available": "Verfügbar",
"unavailable": "Nicht verfügbar",
"brand": "Marke",
"category": "Kategorie",
"categories": "Kategorien",
"image": "Bild",
"images": "Bilder",
"main_image": "Hauptbild",
"gallery": "Galerie",
"weight": "Gewicht",
"dimensions": "Abmessungen",
"color": "Farbe",
"size": "Größe",
"material": "Material",
"condition": "Zustand",
"new": "Neu",
"used": "Gebraucht",
"refurbished": "Generalüberholt",
"no_products": "Keine Produkte gefunden",
"search_products": "Produkte suchen...",
"filter_by_category": "Nach Kategorie filtern",
"filter_by_status": "Nach Status filtern",
"sort_by": "Sortieren nach",
"sort_newest": "Neueste",
"sort_oldest": "Älteste",
"sort_price_low": "Preis: Niedrig bis Hoch",
"sort_price_high": "Preis: Hoch bis Niedrig",
"sort_name_az": "Name: A-Z",
"sort_name_za": "Name: Z-A"
},
"orders": {
"title": "Bestellungen",
"order": "Bestellung",
"order_id": "Bestellnummer",
"order_number": "Bestellnummer",
"order_date": "Bestelldatum",
"order_status": "Bestellstatus",
"order_details": "Bestelldetails",
"order_items": "Bestellartikel",
"order_total": "Bestellsumme",
"subtotal": "Zwischensumme",
"shipping": "Versand",
"tax": "Steuer",
"discount": "Rabatt",
"customer": "Kunde",
"shipping_address": "Lieferadresse",
"billing_address": "Rechnungsadresse",
"payment_method": "Zahlungsmethode",
"payment_status": "Zahlungsstatus",
"tracking": "Sendungsverfolgung",
"tracking_number": "Sendungsnummer",
"carrier": "Versanddienstleister",
"no_orders": "Keine Bestellungen gefunden",
"search_orders": "Bestellungen suchen...",
"filter_by_status": "Nach Status filtern",
"status_pending": "Ausstehend",
"status_processing": "In Bearbeitung",
"status_shipped": "Versendet",
"status_delivered": "Zugestellt",
"status_cancelled": "Storniert",
"status_refunded": "Erstattet",
"status_confirmed": "Bestätigt",
"status_rejected": "Abgelehnt",
"confirm_order": "Bestellung bestätigen",
"reject_order": "Bestellung ablehnen",
"set_tracking": "Sendungsverfolgung setzen",
"view_details": "Details ansehen"
},
"customers": {
"title": "Kunden",
"customer": "Kunde",
"add_customer": "Kunde hinzufügen",
"edit_customer": "Kunde bearbeiten",
"customer_name": "Kundenname",
"customer_email": "Kunden-E-Mail",
"customer_phone": "Kundentelefon",
"customer_number": "Kundennummer",
"first_name": "Vorname",
"last_name": "Nachname",
"company": "Firma",
"total_orders": "Bestellungen gesamt",
"total_spent": "Gesamtausgaben",
"last_order": "Letzte Bestellung",
"registered": "Registriert",
"no_customers": "Keine Kunden gefunden",
"search_customers": "Kunden suchen..."
},
"inventory": {
"title": "Inventar",
"stock_level": "Lagerbestand",
"quantity": "Menge",
"reorder_point": "Nachbestellpunkt",
"adjust_stock": "Bestand anpassen",
"stock_in": "Wareneingang",
"stock_out": "Warenausgang",
"transfer": "Transfer",
"history": "Verlauf",
"low_stock_alert": "Warnung bei geringem Bestand",
"out_of_stock_alert": "Warnung bei Ausverkauf"
},
"marketplace": {
"title": "Marktplatz",
"import": "Importieren",
"export": "Exportieren",
"sync": "Synchronisieren",
"source": "Quelle",
"source_url": "Quell-URL",
"import_products": "Produkte importieren",
"start_import": "Import starten",
"importing": "Importiere...",
"import_complete": "Import abgeschlossen",
"import_failed": "Import fehlgeschlagen",
"import_history": "Import-Verlauf",
"job_id": "Auftrags-ID",
"started_at": "Gestartet um",
"completed_at": "Abgeschlossen um",
"duration": "Dauer",
"imported_count": "Importiert",
"error_count": "Fehler",
"total_processed": "Gesamt verarbeitet",
"progress": "Fortschritt",
"no_import_jobs": "Noch keine Imports",
"start_first_import": "Starten Sie Ihren ersten Import mit dem Formular oben"
},
"letzshop": {
"title": "Letzshop-Integration",
"connection": "Verbindung",
"credentials": "Zugangsdaten",
"api_key": "API-Schlüssel",
"api_endpoint": "API-Endpunkt",
"auto_sync": "Auto-Sync",
"sync_interval": "Sync-Intervall",
"every_hour": "Jede Stunde",
"every_day": "Jeden Tag",
"test_connection": "Verbindung testen",
"save_credentials": "Zugangsdaten speichern",
"connection_success": "Verbindung erfolgreich",
"connection_failed": "Verbindung fehlgeschlagen",
"last_sync": "Letzte Synchronisation",
"sync_status": "Sync-Status",
"import_orders": "Bestellungen importieren",
"export_products": "Produkte exportieren",
"no_credentials": "Konfigurieren Sie Ihren API-Schlüssel in den Einstellungen",
"carriers": {
"dhl": "DHL",
"ups": "UPS",
"fedex": "FedEx",
"dpd": "DPD",
"gls": "GLS",
"post_luxembourg": "Post Luxemburg",
"other": "Andere"
}
},
"team": {
"title": "Team",
"members": "Mitglieder",
"add_member": "Mitglied hinzufügen",
"invite_member": "Mitglied einladen",
"remove_member": "Mitglied entfernen",
"role": "Rolle",
"owner": "Inhaber",
"manager": "Manager",
"editor": "Bearbeiter",
"viewer": "Betrachter",
"permissions": "Berechtigungen",
"pending_invitations": "Ausstehende Einladungen",
"invitation_sent": "Einladung gesendet",
"invitation_accepted": "Einladung angenommen"
},
"settings": {
"title": "Einstellungen",
"general": "Allgemein",
"store": "Shop",
"store_name": "Shop-Name",
"store_description": "Shop-Beschreibung",
"contact_email": "Kontakt-E-Mail",
"contact_phone": "Kontakttelefon",
"business_address": "Geschäftsadresse",
"tax_number": "Steuernummer",
"currency": "Währung",
"timezone": "Zeitzone",
"language": "Sprache",
"language_settings": "Spracheinstellungen",
"default_language": "Standardsprache",
"dashboard_language": "Dashboard-Sprache",
"storefront_language": "Shop-Sprache",
"enabled_languages": "Aktivierte Sprachen",
"notifications": "Benachrichtigungen",
"email_notifications": "E-Mail-Benachrichtigungen",
"integrations": "Integrationen",
"api_keys": "API-Schlüssel",
"webhooks": "Webhooks",
"save_settings": "Einstellungen speichern",
"settings_saved": "Einstellungen erfolgreich gespeichert"
},
"profile": {
"title": "Profil",
"my_profile": "Mein Profil",
"edit_profile": "Profil bearbeiten",
"personal_info": "Persönliche Informationen",
"first_name": "Vorname",
"last_name": "Nachname",
"email": "E-Mail",
"phone": "Telefon",
"avatar": "Profilbild",
"change_avatar": "Profilbild ändern",
"security": "Sicherheit",
"two_factor": "Zwei-Faktor-Authentifizierung",
"sessions": "Aktive Sitzungen",
"preferences": "Präferenzen",
"language_preference": "Sprachpräferenz",
"save_profile": "Profil speichern",
"profile_updated": "Profil erfolgreich aktualisiert"
},
"errors": {
"generic": "Ein Fehler ist aufgetreten",
"not_found": "Nicht gefunden",
"unauthorized": "Nicht autorisiert",
"forbidden": "Verboten",
"bad_request": "Ungültige Anfrage",
"server_error": "Serverfehler",
"network_error": "Netzwerkfehler",
"timeout": "Zeitüberschreitung",
"validation_error": "Validierungsfehler",
"field_required": "Dieses Feld ist erforderlich",
"invalid_email": "Ungültige E-Mail-Adresse",
"invalid_phone": "Ungültige Telefonnummer",
"password_mismatch": "Passwörter stimmen nicht überein",
"password_too_short": "Passwort ist zu kurz",
"try_again": "Bitte versuchen Sie es erneut",
"contact_support": "Bitte kontaktieren Sie den Support, wenn das Problem weiterhin besteht"
},
"confirmations": {
"delete_title": "Löschen bestätigen",
"delete_message": "Sind Sie sicher, dass Sie diesen Eintrag löschen möchten?",
"delete_warning": "Diese Aktion kann nicht rückgängig gemacht werden.",
"cancel_title": "Abbrechen bestätigen",
"cancel_message": "Sind Sie sicher, dass Sie abbrechen möchten?",
"unsaved_changes": "Sie haben ungespeicherte Änderungen. Sind Sie sicher, dass Sie die Seite verlassen möchten?",
"logout_title": "Abmelden bestätigen",
"logout_message": "Sind Sie sicher, dass Sie sich abmelden möchten?"
},
"notifications": {
"title": "Benachrichtigungen",
"mark_read": "Als gelesen markieren",
"mark_all_read": "Alle als gelesen markieren",
"no_notifications": "Keine Benachrichtigungen",
"new_order": "Neue Bestellung",
"order_updated": "Bestellung aktualisiert",
"low_stock": "Warnung bei geringem Bestand",
"import_complete": "Import abgeschlossen",
"import_failed": "Import fehlgeschlagen"
},
"shop": {
"welcome": "Willkommen in unserem Shop",
"browse_products": "Produkte durchstöbern",
"add_to_cart": "In den Warenkorb",
"buy_now": "Jetzt kaufen",
"view_cart": "Warenkorb ansehen",
"checkout": "Zur Kasse",
"continue_shopping": "Weiter einkaufen",
"start_shopping": "Einkaufen starten",
"empty_cart": "Ihr Warenkorb ist leer",
"cart_total": "Warenkorbsumme",
"proceed_checkout": "Zur Kasse gehen",
"payment": "Zahlung",
"place_order": "Bestellung aufgeben",
"order_placed": "Bestellung erfolgreich aufgegeben",
"thank_you": "Vielen Dank für Ihre Bestellung",
"order_confirmation": "Bestellbestätigung"
},
"footer": {
"all_rights_reserved": "Alle Rechte vorbehalten",
"powered_by": "Unterstützt von"
},
"time": {
"now": "Jetzt",
"today": "Heute",
"yesterday": "Gestern",
"tomorrow": "Morgen",
"this_week": "Diese Woche",
"last_week": "Letzte Woche",
"this_month": "Dieser Monat",
"last_month": "Letzter Monat",
"this_year": "Dieses Jahr",
"ago": "vor",
"seconds": "Sekunden",
"minutes": "Minuten",
"hours": "Stunden",
"days": "Tagen",
"weeks": "Wochen",
"months": "Monaten",
"years": "Jahren"
},
"formats": {
"date": "DD.MM.YYYY",
"time": "HH:mm",
"datetime": "DD.MM.YYYY HH:mm",
"currency": "{amount} {symbol}"
}
}

475
static/locales/en.json Normal file
View File

@@ -0,0 +1,475 @@
{
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"create": "Create",
"update": "Update",
"add": "Add",
"remove": "Remove",
"close": "Close",
"back": "Back",
"next": "Next",
"previous": "Previous",
"submit": "Submit",
"confirm": "Confirm",
"yes": "Yes",
"no": "No",
"ok": "OK",
"done": "Done",
"loading": "Loading...",
"saving": "Saving...",
"processing": "Processing...",
"searching": "Searching...",
"refresh": "Refresh",
"retry": "Retry",
"view": "View",
"view_details": "View Details",
"view_all": "View All",
"show_more": "Show More",
"show_less": "Show Less",
"search": "Search",
"filter": "Filter",
"sort": "Sort",
"export": "Export",
"import": "Import",
"download": "Download",
"upload": "Upload",
"select": "Select",
"select_all": "Select All",
"deselect_all": "Deselect All",
"actions": "Actions",
"status": "Status",
"date": "Date",
"time": "Time",
"name": "Name",
"email": "Email",
"phone": "Phone",
"address": "Address",
"description": "Description",
"notes": "Notes",
"total": "Total",
"amount": "Amount",
"quantity": "Quantity",
"price": "Price",
"items": "Items",
"id": "ID",
"type": "Type",
"category": "Category",
"tags": "Tags",
"active": "Active",
"inactive": "Inactive",
"enabled": "Enabled",
"disabled": "Disabled",
"pending": "Pending",
"completed": "Completed",
"failed": "Failed",
"success": "Success",
"error": "Error",
"warning": "Warning",
"info": "Info",
"all": "All",
"none": "None",
"other": "Other",
"unknown": "Unknown",
"not_available": "N/A",
"required": "Required",
"optional": "Optional",
"language": "Language",
"settings": "Settings",
"help": "Help",
"support": "Support",
"contact": "Contact",
"about": "About",
"privacy": "Privacy",
"terms": "Terms",
"copyright": "Copyright"
},
"auth": {
"sign_in": "Sign In",
"sign_out": "Sign Out",
"sign_up": "Sign Up",
"login": "Login",
"logout": "Logout",
"register": "Register",
"forgot_password": "Forgot Password?",
"reset_password": "Reset Password",
"change_password": "Change Password",
"username": "Username",
"password": "Password",
"confirm_password": "Confirm Password",
"current_password": "Current Password",
"new_password": "New Password",
"remember_me": "Remember Me",
"email_placeholder": "Enter your email",
"username_placeholder": "Enter your username",
"password_placeholder": "Enter your password",
"login_success": "Login successful",
"login_failed": "Login failed",
"logout_success": "You have been logged out",
"invalid_credentials": "Invalid username or password",
"session_expired": "Your session has expired. Please login again.",
"account_locked": "Your account has been locked",
"account_inactive": "Your account is inactive"
},
"nav": {
"dashboard": "Dashboard",
"products": "Products",
"orders": "Orders",
"customers": "Customers",
"inventory": "Inventory",
"analytics": "Analytics",
"reports": "Reports",
"settings": "Settings",
"profile": "Profile",
"team": "Team",
"marketplace": "Marketplace",
"integrations": "Integrations",
"notifications": "Notifications",
"help": "Help",
"home": "Home",
"shop": "Shop",
"cart": "Cart",
"checkout": "Checkout",
"account": "Account",
"wishlist": "Wishlist"
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome back",
"overview": "Overview",
"quick_stats": "Quick Stats",
"recent_activity": "Recent Activity",
"total_products": "Total Products",
"total_orders": "Total Orders",
"total_customers": "Total Customers",
"total_revenue": "Total Revenue",
"active_products": "Active Products",
"pending_orders": "Pending Orders",
"new_customers": "New Customers",
"today": "Today",
"this_week": "This Week",
"this_month": "This Month",
"this_year": "This Year",
"error_loading": "Error loading dashboard",
"no_data": "No data available"
},
"products": {
"title": "Products",
"product": "Product",
"add_product": "Add Product",
"edit_product": "Edit Product",
"delete_product": "Delete Product",
"product_name": "Product Name",
"product_code": "Product Code",
"sku": "SKU",
"price": "Price",
"sale_price": "Sale Price",
"cost": "Cost",
"stock": "Stock",
"in_stock": "In Stock",
"out_of_stock": "Out of Stock",
"low_stock": "Low Stock",
"availability": "Availability",
"available": "Available",
"unavailable": "Unavailable",
"brand": "Brand",
"category": "Category",
"categories": "Categories",
"image": "Image",
"images": "Images",
"main_image": "Main Image",
"gallery": "Gallery",
"weight": "Weight",
"dimensions": "Dimensions",
"color": "Color",
"size": "Size",
"material": "Material",
"condition": "Condition",
"new": "New",
"used": "Used",
"refurbished": "Refurbished",
"no_products": "No products found",
"search_products": "Search products...",
"filter_by_category": "Filter by category",
"filter_by_status": "Filter by status",
"sort_by": "Sort by",
"sort_newest": "Newest",
"sort_oldest": "Oldest",
"sort_price_low": "Price: Low to High",
"sort_price_high": "Price: High to Low",
"sort_name_az": "Name: A-Z",
"sort_name_za": "Name: Z-A"
},
"orders": {
"title": "Orders",
"order": "Order",
"order_id": "Order ID",
"order_number": "Order Number",
"order_date": "Order Date",
"order_status": "Order Status",
"order_details": "Order Details",
"order_items": "Order Items",
"order_total": "Order Total",
"subtotal": "Subtotal",
"shipping": "Shipping",
"tax": "Tax",
"discount": "Discount",
"customer": "Customer",
"shipping_address": "Shipping Address",
"billing_address": "Billing Address",
"payment_method": "Payment Method",
"payment_status": "Payment Status",
"tracking": "Tracking",
"tracking_number": "Tracking Number",
"carrier": "Carrier",
"no_orders": "No orders found",
"search_orders": "Search orders...",
"filter_by_status": "Filter by status",
"status_pending": "Pending",
"status_processing": "Processing",
"status_shipped": "Shipped",
"status_delivered": "Delivered",
"status_cancelled": "Cancelled",
"status_refunded": "Refunded",
"status_confirmed": "Confirmed",
"status_rejected": "Rejected",
"confirm_order": "Confirm Order",
"reject_order": "Reject Order",
"set_tracking": "Set Tracking",
"view_details": "View Details"
},
"customers": {
"title": "Customers",
"customer": "Customer",
"add_customer": "Add Customer",
"edit_customer": "Edit Customer",
"customer_name": "Customer Name",
"customer_email": "Customer Email",
"customer_phone": "Customer Phone",
"customer_number": "Customer Number",
"first_name": "First Name",
"last_name": "Last Name",
"company": "Company",
"total_orders": "Total Orders",
"total_spent": "Total Spent",
"last_order": "Last Order",
"registered": "Registered",
"no_customers": "No customers found",
"search_customers": "Search customers..."
},
"inventory": {
"title": "Inventory",
"stock_level": "Stock Level",
"quantity": "Quantity",
"reorder_point": "Reorder Point",
"adjust_stock": "Adjust Stock",
"stock_in": "Stock In",
"stock_out": "Stock Out",
"transfer": "Transfer",
"history": "History",
"low_stock_alert": "Low Stock Alert",
"out_of_stock_alert": "Out of Stock Alert"
},
"marketplace": {
"title": "Marketplace",
"import": "Import",
"export": "Export",
"sync": "Sync",
"source": "Source",
"source_url": "Source URL",
"import_products": "Import Products",
"start_import": "Start Import",
"importing": "Importing...",
"import_complete": "Import Complete",
"import_failed": "Import Failed",
"import_history": "Import History",
"job_id": "Job ID",
"started_at": "Started At",
"completed_at": "Completed At",
"duration": "Duration",
"imported_count": "Imported",
"error_count": "Errors",
"total_processed": "Total Processed",
"progress": "Progress",
"no_import_jobs": "No import jobs yet",
"start_first_import": "Start your first import using the form above"
},
"letzshop": {
"title": "Letzshop Integration",
"connection": "Connection",
"credentials": "Credentials",
"api_key": "API Key",
"api_endpoint": "API Endpoint",
"auto_sync": "Auto Sync",
"sync_interval": "Sync Interval",
"every_hour": "Every hour",
"every_day": "Every day",
"test_connection": "Test Connection",
"save_credentials": "Save Credentials",
"connection_success": "Connection successful",
"connection_failed": "Connection failed",
"last_sync": "Last Sync",
"sync_status": "Sync Status",
"import_orders": "Import Orders",
"export_products": "Export Products",
"no_credentials": "Configure your API key in Settings to get started",
"carriers": {
"dhl": "DHL",
"ups": "UPS",
"fedex": "FedEx",
"dpd": "DPD",
"gls": "GLS",
"post_luxembourg": "Post Luxembourg",
"other": "Other"
}
},
"team": {
"title": "Team",
"members": "Members",
"add_member": "Add Member",
"invite_member": "Invite Member",
"remove_member": "Remove Member",
"role": "Role",
"owner": "Owner",
"manager": "Manager",
"editor": "Editor",
"viewer": "Viewer",
"permissions": "Permissions",
"pending_invitations": "Pending Invitations",
"invitation_sent": "Invitation Sent",
"invitation_accepted": "Invitation Accepted"
},
"settings": {
"title": "Settings",
"general": "General",
"store": "Store",
"store_name": "Store Name",
"store_description": "Store Description",
"contact_email": "Contact Email",
"contact_phone": "Contact Phone",
"business_address": "Business Address",
"tax_number": "Tax Number",
"currency": "Currency",
"timezone": "Timezone",
"language": "Language",
"language_settings": "Language Settings",
"default_language": "Default Language",
"dashboard_language": "Dashboard Language",
"storefront_language": "Storefront Language",
"enabled_languages": "Enabled Languages",
"notifications": "Notifications",
"email_notifications": "Email Notifications",
"integrations": "Integrations",
"api_keys": "API Keys",
"webhooks": "Webhooks",
"save_settings": "Save Settings",
"settings_saved": "Settings saved successfully"
},
"profile": {
"title": "Profile",
"my_profile": "My Profile",
"edit_profile": "Edit Profile",
"personal_info": "Personal Information",
"first_name": "First Name",
"last_name": "Last Name",
"email": "Email",
"phone": "Phone",
"avatar": "Avatar",
"change_avatar": "Change Avatar",
"security": "Security",
"two_factor": "Two-Factor Authentication",
"sessions": "Active Sessions",
"preferences": "Preferences",
"language_preference": "Language Preference",
"save_profile": "Save Profile",
"profile_updated": "Profile updated successfully"
},
"errors": {
"generic": "An error occurred",
"not_found": "Not Found",
"unauthorized": "Unauthorized",
"forbidden": "Forbidden",
"bad_request": "Bad Request",
"server_error": "Server Error",
"network_error": "Network Error",
"timeout": "Request Timeout",
"validation_error": "Validation Error",
"field_required": "This field is required",
"invalid_email": "Invalid email address",
"invalid_phone": "Invalid phone number",
"password_mismatch": "Passwords do not match",
"password_too_short": "Password is too short",
"try_again": "Please try again",
"contact_support": "Please contact support if the problem persists"
},
"confirmations": {
"delete_title": "Confirm Delete",
"delete_message": "Are you sure you want to delete this item?",
"delete_warning": "This action cannot be undone.",
"cancel_title": "Confirm Cancel",
"cancel_message": "Are you sure you want to cancel?",
"unsaved_changes": "You have unsaved changes. Are you sure you want to leave?",
"logout_title": "Confirm Logout",
"logout_message": "Are you sure you want to log out?"
},
"notifications": {
"title": "Notifications",
"mark_read": "Mark as Read",
"mark_all_read": "Mark All as Read",
"no_notifications": "No notifications",
"new_order": "New Order",
"order_updated": "Order Updated",
"low_stock": "Low Stock Alert",
"import_complete": "Import Complete",
"import_failed": "Import Failed"
},
"shop": {
"welcome": "Welcome to our store",
"browse_products": "Browse Products",
"add_to_cart": "Add to Cart",
"buy_now": "Buy Now",
"view_cart": "View Cart",
"checkout": "Checkout",
"continue_shopping": "Continue Shopping",
"start_shopping": "Start Shopping",
"empty_cart": "Your cart is empty",
"cart_total": "Cart Total",
"proceed_checkout": "Proceed to Checkout",
"payment": "Payment",
"place_order": "Place Order",
"order_placed": "Order Placed Successfully",
"thank_you": "Thank you for your order",
"order_confirmation": "Order Confirmation"
},
"footer": {
"all_rights_reserved": "All rights reserved",
"powered_by": "Powered by"
},
"time": {
"now": "Now",
"today": "Today",
"yesterday": "Yesterday",
"tomorrow": "Tomorrow",
"this_week": "This Week",
"last_week": "Last Week",
"this_month": "This Month",
"last_month": "Last Month",
"this_year": "This Year",
"ago": "ago",
"seconds": "seconds",
"minutes": "minutes",
"hours": "hours",
"days": "days",
"weeks": "weeks",
"months": "months",
"years": "years"
},
"formats": {
"date": "MM/DD/YYYY",
"time": "HH:mm",
"datetime": "MM/DD/YYYY HH:mm",
"currency": "{symbol}{amount}"
}
}

475
static/locales/fr.json Normal file
View File

@@ -0,0 +1,475 @@
{
"common": {
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer",
"edit": "Modifier",
"create": "Créer",
"update": "Mettre à jour",
"add": "Ajouter",
"remove": "Retirer",
"close": "Fermer",
"back": "Retour",
"next": "Suivant",
"previous": "Précédent",
"submit": "Soumettre",
"confirm": "Confirmer",
"yes": "Oui",
"no": "Non",
"ok": "OK",
"done": "Terminé",
"loading": "Chargement...",
"saving": "Enregistrement...",
"processing": "Traitement...",
"searching": "Recherche...",
"refresh": "Actualiser",
"retry": "Réessayer",
"view": "Voir",
"view_details": "Voir les détails",
"view_all": "Voir tout",
"show_more": "Voir plus",
"show_less": "Voir moins",
"search": "Rechercher",
"filter": "Filtrer",
"sort": "Trier",
"export": "Exporter",
"import": "Importer",
"download": "Télécharger",
"upload": "Téléverser",
"select": "Sélectionner",
"select_all": "Tout sélectionner",
"deselect_all": "Tout désélectionner",
"actions": "Actions",
"status": "Statut",
"date": "Date",
"time": "Heure",
"name": "Nom",
"email": "E-mail",
"phone": "Téléphone",
"address": "Adresse",
"description": "Description",
"notes": "Notes",
"total": "Total",
"amount": "Montant",
"quantity": "Quantité",
"price": "Prix",
"items": "Articles",
"id": "ID",
"type": "Type",
"category": "Catégorie",
"tags": "Tags",
"active": "Actif",
"inactive": "Inactif",
"enabled": "Activé",
"disabled": "Désactivé",
"pending": "En attente",
"completed": "Terminé",
"failed": "Échoué",
"success": "Succès",
"error": "Erreur",
"warning": "Avertissement",
"info": "Info",
"all": "Tous",
"none": "Aucun",
"other": "Autre",
"unknown": "Inconnu",
"not_available": "N/D",
"required": "Obligatoire",
"optional": "Optionnel",
"language": "Langue",
"settings": "Paramètres",
"help": "Aide",
"support": "Support",
"contact": "Contact",
"about": "À propos",
"privacy": "Confidentialité",
"terms": "Conditions",
"copyright": "Droits d'auteur"
},
"auth": {
"sign_in": "Se connecter",
"sign_out": "Se déconnecter",
"sign_up": "S'inscrire",
"login": "Connexion",
"logout": "Déconnexion",
"register": "Inscription",
"forgot_password": "Mot de passe oublié ?",
"reset_password": "Réinitialiser le mot de passe",
"change_password": "Changer le mot de passe",
"username": "Nom d'utilisateur",
"password": "Mot de passe",
"confirm_password": "Confirmer le mot de passe",
"current_password": "Mot de passe actuel",
"new_password": "Nouveau mot de passe",
"remember_me": "Se souvenir de moi",
"email_placeholder": "Entrez votre e-mail",
"username_placeholder": "Entrez votre nom d'utilisateur",
"password_placeholder": "Entrez votre mot de passe",
"login_success": "Connexion réussie",
"login_failed": "Échec de la connexion",
"logout_success": "Vous avez été déconnecté",
"invalid_credentials": "Nom d'utilisateur ou mot de passe invalide",
"session_expired": "Votre session a expiré. Veuillez vous reconnecter.",
"account_locked": "Votre compte a été verrouillé",
"account_inactive": "Votre compte est inactif"
},
"nav": {
"dashboard": "Tableau de bord",
"products": "Produits",
"orders": "Commandes",
"customers": "Clients",
"inventory": "Inventaire",
"analytics": "Analyses",
"reports": "Rapports",
"settings": "Paramètres",
"profile": "Profil",
"team": "Équipe",
"marketplace": "Marketplace",
"integrations": "Intégrations",
"notifications": "Notifications",
"help": "Aide",
"home": "Accueil",
"shop": "Boutique",
"cart": "Panier",
"checkout": "Paiement",
"account": "Compte",
"wishlist": "Liste de souhaits"
},
"dashboard": {
"title": "Tableau de bord",
"welcome": "Bienvenue",
"overview": "Vue d'ensemble",
"quick_stats": "Statistiques rapides",
"recent_activity": "Activité récente",
"total_products": "Total des produits",
"total_orders": "Total des commandes",
"total_customers": "Total des clients",
"total_revenue": "Chiffre d'affaires total",
"active_products": "Produits actifs",
"pending_orders": "Commandes en attente",
"new_customers": "Nouveaux clients",
"today": "Aujourd'hui",
"this_week": "Cette semaine",
"this_month": "Ce mois",
"this_year": "Cette année",
"error_loading": "Erreur lors du chargement du tableau de bord",
"no_data": "Aucune donnée disponible"
},
"products": {
"title": "Produits",
"product": "Produit",
"add_product": "Ajouter un produit",
"edit_product": "Modifier le produit",
"delete_product": "Supprimer le produit",
"product_name": "Nom du produit",
"product_code": "Code produit",
"sku": "SKU",
"price": "Prix",
"sale_price": "Prix de vente",
"cost": "Coût",
"stock": "Stock",
"in_stock": "En stock",
"out_of_stock": "Rupture de stock",
"low_stock": "Stock faible",
"availability": "Disponibilité",
"available": "Disponible",
"unavailable": "Indisponible",
"brand": "Marque",
"category": "Catégorie",
"categories": "Catégories",
"image": "Image",
"images": "Images",
"main_image": "Image principale",
"gallery": "Galerie",
"weight": "Poids",
"dimensions": "Dimensions",
"color": "Couleur",
"size": "Taille",
"material": "Matériau",
"condition": "État",
"new": "Neuf",
"used": "Occasion",
"refurbished": "Reconditionné",
"no_products": "Aucun produit trouvé",
"search_products": "Rechercher des produits...",
"filter_by_category": "Filtrer par catégorie",
"filter_by_status": "Filtrer par statut",
"sort_by": "Trier par",
"sort_newest": "Plus récent",
"sort_oldest": "Plus ancien",
"sort_price_low": "Prix : croissant",
"sort_price_high": "Prix : décroissant",
"sort_name_az": "Nom : A-Z",
"sort_name_za": "Nom : Z-A"
},
"orders": {
"title": "Commandes",
"order": "Commande",
"order_id": "ID de commande",
"order_number": "Numéro de commande",
"order_date": "Date de commande",
"order_status": "Statut de la commande",
"order_details": "Détails de la commande",
"order_items": "Articles de la commande",
"order_total": "Total de la commande",
"subtotal": "Sous-total",
"shipping": "Livraison",
"tax": "Taxe",
"discount": "Remise",
"customer": "Client",
"shipping_address": "Adresse de livraison",
"billing_address": "Adresse de facturation",
"payment_method": "Mode de paiement",
"payment_status": "Statut du paiement",
"tracking": "Suivi",
"tracking_number": "Numéro de suivi",
"carrier": "Transporteur",
"no_orders": "Aucune commande trouvée",
"search_orders": "Rechercher des commandes...",
"filter_by_status": "Filtrer par statut",
"status_pending": "En attente",
"status_processing": "En cours",
"status_shipped": "Expédiée",
"status_delivered": "Livrée",
"status_cancelled": "Annulée",
"status_refunded": "Remboursée",
"status_confirmed": "Confirmée",
"status_rejected": "Rejetée",
"confirm_order": "Confirmer la commande",
"reject_order": "Rejeter la commande",
"set_tracking": "Définir le suivi",
"view_details": "Voir les détails"
},
"customers": {
"title": "Clients",
"customer": "Client",
"add_customer": "Ajouter un client",
"edit_customer": "Modifier le client",
"customer_name": "Nom du client",
"customer_email": "E-mail du client",
"customer_phone": "Téléphone du client",
"customer_number": "Numéro client",
"first_name": "Prénom",
"last_name": "Nom",
"company": "Entreprise",
"total_orders": "Total des commandes",
"total_spent": "Total dépensé",
"last_order": "Dernière commande",
"registered": "Inscrit",
"no_customers": "Aucun client trouvé",
"search_customers": "Rechercher des clients..."
},
"inventory": {
"title": "Inventaire",
"stock_level": "Niveau de stock",
"quantity": "Quantité",
"reorder_point": "Seuil de réapprovisionnement",
"adjust_stock": "Ajuster le stock",
"stock_in": "Entrée de stock",
"stock_out": "Sortie de stock",
"transfer": "Transfert",
"history": "Historique",
"low_stock_alert": "Alerte stock faible",
"out_of_stock_alert": "Alerte rupture de stock"
},
"marketplace": {
"title": "Marketplace",
"import": "Importer",
"export": "Exporter",
"sync": "Synchroniser",
"source": "Source",
"source_url": "URL source",
"import_products": "Importer des produits",
"start_import": "Démarrer l'importation",
"importing": "Importation en cours...",
"import_complete": "Importation terminée",
"import_failed": "Échec de l'importation",
"import_history": "Historique des importations",
"job_id": "ID du travail",
"started_at": "Démarré à",
"completed_at": "Terminé à",
"duration": "Durée",
"imported_count": "Importés",
"error_count": "Erreurs",
"total_processed": "Total traité",
"progress": "Progression",
"no_import_jobs": "Aucune importation pour le moment",
"start_first_import": "Lancez votre première importation avec le formulaire ci-dessus"
},
"letzshop": {
"title": "Intégration Letzshop",
"connection": "Connexion",
"credentials": "Identifiants",
"api_key": "Clé API",
"api_endpoint": "Point d'accès API",
"auto_sync": "Synchronisation automatique",
"sync_interval": "Intervalle de synchronisation",
"every_hour": "Toutes les heures",
"every_day": "Tous les jours",
"test_connection": "Tester la connexion",
"save_credentials": "Enregistrer les identifiants",
"connection_success": "Connexion réussie",
"connection_failed": "Échec de la connexion",
"last_sync": "Dernière synchronisation",
"sync_status": "Statut de synchronisation",
"import_orders": "Importer les commandes",
"export_products": "Exporter les produits",
"no_credentials": "Configurez votre clé API dans les paramètres pour commencer",
"carriers": {
"dhl": "DHL",
"ups": "UPS",
"fedex": "FedEx",
"dpd": "DPD",
"gls": "GLS",
"post_luxembourg": "Post Luxembourg",
"other": "Autre"
}
},
"team": {
"title": "Équipe",
"members": "Membres",
"add_member": "Ajouter un membre",
"invite_member": "Inviter un membre",
"remove_member": "Retirer un membre",
"role": "Rôle",
"owner": "Propriétaire",
"manager": "Gestionnaire",
"editor": "Éditeur",
"viewer": "Lecteur",
"permissions": "Permissions",
"pending_invitations": "Invitations en attente",
"invitation_sent": "Invitation envoyée",
"invitation_accepted": "Invitation acceptée"
},
"settings": {
"title": "Paramètres",
"general": "Général",
"store": "Boutique",
"store_name": "Nom de la boutique",
"store_description": "Description de la boutique",
"contact_email": "E-mail de contact",
"contact_phone": "Téléphone de contact",
"business_address": "Adresse professionnelle",
"tax_number": "Numéro de TVA",
"currency": "Devise",
"timezone": "Fuseau horaire",
"language": "Langue",
"language_settings": "Paramètres de langue",
"default_language": "Langue par défaut",
"dashboard_language": "Langue du tableau de bord",
"storefront_language": "Langue de la boutique",
"enabled_languages": "Langues activées",
"notifications": "Notifications",
"email_notifications": "Notifications par e-mail",
"integrations": "Intégrations",
"api_keys": "Clés API",
"webhooks": "Webhooks",
"save_settings": "Enregistrer les paramètres",
"settings_saved": "Paramètres enregistrés avec succès"
},
"profile": {
"title": "Profil",
"my_profile": "Mon profil",
"edit_profile": "Modifier le profil",
"personal_info": "Informations personnelles",
"first_name": "Prénom",
"last_name": "Nom",
"email": "E-mail",
"phone": "Téléphone",
"avatar": "Avatar",
"change_avatar": "Changer l'avatar",
"security": "Sécurité",
"two_factor": "Authentification à deux facteurs",
"sessions": "Sessions actives",
"preferences": "Préférences",
"language_preference": "Préférence de langue",
"save_profile": "Enregistrer le profil",
"profile_updated": "Profil mis à jour avec succès"
},
"errors": {
"generic": "Une erreur s'est produite",
"not_found": "Non trouvé",
"unauthorized": "Non autorisé",
"forbidden": "Interdit",
"bad_request": "Requête invalide",
"server_error": "Erreur serveur",
"network_error": "Erreur réseau",
"timeout": "Délai d'attente dépassé",
"validation_error": "Erreur de validation",
"field_required": "Ce champ est obligatoire",
"invalid_email": "Adresse e-mail invalide",
"invalid_phone": "Numéro de téléphone invalide",
"password_mismatch": "Les mots de passe ne correspondent pas",
"password_too_short": "Le mot de passe est trop court",
"try_again": "Veuillez réessayer",
"contact_support": "Veuillez contacter le support si le problème persiste"
},
"confirmations": {
"delete_title": "Confirmer la suppression",
"delete_message": "Êtes-vous sûr de vouloir supprimer cet élément ?",
"delete_warning": "Cette action est irréversible.",
"cancel_title": "Confirmer l'annulation",
"cancel_message": "Êtes-vous sûr de vouloir annuler ?",
"unsaved_changes": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir quitter ?",
"logout_title": "Confirmer la déconnexion",
"logout_message": "Êtes-vous sûr de vouloir vous déconnecter ?"
},
"notifications": {
"title": "Notifications",
"mark_read": "Marquer comme lu",
"mark_all_read": "Tout marquer comme lu",
"no_notifications": "Aucune notification",
"new_order": "Nouvelle commande",
"order_updated": "Commande mise à jour",
"low_stock": "Alerte stock faible",
"import_complete": "Importation terminée",
"import_failed": "Échec de l'importation"
},
"shop": {
"welcome": "Bienvenue dans notre boutique",
"browse_products": "Parcourir les produits",
"add_to_cart": "Ajouter au panier",
"buy_now": "Acheter maintenant",
"view_cart": "Voir le panier",
"checkout": "Paiement",
"continue_shopping": "Continuer vos achats",
"start_shopping": "Commencer vos achats",
"empty_cart": "Votre panier est vide",
"cart_total": "Total du panier",
"proceed_checkout": "Passer à la caisse",
"payment": "Paiement",
"place_order": "Passer la commande",
"order_placed": "Commande passée avec succès",
"thank_you": "Merci pour votre commande",
"order_confirmation": "Confirmation de commande"
},
"footer": {
"all_rights_reserved": "Tous droits réservés",
"powered_by": "Propulsé par"
},
"time": {
"now": "Maintenant",
"today": "Aujourd'hui",
"yesterday": "Hier",
"tomorrow": "Demain",
"this_week": "Cette semaine",
"last_week": "La semaine dernière",
"this_month": "Ce mois-ci",
"last_month": "Le mois dernier",
"this_year": "Cette année",
"ago": "il y a",
"seconds": "secondes",
"minutes": "minutes",
"hours": "heures",
"days": "jours",
"weeks": "semaines",
"months": "mois",
"years": "années"
},
"formats": {
"date": "DD/MM/YYYY",
"time": "HH:mm",
"datetime": "DD/MM/YYYY HH:mm",
"currency": "{amount} {symbol}"
}
}

475
static/locales/lb.json Normal file
View File

@@ -0,0 +1,475 @@
{
"common": {
"save": "Späicheren",
"cancel": "Ofbriechen",
"delete": "Läschen",
"edit": "Änneren",
"create": "Erstellen",
"update": "Aktualiséieren",
"add": "Derbäisetzen",
"remove": "Ewechhuelen",
"close": "Zoumaachen",
"back": "Zréck",
"next": "Weider",
"previous": "Virdrun",
"submit": "Ofschécken",
"confirm": "Bestätegen",
"yes": "Jo",
"no": "Nee",
"ok": "OK",
"done": "Fäerdeg",
"loading": "Lueden...",
"saving": "Späicheren...",
"processing": "Veraarbechten...",
"searching": "Sichen...",
"refresh": "Aktualiséieren",
"retry": "Nach eng Kéier probéieren",
"view": "Kucken",
"view_details": "Detailer kucken",
"view_all": "Alles kucken",
"show_more": "Méi weisen",
"show_less": "Manner weisen",
"search": "Sichen",
"filter": "Filteren",
"sort": "Sortéieren",
"export": "Exportéieren",
"import": "Importéieren",
"download": "Eroflueden",
"upload": "Eroplueden",
"select": "Auswielen",
"select_all": "Alles auswielen",
"deselect_all": "Alles ofwielen",
"actions": "Aktiounen",
"status": "Status",
"date": "Datum",
"time": "Zäit",
"name": "Numm",
"email": "E-Mail",
"phone": "Telefon",
"address": "Adress",
"description": "Beschreiwung",
"notes": "Notizen",
"total": "Total",
"amount": "Betrag",
"quantity": "Quantitéit",
"price": "Präis",
"items": "Artikelen",
"id": "ID",
"type": "Typ",
"category": "Kategorie",
"tags": "Tags",
"active": "Aktiv",
"inactive": "Inaktiv",
"enabled": "Aktivéiert",
"disabled": "Deaktivéiert",
"pending": "Aussteesend",
"completed": "Fäerdeg",
"failed": "Feelgeschloen",
"success": "Erfollegräich",
"error": "Feeler",
"warning": "Warnung",
"info": "Info",
"all": "Alles",
"none": "Keen",
"other": "Anerer",
"unknown": "Onbekannt",
"not_available": "N/A",
"required": "Obligatoresch",
"optional": "Optional",
"language": "Sprooch",
"settings": "Astellungen",
"help": "Hëllef",
"support": "Ënnerstëtzung",
"contact": "Kontakt",
"about": "Iwwer eis",
"privacy": "Dateschutz",
"terms": "Konditiounen",
"copyright": "Copyright"
},
"auth": {
"sign_in": "Umellen",
"sign_out": "Ofmellen",
"sign_up": "Registréieren",
"login": "Login",
"logout": "Logout",
"register": "Registréieren",
"forgot_password": "Passwuert vergiess?",
"reset_password": "Passwuert zrécksetzen",
"change_password": "Passwuert änneren",
"username": "Benotzernumm",
"password": "Passwuert",
"confirm_password": "Passwuert bestätegen",
"current_password": "Aktuellt Passwuert",
"new_password": "Neit Passwuert",
"remember_me": "Un mech erënneren",
"email_placeholder": "Gitt Är E-Mail an",
"username_placeholder": "Gitt Äre Benotzernumm an",
"password_placeholder": "Gitt Äert Passwuert an",
"login_success": "Login erfollegräich",
"login_failed": "Login feelgeschloen",
"logout_success": "Dir sidd ofgemellt",
"invalid_credentials": "Ongëltege Benotzernumm oder Passwuert",
"session_expired": "Är Sessioun ass ofgelaf. Mellt Iech w.e.g. erëm un.",
"account_locked": "Äre Kont ass gespaart",
"account_inactive": "Äre Kont ass inaktiv"
},
"nav": {
"dashboard": "Dashboard",
"products": "Produkter",
"orders": "Bestellungen",
"customers": "Clienten",
"inventory": "Inventar",
"analytics": "Analysen",
"reports": "Rapporten",
"settings": "Astellungen",
"profile": "Profil",
"team": "Team",
"marketplace": "Marchéplaz",
"integrations": "Integratiounen",
"notifications": "Notifikatiounen",
"help": "Hëllef",
"home": "Heem",
"shop": "Buttek",
"cart": "Kuerf",
"checkout": "Bezuelen",
"account": "Kont",
"wishlist": "Wonschlëscht"
},
"dashboard": {
"title": "Dashboard",
"welcome": "Wëllkomm zréck",
"overview": "Iwwersiicht",
"quick_stats": "Séier Statistiken",
"recent_activity": "Rezent Aktivitéit",
"total_products": "Produkter insgesamt",
"total_orders": "Bestellungen insgesamt",
"total_customers": "Clienten insgesamt",
"total_revenue": "Ëmsaz insgesamt",
"active_products": "Aktiv Produkter",
"pending_orders": "Aussteesend Bestellungen",
"new_customers": "Nei Clienten",
"today": "Haut",
"this_week": "Dës Woch",
"this_month": "Dëse Mount",
"this_year": "Dëst Joer",
"error_loading": "Feeler beim Lueden vum Dashboard",
"no_data": "Keng Donnéeën disponibel"
},
"products": {
"title": "Produkter",
"product": "Produkt",
"add_product": "Produkt derbäisetzen",
"edit_product": "Produkt änneren",
"delete_product": "Produkt läschen",
"product_name": "Produktnumm",
"product_code": "Produktcode",
"sku": "SKU",
"price": "Präis",
"sale_price": "Verkafspräis",
"cost": "Käschten",
"stock": "Lager",
"in_stock": "Op Lager",
"out_of_stock": "Net op Lager",
"low_stock": "Niddregen Stock",
"availability": "Disponibilitéit",
"available": "Disponibel",
"unavailable": "Net disponibel",
"brand": "Mark",
"category": "Kategorie",
"categories": "Kategorien",
"image": "Bild",
"images": "Biller",
"main_image": "Haaptbild",
"gallery": "Galerie",
"weight": "Gewiicht",
"dimensions": "Dimensiounen",
"color": "Faarf",
"size": "Gréisst",
"material": "Material",
"condition": "Zoustand",
"new": "Nei",
"used": "Gebraucht",
"refurbished": "Iwwerholl",
"no_products": "Keng Produkter fonnt",
"search_products": "Produkter sichen...",
"filter_by_category": "No Kategorie filteren",
"filter_by_status": "No Status filteren",
"sort_by": "Sortéieren no",
"sort_newest": "Neisten",
"sort_oldest": "Eelsten",
"sort_price_low": "Präis: Niddreg op Héich",
"sort_price_high": "Präis: Héich op Niddreg",
"sort_name_az": "Numm: A-Z",
"sort_name_za": "Numm: Z-A"
},
"orders": {
"title": "Bestellungen",
"order": "Bestellung",
"order_id": "Bestellungs-ID",
"order_number": "Bestellungsnummer",
"order_date": "Bestellungsdatum",
"order_status": "Bestellungsstatus",
"order_details": "Bestellungsdetailer",
"order_items": "Bestellungsartikelen",
"order_total": "Bestellungstotal",
"subtotal": "Subtotal",
"shipping": "Versand",
"tax": "Steier",
"discount": "Rabatt",
"customer": "Client",
"shipping_address": "Liwweradress",
"billing_address": "Rechnungsadress",
"payment_method": "Bezuelmethod",
"payment_status": "Bezuelstatus",
"tracking": "Tracking",
"tracking_number": "Trackingnummer",
"carrier": "Transporteur",
"no_orders": "Keng Bestellunge fonnt",
"search_orders": "Bestellunge sichen...",
"filter_by_status": "No Status filteren",
"status_pending": "Aussteesend",
"status_processing": "A Veraarbechtung",
"status_shipped": "Verschéckt",
"status_delivered": "Geliwwert",
"status_cancelled": "Annuléiert",
"status_refunded": "Rembourséiert",
"status_confirmed": "Bestätegt",
"status_rejected": "Ofgeleent",
"confirm_order": "Bestellung bestätegen",
"reject_order": "Bestellung oflehnen",
"set_tracking": "Tracking setzen",
"view_details": "Detailer kucken"
},
"customers": {
"title": "Clienten",
"customer": "Client",
"add_customer": "Client derbäisetzen",
"edit_customer": "Client änneren",
"customer_name": "Clientennumm",
"customer_email": "Client E-Mail",
"customer_phone": "Client Telefon",
"customer_number": "Clientennummer",
"first_name": "Virnumm",
"last_name": "Nonumm",
"company": "Firma",
"total_orders": "Bestellungen insgesamt",
"total_spent": "Total ausginn",
"last_order": "Lescht Bestellung",
"registered": "Registréiert",
"no_customers": "Keng Clienten fonnt",
"search_customers": "Clienten sichen..."
},
"inventory": {
"title": "Inventar",
"stock_level": "Lagerniveau",
"quantity": "Quantitéit",
"reorder_point": "Nobestellungspunkt",
"adjust_stock": "Lager upaassen",
"stock_in": "Lager eran",
"stock_out": "Lager eraus",
"transfer": "Transfer",
"history": "Geschicht",
"low_stock_alert": "Niddreg Lager Alarm",
"out_of_stock_alert": "Net op Lager Alarm"
},
"marketplace": {
"title": "Marchéplaz",
"import": "Import",
"export": "Export",
"sync": "Synchroniséieren",
"source": "Quell",
"source_url": "Quell URL",
"import_products": "Produkter importéieren",
"start_import": "Import starten",
"importing": "Importéieren...",
"import_complete": "Import fäerdeg",
"import_failed": "Import feelgeschloen",
"import_history": "Importgeschicht",
"job_id": "Job ID",
"started_at": "Ugefaang um",
"completed_at": "Fäerdeg um",
"duration": "Dauer",
"imported_count": "Importéiert",
"error_count": "Feeler",
"total_processed": "Total veraarbecht",
"progress": "Fortschrëtt",
"no_import_jobs": "Nach keng Import Jobs",
"start_first_import": "Start Ären éischten Import mat der Form uewendriwwer"
},
"letzshop": {
"title": "Letzshop Integratioun",
"connection": "Verbindung",
"credentials": "Umeldungsdaten",
"api_key": "API Schlëssel",
"api_endpoint": "API Endpunkt",
"auto_sync": "Automatesch Sync",
"sync_interval": "Sync Intervall",
"every_hour": "All Stonn",
"every_day": "All Dag",
"test_connection": "Verbindung testen",
"save_credentials": "Umeldungsdaten späicheren",
"connection_success": "Verbindung erfollegräich",
"connection_failed": "Verbindung feelgeschloen",
"last_sync": "Läschte Sync",
"sync_status": "Sync Status",
"import_orders": "Bestellungen importéieren",
"export_products": "Produkter exportéieren",
"no_credentials": "Konfiguréiert Ären API Schlëssel an den Astellungen fir unzefänken",
"carriers": {
"dhl": "DHL",
"ups": "UPS",
"fedex": "FedEx",
"dpd": "DPD",
"gls": "GLS",
"post_luxembourg": "Post Lëtzebuerg",
"other": "Anerer"
}
},
"team": {
"title": "Team",
"members": "Memberen",
"add_member": "Member derbäisetzen",
"invite_member": "Member invitéieren",
"remove_member": "Member ewechhuelen",
"role": "Roll",
"owner": "Proprietär",
"manager": "Manager",
"editor": "Editeur",
"viewer": "Betruechter",
"permissions": "Rechter",
"pending_invitations": "Aussteesend Invitatiounen",
"invitation_sent": "Invitatioun geschéckt",
"invitation_accepted": "Invitatioun ugeholl"
},
"settings": {
"title": "Astellungen",
"general": "Allgemeng",
"store": "Buttek",
"store_name": "Butteknumm",
"store_description": "Buttekbeschreiwung",
"contact_email": "Kontakt E-Mail",
"contact_phone": "Kontakt Telefon",
"business_address": "Geschäftsadress",
"tax_number": "Steiernummer",
"currency": "Wärung",
"timezone": "Zäitzon",
"language": "Sprooch",
"language_settings": "Sproochastellungen",
"default_language": "Standard Sprooch",
"dashboard_language": "Dashboard Sprooch",
"storefront_language": "Buttek Sprooch",
"enabled_languages": "Aktivéiert Sproochen",
"notifications": "Notifikatiounen",
"email_notifications": "E-Mail Notifikatiounen",
"integrations": "Integratiounen",
"api_keys": "API Schlësselen",
"webhooks": "Webhooks",
"save_settings": "Astellunge späicheren",
"settings_saved": "Astellungen erfollegräich gespäichert"
},
"profile": {
"title": "Profil",
"my_profile": "Mäi Profil",
"edit_profile": "Profil änneren",
"personal_info": "Perséinlech Informatiounen",
"first_name": "Virnumm",
"last_name": "Nonumm",
"email": "E-Mail",
"phone": "Telefon",
"avatar": "Avatar",
"change_avatar": "Avatar änneren",
"security": "Sécherheet",
"two_factor": "Zwee-Faktor Authentifikatioun",
"sessions": "Aktiv Sessiounen",
"preferences": "Astellungen",
"language_preference": "Sproochpräferenz",
"save_profile": "Profil späicheren",
"profile_updated": "Profil erfollegräich aktualiséiert"
},
"errors": {
"generic": "E Feeler ass opgetrueden",
"not_found": "Net fonnt",
"unauthorized": "Net autoriséiert",
"forbidden": "Verbueden",
"bad_request": "Ongëlteg Ufro",
"server_error": "Server Feeler",
"network_error": "Netzwierk Feeler",
"timeout": "Ufro Timeout",
"validation_error": "Validéierungsfeeler",
"field_required": "Dëst Feld ass obligatoresch",
"invalid_email": "Ongëlteg E-Mail Adress",
"invalid_phone": "Ongëlteg Telefonsnummer",
"password_mismatch": "Passwierder stëmmen net iwwereneen",
"password_too_short": "Passwuert ass ze kuerz",
"try_again": "Probéiert w.e.g. nach eng Kéier",
"contact_support": "Kontaktéiert w.e.g. den Support wann de Problem bestoe bleift"
},
"confirmations": {
"delete_title": "Läsche bestätegen",
"delete_message": "Sidd Dir sécher datt Dir dësen Artikel läsche wëllt?",
"delete_warning": "Dës Aktioun kann net réckgängeg gemaach ginn.",
"cancel_title": "Ofbriechen bestätegen",
"cancel_message": "Sidd Dir sécher datt Dir ofbrieche wëllt?",
"unsaved_changes": "Dir hutt net gespäichert Ännerungen. Sidd Dir sécher datt Dir fortfuere wëllt?",
"logout_title": "Ofmellen bestätegen",
"logout_message": "Sidd Dir sécher datt Dir Iech ofmelle wëllt?"
},
"notifications": {
"title": "Notifikatiounen",
"mark_read": "Als gelies markéieren",
"mark_all_read": "Alles als gelies markéieren",
"no_notifications": "Keng Notifikatiounen",
"new_order": "Nei Bestellung",
"order_updated": "Bestellung aktualiséiert",
"low_stock": "Niddreg Lager Alarm",
"import_complete": "Import fäerdeg",
"import_failed": "Import feelgeschloen"
},
"shop": {
"welcome": "Wëllkomm an eisem Buttek",
"browse_products": "Produkter duerchsichen",
"add_to_cart": "An de Kuerf",
"buy_now": "Elo kafen",
"view_cart": "Kuerf kucken",
"checkout": "Bezuelen",
"continue_shopping": "Weider akafen",
"start_shopping": "Ufänken mat Akafen",
"empty_cart": "Äre Kuerf ass eidel",
"cart_total": "Kuerf Total",
"proceed_checkout": "Zur Bezuelung goen",
"payment": "Bezuelung",
"place_order": "Bestellung opgi",
"order_placed": "Bestellung erfollegräich opginn",
"thank_you": "Merci fir Är Bestellung",
"order_confirmation": "Bestellungsbestätegung"
},
"footer": {
"all_rights_reserved": "All Rechter reservéiert",
"powered_by": "Ënnerstëtzt vun"
},
"time": {
"now": "Elo",
"today": "Haut",
"yesterday": "Gëschter",
"tomorrow": "Muer",
"this_week": "Dës Woch",
"last_week": "Lescht Woch",
"this_month": "Dëse Mount",
"last_month": "Läschte Mount",
"this_year": "Dëst Joer",
"ago": "hier",
"seconds": "Sekonnen",
"minutes": "Minutten",
"hours": "Stonnen",
"days": "Deeg",
"weeks": "Wochen",
"months": "Méint",
"years": "Joer"
},
"formats": {
"date": "DD.MM.YYYY",
"time": "HH:mm",
"datetime": "DD.MM.YYYY HH:mm",
"currency": "{amount} {symbol}"
}
}

View File

@@ -242,4 +242,50 @@ function shopLayoutData() {
// Make available globally
window.shopLayoutData = shopLayoutData;
/**
* Language Selector Component
* Alpine.js component for language switching
*/
function languageSelector(currentLang, enabledLanguages) {
return {
isLangOpen: false,
currentLang: currentLang || 'fr',
languages: enabledLanguages || ['fr', 'de', 'en'],
languageNames: {
'en': 'English',
'fr': 'Français',
'de': 'Deutsch',
'lb': 'Lëtzebuergesch'
},
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;
}
};
}
window.languageSelector = languageSelector;
shopLog.info('Shop layout module loaded');

View File

@@ -118,4 +118,50 @@ function data() {
}
}
};
}
}
/**
* Language Selector Component
* Alpine.js component for language switching in vendor dashboard
*/
function languageSelector(currentLang, enabledLanguages) {
return {
isLangOpen: false,
currentLang: currentLang || 'fr',
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
languageNames: {
'en': 'English',
'fr': 'Français',
'de': 'Deutsch',
'lb': 'Lëtzebuergesch'
},
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;
}
};
}
window.languageSelector = languageSelector;

View File

@@ -73,6 +73,11 @@ function vendorLetzshop() {
tracking_carrier: ''
},
// Export
exportLanguage: 'fr',
exportIncludeInactive: false,
exporting: false,
async init() {
// Guard against multiple initialization
if (window._vendorLetzshopInitialized) {
@@ -410,6 +415,68 @@ function vendorLetzshop() {
if (!dateStr) return 'N/A';
const date = new Date(dateStr);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
},
/**
* Download product export CSV
*/
async downloadExport() {
this.exporting = true;
this.error = '';
try {
const params = new URLSearchParams({
language: this.exportLanguage,
include_inactive: this.exportIncludeInactive.toString()
});
// Get the token for authentication
const token = localStorage.getItem('wizamart_token');
if (!token) {
throw new Error('Not authenticated');
}
const response = await fetch(`/api/v1/vendor/letzshop/export?${params}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || 'Export failed');
}
// Get filename from Content-Disposition header
const contentDisposition = response.headers.get('Content-Disposition');
let filename = `letzshop_export_${this.exportLanguage}.csv`;
if (contentDisposition) {
const match = contentDisposition.match(/filename="(.+)"/);
if (match) {
filename = match[1];
}
}
// Download the file
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
this.successMessage = `Export downloaded: ${filename}`;
} catch (error) {
console.error('[VENDOR LETZSHOP] Export failed:', error);
this.error = error.message || 'Failed to export products';
} finally {
this.exporting = false;
setTimeout(() => this.successMessage = '', 5000);
}
}
};
}