feat(storefront): homepage, module gating, widget protocol, i18n fixes
Some checks failed
CI / ruff (push) Successful in 14s
CI / pytest (push) Failing after 2h32m45s
CI / validate (push) Successful in 30s
CI / dependency-scanning (push) Successful in 31s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

Storefront homepage & module gating:
- CMS owns storefront GET / (slug="home" with 3-tier resolution)
- Catalog loses GET / (keeps /products only)
- Store root redirect (GET / → /store/dashboard or /store/login)
- Route gating: non-core modules return 404 when disabled for platform
- Seed store default homepages per platform

Widget protocol for customer dashboard:
- StorefrontDashboardCard contract in widgets.py
- Widget aggregator get_storefront_dashboard_cards()
- Orders and Loyalty module widget providers
- Dashboard template renders contributed cards (no module names)

Landing template module-agnostic:
- CTAs driven by storefront_nav (not hardcoded module names)
- Header actions check nav item IDs (not enabled_modules)
- Remove hardcoded "Add Product" sidebar button
- Remove all enabled_modules checks from storefront templates

i18n fixes:
- Title placeholder resolution ({{store_name}}) for store default pages
- Storefront nav label_keys prefixed with module code
- Add storefront.account.* keys to 6 modules (en/fr/de/lb)
- Header/footer CMS pages use get_translated_title(current_language)
- Footer labels use i18n keys instead of hardcoded English

Icon cleanup:
- Standardize on map-pin (remove location-marker alias)
- Replace all location-marker references across templates and docs

Docs:
- Storefront builder vision proposal (6 phases)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 22:53:17 +02:00
parent dd9dc04328
commit adc36246b8
58 changed files with 4691 additions and 3806 deletions

View File

@@ -1744,3 +1744,39 @@ def get_current_customer_optional(
except Exception:
# Invalid token, store mismatch, or other error
return None
# =============================================================================
# STOREFRONT MODULE GATING
# =============================================================================
def make_storefront_module_gate(module_code: str):
"""
Create a FastAPI dependency that gates storefront routes by module enablement.
Used by main.py at route registration time: each non-core module's storefront
router gets this dependency injected automatically. The framework already knows
which module owns each route via RouteInfo.module_code — no hardcoded path map.
Args:
module_code: The module code to check (e.g. "catalog", "orders", "loyalty")
Returns:
A FastAPI dependency function
"""
async def _check_module_enabled(
request: Request,
db: Session = Depends(get_db),
) -> None:
from app.modules.service import module_service
platform = getattr(request.state, "platform", None)
if not platform:
return # No platform context — let other middleware handle it
if not module_service.is_module_enabled(db, platform.id, module_code):
raise HTTPException(status_code=404, detail="Page not found")
return _check_module_enabled

View File

@@ -143,7 +143,7 @@
</div>
<div class="flex items-center">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('location-marker', 'w-6 h-6')"></span>
<span x-html="$icon('map-pin', 'w-6 h-6')"></span>
</div>
<div>
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="formatNumber(analytics.inventory?.total_locations || 0)"></p>

View File

@@ -68,7 +68,7 @@ cart_module = ModuleDefinition(
items=[
MenuItemDefinition(
id="cart",
label_key="storefront.actions.cart",
label_key="cart.storefront.actions.cart",
icon="shopping-cart",
route="cart",
order=20,

View File

@@ -44,5 +44,10 @@
"view_desc": "Warenkörbe der Kunden anzeigen",
"manage": "Warenkörbe verwalten",
"manage_desc": "Warenkörbe der Kunden bearbeiten und verwalten"
},
"storefront": {
"actions": {
"cart": "Warenkorb"
}
}
}

View File

@@ -44,5 +44,10 @@
"product_not_available": "Product not available",
"error_adding": "Error adding item to cart",
"error_updating": "Error updating cart"
},
"storefront": {
"actions": {
"cart": "Cart"
}
}
}

View File

@@ -44,5 +44,10 @@
"view_desc": "Voir les paniers des clients",
"manage": "Gérer les paniers",
"manage_desc": "Modifier et gérer les paniers des clients"
},
"storefront": {
"actions": {
"cart": "Panier"
}
}
}

View File

@@ -44,5 +44,10 @@
"view_desc": "Clientekuerf kucken",
"manage": "Kuerf verwalten",
"manage_desc": "Clientekuerf änneren a verwalten"
},
"storefront": {
"actions": {
"cart": "Kuerf"
}
}
}

View File

@@ -134,7 +134,7 @@ catalog_module = ModuleDefinition(
items=[
MenuItemDefinition(
id="products",
label_key="storefront.nav.products",
label_key="catalog.storefront.nav.products",
icon="shopping-bag",
route="products",
order=10,
@@ -148,7 +148,7 @@ catalog_module = ModuleDefinition(
items=[
MenuItemDefinition(
id="search",
label_key="storefront.actions.search",
label_key="catalog.storefront.actions.search",
icon="search",
route="",
order=10,

View File

@@ -89,5 +89,13 @@
"products_import_desc": "Massenimport von Produkten",
"products_export": "Produkte exportieren",
"products_export_desc": "Produktdaten exportieren"
},
"storefront": {
"nav": {
"products": "Produkte"
},
"actions": {
"search": "Suchen"
}
}
}

View File

@@ -107,5 +107,13 @@
"menu": {
"products_inventory": "Products & Inventory",
"all_products": "All Products"
},
"storefront": {
"nav": {
"products": "Products"
},
"actions": {
"search": "Search"
}
}
}

View File

@@ -89,5 +89,13 @@
"products_import_desc": "Importation en masse de produits",
"products_export": "Exporter les produits",
"products_export_desc": "Exporter les données produits"
},
"storefront": {
"nav": {
"products": "Produits"
},
"actions": {
"search": "Rechercher"
}
}
}

View File

@@ -89,5 +89,13 @@
"products_import_desc": "Massenimport vu Produiten",
"products_export": "Produiten exportéieren",
"products_export_desc": "Produitdaten exportéieren"
},
"storefront": {
"nav": {
"products": "Produkter"
},
"actions": {
"search": "Sichen"
}
}
}

View File

@@ -30,11 +30,10 @@ router = APIRouter()
# ============================================================================
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
@router.get("/products", response_class=HTMLResponse, include_in_schema=False)
async def shop_products_page(request: Request, db: Session = Depends(get_db)):
"""
Render shop homepage / product catalog.
Render product catalog listing.
Shows featured products and categories.
"""
logger.debug(

View File

@@ -388,5 +388,15 @@
},
"confirmations": {
"delete_file": "Sind Sie sicher, dass Sie diese Datei löschen möchten? Dies kann nicht rückgängig gemacht werden."
},
"storefront": {
"my_account": "Mein Konto",
"learn_more": "Mehr erfahren",
"explore": "Entdecken",
"quick_links": "Schnellzugriff",
"information": "Informationen",
"about": "Über uns",
"contact": "Kontakt",
"faq": "FAQ"
}
}

View File

@@ -388,5 +388,15 @@
"content_pages": "Content Pages",
"store_themes": "Store Themes",
"media_library": "Media Library"
},
"storefront": {
"my_account": "My Account",
"learn_more": "Learn More",
"explore": "Explore",
"quick_links": "Quick Links",
"information": "Information",
"about": "About Us",
"contact": "Contact",
"faq": "FAQ"
}
}

