feat(storefront): section-based homepages, header action partials, fixes
Phase 1 — Section-based store homepages:
- Store defaults use template="full" with per-platform sections JSON
- OMS: shop hero + features + CTA; Loyalty: rewards hero + features + CTA
- Hosting: services hero + features + CTA
- Deep placeholder resolution for {{store_name}} inside sections JSON
- landing-full.html uses resolved page_sections from context
Phase 2 — Module-contributed header actions:
- header_template field on MenuItemDefinition + DiscoveredMenuItem
- Catalog provides header-search.html partial
- Cart provides header-cart.html partial with badge
- Base template iterates storefront_nav.actions with {% include %}
- Generic icon fallback for actions without a template
Fixes:
- Store theme API: get_store_by_code → get_store_by_code_or_subdomain
Docs:
- CMS redesign proposal: menu restructure, page types, translations UI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -95,6 +95,7 @@ class MenuItemDefinition:
|
||||
requires_permission: str | None = None
|
||||
badge_source: str | None = None
|
||||
is_super_admin_only: bool = False
|
||||
header_template: str | None = None # Optional partial for custom header rendering
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -72,6 +72,7 @@ cart_module = ModuleDefinition(
|
||||
icon="shopping-cart",
|
||||
route="cart",
|
||||
order=20,
|
||||
header_template="cart/storefront/partials/header-cart.html",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{# cart/storefront/partials/header-cart.html #}
|
||||
{# Cart icon with badge for storefront header — provided by cart module #}
|
||||
<a href="{{ base_url }}cart" class="relative p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<span class="w-5 h-5" x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
|
||||
<span x-show="cartCount > 0"
|
||||
x-text="cartCount"
|
||||
class="absolute -top-1 -right-1 bg-accent text-white text-xs rounded-full h-5 w-5 flex items-center justify-center"
|
||||
style="background-color: var(--color-accent)">
|
||||
</span>
|
||||
</a>
|
||||
@@ -152,6 +152,7 @@ catalog_module = ModuleDefinition(
|
||||
icon="search",
|
||||
route="",
|
||||
order=10,
|
||||
header_template="catalog/storefront/partials/header-search.html",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{# catalog/storefront/partials/header-search.html #}
|
||||
{# Search button for storefront header — provided by catalog module #}
|
||||
<button @click="openSearch()" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<span class="w-5 h-5" x-html="$icon('search', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
@@ -60,12 +60,14 @@ async def storefront_homepage(
|
||||
include_unpublished=False,
|
||||
)
|
||||
|
||||
# Resolve placeholders for store default pages (title + content)
|
||||
# Resolve placeholders for store default pages (title, content, sections)
|
||||
page_content = None
|
||||
page_title = None
|
||||
page_sections = None
|
||||
if page:
|
||||
page_content = page.content
|
||||
page_title = page.title
|
||||
page_sections = page.sections
|
||||
if page.is_store_default and store:
|
||||
page_content = content_page_service.resolve_placeholders(
|
||||
page.content, store
|
||||
@@ -73,12 +75,18 @@ async def storefront_homepage(
|
||||
page_title = content_page_service.resolve_placeholders(
|
||||
page.title, store
|
||||
)
|
||||
if page_sections:
|
||||
page_sections = content_page_service.resolve_placeholders_deep(
|
||||
page_sections, 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
|
||||
if page_sections:
|
||||
context["page_sections"] = page_sections
|
||||
|
||||
# Select template based on page.template field (or default)
|
||||
template_map = {
|
||||
|
||||
@@ -24,6 +24,7 @@ Lookup Strategy for Store Storefronts:
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -991,6 +992,28 @@ class ContentPageService:
|
||||
content = content.replace(placeholder, value)
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def resolve_placeholders_deep(data, store) -> Any:
|
||||
"""
|
||||
Recursively resolve {{store_name}} etc. in a nested data structure
|
||||
(dicts, lists, strings). Used for sections JSON in store default pages.
|
||||
"""
|
||||
if not data or not store:
|
||||
return data
|
||||
if isinstance(data, str):
|
||||
return ContentPageService.resolve_placeholders(data, store)
|
||||
if isinstance(data, dict):
|
||||
return {
|
||||
k: ContentPageService.resolve_placeholders_deep(v, store)
|
||||
for k, v in data.items()
|
||||
}
|
||||
if isinstance(data, list):
|
||||
return [
|
||||
ContentPageService.resolve_placeholders_deep(item, store)
|
||||
for item in data
|
||||
]
|
||||
return data
|
||||
|
||||
# =========================================================================
|
||||
# Homepage Sections Management
|
||||
# =========================================================================
|
||||
|
||||
@@ -70,7 +70,7 @@ class StoreThemeService:
|
||||
"""
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
|
||||
store = store_service.get_store_by_code(db, store_code)
|
||||
store = store_service.get_store_by_code_or_subdomain(db, store_code)
|
||||
|
||||
if not store:
|
||||
self.logger.warning(f"Store not found: {store_code}")
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
{# SECTION-BASED RENDERING (when page.sections is configured) #}
|
||||
{# Used by POC builder templates — takes priority over hardcoded HTML #}
|
||||
{# ═══════════════════════════════════════════════════════════════════ #}
|
||||
{% if page and page.sections %}
|
||||
{% set sections = page_sections if page_sections is defined and page_sections else (page.sections if page else none) %}
|
||||
{% if sections %}
|
||||
{% from 'cms/platform/sections/_hero.html' import render_hero %}
|
||||
{% from 'cms/platform/sections/_features.html' import render_features %}
|
||||
{% from 'cms/platform/sections/_testimonials.html' import render_testimonials %}
|
||||
@@ -26,12 +27,12 @@
|
||||
{% set default_lang = 'fr' %}
|
||||
|
||||
<div class="min-h-screen">
|
||||
{% if page.sections.hero %}{{ render_hero(page.sections.hero, lang, default_lang) }}{% endif %}
|
||||
{% if page.sections.features %}{{ render_features(page.sections.features, lang, default_lang) }}{% endif %}
|
||||
{% if page.sections.testimonials %}{{ render_testimonials(page.sections.testimonials, lang, default_lang) }}{% endif %}
|
||||
{% if page.sections.gallery %}{{ render_gallery(page.sections.gallery, lang, default_lang) }}{% endif %}
|
||||
{% if page.sections.contact_info %}{{ render_contact_info(page.sections.contact_info, lang, default_lang) }}{% endif %}
|
||||
{% if page.sections.cta %}{{ render_cta(page.sections.cta, lang, default_lang) }}{% endif %}
|
||||
{% if sections.hero %}{{ render_hero(sections.hero, lang, default_lang) }}{% endif %}
|
||||
{% if sections.features %}{{ render_features(sections.features, lang, default_lang) }}{% endif %}
|
||||
{% if sections.testimonials %}{{ render_testimonials(sections.testimonials, lang, default_lang) }}{% endif %}
|
||||
{% if sections.gallery %}{{ render_gallery(sections.gallery, lang, default_lang) }}{% endif %}
|
||||
{% if sections.contact_info %}{{ render_contact_info(sections.contact_info, lang, default_lang) }}{% endif %}
|
||||
{% if sections.cta %}{{ render_cta(sections.cta, lang, default_lang) }}{% endif %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
|
||||
@@ -67,6 +67,7 @@ class DiscoveredMenuItem:
|
||||
section_order: int
|
||||
is_visible: bool = True
|
||||
is_module_enabled: bool = True
|
||||
header_template: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -191,6 +192,7 @@ class MenuDiscoveryService:
|
||||
section_label_key=section.label_key,
|
||||
section_order=section.order,
|
||||
is_module_enabled=is_module_enabled,
|
||||
header_template=item.header_template,
|
||||
)
|
||||
sections_map[section.id].items.append(discovered_item)
|
||||
|
||||
|
||||
@@ -105,36 +105,17 @@
|
||||
{% endfor %}
|
||||
</nav>
|
||||
|
||||
{# Right side actions #}
|
||||
{# Right side actions — module-provided via header_template #}
|
||||
<div class="flex items-center space-x-4">
|
||||
|
||||
{# 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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% 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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"></path>
|
||||
</svg>
|
||||
<span x-show="cartCount > 0"
|
||||
x-text="cartCount"
|
||||
class="absolute -top-1 -right-1 bg-accent text-white text-xs rounded-full h-5 w-5 flex items-center justify-center"
|
||||
style="background-color: var(--color-accent)">
|
||||
</span>
|
||||
</a>
|
||||
{% endif %}{# cart #}
|
||||
{% for item in storefront_nav.get('actions', []) %}
|
||||
{% if item.header_template %}
|
||||
{% include item.header_template %}
|
||||
{% else %}
|
||||
<a href="{{ base_url }}{{ item.route }}" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700" title="{{ _(item.label_key) }}">
|
||||
<span class="w-5 h-5" x-html="$icon('{{ item.icon }}', 'w-5 h-5')"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{# Theme toggle #}
|
||||
<button @click="toggleTheme()"
|
||||
|
||||
Reference in New Issue
Block a user