diff --git a/.gitignore b/.gitignore index 518809c7..db97ada3 100644 --- a/.gitignore +++ b/.gitignore @@ -176,3 +176,6 @@ yarn-error.log* package-lock.json tailadmin-free-tailwind-dashboard-template/ static/shared/css/tailwind.css + +# Export files +wizamart_letzshop_export_*.csv diff --git a/alembic/versions/fcfdc02d5138_add_language_settings_to_vendor_user_.py b/alembic/versions/fcfdc02d5138_add_language_settings_to_vendor_user_.py new file mode 100644 index 00000000..34d7fcfb --- /dev/null +++ b/alembic/versions/fcfdc02d5138_add_language_settings_to_vendor_user_.py @@ -0,0 +1,84 @@ +"""add_language_settings_to_vendor_user_customer + +Revision ID: fcfdc02d5138 +Revises: b412e0b49c2e +Create Date: 2025-12-13 20:08:27.120863 + +This migration adds language preference fields to support multi-language UI: +- Vendor: default_language, dashboard_language, storefront_language +- User: preferred_language +- Customer: preferred_language + +Supported languages: en (English), fr (French), de (German), lb (Luxembourgish) +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'fcfdc02d5138' +down_revision: Union[str, None] = 'b412e0b49c2e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ======================================================================== + # Vendor language settings + # ======================================================================== + # default_language: Default language for vendor content (products, etc.) + op.add_column( + 'vendors', + sa.Column('default_language', sa.String(5), nullable=False, server_default='fr') + ) + # dashboard_language: Language for vendor team dashboard UI + op.add_column( + 'vendors', + sa.Column('dashboard_language', sa.String(5), nullable=False, server_default='fr') + ) + # storefront_language: Default language for customer-facing shop + op.add_column( + 'vendors', + sa.Column('storefront_language', sa.String(5), nullable=False, server_default='fr') + ) + # storefront_languages: JSON array of enabled languages for storefront + # Allows vendors to enable/disable specific languages + op.add_column( + 'vendors', + sa.Column( + 'storefront_languages', + sa.JSON, + nullable=False, + server_default='["fr", "de", "en"]' + ) + ) + + # ======================================================================== + # User language preference + # ======================================================================== + # preferred_language: User's preferred UI language (NULL = use context default) + op.add_column( + 'users', + sa.Column('preferred_language', sa.String(5), nullable=True) + ) + + # ======================================================================== + # Customer language preference + # ======================================================================== + # preferred_language: Customer's preferred language (NULL = use storefront default) + op.add_column( + 'customers', + sa.Column('preferred_language', sa.String(5), nullable=True) + ) + + +def downgrade() -> None: + # Remove columns in reverse order + op.drop_column('customers', 'preferred_language') + op.drop_column('users', 'preferred_language') + op.drop_column('vendors', 'storefront_languages') + op.drop_column('vendors', 'storefront_language') + op.drop_column('vendors', 'dashboard_language') + op.drop_column('vendors', 'default_language') diff --git a/app/api/main.py b/app/api/main.py index 1f1c43be..865d33b3 100644 --- a/app/api/main.py +++ b/app/api/main.py @@ -11,6 +11,7 @@ This module provides: from fastapi import APIRouter from app.api.v1 import admin, shop, vendor +from app.api.v1.shared import language api_router = APIRouter() @@ -34,3 +35,10 @@ api_router.include_router(vendor.router, prefix="/v1/vendor", tags=["vendor"]) # ============================================================================ api_router.include_router(shop.router, prefix="/v1/shop", tags=["shop"]) + +# ============================================================================ +# SHARED ROUTES (Cross-context utilities) +# Prefix: /api/v1 +# ============================================================================ + +api_router.include_router(language.router, prefix="/v1", tags=["language"]) diff --git a/app/api/v1/shared/language.py b/app/api/v1/shared/language.py new file mode 100644 index 00000000..57e565b2 --- /dev/null +++ b/app/api/v1/shared/language.py @@ -0,0 +1,179 @@ +# app/api/v1/shared/language.py +""" +Language API endpoints for setting user/customer language preferences. + +These endpoints handle: +- Setting language preference via cookie +- Getting current language info +- Listing available languages +""" +import logging + +from fastapi import APIRouter, Request, Response +from pydantic import BaseModel, Field + +from app.utils.i18n import ( + DEFAULT_LANGUAGE, + LANGUAGE_FLAGS, + LANGUAGE_NAMES, + LANGUAGE_NAMES_EN, + SUPPORTED_LANGUAGES, + get_language_info, +) +from middleware.language import LANGUAGE_COOKIE_NAME, set_language_cookie + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/language", tags=["language"]) + + +class SetLanguageRequest(BaseModel): + """Request body for setting language preference.""" + + language: str = Field( + ..., + description="Language code (en, fr, de, lb)", + min_length=2, + max_length=5, + ) + + +class SetLanguageResponse(BaseModel): + """Response after setting language preference.""" + + success: bool + language: str + message: str + + +class LanguageInfo(BaseModel): + """Information about a single language.""" + + code: str + name: str + name_en: str + flag: str + + +class LanguageListResponse(BaseModel): + """Response listing all available languages.""" + + languages: list[LanguageInfo] + current: str + default: str + + +class CurrentLanguageResponse(BaseModel): + """Response with current language information.""" + + code: str + name: str + name_en: str + flag: str + source: str # Where the language was determined from (cookie, browser, default) + + +# public - Language preference can be set without authentication +@router.post("/set", response_model=SetLanguageResponse) +async def set_language( + request: Request, + response: Response, + body: SetLanguageRequest, +) -> SetLanguageResponse: + """ + Set the user's language preference. + + This sets a cookie that will be used for subsequent requests. + The page should be reloaded after calling this endpoint. + """ + language = body.language.lower() + + if language not in SUPPORTED_LANGUAGES: + return SetLanguageResponse( + success=False, + language=language, + message=f"Unsupported language: {language}. Supported: {', '.join(SUPPORTED_LANGUAGES)}", + ) + + # Set language cookie + set_language_cookie(response, language) + + logger.info(f"Language preference set to: {language}") + + return SetLanguageResponse( + success=True, + language=language, + message=f"Language set to {LANGUAGE_NAMES.get(language, language)}", + ) + + +@router.get("/current", response_model=CurrentLanguageResponse) +async def get_current_language(request: Request) -> CurrentLanguageResponse: + """ + Get the current language for this request. + + Returns information about the detected language and where it came from. + """ + # Get language from request state (set by middleware) + language = getattr(request.state, "language", DEFAULT_LANGUAGE) + language_info = getattr(request.state, "language_info", {}) + + # Determine source + source = "default" + if language_info.get("cookie"): + source = "cookie" + elif language_info.get("browser"): + source = "browser" + + info = get_language_info(language) + + return CurrentLanguageResponse( + code=info["code"], + name=info["name"], + name_en=info["name_en"], + flag=info["flag"], + source=source, + ) + + +@router.get("/list", response_model=LanguageListResponse) +async def list_languages(request: Request) -> LanguageListResponse: + """ + List all available languages. + + Returns all supported languages with their display names. + """ + current = getattr(request.state, "language", DEFAULT_LANGUAGE) + + languages = [ + LanguageInfo( + code=code, + name=LANGUAGE_NAMES.get(code, code), + name_en=LANGUAGE_NAMES_EN.get(code, code), + flag=LANGUAGE_FLAGS.get(code, ""), + ) + for code in SUPPORTED_LANGUAGES + ] + + return LanguageListResponse( + languages=languages, + current=current, + default=DEFAULT_LANGUAGE, + ) + + +# public - Language preference clearing doesn't require authentication +@router.delete("/clear") +async def clear_language(response: Response) -> SetLanguageResponse: + """ + Clear the language preference cookie. + + After clearing, the language will be determined from browser settings or defaults. + """ + response.delete_cookie(key=LANGUAGE_COOKIE_NAME) + + return SetLanguageResponse( + success=True, + language=DEFAULT_LANGUAGE, + message="Language preference cleared. Using browser/default language.", + ) diff --git a/app/templates/shared/macros/language_selector.html b/app/templates/shared/macros/language_selector.html new file mode 100644 index 00000000..b49e8419 --- /dev/null +++ b/app/templates/shared/macros/language_selector.html @@ -0,0 +1,329 @@ +{# + Language Selector Macros + ======================== + Reusable language selector components for vendor dashboard and storefront. + + Usage: + {% from 'shared/macros/language_selector.html' import language_selector, language_selector_compact %} + + {# Full language selector with labels #} + {{ language_selector(current_language='fr', enabled_languages=['fr', 'de', 'en'], position='right') }} + + {# Compact selector (flag only) #} + {{ language_selector_compact(current_language='fr', enabled_languages=['fr', 'de', 'en']) }} +#} + + +{# + Language configuration - matches app/utils/i18n.py + Uses native language names per LANG-005 architecture rule +#} +{% set LANGUAGE_NAMES = { + 'en': 'English', + 'fr': 'Français', + 'de': 'Deutsch', + 'lb': 'Lëtzebuergesch' +} %} + +{% set LANGUAGE_NATIVE = { + 'en': 'English', + 'fr': 'Français', + 'de': 'Deutsch', + 'lb': 'Lëtzebuergesch' +} %} + +{% set LANGUAGE_FLAGS = { + 'en': 'gb', + 'fr': 'fr', + 'de': 'de', + 'lb': 'lu' +} %} + + +{# + Language Selector (Full) + ======================== + A dropdown language selector showing flag and language name. + + Parameters: + - current_language: Current language code (default: 'fr') + - enabled_languages: List of enabled language codes (default: all) + - position: 'left' | 'right' (default: 'right') + - context: 'vendor' | 'shop' | 'admin' (affects API endpoint) + - show_label: Show language name next to flag (default: true) +#} +{% macro language_selector(current_language='fr', enabled_languages=none, position='right', context='shop', show_label=true) %} +{% set langs = enabled_languages or ['en', 'fr', 'de', 'lb'] %} +{% set current = current_language if current_language in langs else langs[0] %} +{% set positions = {'left': 'left-0', 'right': 'right-0'} %} +{# Uses languageSelector() function per LANG-002 architecture rule #} +
+ Language used as fallback when content is not available in the requested language. +
+ ++ Default language for your vendor dashboard (team members can override in their profile). +
+ ++ Default language shown to customers when they first visit your store. +
+ ++ Languages available to customers in the language selector. +
+