View File

@@ -388,5 +388,15 @@
},
"confirmations": {
"delete_file": "Êtes-vous sûr de vouloir supprimer ce fichier ? Cette action est irréversible."
},
"storefront": {
"my_account": "Mon Compte",
"learn_more": "En savoir plus",
"explore": "Découvrir",
"quick_links": "Liens rapides",
"information": "Informations",
"about": "À propos",
"contact": "Contact",
"faq": "FAQ"
}
}

View File

@@ -388,5 +388,15 @@
},
"confirmations": {
"delete_file": "Sidd Dir sécher datt Dir dëse Fichier läsche wëllt? Dat kann net réckgängeg gemaach ginn."
},
"storefront": {
"my_account": "Mäi Kont",
"learn_more": "Méi gewuer ginn",
"explore": "Entdecken",
"quick_links": "Schnellzougrëff",
"information": "Informatiounen",
"about": "Iwwer eis",
"contact": "Kontakt",
"faq": "FAQ"
}
}

View File

@@ -28,6 +28,71 @@ ROUTE_CONFIG = {
}
# ============================================================================
# STOREFRONT HOMEPAGE
# ============================================================================
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
async def storefront_homepage(
request: Request,
db: Session = Depends(get_db),
):
"""
Storefront homepage handler.
Looks for a CMS page with slug="home" (store override → store default),
and renders the appropriate landing template. Falls back to the default
landing template when no CMS homepage exists.
"""
store = getattr(request.state, "store", None)
platform = getattr(request.state, "platform", None)
store_id = store.id if store else None
if not platform:
raise HTTPException(status_code=400, detail="Platform context required")
# Try to load a homepage from CMS (store override → store default)
page = content_page_service.get_page_for_store(
db,
platform_id=platform.id,
slug="home",
store_id=store_id,
include_unpublished=False,
)
# Resolve placeholders for store default pages (title + content)
page_content = None
page_title = None
if page:
page_content = page.content
page_title = page.title
if page.is_store_default and store:
page_content = content_page_service.resolve_placeholders(
page.content, store
)
page_title = content_page_service.resolve_placeholders(
page.title, store
)
context = get_storefront_context(request, db=db, page=page)
if page_content:
context["page_content"] = page_content
if page_title:
context["page_title"] = page_title
# Select template based on page.template field (or default)
template_map = {
"full": "cms/storefront/landing-full.html",
"modern": "cms/storefront/landing-modern.html",
"minimal": "cms/storefront/landing-minimal.html",
}
template_name = "cms/storefront/landing-default.html"
if page and page.template:
template_name = template_map.get(page.template, template_name)
return templates.TemplateResponse(template_name, context)
# ============================================================================
# DYNAMIC CONTENT PAGES (CMS)
# ============================================================================
@@ -103,10 +168,13 @@ async def generic_content_page(
# Resolve placeholders in store default pages ({{store_name}}, etc.)
page_content = page.content
page_title = page.title
if page.is_store_default and store:
page_content = content_page_service.resolve_placeholders(page.content, store)
page_title = content_page_service.resolve_placeholders(page.title, store)
context = get_storefront_context(request, db=db, page=page)
context["page_title"] = page_title
context["page_content"] = page_content
# Select template based on page.template field

View File

@@ -3,7 +3,7 @@
{% extends "storefront/base.html" %}
{# Dynamic title from CMS #}
{% block title %}{{ page.title }}{% endblock %}
{% block title %}{{ page_title or page.title }}{% endblock %}
{# SEO from CMS #}
{% block meta_description %}{{ page.meta_description or page.title }}{% endblock %}
@@ -16,13 +16,13 @@
<div class="breadcrumb mb-6">
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
<span>/</span>
<span class="text-gray-900 dark:text-gray-200 font-medium">{{ page.title }}</span>
<span class="text-gray-900 dark:text-gray-200 font-medium">{{ page_title or page.title }}</span>
</div>
{# Page Header #}
<div class="mb-8">
<h1 class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-4">
{{ page.title }}
{{ page_title or page.title }}
</h1>
{# Optional: Show store override badge for debugging #}

View File

@@ -1,10 +1,9 @@
{# app/templates/store/landing-default.html #}
{# standalone #}
{# app/modules/cms/templates/cms/storefront/landing-default.html #}
{# Default/Minimal Landing Page Template #}
{% extends "storefront/base.html" %}
{% block title %}{{ store.name }}{% endblock %}
{% block meta_description %}{{ page.meta_description or store.description or store.name }}{% endblock %}
{% block meta_description %}{{ page.meta_description or store.description or store.name if page else store.description or store.name }}{% endblock %}
{% block content %}
<div class="min-h-screen">
@@ -24,7 +23,7 @@
{# Title #}
<h1 class="text-4xl md:text-6xl font-bold text-gray-900 dark:text-white mb-6">
{{ page.title or store.name }}
{{ page_title or store.name }}
</h1>
{# Tagline #}
@@ -34,18 +33,31 @@
</p>
{% endif %}
{# CTA Button #}
{# CTA Buttons — driven by storefront_nav (module-agnostic) #}
{% set nav_items = storefront_nav.get('nav', []) %}
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="{{ base_url }}"
{% if nav_items %}
{# Primary CTA: first nav item from enabled modules #}
<a href="{{ base_url }}{{ nav_items[0].route }}"
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-white bg-primary hover:bg-primary-dark transition-colors shadow-lg hover:shadow-xl"
style="background-color: var(--color-primary)">
Browse Our Shop
{{ _(nav_items[0].label_key) }}
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>
</a>
{% if page.content %}
{% else %}
{# Fallback: account link when no module nav items #}
<a href="{{ base_url }}account/login"
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-white bg-primary hover:bg-primary-dark transition-colors shadow-lg hover:shadow-xl"
style="background-color: var(--color-primary)">
{{ _('cms.storefront.my_account') }}
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>
</a>
{% endif %}
{% if page and page.content %}
<a href="#about"
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-2 border-gray-200 dark:border-gray-600">
Learn More
{{ _('cms.storefront.learn_more') }}
</a>
{% endif %}
</div>
@@ -54,73 +66,65 @@
</section>
{# Content Section (if provided) #}
{% if page.content %}
{% if page_content %}
<section id="about" class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="prose prose-lg dark:prose-invert max-w-none">
{{ page.content | safe }}{# sanitized: CMS content #}
{{ page_content | safe }}{# sanitized: CMS content #}
</div>
</div>
</section>
{% endif %}
{# Quick Links Section #}
{# Quick Links Section — driven by nav items and CMS pages #}
{% set account_items = storefront_nav.get('account', []) %}
{% set all_links = nav_items + account_items %}
{% if all_links or header_pages %}
<section class="py-16 bg-gray-50 dark:bg-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-3xl font-bold text-center text-gray-900 dark:text-white mb-12">
Explore
{{ _('cms.storefront.explore') }}
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<a href="{{ base_url }}products"
{# Module nav items (products, loyalty, etc.) #}
{% for item in all_links[:3] %}
<a href="{{ base_url }}{{ item.route }}"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4">🛍️</div>
<div class="mb-4">
<span class="h-10 w-10 text-primary mx-auto" style="color: var(--color-primary)"
x-html="$icon('{{ item.icon }}', 'h-10 w-10 mx-auto')"></span>
</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
Shop Products
{{ _(item.label_key) }}
</h3>
<p class="text-gray-600 dark:text-gray-400">
Browse our complete catalog
</p>
</a>
{% endfor %}
{% if header_pages %}
{% for page in header_pages[:2] %}
{# Fill remaining slots with CMS header pages #}
{% set remaining = 3 - all_links[:3]|length %}
{% if remaining > 0 and header_pages %}
{% for page in header_pages[:remaining] %}
<a href="{{ base_url }}{{ page.slug }}"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4">📄</div>
<div class="mb-4">
<span class="h-10 w-10 text-primary mx-auto" style="color: var(--color-primary)"
x-html="$icon('document-text', 'h-10 w-10 mx-auto')"></span>
</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
{{ page.title }}
</h3>
{% if page.meta_description %}
<p class="text-gray-600 dark:text-gray-400">
{{ page.meta_description or 'Learn more' }}
{{ page.meta_description }}
</p>
{% endif %}
</a>
{% endfor %}
{% else %}
<a href="{{ base_url }}about"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4"></div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
About Us
</h3>
<p class="text-gray-600 dark:text-gray-400">
Learn about our story
</p>
</a>
<a href="{{ base_url }}contact"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4">📧</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
Contact
</h3>
<p class="text-gray-600 dark:text-gray-400">
Get in touch with us
</p>
</a>
{% endif %}
</div>
</div>
</section>
{% endif %}
</div>
{% endblock %}

View File

@@ -80,6 +80,44 @@ class WidgetContext:
include_details: bool = False
# =============================================================================
# Storefront Dashboard Card
# =============================================================================
@dataclass
class StorefrontDashboardCard:
"""
A card contributed by a module to the storefront customer dashboard.
Modules implement get_storefront_dashboard_cards() to provide these.
The dashboard template renders them without knowing which module provided them.
Attributes:
key: Unique identifier (e.g. "orders.summary", "loyalty.points")
icon: Lucide icon name (e.g. "shopping-bag", "gift")
title: Card title (i18n key or plain text)
subtitle: Card subtitle / description
route: Link destination relative to base_url (e.g. "account/orders")
value: Primary display value (e.g. order count, points balance)
value_label: Label for the value (e.g. "Total Orders", "Points Balance")
order: Sort order (lower = shown first)
template: Optional custom template path for complex rendering
extra_data: Additional data for custom template rendering
"""
key: str
icon: str
title: str
subtitle: str
route: str
value: str | int | None = None
value_label: str | None = None
order: int = 100
template: str | None = None
extra_data: dict[str, Any] = field(default_factory=dict)
# =============================================================================
# Widget Item Types
# =============================================================================
@@ -330,6 +368,30 @@ class DashboardWidgetProviderProtocol(Protocol):
"""
...
def get_storefront_dashboard_cards(
self,
db: "Session",
store_id: int,
customer_id: int,
context: WidgetContext | None = None,
) -> list["StorefrontDashboardCard"]:
"""
Get cards for the storefront customer dashboard.
Called by the customer account dashboard. Each module contributes
its own cards (e.g. orders summary, loyalty points).
Args:
db: Database session for queries
store_id: ID of the store
customer_id: ID of the logged-in customer
context: Optional filtering/scoping context
Returns:
List of StorefrontDashboardCard objects
"""
...
__all__ = [
# Context
@@ -343,6 +405,8 @@ __all__ = [
"WidgetData",
# Main envelope
"DashboardWidget",
# Storefront
"StorefrontDashboardCard",
# Protocol
"DashboardWidgetProviderProtocol",
]

View File

@@ -9,11 +9,13 @@ Store pages for core functionality:
"""
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.api.deps import (
UserContext,
get_current_store_from_cookie_or_header,
get_current_store_optional,
get_db,
get_resolved_store_code,
)
@@ -24,6 +26,21 @@ from app.templates_config import templates
router = APIRouter()
# ============================================================================
# STORE ROOT REDIRECT
# ============================================================================
@router.get("/", response_class=RedirectResponse, include_in_schema=False)
async def store_root(
current_user: UserContext | None = Depends(get_current_store_optional),
):
"""Redirect /store/ based on authentication status."""
if current_user:
return RedirectResponse(url="/store/dashboard", status_code=302)
return RedirectResponse(url="/store/login", status_code=302)
# ============================================================================
# STORE DASHBOARD
# ============================================================================

View File

@@ -34,6 +34,7 @@ from sqlalchemy.orm import Session
from app.modules.contracts.widgets import (
DashboardWidget,
DashboardWidgetProviderProtocol,
StorefrontDashboardCard,
WidgetContext,
)
@@ -233,6 +234,49 @@ class WidgetAggregatorService:
return widget
return None
def get_storefront_dashboard_cards(
self,
db: Session,
store_id: int,
customer_id: int,
platform_id: int,
context: WidgetContext | None = None,
) -> list[StorefrontDashboardCard]:
"""
Get dashboard cards for the storefront customer account page.
Collects cards from all enabled modules that implement
get_storefront_dashboard_cards(), sorted by order.
Args:
db: Database session
store_id: ID of the store
customer_id: ID of the logged-in customer
platform_id: Platform ID (for module enablement check)
context: Optional filtering/scoping context
Returns:
Flat list of StorefrontDashboardCard sorted by order
"""
providers = self._get_enabled_providers(db, platform_id)
cards: list[StorefrontDashboardCard] = []
for module, provider in providers:
if not hasattr(provider, "get_storefront_dashboard_cards"):
continue
try:
module_cards = provider.get_storefront_dashboard_cards(
db, store_id, customer_id, context
)
if module_cards:
cards.extend(module_cards)
except Exception as e:
logger.warning(
f"Failed to get storefront cards from module {module.code}: {e}"
)
return sorted(cards, key=lambda c: c.order)
def get_available_categories(
self, db: Session, platform_id: int
) -> list[str]:

View File

@@ -141,28 +141,28 @@ customers_module = ModuleDefinition(
items=[
MenuItemDefinition(
id="dashboard",
label_key="storefront.account.dashboard",
label_key="customers.storefront.account.dashboard",
icon="home",
route="account/dashboard",
order=10,
),
MenuItemDefinition(
id="profile",
label_key="storefront.account.profile",
label_key="customers.storefront.account.profile",
icon="user",
route="account/profile",
order=20,
),
MenuItemDefinition(
id="addresses",
label_key="storefront.account.addresses",
label_key="customers.storefront.account.addresses",
icon="map-pin",
route="account/addresses",
order=30,
),
MenuItemDefinition(
id="settings",
label_key="storefront.account.settings",
label_key="customers.storefront.account.settings",
icon="cog",
route="account/settings",
order=90,

View File

@@ -52,5 +52,13 @@
"customers_delete_desc": "Kundendatensätze entfernen",
"customers_export": "Kunden exportieren",
"customers_export_desc": "Kundendaten exportieren"
},
"storefront": {
"account": {
"dashboard": "Dashboard",
"profile": "Profil",
"addresses": "Adressen",
"settings": "Einstellungen"
}
}
}

View File

@@ -52,5 +52,13 @@
"customers_section": "Customers",
"customers": "Customers",
"all_customers": "All Customers"
},
"storefront": {
"account": {
"dashboard": "Dashboard",
"profile": "Profile",
"addresses": "Addresses",
"settings": "Settings"
}
}
}

View File

@@ -52,5 +52,13 @@
"customers_delete_desc": "Supprimer les fiches clients",
"customers_export": "Exporter les clients",
"customers_export_desc": "Exporter les données clients"
},
"storefront": {
"account": {
"dashboard": "Tableau de bord",
"profile": "Profil",
"addresses": "Adresses",
"settings": "Paramètres"
}
}
}

View File

@@ -52,5 +52,13 @@
"customers_delete_desc": "Clientedossieren ewechhuelen",
"customers_export": "Clienten exportéieren",
"customers_export_desc": "Clientedaten exportéieren"
},
"storefront": {
"account": {
"dashboard": "Dashboard",
"profile": "Profil",
"addresses": "Adressen",
"settings": "Astellungen"
}
}
}

View File

@@ -195,9 +195,25 @@ async def shop_account_dashboard_page(
},
)
# Collect dashboard cards from enabled modules via widget protocol
from app.modules.core.services.widget_aggregator import widget_aggregator
store = getattr(request.state, "store", None)
platform = getattr(request.state, "platform", None)
dashboard_cards = []
if store and platform:
dashboard_cards = widget_aggregator.get_storefront_dashboard_cards(
db,
store_id=store.id,
customer_id=current_customer.id,
platform_id=platform.id,
)
return templates.TemplateResponse(
"customers/storefront/dashboard.html",
get_storefront_context(request, db=db, user=current_customer),
get_storefront_context(
request, db=db, user=current_customer, dashboard_cards=dashboard_cards
),
)

View File

@@ -37,7 +37,7 @@
<!-- Empty State -->
<div x-show="!loading && !error && addresses.length === 0"
class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-12 text-center">
<span class="mx-auto h-12 w-12 text-gray-400 block" x-html="$icon('location-marker', 'h-12 w-12 mx-auto')"></span>
<span class="mx-auto h-12 w-12 text-gray-400 block" x-html="$icon('map-pin', 'h-12 w-12 mx-auto')"></span>
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No addresses yet</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Add your first address to speed up checkout.</p>
<button @click="openAddModal()"

View File

@@ -17,25 +17,31 @@
<!-- Dashboard Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<!-- Orders Card -->
<a href="{{ base_url }}account/orders"
{# Module-contributed cards (orders, loyalty, etc.) — rendered via widget protocol #}
{% for card in dashboard_cards|default([]) %}
<a href="{{ base_url }}{{ card.route }}"
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center mb-4">
<div class="flex-shrink-0">
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('shopping-bag', 'h-8 w-8')"></span>
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('{{ card.icon }}', 'h-8 w-8')"></span>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Orders</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">View order history</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ card.title }}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ card.subtitle }}</p>
</div>
</div>
{% if card.value is not none %}
<div>
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)">{{ user.total_orders }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Total Orders</p>
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)">{{ card.value }}</p>
{% if card.value_label %}
<p class="text-sm text-gray-500 dark:text-gray-400">{{ card.value_label }}</p>
{% endif %}
</div>
{% endif %}
</a>
{% endfor %}
<!-- Profile Card -->
<!-- Profile Card (always shown — core) -->
<a href="{{ base_url }}account/profile"
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center mb-4">
@@ -52,12 +58,12 @@
</div>
</a>
<!-- Addresses Card -->
<!-- Addresses Card (always shown — core) -->
<a href="{{ base_url }}account/addresses"
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center mb-4">
<div class="flex-shrink-0">
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('location-marker', 'h-8 w-8')"></span>
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('map-pin', 'h-8 w-8')"></span>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Addresses</h3>
@@ -66,36 +72,7 @@
</div>
</a>
{% if 'loyalty' in enabled_modules %}
<!-- Loyalty Rewards Card -->
<a href="{{ base_url }}account/loyalty"
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700"
x-data="{ points: null, loaded: false }"
x-init="fetch('/api/v1/storefront/loyalty/card').then(r => r.json()).then(d => { if (d.card) { points = d.card.points_balance; } loaded = true; }).catch(() => { loaded = true; })">
<div class="flex items-center mb-4">
<div class="flex-shrink-0">
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('gift', 'h-8 w-8')"></span>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Loyalty Rewards</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">View your points & rewards</p>
</div>
</div>
<div>
<template x-if="loaded && points !== null">
<div>
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)" x-text="points.toLocaleString()"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">Points Balance</p>
</div>
</template>
<template x-if="loaded && points === null">
<p class="text-sm text-gray-500 dark:text-gray-400">Join our rewards program</p>
</template>
</div>
</a>
{% endif %}
<!-- Messages Card -->
<!-- Messages Card (always shown — messaging is core) -->
<a href="{{ base_url }}account/messages"
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700"
x-data="{ unreadCount: 0 }"
@@ -126,10 +103,6 @@
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Customer Since</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.created_at.strftime('%B %Y') }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Total Orders</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.total_orders }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Customer Number</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.customer_number }}</p>

View File

@@ -58,6 +58,13 @@ def _get_feature_provider():
return loyalty_feature_provider
def _get_widget_provider():
"""Lazy import of widget provider to avoid circular imports."""
from app.modules.loyalty.services.loyalty_widgets import loyalty_widget_provider
return loyalty_widget_provider
def _get_onboarding_provider():
"""Lazy import of onboarding provider to avoid circular imports."""
from app.modules.loyalty.services.loyalty_onboarding_service import (
@@ -289,7 +296,7 @@ loyalty_module = ModuleDefinition(
items=[
MenuItemDefinition(
id="loyalty",
label_key="storefront.account.loyalty",
label_key="loyalty.storefront.account.loyalty",
icon="gift",
route="account/loyalty",
order=60,
@@ -328,6 +335,8 @@ loyalty_module = ModuleDefinition(
],
# Feature provider for billing feature gating
feature_provider=_get_feature_provider,
# Widget provider for storefront dashboard cards
widget_provider=_get_widget_provider,
# Onboarding provider for post-signup checklist
onboarding_provider=_get_onboarding_provider,
)

View File

@@ -879,6 +879,9 @@
"previous": "Zurück",
"next": "Weiter",
"page_x_of_y": "Seite {page} von {pages}"
},
"account": {
"loyalty": "Treueprogramm"
}
},
"toasts": {

View File

@@ -887,6 +887,9 @@
"previous": "Previous",
"next": "Next",
"page_x_of_y": "Page {page} of {pages}"
},
"account": {
"loyalty": "Loyalty Rewards"
}
},
"toasts": {

View File

@@ -879,6 +879,9 @@
"previous": "Précédent",
"next": "Suivant",
"page_x_of_y": "Page {page} sur {pages}"
},
"account": {
"loyalty": "Récompenses fidélité"
}
},
"toasts": {

View File

@@ -879,6 +879,9 @@
"previous": "Zréck",
"next": "Weider",
"page_x_of_y": "Säit {page} vun {pages}"
},
"account": {
"loyalty": "Treieprogramm"
}
},
"toasts": {

View File

@@ -0,0 +1,81 @@
# app/modules/loyalty/services/loyalty_widgets.py
"""
Loyalty dashboard widget provider.
Provides storefront dashboard cards for loyalty-related data.
Implements get_storefront_dashboard_cards from DashboardWidgetProviderProtocol.
"""
import logging
from sqlalchemy.orm import Session
from app.modules.contracts.widgets import (
DashboardWidget,
StorefrontDashboardCard,
WidgetContext,
)
logger = logging.getLogger(__name__)
class LoyaltyWidgetProvider:
"""Widget provider for loyalty module."""
@property
def widgets_category(self) -> str:
return "loyalty"
def get_store_widgets(
self,
db: Session,
store_id: int,
context: WidgetContext | None = None,
) -> list[DashboardWidget]:
return []
def get_platform_widgets(
self,
db: Session,
platform_id: int,
context: WidgetContext | None = None,
) -> list[DashboardWidget]:
return []
def get_storefront_dashboard_cards(
self,
db: Session,
store_id: int,
customer_id: int,
context: WidgetContext | None = None,
) -> list[StorefrontDashboardCard]:
"""Provide the Loyalty Rewards card for the customer dashboard."""
from app.modules.loyalty.models.loyalty_card import LoyaltyCard
card = (
db.query(LoyaltyCard)
.filter(
LoyaltyCard.customer_id == customer_id,
LoyaltyCard.is_active.is_(True),
)
.first()
)
points = card.points_balance if card else None
subtitle = "View your points & rewards" if card else "Join our rewards program"
return [
StorefrontDashboardCard(
key="loyalty.rewards",
icon="gift",
title="Loyalty Rewards",
subtitle=subtitle,
route="account/loyalty",
value=points,
value_label="Points Balance" if points is not None else None,
order=30,
),
]
loyalty_widget_provider = LoyaltyWidgetProvider()

View File

@@ -115,7 +115,7 @@
<!-- Shipping Address -->
<div class="min-w-0 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800" x-show="order">
<h4 class="mb-4 font-semibold text-gray-600 dark:text-gray-300 flex items-center gap-2">
<span x-html="$icon('location-marker', 'w-5 h-5')"></span>
<span x-html="$icon('map-pin', 'w-5 h-5')"></span>
Shipping Address
</h4>
<div class="text-sm text-gray-700 dark:text-gray-300 space-y-1">

View File

@@ -192,7 +192,7 @@ messaging_module = ModuleDefinition(
items=[
MenuItemDefinition(
id="messages",
label_key="storefront.account.messages",
label_key="messaging.storefront.account.messages",
icon="chat-bubble-left-right",
route="account/messages",
order=50,

View File

@@ -125,5 +125,10 @@
"send_messages_desc": "Nachrichten an Kunden senden",
"manage_templates": "Vorlagen verwalten",
"manage_templates_desc": "Nachrichtenvorlagen erstellen und bearbeiten"
},
"storefront": {
"account": {
"messages": "Nachrichten"
}
}
}

View File

@@ -125,5 +125,10 @@
"status_opened": "Opened",
"status_clicked": "Clicked",
"retention_note": "Email body content is retained for 90 days. Metadata is kept indefinitely."
},
"storefront": {
"account": {
"messages": "Messages"
}
}
}

View File

@@ -125,5 +125,10 @@
"send_messages_desc": "Envoyer des messages aux clients",
"manage_templates": "Gérer les modèles",
"manage_templates_desc": "Créer et modifier les modèles de messages"
},
"storefront": {
"account": {
"messages": "Messages"
}
}
}

View File

@@ -125,5 +125,10 @@
"send_messages_desc": "Messagen u Clienten schécken",
"manage_templates": "Schablounen verwalten",
"manage_templates_desc": "Messageschablounen erstellen an änneren"
},
"storefront": {
"account": {
"messages": "Noriichten"
}
}
}

