feat(storefront): homepage, module gating, widget protocol, i18n fixes
Some checks failed
Some checks failed
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,5 +44,10 @@
|
||||
"view_desc": "Clientekuerf kucken",
|
||||
"manage": "Kuerf verwalten",
|
||||
"manage_desc": "Clientekuerf änneren a verwalten"
|
||||
},
|
||||
"storefront": {
|
||||
"actions": {
|
||||
"cart": "Kuerf"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,5 +107,13 @@
|
||||
"menu": {
|
||||
"products_inventory": "Products & Inventory",
|
||||
"all_products": "All Products"
|
||||
},
|
||||
"storefront": {
|
||||
"nav": {
|
||||
"products": "Products"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Search"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 #}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,5 +52,13 @@
|
||||
"customers_section": "Customers",
|
||||
"customers": "Customers",
|
||||
"all_customers": "All Customers"
|
||||
},
|
||||
"storefront": {
|
||||
"account": {
|
||||
"dashboard": "Dashboard",
|
||||
"profile": "Profile",
|
||||
"addresses": "Addresses",
|
||||
"settings": "Settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
81
app/modules/loyalty/services/loyalty_widgets.py
Normal file
81
app/modules/loyalty/services/loyalty_widgets.py
Normal 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()
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,5 +89,10 @@
|
||||
"store_operations": "Store Operations",
|
||||
"sales_orders": "Sales & Orders",
|
||||
"orders": "Orders"
|
||||
},
|
||||
"storefront": {
|
||||
"account": {
|
||||
"orders": "Orders"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
80
app/modules/orders/services/order_widgets.py
Normal file
80
app/modules/orders/services/order_widgets.py
Normal 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()
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
256
docs/proposals/storefront-builder-vision.md
Normal file
256
docs/proposals/storefront-builder-vision.md
Normal 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
20
main.py
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>`,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user