feat: add configurable currency locale and fix vendor JS init

Currency Locale Configuration:
- Add platform-level storefront settings (locale, currency)
- Create PlatformSettingsService with resolution chain:
  vendor → AdminSetting → environment → hardcoded fallback
- Add storefront_locale nullable field to Vendor model
- Update shop routes to resolve and pass locale to templates
- Add window.SHOP_CONFIG for frontend JavaScript access
- Centralize formatPrice() in shop-layout.js using SHOP_CONFIG
- Remove local formatPrice functions from shop templates

Vendor JS Bug Fix:
- Fix vendorCode being null on all vendor pages
- Root cause: page components overriding init() without calling parent
- Add parent init call to 14 vendor JS files
- Add JS-013 architecture rule to prevent future regressions
- Validator now checks vendor JS files for parent init pattern

Files changed:
- New: app/services/platform_settings_service.py
- New: alembic/versions/s7a8b9c0d1e2_add_storefront_locale_to_vendors.py
- Modified: 14 vendor JS files, shop templates, validation scripts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-02 21:26:12 +01:00
parent d9d34ab102
commit c87bdfa129
30 changed files with 522 additions and 48 deletions

View File

@@ -15,6 +15,7 @@ from app.api.deps import get_current_customer_api
from app.core.database import get_db
from app.exceptions import ValidationException
from app.services.auth_service import AuthService
from app.services.customer_service import customer_service
from models.database.customer import Customer
from models.schema.customer import (
CustomerPasswordChange,
@@ -81,16 +82,10 @@ def update_profile(
# If email is being changed, check uniqueness within vendor
if update_data.email and update_data.email != customer.email:
existing = (
db.query(Customer)
.filter(
Customer.vendor_id == customer.vendor_id,
Customer.email == update_data.email,
Customer.id != customer.id,
)
.first()
existing = customer_service.get_customer_by_email(
db, customer.vendor_id, update_data.email
)
if existing:
if existing and existing.id != customer.id:
raise ValidationException("Email already in use")
# Update only provided fields

View File

@@ -158,6 +158,13 @@ class Settings(BaseSettings):
email_enabled: bool = True # Set to False to disable all emails
email_debug: bool = False # Log emails instead of sending (for development)
# =============================================================================
# STOREFRONT DEFAULTS
# =============================================================================
# These can be overridden by AdminSetting in the database
default_storefront_locale: str = "fr-LU" # Currency/number formatting locale
default_currency: str = "EUR" # Default currency code
# =============================================================================
# DEMO/SEED DATA CONFIGURATION
# =============================================================================

View File

@@ -39,6 +39,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_from_cookie_or_header, get_db
from app.services.content_page_service import content_page_service
from app.services.platform_settings_service import platform_settings_service
from models.database.customer import Customer
router = APIRouter()
@@ -47,6 +48,40 @@ templates = Jinja2Templates(directory="app/templates")
logger = logging.getLogger(__name__)
# ============================================================================
# HELPER: Resolve Storefront Locale
# ============================================================================
def get_resolved_storefront_config(db: Session, vendor) -> dict:
"""
Resolve storefront locale and currency with priority:
1. Vendor's storefront_locale (if set)
2. Platform's default_storefront_locale (from AdminSetting)
3. Environment variable (from config)
4. Hardcoded fallback: 'fr-LU'
Args:
db: Database session
vendor: Vendor model instance
Returns:
dict with 'locale' and 'currency' keys
"""
# Get platform defaults from service (handles resolution chain 2-4)
platform_config = platform_settings_service.get_storefront_config(db)
# Check for vendor override (step 1)
locale = platform_config["locale"]
if vendor and vendor.storefront_locale:
locale = vendor.storefront_locale
return {
"locale": locale,
"currency": platform_config["currency"],
}
# ============================================================================
# HELPER: Build Shop Template Context
# ============================================================================
@@ -133,6 +168,11 @@ def get_shop_context(request: Request, db: Session = None, **extra_context) -> d
extra={"error": str(e), "vendor_id": vendor.id if vendor else None},
)
# Resolve storefront locale and currency
storefront_config = {"locale": "fr-LU", "currency": "EUR"} # defaults
if db and vendor:
storefront_config = get_resolved_storefront_config(db, vendor)
context = {
"request": request,
"vendor": vendor,
@@ -142,6 +182,8 @@ def get_shop_context(request: Request, db: Session = None, **extra_context) -> d
"base_url": base_url,
"footer_pages": footer_pages,
"header_pages": header_pages,
"storefront_locale": storefront_config["locale"],
"storefront_currency": storefront_config["currency"],
}
# Add any extra context (user, product_id, category_slug, etc.)
@@ -157,6 +199,8 @@ def get_shop_context(request: Request, db: Session = None, **extra_context) -> d
"has_theme": theme is not None,
"access_method": access_method,
"base_url": base_url,
"storefront_locale": storefront_config["locale"],
"storefront_currency": storefront_config["currency"],
"footer_pages_count": len(footer_pages),
"header_pages_count": len(header_pages),
"extra_keys": list(extra_context.keys()) if extra_context else [],

View File

@@ -0,0 +1,176 @@
# app/services/platform_settings_service.py
"""
Platform Settings Service
Provides access to platform-wide settings with a resolution chain:
1. AdminSetting from database (can be set via admin UI)
2. Environment variables (from .env/config)
3. Hardcoded defaults
This allows admins to override defaults without code changes,
while still supporting environment-based configuration.
"""
import logging
from typing import Any
from sqlalchemy.orm import Session
from app.core.config import settings
from models.database.admin import AdminSetting
logger = logging.getLogger(__name__)
class PlatformSettingsService:
"""
Service for accessing platform-wide settings.
Resolution order:
1. AdminSetting in database (highest priority)
2. Environment variable via config
3. Hardcoded default (lowest priority)
"""
# Mapping of setting keys to their config attribute names and defaults
SETTINGS_MAP = {
"default_storefront_locale": {
"config_attr": "default_storefront_locale",
"default": "fr-LU",
"description": "Default locale for currency/number formatting (e.g., fr-LU, de-DE)",
"category": "storefront",
},
"default_currency": {
"config_attr": "default_currency",
"default": "EUR",
"description": "Default currency code for the platform",
"category": "storefront",
},
}
def get(self, db: Session, key: str) -> str | None:
"""
Get a setting value with full resolution chain.
Args:
db: Database session
key: Setting key (e.g., 'default_storefront_locale')
Returns:
Setting value or None if not found
"""
# 1. Check AdminSetting in database
admin_setting = db.query(AdminSetting).filter_by(key=key).first()
if admin_setting and admin_setting.value:
logger.debug(f"Setting '{key}' resolved from AdminSetting: {admin_setting.value}")
return admin_setting.value
# 2. Check environment/config
setting_info = self.SETTINGS_MAP.get(key)
if setting_info:
config_attr = setting_info.get("config_attr")
if config_attr and hasattr(settings, config_attr):
value = getattr(settings, config_attr)
logger.debug(f"Setting '{key}' resolved from config: {value}")
return value
# 3. Return hardcoded default
default = setting_info.get("default")
logger.debug(f"Setting '{key}' resolved from default: {default}")
return default
logger.warning(f"Unknown setting key: {key}")
return None
def get_storefront_locale(self, db: Session) -> str:
"""Get the default storefront locale."""
return self.get(db, "default_storefront_locale") or "fr-LU"
def get_currency(self, db: Session) -> str:
"""Get the default currency."""
return self.get(db, "default_currency") or "EUR"
def get_storefront_config(self, db: Session) -> dict[str, str]:
"""
Get all storefront-related settings as a dict.
Returns:
Dict with 'locale' and 'currency' keys
"""
return {
"locale": self.get_storefront_locale(db),
"currency": self.get_currency(db),
}
def set(self, db: Session, key: str, value: str, user_id: int | None = None) -> AdminSetting:
"""
Set a platform setting in the database.
Args:
db: Database session
key: Setting key
value: Setting value
user_id: ID of user making the change (for audit)
Returns:
The created/updated AdminSetting
"""
setting_info = self.SETTINGS_MAP.get(key, {})
admin_setting = db.query(AdminSetting).filter_by(key=key).first()
if admin_setting:
admin_setting.value = value
if user_id:
admin_setting.last_modified_by_user_id = user_id
else:
admin_setting = AdminSetting(
key=key,
value=value,
value_type="string",
category=setting_info.get("category", "system"),
description=setting_info.get("description", ""),
last_modified_by_user_id=user_id,
)
db.add(admin_setting)
db.commit() # noqa: SVC-006 - Setting change is atomic, commit is intentional
db.refresh(admin_setting)
logger.info(f"Platform setting '{key}' set to '{value}' by user {user_id}")
return admin_setting
def get_all_storefront_settings(self, db: Session) -> dict[str, Any]:
"""
Get all storefront settings with their current values and metadata.
Useful for admin UI to display current settings.
Returns:
Dict with setting info including current value and source
"""
result = {}
for key, info in self.SETTINGS_MAP.items():
if info.get("category") == "storefront":
current_value = self.get(db, key)
# Determine source
admin_setting = db.query(AdminSetting).filter_by(key=key).first()
if admin_setting and admin_setting.value:
source = "database"
elif hasattr(settings, info.get("config_attr", "")):
source = "environment"
else:
source = "default"
result[key] = {
"value": current_value,
"source": source,
"description": info.get("description", ""),
"default": info.get("default"),
}
return result
# Singleton instance
platform_settings_service = PlatformSettingsService()

View File

@@ -462,13 +462,7 @@ function shopOrderDetailPage() {
return this.statuses[status]?.class || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
},
formatPrice(amount) {
if (!amount && amount !== 0) return '-';
return new Intl.NumberFormat('fr-LU', {
style: 'currency',
currency: 'EUR'
}).format(amount);
},
// formatPrice is inherited from shopLayoutData() via spread operator
formatDateTime(dateStr) {
if (!dateStr) return '-';

View File

@@ -217,13 +217,7 @@ function shopOrdersPage() {
return this.statuses[status]?.class || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
},
formatPrice(amount) {
if (!amount && amount !== 0) return '-';
return new Intl.NumberFormat('fr-LU', {
style: 'currency',
currency: 'EUR'
}).format(amount);
},
// formatPrice is inherited from shopLayoutData() via spread operator
formatDate(dateStr) {
if (!dateStr) return '-';

View File

@@ -523,13 +523,7 @@ function shopProfilePage() {
}
},
formatPrice(amount) {
if (!amount && amount !== 0) return '-';
return new Intl.NumberFormat('fr-LU', {
style: 'currency',
currency: 'EUR'
}).format(amount);
},
// formatPrice is inherited from shopLayoutData() via spread operator
formatDate(dateStr) {
if (!dateStr) return '-';

View File

@@ -308,19 +308,28 @@
{# 1. Log Configuration (must load first) #}
<script src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
{# 2. Icon System #}
{# 2. Global Shop Configuration (currency/locale settings) #}
<script>
window.SHOP_CONFIG = {
locale: '{{ storefront_locale | default("fr-LU") }}',
currency: '{{ storefront_currency | default("EUR") }}',
language: '{{ request.state.language|default("fr") }}'
};
</script>
{# 3. Icon System #}
<script src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
{# 3. Base Shop Layout (Alpine.js component - must load before Alpine) #}
{# 4. Base Shop Layout (Alpine.js component - must load before Alpine) #}
<script src="{{ url_for('static', path='shop/js/shop-layout.js') }}"></script>
{# 4. Utilities #}
{# 5. Utilities #}
<script src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
{# 5. API Client #}
{# 6. API Client #}
<script src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
{# 6. Alpine.js with CDN fallback (deferred - loads last) #}
{# 7. Alpine.js with CDN fallback (deferred - loads last) #}
<script>
(function() {
var script = document.createElement('script');
@@ -339,7 +348,7 @@
})();
</script>
{# 7. Page-specific JavaScript #}
{# 8. Page-specific JavaScript #}
{% block extra_scripts %}{% endblock %}
{# Toast notification container #}

View File

@@ -207,13 +207,7 @@ document.addEventListener('alpine:init', () => {
this.loadProducts();
},
formatPrice(amount) {
if (!amount && amount !== 0) return '';
return new Intl.NumberFormat('fr-LU', {
style: 'currency',
currency: 'EUR'
}).format(amount);
},
// formatPrice is inherited from shopLayoutData() via spread operator
async addToCart(product) {
console.log('[SHOP] Adding to cart:', product);