View File

@@ -43,6 +43,13 @@ def _get_feature_provider():
return order_feature_provider
def _get_widget_provider():
"""Lazy import of widget provider to avoid circular imports."""
from app.modules.orders.services.order_widgets import order_widget_provider
return order_widget_provider
# Orders module definition
orders_module = ModuleDefinition(
code="orders",
@@ -146,7 +153,7 @@ orders_module = ModuleDefinition(
items=[
MenuItemDefinition(
id="orders",
label_key="storefront.account.orders",
label_key="orders.storefront.account.orders",
icon="clipboard-list",
route="account/orders",
order=40,
@@ -168,6 +175,7 @@ orders_module = ModuleDefinition(
# Metrics provider for dashboard statistics
metrics_provider=_get_metrics_provider,
feature_provider=_get_feature_provider,
widget_provider=_get_widget_provider,
)

View File

@@ -89,5 +89,10 @@
"orders_cancel_desc": "Ausstehende oder in Bearbeitung befindliche Bestellungen stornieren",
"orders_refund": "Bestellungen erstatten",
"orders_refund_desc": "Rückerstattungen für Bestellungen verarbeiten"
},
"storefront": {
"account": {
"orders": "Bestellungen"
}
}
}

View File

@@ -89,5 +89,10 @@
"store_operations": "Store Operations",
"sales_orders": "Sales & Orders",
"orders": "Orders"
},
"storefront": {
"account": {
"orders": "Orders"
}
}
}

