From 3044490a3eab63b4fa85e1efd8820f05cf7af035 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Tue, 14 Apr 2026 23:33:06 +0200 Subject: [PATCH] feat(storefront): section-based homepages, header action partials, fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/modules/base.py | 1 + app/modules/cart/definition.py | 1 + .../cart/storefront/partials/header-cart.html | 10 + app/modules/catalog/definition.py | 1 + .../storefront/partials/header-search.html | 5 + app/modules/cms/routes/pages/storefront.py | 10 +- .../cms/services/content_page_service.py | 23 ++ .../cms/services/store_theme_service.py | 2 +- .../cms/storefront/landing-full.html | 15 +- .../core/services/menu_discovery_service.py | 2 + app/templates/storefront/base.html | 39 +- docs/proposals/cms-redesign-alignment.md | 253 +++++++++++++ scripts/seed/create_default_content_pages.py | 352 ++++++++++++++++-- 13 files changed, 641 insertions(+), 73 deletions(-) create mode 100644 app/modules/cart/templates/cart/storefront/partials/header-cart.html create mode 100644 app/modules/catalog/templates/catalog/storefront/partials/header-search.html create mode 100644 docs/proposals/cms-redesign-alignment.md diff --git a/app/modules/base.py b/app/modules/base.py index b1552532..e92b952e 100644 --- a/app/modules/base.py +++ b/app/modules/base.py @@ -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 diff --git a/app/modules/cart/definition.py b/app/modules/cart/definition.py index 795f3a37..92097c61 100644 --- a/app/modules/cart/definition.py +++ b/app/modules/cart/definition.py @@ -72,6 +72,7 @@ cart_module = ModuleDefinition( icon="shopping-cart", route="cart", order=20, + header_template="cart/storefront/partials/header-cart.html", ), ], ), diff --git a/app/modules/cart/templates/cart/storefront/partials/header-cart.html b/app/modules/cart/templates/cart/storefront/partials/header-cart.html new file mode 100644 index 00000000..1d026b19 --- /dev/null +++ b/app/modules/cart/templates/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 #} + + + + + diff --git a/app/modules/catalog/definition.py b/app/modules/catalog/definition.py index 77f23529..c95d3c38 100644 --- a/app/modules/catalog/definition.py +++ b/app/modules/catalog/definition.py @@ -152,6 +152,7 @@ catalog_module = ModuleDefinition( icon="search", route="", order=10, + header_template="catalog/storefront/partials/header-search.html", ), ], ), diff --git a/app/modules/catalog/templates/catalog/storefront/partials/header-search.html b/app/modules/catalog/templates/catalog/storefront/partials/header-search.html new file mode 100644 index 00000000..8818e0c0 --- /dev/null +++ b/app/modules/catalog/templates/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 #} + diff --git a/app/modules/cms/routes/pages/storefront.py b/app/modules/cms/routes/pages/storefront.py index fd8c734e..1808a289 100644 --- a/app/modules/cms/routes/pages/storefront.py +++ b/app/modules/cms/routes/pages/storefront.py @@ -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 = { diff --git a/app/modules/cms/services/content_page_service.py b/app/modules/cms/services/content_page_service.py index 4757f73b..ac6c4f1d 100644 --- a/app/modules/cms/services/content_page_service.py +++ b/app/modules/cms/services/content_page_service.py @@ -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 # ========================================================================= diff --git a/app/modules/cms/services/store_theme_service.py b/app/modules/cms/services/store_theme_service.py index 2e34fc85..cbcf759f 100644 --- a/app/modules/cms/services/store_theme_service.py +++ b/app/modules/cms/services/store_theme_service.py @@ -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}") diff --git a/app/modules/cms/templates/cms/storefront/landing-full.html b/app/modules/cms/templates/cms/storefront/landing-full.html index 8b1fff14..dbea2f23 100644 --- a/app/modules/cms/templates/cms/storefront/landing-full.html +++ b/app/modules/cms/templates/cms/storefront/landing-full.html @@ -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' %}
- {% 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 %}
{% else %} diff --git a/app/modules/core/services/menu_discovery_service.py b/app/modules/core/services/menu_discovery_service.py index afd02a4c..fe775bc5 100644 --- a/app/modules/core/services/menu_discovery_service.py +++ b/app/modules/core/services/menu_discovery_service.py @@ -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) diff --git a/app/templates/storefront/base.html b/app/templates/storefront/base.html index 6d98ed52..9b3ed4d7 100644 --- a/app/templates/storefront/base.html +++ b/app/templates/storefront/base.html @@ -105,36 +105,17 @@ {% endfor %} - {# Right side actions #} + {# Right side actions — module-provided via header_template #}
- - {# 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 #} - - {% endif %} - - {% if 'cart' in action_ids %} - {# Cart #} - - - - - - - - {% endif %}{# cart #} + {% for item in storefront_nav.get('actions', []) %} + {% if item.header_template %} + {% include item.header_template %} + {% else %} + + + + {% endif %} + {% endfor %} {# Theme toggle #}
""", - "content_translations": tt( - # English - """
-

Welcome

-

{{store_name}} is here to serve you. Browse our offerings and discover what we have for you.

-
""", - # French - """
-

Bienvenue

-

{{store_name}} est là pour vous servir. Découvrez nos offres et ce que nous avons pour vous.

-
""", - # German - """
-

Willkommen

-

{{store_name}} ist für Sie da. Entdecken Sie unser Angebot.

-
""", - # Luxembourgish - """
-

Wëllkomm

-

{{store_name}} ass fir Iech do. Entdeckt eist Angebot.

-
""", - ), - "template": "default", - "meta_description": "Welcome to {{store_name}}", + "content": "", + "template": "full", + "meta_description": "{{store_name}}", "show_in_header": False, "show_in_footer": False, "display_order": 0, } +def _store_homepage_sections_oms() -> dict: + """Store homepage sections for OMS platform stores.""" + return { + "hero": { + "enabled": True, + "title": t( + "Bienvenue chez {{store_name}}", + "Welcome to {{store_name}}", + "Willkommen bei {{store_name}}", + ), + "subtitle": t( + "Découvrez notre sélection de produits et profitez d'une expérience d'achat exceptionnelle.", + "Discover our product selection and enjoy an exceptional shopping experience.", + "Entdecken Sie unsere Produktauswahl und genießen Sie ein außergewöhnliches Einkaufserlebnis.", + ), + "background_type": "gradient", + "buttons": [ + { + "text": t("Voir nos produits", "Browse Products", "Produkte ansehen"), + "url": "products", + "style": "primary", + }, + { + "text": t("À propos", "About Us", "Über uns"), + "url": "about", + "style": "secondary", + }, + ], + }, + "features": { + "enabled": True, + "title": t( + "Pourquoi nous choisir", + "Why Choose Us", + "Warum uns wählen", + ), + "subtitle": t( + "Ce qui nous distingue", + "What sets us apart", + "Was uns auszeichnet", + ), + "layout": "grid", + "features": [ + { + "icon": "check", + "title": t("Qualité premium", "Premium Quality", "Premium-Qualität"), + "description": t( + "Des produits soigneusement sélectionnés pour vous.", + "Carefully selected products just for you.", + "Sorgfältig ausgewählte Produkte für Sie.", + ), + }, + { + "icon": "truck", + "title": t("Livraison rapide", "Fast Shipping", "Schnelle Lieferung"), + "description": t( + "Livraison rapide directement chez vous.", + "Quick delivery right to your door.", + "Schnelle Lieferung direkt zu Ihnen.", + ), + }, + { + "icon": "shield-check", + "title": t("Paiement sécurisé", "Secure Payment", "Sichere Zahlung"), + "description": t( + "Vos transactions sont protégées à 100%.", + "Your transactions are 100% protected.", + "Ihre Transaktionen sind 100% geschützt.", + ), + }, + { + "icon": "chat-bubble-left", + "title": t("Support client", "Customer Support", "Kundensupport"), + "description": t( + "Une équipe à votre écoute pour vous accompagner.", + "A dedicated team ready to assist you.", + "Ein engagiertes Team, das Ihnen zur Seite steht.", + ), + }, + ], + }, + "cta": { + "enabled": True, + "title": t( + "Prêt à découvrir nos produits ?", + "Ready to Explore Our Products?", + "Bereit, unsere Produkte zu entdecken?", + ), + "subtitle": t( + "Parcourez notre catalogue et trouvez ce qui vous convient.", + "Browse our catalog and find what suits you.", + "Durchstöbern Sie unseren Katalog und finden Sie, was zu Ihnen passt.", + ), + "background_type": "gradient", + "buttons": [ + { + "text": t("Voir les produits", "View Products", "Produkte ansehen"), + "url": "products", + "style": "primary", + }, + ], + }, + } + + +def _store_homepage_sections_loyalty() -> dict: + """Store homepage sections for Loyalty platform stores.""" + return { + "hero": { + "enabled": True, + "title": t( + "Bienvenue chez {{store_name}}", + "Welcome to {{store_name}}", + "Willkommen bei {{store_name}}", + ), + "subtitle": t( + "Rejoignez notre programme de fidélité et profitez de récompenses exclusives à chaque visite.", + "Join our loyalty program and enjoy exclusive rewards with every visit.", + "Treten Sie unserem Treueprogramm bei und genießen Sie exklusive Prämien bei jedem Besuch.", + ), + "background_type": "gradient", + "buttons": [ + { + "text": t("Rejoindre le programme", "Join Rewards", "Programm beitreten"), + "url": "account/loyalty", + "style": "primary", + }, + { + "text": t("En savoir plus", "Learn More", "Mehr erfahren"), + "url": "about", + "style": "secondary", + }, + ], + }, + "features": { + "enabled": True, + "title": t( + "Votre fidélité récompensée", + "Your Loyalty Rewarded", + "Ihre Treue wird belohnt", + ), + "subtitle": t( + "Découvrez les avantages de notre programme", + "Discover the benefits of our program", + "Entdecken Sie die Vorteile unseres Programms", + ), + "layout": "grid", + "features": [ + { + "icon": "star", + "title": t("Gagnez des points", "Earn Points", "Punkte sammeln"), + "description": t( + "Cumulez des points à chaque achat et échangez-les contre des récompenses.", + "Accumulate points with every purchase and redeem them for rewards.", + "Sammeln Sie bei jedem Einkauf Punkte und lösen Sie sie gegen Prämien ein.", + ), + }, + { + "icon": "gift", + "title": t("Récompenses exclusives", "Exclusive Rewards", "Exklusive Prämien"), + "description": t( + "Accédez à des offres et récompenses réservées aux membres.", + "Access offers and rewards reserved for members.", + "Zugang zu Angeboten und Prämien nur für Mitglieder.", + ), + }, + { + "icon": "heart", + "title": t("Avantages membres", "Member Benefits", "Mitgliedervorteile"), + "description": t( + "Profitez d'avantages exclusifs en tant que membre fidèle.", + "Enjoy exclusive benefits as a loyal member.", + "Genießen Sie exklusive Vorteile als treues Mitglied.", + ), + }, + ], + }, + "cta": { + "enabled": True, + "title": t( + "Rejoignez-nous aujourd'hui", + "Join Us Today", + "Treten Sie uns noch heute bei", + ), + "subtitle": t( + "Inscrivez-vous à notre programme de fidélité et commencez à gagner des récompenses.", + "Sign up for our loyalty program and start earning rewards.", + "Melden Sie sich für unser Treueprogramm an und beginnen Sie, Prämien zu verdienen.", + ), + "background_type": "gradient", + "buttons": [ + { + "text": t("S'inscrire", "Sign Up", "Anmelden"), + "url": "account/loyalty", + "style": "primary", + }, + ], + }, + } + + +def _store_homepage_sections_hosting() -> dict: + """Store homepage sections for Hosting platform stores (client sites).""" + return { + "hero": { + "enabled": True, + "title": t( + "Bienvenue chez {{store_name}}", + "Welcome to {{store_name}}", + "Willkommen bei {{store_name}}", + ), + "subtitle": t( + "Votre partenaire de confiance pour des solutions numériques sur mesure.", + "Your trusted partner for tailored digital solutions.", + "Ihr vertrauenswürdiger Partner für maßgeschneiderte digitale Lösungen.", + ), + "background_type": "gradient", + "buttons": [ + { + "text": t("Nous contacter", "Contact Us", "Kontaktieren Sie uns"), + "url": "contact", + "style": "primary", + }, + { + "text": t("À propos", "About Us", "Über uns"), + "url": "about", + "style": "secondary", + }, + ], + }, + "features": { + "enabled": True, + "title": t( + "Nos services", + "Our Services", + "Unsere Dienstleistungen", + ), + "subtitle": t( + "Ce que nous pouvons faire pour vous", + "What we can do for you", + "Was wir für Sie tun können", + ), + "layout": "grid", + "features": [ + { + "icon": "globe-alt", + "title": t("Site web", "Website", "Webseite"), + "description": t( + "Un site web professionnel qui reflète votre activité.", + "A professional website that reflects your business.", + "Eine professionelle Website, die Ihr Unternehmen widerspiegelt.", + ), + }, + { + "icon": "shield-check", + "title": t("Hébergement sécurisé", "Secure Hosting", "Sicheres Hosting"), + "description": t( + "Hébergement fiable avec certificat SSL inclus.", + "Reliable hosting with SSL certificate included.", + "Zuverlässiges Hosting mit SSL-Zertifikat inklusive.", + ), + }, + { + "icon": "mail", + "title": t("Email professionnel", "Professional Email", "Professionelle E-Mail"), + "description": t( + "Adresses email personnalisées pour votre entreprise.", + "Custom email addresses for your business.", + "Individuelle E-Mail-Adressen für Ihr Unternehmen.", + ), + }, + ], + }, + "cta": { + "enabled": True, + "title": t( + "Besoin d'aide ?", + "Need Help?", + "Brauchen Sie Hilfe?", + ), + "subtitle": t( + "Contactez-nous pour discuter de votre projet.", + "Contact us to discuss your project.", + "Kontaktieren Sie uns, um Ihr Projekt zu besprechen.", + ), + "background_type": "gradient", + "buttons": [ + { + "text": t("Nous contacter", "Contact Us", "Kontaktieren Sie uns"), + "url": "contact", + "style": "primary", + }, + ], + }, + } + + STORE_DEFAULTS_COMMON = [ { "slug": "about", @@ -2796,8 +3068,18 @@ 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): + # Store homepage (slug="home") with platform-specific sections + store_sections_map = { + "oms": _store_homepage_sections_oms, + "loyalty": _store_homepage_sections_loyalty, + "hosting": _store_homepage_sections_hosting, + } + store_sections_fn = store_sections_map.get(platform.code) + store_sections = store_sections_fn() if store_sections_fn else None + if _create_page( + db, platform.id, STORE_DEFAULT_HOME, + is_platform_page=False, sections=store_sections, + ): created_count += 1 else: skipped_count += 1