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