View File

@@ -89,5 +89,10 @@
"orders_cancel_desc": "Annuler les commandes en attente ou en traitement",
"orders_refund": "Rembourser les commandes",
"orders_refund_desc": "Traiter les remboursements des commandes"
},
"storefront": {
"account": {
"orders": "Commandes"
}
}
}

View File

@@ -89,5 +89,10 @@
"orders_cancel_desc": "Aussteesend oder a Veraarbechtung Bestellunge stornéieren",
"orders_refund": "Bestellungen erstatten",
"orders_refund_desc": "Réckerstattunge fir Bestellunge veraarbechten"
},
"storefront": {
"account": {
"orders": "Bestellungen"
}
}
}

View File

@@ -0,0 +1,80 @@
# app/modules/orders/services/order_widgets.py
"""
Orders dashboard widget provider.
Provides storefront dashboard cards for order-related data.
Implements get_storefront_dashboard_cards from DashboardWidgetProviderProtocol.
"""
import logging
from sqlalchemy.orm import Session
from app.modules.contracts.widgets import (
DashboardWidget,
StorefrontDashboardCard,
WidgetContext,
)
logger = logging.getLogger(__name__)
class OrderWidgetProvider:
"""Widget provider for orders module."""
@property
def widgets_category(self) -> str:
return "orders"
def get_store_widgets(
self,
db: Session,
store_id: int,
context: WidgetContext | None = None,
) -> list[DashboardWidget]:
return []
def get_platform_widgets(
self,
db: Session,
platform_id: int,
context: WidgetContext | None = None,
) -> list[DashboardWidget]:
return []
def get_storefront_dashboard_cards(
self,
db: Session,
store_id: int,
customer_id: int,
context: WidgetContext | None = None,
) -> list[StorefrontDashboardCard]:
"""Provide the Orders card for the customer dashboard."""
from app.modules.orders.models.customer_order_stats import CustomerOrderStats
stats = (
db.query(CustomerOrderStats)
.filter(
CustomerOrderStats.store_id == store_id,
CustomerOrderStats.customer_id == customer_id,
)
.first()
)
total_orders = stats.total_orders if stats else 0
return [
StorefrontDashboardCard(
key="orders.summary",
icon="shopping-bag",
title="Orders",
subtitle="View order history",
route="account/orders",
value=total_orders,
value_label="Total Orders",
order=10,
),
]
order_widget_provider = OrderWidgetProvider()

