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

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

@@ -1,48 +1,53 @@
{
"title": "Shopping Cart",
"description": "Shopping cart management for customers",
"cart": {
"title": "Your Cart",
"empty": "Your cart is empty",
"empty_subtitle": "Add items to start shopping",
"continue_shopping": "Continue Shopping",
"proceed_to_checkout": "Proceed to Checkout"
},
"item": {
"product": "Product",
"quantity": "Quantity",
"price": "Price",
"total": "Total",
"remove": "Remove",
"update": "Update"
},
"summary": {
"title": "Order Summary",
"subtotal": "Subtotal",
"shipping": "Shipping",
"estimated_shipping": "Calculated at checkout",
"tax": "Tax",
"total": "Total"
},
"validation": {
"invalid_quantity": "Invalid quantity",
"min_quantity": "Minimum quantity is {min}",
"max_quantity": "Maximum quantity is {max}",
"insufficient_inventory": "Only {available} available"
},
"permissions": {
"view": "View Carts",
"view_desc": "View customer shopping carts",
"manage": "Manage Carts",
"manage_desc": "Modify and manage customer carts"
},
"messages": {
"item_added": "Item added to cart",
"item_updated": "Cart updated",
"item_removed": "Item removed from cart",
"cart_cleared": "Cart cleared",
"product_not_available": "Product not available",
"error_adding": "Error adding item to cart",
"error_updating": "Error updating cart"
}
"title": "Shopping Cart",
"description": "Shopping cart management for customers",
"cart": {
"title": "Your Cart",
"empty": "Your cart is empty",
"empty_subtitle": "Add items to start shopping",
"continue_shopping": "Continue Shopping",
"proceed_to_checkout": "Proceed to Checkout"
},
"item": {
"product": "Product",
"quantity": "Quantity",
"price": "Price",
"total": "Total",
"remove": "Remove",
"update": "Update"
},
"summary": {
"title": "Order Summary",
"subtotal": "Subtotal",
"shipping": "Shipping",
"estimated_shipping": "Calculated at checkout",
"tax": "Tax",
"total": "Total"
},
"validation": {
"invalid_quantity": "Invalid quantity",
"min_quantity": "Minimum quantity is {min}",
"max_quantity": "Maximum quantity is {max}",
"insufficient_inventory": "Only {available} available"
},
"permissions": {
"view": "View Carts",
"view_desc": "View customer shopping carts",
"manage": "Manage Carts",
"manage_desc": "Modify and manage customer carts"
},
"messages": {
"item_added": "Item added to cart",
"item_updated": "Cart updated",
"item_removed": "Item removed from cart",
"cart_cleared": "Cart cleared",
"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,
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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