View File

@@ -178,7 +178,7 @@
<!-- Shipping Address -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<span class="h-5 w-5 mr-2 text-gray-400" x-html="$icon('location-marker', 'h-5 w-5')"></span>
<span class="h-5 w-5 mr-2 text-gray-400" x-html="$icon('map-pin', 'h-5 w-5')"></span>
Shipping Address
</h3>
<div class="text-sm text-gray-600 dark:text-gray-300 space-y-1">
@@ -302,7 +302,7 @@
rel="noopener noreferrer"
class="mt-3 w-full inline-flex justify-center items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white transition-colors"
style="background-color: var(--color-primary)">
<span class="h-4 w-4 mr-2" x-html="$icon('location-marker', 'h-4 w-4')"></span>
<span class="h-4 w-4 mr-2" x-html="$icon('map-pin', 'h-4 w-4')"></span>
Track Package
</a>
</div>

View File

@@ -105,7 +105,7 @@
<button type="button" @click="getLocation()"
:disabled="gettingLocation"
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 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:shadow-outline-gray disabled:opacity-50">
<span x-html="$icon('location-marker', 'w-4 h-4 mr-2')"></span>
<span x-html="$icon('map-pin', 'w-4 h-4 mr-2')"></span>
<span x-text="gettingLocation ? 'Getting location...' : (form.location_lat ? 'Location saved' : 'Get Location')"></span>
</button>
<span x-show="form.location_lat" class="text-xs text-green-600 dark:text-green-400 self-center">

View File

@@ -106,16 +106,6 @@
</div>
</div>
{% if 'catalog' in enabled_modules %}
<!-- Quick Actions (static, outside dynamic menu) -->
<div class="px-6 my-6">
<button class="flex items-center justify-between w-full px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
@click="$dispatch('open-add-product-modal')">
<span x-html="$icon('plus', 'w-4 h-4')"></span>
<span class="ml-2">Add Product</span>
</button>
</div>
{% endif %}
</div>
{% endmacro %}

View File

@@ -100,7 +100,7 @@
{# CMS pages (About, Contact) are already dynamic via header_pages #}
{% for page in header_pages|default([]) %}
<a href="{{ base_url }}{{ page.slug }}" class="text-gray-700 dark:text-gray-300 hover:text-primary">
{{ page.title }}
{{ page.get_translated_title(current_language) }}
</a>
{% endfor %}
</nav>
@@ -108,7 +108,10 @@
{# Right side actions #}
<div class="flex items-center space-x-4">
{% if 'catalog' in enabled_modules|default([]) %}
{# Action items from enabled modules (search, cart, etc.) #}
{% set action_ids = storefront_nav.get('actions', [])|map(attribute='id')|list %}
{% if 'search' in action_ids %}
{# Search #}
<button @click="openSearch()" 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">
@@ -118,7 +121,7 @@
</button>
{% endif %}
{% if 'cart' in enabled_modules|default([]) %}
{% if 'cart' in action_ids %}
{# Cart #}
<a href="{{ base_url }}cart" class="relative 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">
@@ -131,7 +134,7 @@
style="background-color: var(--color-accent)">
</span>
</a>
{% endif %}
{% endif %}{# cart #}
{# Theme toggle #}
<button @click="toggleTheme()"
@@ -272,7 +275,7 @@
{% for page in header_pages|default([]) %}
<a href="{{ base_url }}{{ page.slug }}" @click="closeMobileMenu()"
class="block px-3 py-2 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
{{ page.title }}
{{ page.get_translated_title(current_language) }}
</a>
{% endfor %}
</nav>
@@ -332,13 +335,10 @@
{# Column 1 #}
<div>
<h4 class="font-semibold mb-4">Quick Links</h4>
<h4 class="font-semibold mb-4">{{ _('cms.storefront.quick_links') }}</h4>
<ul class="space-y-2">
{% if 'catalog' in enabled_modules|default([]) %}
<li><a href="{{ base_url }}products" class="text-gray-600 hover:text-primary dark:text-gray-400">Products</a></li>
{% endif %}
{% for page in col1_pages %}
<li><a href="{{ base_url }}{{ page.slug }}" class="text-gray-600 hover:text-primary dark:text-gray-400">{{ page.title }}</a></li>
<li><a href="{{ base_url }}{{ page.slug }}" class="text-gray-600 hover:text-primary dark:text-gray-400">{{ page.get_translated_title(current_language) }}</a></li>
{% endfor %}
</ul>
</div>
@@ -346,10 +346,10 @@
{# Column 2 #}
{% if col2_pages %}
<div>
<h4 class="font-semibold mb-4">Information</h4>
<h4 class="font-semibold mb-4">{{ _('cms.storefront.information') }}</h4>
<ul class="space-y-2">
{% for page in col2_pages %}
<li><a href="{{ base_url }}{{ page.slug }}" class="text-gray-600 hover:text-primary dark:text-gray-400">{{ page.title }}</a></li>
<li><a href="{{ base_url }}{{ page.slug }}" class="text-gray-600 hover:text-primary dark:text-gray-400">{{ page.get_translated_title(current_language) }}</a></li>
{% endfor %}
</ul>
</div>
@@ -357,22 +357,17 @@
{% else %}
{# Fallback: Static links if no CMS pages configured #}
<div>
<h4 class="font-semibold mb-4">Quick Links</h4>
<h4 class="font-semibold mb-4">{{ _('cms.storefront.quick_links') }}</h4>
<ul class="space-y-2">
{% if 'catalog' in enabled_modules|default([]) %}
<li><a href="{{ base_url }}products" class="text-gray-600 hover:text-primary dark:text-gray-400">Products</a></li>
{% endif %}
<li><a href="{{ base_url }}about" class="text-gray-600 hover:text-primary dark:text-gray-400">About Us</a></li>
<li><a href="{{ base_url }}contact" class="text-gray-600 hover:text-primary dark:text-gray-400">Contact</a></li>
<li><a href="{{ base_url }}about" class="text-gray-600 hover:text-primary dark:text-gray-400">{{ _('cms.storefront.about') }}</a></li>
<li><a href="{{ base_url }}contact" class="text-gray-600 hover:text-primary dark:text-gray-400">{{ _('cms.storefront.contact') }}</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-4">Information</h4>
<h4 class="font-semibold mb-4">{{ _('cms.storefront.information') }}</h4>
<ul class="space-y-2">
<li><a href="{{ base_url }}faq" class="text-gray-600 hover:text-primary dark:text-gray-400">FAQ</a></li>
<li><a href="{{ base_url }}shipping" class="text-gray-600 hover:text-primary dark:text-gray-400">Shipping</a></li>
<li><a href="{{ base_url }}returns" class="text-gray-600 hover:text-primary dark:text-gray-400">Returns</a></li>
<li><a href="{{ base_url }}faq" class="text-gray-600 hover:text-primary dark:text-gray-400">{{ _('cms.storefront.faq') }}</a></li>
</ul>
</div>
{% endif %}

View File

@@ -159,7 +159,7 @@ Add this script **before** Alpine.js in your HTML pages:
### Location Icons
| Icon Name | Usage | Description |
|-----------|-------|-------------|
| `location-marker` | `$icon('location-marker')` | Location, address |
| `map-pin` | `$icon('map-pin')` | Location, address |
| `globe` | `$icon('globe')` | International, language |
### Status & Indicators

View File

@@ -0,0 +1,256 @@
# Proposal: Storefront Builder Vision
**Date:** 2026-04-13
**Status:** Draft
**Author:** Samir / Claude
---
## Problem Statement
The platform has a solid CMS foundation — section-based homepages, module-driven menus, widget contracts, theme system, 3-tier content hierarchy, multi-language support. However, there are gaps between the platform marketing site (well-built) and the store storefront (catching up). The goal is to enable "yes, we can build you a one-page ecommerce site" as a real capability.
---
## Current State
### What works well
- **Platform homepage**: Section-based rendering via `page.sections` JSON with 8 section partials (hero, features, pricing, CTA, testimonials, gallery, contact, products)
- **Admin section editor**: API for editing sections individually or all at once, multi-language
- **Module contracts**: Widget protocol, context providers, menu declarations, route gating
- **Store theme**: Colors, fonts, logo, custom CSS — all applied via CSS variables
- **Component library**: Ecommerce macros (product cards, grids, add-to-cart, mini-cart) at `/admin/components#ecommerce`
- **3-tier content**: Platform pages → store defaults → store overrides, with placeholder resolution
### What's missing
1. Store homepages don't use sections (only `landing-full.html` supports them, but defaults use `landing-default.html`)
2. Header actions (search, cart) are bespoke in the base template
3. No widget slots on storefront pages (widgets only on dashboards)
4. No per-store menu customization (menus are platform-wide)
5. Section types are fixed (8 types, not pluggable by modules)
6. No visual editor (admin edits via JSON-backed forms)
---
## Phase 1: Wire Sections to Store Homepages
**Goal:** Store homepages get section-based rendering immediately, using existing infrastructure.
### Changes
- Change seed script: store default homepages use `template="full"` instead of `template="default"`
- Populate `sections` JSON per platform:
- **OMS stores**: Hero → Product Showcase → Features → CTA
- **Loyalty stores**: Hero → Loyalty Signup → Features → CTA
- **Hosting stores**: Hero → Services → Features → CTA
- Admin can already edit sections via existing API at `/admin/content-pages/{id}/edit`
### Impact
- Every store immediately gets a section-based homepage
- Merchants can customize via admin UI (no code changes needed)
- All 8 existing section types available
### Effort
Small — mostly seed data and one template field change.
---
## Phase 2: Module-Contributed Header Actions
**Goal:** Remove bespoke header rendering. Modules provide their own action templates.
### Changes
- Add optional `header_template` field to `MenuItemDefinition` in `app/modules/base.py`
- Cart module provides `cart/storefront/partials/header-cart.html` (cart icon + Alpine badge)
- Catalog module provides `catalog/storefront/partials/header-search.html` (search button + modal trigger)
- Update `storefront/base.html`: iterate `storefront_nav.get('actions', [])`, use `{% include item.header_template %}` when present, generic link otherwise
- Remove all hardcoded action rendering from base template
### Architecture
```
Module definition:
MenuItemDefinition(
id="cart",
label_key="cart.storefront.actions.cart",
icon="shopping-cart",
route="cart",
header_template="cart/storefront/partials/header-cart.html",
)
Base template:
{% for item in storefront_nav.get('actions', []) %}
{% if item.header_template %}
{% include item.header_template %}
{% else %}
<a href="{{ base_url }}{{ item.route }}">{{ _(item.label_key) }}</a>
{% endif %}
{% endfor %}
```
### Impact
- Any module can contribute a header action with custom UI
- Adding wishlist, notifications, etc. requires zero base template changes
- Fully module-agnostic
### Effort
Small — new field on MenuItemDefinition, two partial templates, base template update.
---
## Phase 3: Module-Contributed Page Sections
**Goal:** Modules can register their own section types for storefront pages.
### Changes
- New contract: `StorefrontSectionProviderProtocol` in `app/modules/contracts/`
```python
class StorefrontSectionProviderProtocol(Protocol):
def get_section_types(self) -> list[SectionTypeDefinition]: ...
# SectionTypeDefinition: id, name, icon, template_path, default_config
```
- New field on `ModuleDefinition`: `section_provider`
- Section registry in CMS module aggregates section types from all enabled modules
- Catalog contributes:
- `product-showcase` — featured products grid (uses existing product card macros)
- `category-grid` — browse by category
- Loyalty contributes:
- `loyalty-signup` — join rewards CTA with program details
- `rewards-overview` — points/stamps explanation
- Update admin section editor to show available sections from enabled modules
### Architecture
```
Section rendering flow:
1. Page has sections JSON: {"hero": {...}, "product-showcase": {...}, ...}
2. Template iterates sections in order
3. For each section, looks up the partial from the section registry
4. Renders: {% include section_registry[section_type].template_path %}
5. Passes section data + language context
```
### Impact
- Platform-specific storefronts: OMS stores get product sections, Loyalty stores get loyalty sections
- Admin editor adapts to available sections based on platform modules
- No template changes needed when adding new section types
### Effort
Medium — new contract, section registry service, admin UI update, 4-6 new section partials.
---
## Phase 4: Storefront Page Widget Slots
**Goal:** Place smaller components within pages — product carousels, loyalty badges, social feeds.
### Changes
- Define named slots in storefront templates: `below-hero`, `above-footer`, `content-sidebar`
- New contract method on widget protocol:
```python
def get_page_widgets(self, db, store_id, slot, page_slug, context) -> list[StorefrontPageWidget]
# StorefrontPageWidget: key, template, data, order
```
- Widget aggregator collects from enabled modules per slot
- Templates render:
```jinja
{% for widget in page_widgets.get('below-hero', []) %}
{% include widget.template %}
{% endfor %}
```
- Catalog contributes: "featured products" widget, "new arrivals" widget
- Loyalty contributes: "join rewards" widget, "points balance" widget
### Sections vs Widgets
- **Sections** = full-width page blocks, ordered top-to-bottom, part of page structure
- **Widgets** = smaller components placed within named slots, multiple per slot
### Impact
- Mix-and-match content: a loyalty store can add a product widget to their homepage
- Modules contribute without coupling — templates never check module names
- Foundation for "one-page ecommerce" capability
### Effort
Medium — builds on existing widget infrastructure, new slot rendering in templates.
---
## Phase 5: Per-Store Menu & Section Ordering
**Goal:** Stores can customize their navigation and homepage layout.
### Changes
- **Menu overrides**: `store_menu_config` table (store_id, item_id, is_visible, display_order)
- Stores can hide/reorder items from the platform menu
- menu_discovery_service gets a store-level filter on top of platform-level
- **Section ordering**: `display_order` field per section in the `sections` JSON
- Admin UI: drag-and-drop section reordering
- Sections can be enabled/disabled per store override
- **Section visibility**: Store overrides can hide sections from the default homepage
### Impact
- Each store can have a unique navigation and homepage layout
- Platform provides the base, stores customize
- Follows existing 3-tier pattern (platform → store default → store override)
### Effort
Medium-large — new table, menu service update, admin drag-and-drop UI.
---
## Phase 6: Visual Editor (Future)
**Goal:** WYSIWYG editing experience for storefront pages.
### Changes
- Live preview panel showing section rendering as you edit
- Drag-and-drop section placement and reordering
- Inline text editing with rich text toolbar
- Template/section picker with visual thumbnails
- Mobile/desktop preview toggle
### Architecture
- Frontend-only addition — calls the same section APIs
- Could use iframe-based preview with postMessage communication
- Section partials render identically in preview and production
### Impact
- Non-technical merchants can build their own storefronts
- Reduces support burden
- Competitive feature for attracting merchants
### Effort
Large — significant frontend work, but backend APIs already exist.
---
## One-Page Ecommerce Site — End-to-End Flow
With Phases 1-4 complete, here's the workflow:
1. **Admin creates store** on OMS platform
2. **Store homepage** auto-created with section-based default (hero, product showcase, features, CTA)
3. **Merchant edits** at `/admin/content-pages/{id}/edit`:
- Hero: store branding, tagline, "Shop Now" button
- Product Showcase: featured products (from catalog module section)
- Testimonials: customer reviews
- CTA: newsletter signup
4. **Theme** at `/admin/stores/{code}/theme`: colors, logo, fonts
5. **Customer visits** storefront → sees professional one-page site with products, cart, checkout
---
## Key Files Reference
| Component | File |
|-----------|------|
| Section partials | `app/modules/cms/templates/cms/platform/sections/_*.html` |
| ContentPage model | `app/modules/cms/models/content_page.py` |
| Section schemas | `app/modules/cms/schemas/homepage_sections.py` |
| Widget contracts | `app/modules/contracts/widgets.py` |
| Module base | `app/modules/base.py` (MenuItemDefinition, ModuleDefinition) |
| Storefront base | `app/templates/storefront/base.html` |
| Store theme model | `app/modules/cms/models/store_theme.py` |
| Menu discovery | `app/modules/core/services/menu_discovery_service.py` |
| Widget aggregator | `app/modules/core/services/widget_aggregator.py` |
| Component library | `app/modules/dev_tools/templates/dev_tools/admin/components.html` |
| Seed data | `scripts/seed/create_default_content_pages.py` |
| Storefront routes | `app/modules/cms/routes/pages/storefront.py` |
| Admin section API | `app/modules/cms/routes/api/admin_content_pages.py` |

20
main.py
View File

@@ -463,10 +463,28 @@ for route_info in store_page_routes:
# Customer shop pages - Register at TWO prefixes:
# 1. /storefront/* (for prod: subdomain/custom domain, after path rewrite by middleware)
# 2. /storefront/{store_code}/* (for dev: path-based, after /platforms/{code}/ strip)
#
# Non-core module routes get a module-gate dependency injected automatically
# so disabled modules return 404 without any hardcoded path map.
logger.info("Auto-discovering storefront page routes...")
storefront_page_routes = get_storefront_page_routes()
logger.info(f" Found {len(storefront_page_routes)} storefront page route modules")
def _storefront_deps_for(route_info):
"""Build dependency list for a storefront route — gates non-core modules."""
from app.api.deps import make_storefront_module_gate
from app.modules.registry import MODULES
code = route_info.module_code
if not code:
return []
module_def = MODULES.get(code)
if module_def and module_def.is_core:
return []
return [Depends(make_storefront_module_gate(code))]
# Register at /storefront/* (prod mode — middleware rewrites /products → /storefront/products)
logger.info(" Registering storefront routes at /storefront/*")
for route_info in storefront_page_routes:
@@ -477,6 +495,7 @@ for route_info in storefront_page_routes:
prefix=prefix,
tags=["storefront-pages"],
include_in_schema=False,
dependencies=_storefront_deps_for(route_info),
)
# Register at /storefront/{store_code}/* (dev mode — /platforms/oms/storefront/WIZATECH/...)
@@ -488,6 +507,7 @@ for route_info in storefront_page_routes:
prefix=prefix,
tags=["storefront-pages"],
include_in_schema=False,
dependencies=_storefront_deps_for(route_info),
)

View File

@@ -2097,6 +2097,49 @@ SHARED_PLATFORM_PAGES = [
# ============================================================================
STORE_DEFAULT_HOME = {
"slug": "home",
"title": "Welcome to {{store_name}}",
"title_translations": tt(
"Welcome to {{store_name}}",
"Bienvenue chez {{store_name}}",
"Willkommen bei {{store_name}}",
"Wëllkomm bei {{store_name}}",
),
"content": """<div class="prose-content">
<h2>Welcome</h2>
<p>{{store_name}} is here to serve you. Browse our offerings and discover what we have for you.</p>
</div>""",
"content_translations": tt(
# English
"""<div class="prose-content">
<h2>Welcome</h2>
<p>{{store_name}} is here to serve you. Browse our offerings and discover what we have for you.</p>
</div>""",
# French
"""<div class="prose-content">
<h2>Bienvenue</h2>
<p>{{store_name}} est là pour vous servir. Découvrez nos offres et ce que nous avons pour vous.</p>
</div>""",
# German
"""<div class="prose-content">
<h2>Willkommen</h2>
<p>{{store_name}} ist für Sie da. Entdecken Sie unser Angebot.</p>
</div>""",
# Luxembourgish
"""<div class="prose-content">
<h2>Wëllkomm</h2>
<p>{{store_name}} ass fir Iech do. Entdeckt eist Angebot.</p>
</div>""",
),
"template": "default",
"meta_description": "Welcome to {{store_name}}",
"show_in_header": False,
"show_in_footer": False,
"display_order": 0,
}
STORE_DEFAULTS_COMMON = [
{
"slug": "about",
@@ -2753,6 +2796,12 @@ def create_default_pages(db: Session) -> None:
# Only for platforms that host stores (not wizard.lu main)
# ------------------------------------------------------------------
if platform.code != "main":
# Store homepage (slug="home")
if _create_page(db, platform.id, STORE_DEFAULT_HOME, is_platform_page=False):
created_count += 1
else:
skipped_count += 1
for page_data in STORE_DEFAULTS_COMMON:
if _create_page(db, platform.id, page_data, is_platform_page=False):
created_count += 1

View File

@@ -118,7 +118,7 @@ const Icons = {
'color-swatch': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"/></svg>`,
// Location
'location-marker': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>`,
'map-pin': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>`,
'globe': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>`,
'globe-alt': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/></svg>`,