Compare commits

...

2 Commits

Author SHA1 Message Date
64fe58c171 fix(loyalty): normalize card id field, fix terminal redeem bug
Some checks failed
CI / ruff (push) Successful in 15s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
The terminal redeem failed with "card not found: unknown" because
CardLookupResponse used card_id while CardDetailResponse (from
refreshCard) used id. After refresh, selectedCard.card_id was
undefined.

Fix: standardize on 'id' everywhere (the universal convention):
- CardLookupResponse: card_id → id
- _build_card_lookup_response: card_id= → id=
- loyalty-terminal.js: selectedCard.card_id → selectedCard.id (5 refs)
- Removed the card_id/model_validator approach as unnecessary

Also fixes Chart.js recursion error on analytics page (inline CDN
script instead of optional-libs.html include which caused infinite
template recursion in test context).

342 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:01:26 +02:00
3044490a3e 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>
2026-04-14 23:33:06 +02:00
17 changed files with 649 additions and 82 deletions

View File

@@ -95,6 +95,7 @@ class MenuItemDefinition:
requires_permission: str | None = None requires_permission: str | None = None
badge_source: str | None = None badge_source: str | None = None
is_super_admin_only: bool = False is_super_admin_only: bool = False
header_template: str | None = None # Optional partial for custom header rendering
@dataclass @dataclass

View File

@@ -72,6 +72,7 @@ cart_module = ModuleDefinition(
icon="shopping-cart", icon="shopping-cart",
route="cart", route="cart",
order=20, order=20,
header_template="cart/storefront/partials/header-cart.html",
), ),
], ],
), ),

View File

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

View File

@@ -152,6 +152,7 @@ catalog_module = ModuleDefinition(
icon="search", icon="search",
route="", route="",
order=10, order=10,
header_template="catalog/storefront/partials/header-search.html",
), ),
], ],
), ),

View File

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

View File

@@ -60,12 +60,14 @@ async def storefront_homepage(
include_unpublished=False, include_unpublished=False,
) )
# Resolve placeholders for store default pages (title + content) # Resolve placeholders for store default pages (title, content, sections)
page_content = None page_content = None
page_title = None page_title = None
page_sections = None
if page: if page:
page_content = page.content page_content = page.content
page_title = page.title page_title = page.title
page_sections = page.sections
if page.is_store_default and store: if page.is_store_default and store:
page_content = content_page_service.resolve_placeholders( page_content = content_page_service.resolve_placeholders(
page.content, store page.content, store
@@ -73,12 +75,18 @@ async def storefront_homepage(
page_title = content_page_service.resolve_placeholders( page_title = content_page_service.resolve_placeholders(
page.title, store 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) context = get_storefront_context(request, db=db, page=page)
if page_content: if page_content:
context["page_content"] = page_content context["page_content"] = page_content
if page_title: if page_title:
context["page_title"] = page_title context["page_title"] = page_title
if page_sections:
context["page_sections"] = page_sections
# Select template based on page.template field (or default) # Select template based on page.template field (or default)
template_map = { template_map = {

View File

@@ -24,6 +24,7 @@ Lookup Strategy for Store Storefronts:
import logging import logging
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any
from sqlalchemy import and_ from sqlalchemy import and_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -991,6 +992,28 @@ class ContentPageService:
content = content.replace(placeholder, value) content = content.replace(placeholder, value)
return content 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 # Homepage Sections Management
# ========================================================================= # =========================================================================

View File

@@ -70,7 +70,7 @@ class StoreThemeService:
""" """
from app.modules.tenancy.services.store_service import store_service 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: if not store:
self.logger.warning(f"Store not found: {store_code}") self.logger.warning(f"Store not found: {store_code}")

View File

@@ -14,7 +14,8 @@
{# SECTION-BASED RENDERING (when page.sections is configured) #} {# SECTION-BASED RENDERING (when page.sections is configured) #}
{# Used by POC builder templates — takes priority over hardcoded HTML #} {# 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/_hero.html' import render_hero %}
{% from 'cms/platform/sections/_features.html' import render_features %} {% from 'cms/platform/sections/_features.html' import render_features %}
{% from 'cms/platform/sections/_testimonials.html' import render_testimonials %} {% from 'cms/platform/sections/_testimonials.html' import render_testimonials %}
@@ -26,12 +27,12 @@
{% set default_lang = 'fr' %} {% set default_lang = 'fr' %}
<div class="min-h-screen"> <div class="min-h-screen">
{% if page.sections.hero %}{{ render_hero(page.sections.hero, lang, default_lang) }}{% endif %} {% if sections.hero %}{{ render_hero(sections.hero, lang, default_lang) }}{% endif %}
{% if page.sections.features %}{{ render_features(page.sections.features, lang, default_lang) }}{% endif %} {% if sections.features %}{{ render_features(sections.features, lang, default_lang) }}{% endif %}
{% if page.sections.testimonials %}{{ render_testimonials(page.sections.testimonials, lang, default_lang) }}{% endif %} {% if sections.testimonials %}{{ render_testimonials(sections.testimonials, lang, default_lang) }}{% endif %}
{% if page.sections.gallery %}{{ render_gallery(page.sections.gallery, lang, default_lang) }}{% endif %} {% if sections.gallery %}{{ render_gallery(sections.gallery, lang, default_lang) }}{% endif %}
{% if page.sections.contact_info %}{{ render_contact_info(page.sections.contact_info, lang, default_lang) }}{% endif %} {% if sections.contact_info %}{{ render_contact_info(sections.contact_info, lang, default_lang) }}{% endif %}
{% if page.sections.cta %}{{ render_cta(page.sections.cta, lang, default_lang) }}{% endif %} {% if sections.cta %}{{ render_cta(sections.cta, lang, default_lang) }}{% endif %}
</div> </div>
{% else %} {% else %}

View File

@@ -67,6 +67,7 @@ class DiscoveredMenuItem:
section_order: int section_order: int
is_visible: bool = True is_visible: bool = True
is_module_enabled: bool = True is_module_enabled: bool = True
header_template: str | None = None
@dataclass @dataclass
@@ -191,6 +192,7 @@ class MenuDiscoveryService:
section_label_key=section.label_key, section_label_key=section.label_key,
section_order=section.order, section_order=section.order,
is_module_enabled=is_module_enabled, is_module_enabled=is_module_enabled,
header_template=item.header_template,
) )
sections_map[section.id].items.append(discovered_item) sections_map[section.id].items.append(discovered_item)

View File

@@ -420,7 +420,7 @@ def _build_card_lookup_response(card, db=None) -> CardLookupResponse:
available_rewards.append(reward) available_rewards.append(reward)
return CardLookupResponse( return CardLookupResponse(
card_id=card.id, id=card.id,
card_number=card.card_number, card_number=card.card_number,
customer_id=card.customer_id, customer_id=card.customer_id,
customer_name=card.customer.full_name if card.customer else None, customer_name=card.customer.full_name if card.customer else None,

View File

@@ -128,7 +128,7 @@ class CardLookupResponse(BaseModel):
"""Schema for card lookup by QR code or card number.""" """Schema for card lookup by QR code or card number."""
# Card info # Card info
card_id: int id: int
card_number: str card_number: str
# Customer # Customer

View File

@@ -284,7 +284,7 @@ function storeLoyaltyTerminal() {
loyaltyTerminalLog.info('Adding stamp...'); loyaltyTerminalLog.info('Adding stamp...');
await apiClient.post('/store/loyalty/stamp', { await apiClient.post('/store/loyalty/stamp', {
card_id: this.selectedCard.card_id, card_id: this.selectedCard.id,
staff_pin: this.pinDigits staff_pin: this.pinDigits
}); });
@@ -296,7 +296,7 @@ function storeLoyaltyTerminal() {
loyaltyTerminalLog.info('Redeeming stamps...'); loyaltyTerminalLog.info('Redeeming stamps...');
await apiClient.post('/store/loyalty/stamp/redeem', { await apiClient.post('/store/loyalty/stamp/redeem', {
card_id: this.selectedCard.card_id, card_id: this.selectedCard.id,
staff_pin: this.pinDigits staff_pin: this.pinDigits
}); });
@@ -308,7 +308,7 @@ function storeLoyaltyTerminal() {
loyaltyTerminalLog.info('Earning points...', { amount: this.earnAmount }); loyaltyTerminalLog.info('Earning points...', { amount: this.earnAmount });
const response = await apiClient.post('/store/loyalty/points/earn', { const response = await apiClient.post('/store/loyalty/points/earn', {
card_id: this.selectedCard.card_id, card_id: this.selectedCard.id,
purchase_amount_cents: Math.round(this.earnAmount * 100), purchase_amount_cents: Math.round(this.earnAmount * 100),
staff_pin: this.pinDigits staff_pin: this.pinDigits
}); });
@@ -327,7 +327,7 @@ function storeLoyaltyTerminal() {
loyaltyTerminalLog.info('Redeeming reward...', { reward: reward.name }); loyaltyTerminalLog.info('Redeeming reward...', { reward: reward.name });
await apiClient.post('/store/loyalty/points/redeem', { await apiClient.post('/store/loyalty/points/redeem', {
card_id: this.selectedCard.card_id, card_id: this.selectedCard.id,
reward_id: this.selectedReward, reward_id: this.selectedReward,
staff_pin: this.pinDigits staff_pin: this.pinDigits
}); });
@@ -340,7 +340,7 @@ function storeLoyaltyTerminal() {
// Refresh card data // Refresh card data
async refreshCard() { async refreshCard() {
try { try {
const response = await apiClient.get(`/store/loyalty/cards/${this.selectedCard.card_id}`); const response = await apiClient.get(`/store/loyalty/cards/${this.selectedCard.id}`);
if (response) { if (response) {
this.selectedCard = response; this.selectedCard = response;
} }

View File

@@ -155,7 +155,6 @@
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}
{% include 'shared/includes/optional-libs.html' with context %} <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
{{ chartjs_loader() }}
<script defer src="{{ url_for('loyalty_static', path='store/js/loyalty-analytics.js') }}"></script> <script defer src="{{ url_for('loyalty_static', path='store/js/loyalty-analytics.js') }}"></script>
{% endblock %} {% endblock %}

View File

@@ -105,36 +105,17 @@
{% endfor %} {% endfor %}
</nav> </nav>
{# Right side actions #} {# Right side actions — module-provided via header_template #}
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
{% for item in storefront_nav.get('actions', []) %}
{# Action items from enabled modules (search, cart, etc.) #} {% if item.header_template %}
{% set action_ids = storefront_nav.get('actions', [])|map(attribute='id')|list %} {% include item.header_template %}
{% else %}
{% if 'search' in action_ids %} <a href="{{ base_url }}{{ item.route }}" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700" title="{{ _(item.label_key) }}">
{# Search #} <span class="w-5 h-5" x-html="$icon('{{ item.icon }}', 'w-5 h-5')"></span>
<button @click="openSearch()" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"> </a>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {% endif %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" {% endfor %}
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 #}
{# Theme toggle #} {# Theme toggle #}
<button @click="toggleTheme()" <button @click="toggleTheme()"

View File

@@ -0,0 +1,253 @@
# Proposal: CMS Redesign — Alignment with Market Standards
**Date:** 2026-04-14
**Status:** Draft
**Author:** Samir / Claude
---
## Problem Statement
The CMS has good foundations but several design flaws that create confusion and limit usability:
1. **Template vs Theme are disconnected** — Theme (colors/fonts at `/admin/stores/{code}/theme`) and page template (layout structure in `ContentPage.template`) are separate concepts with overlapping names, no UI connection
2. **No template selector in admin** — The `template` field can only be set via seed data or API, not the UI
3. **Content vs Sections duality** — A page has both a freeform `content` field AND a structured `sections` JSON. Which one renders depends on the template. Confusing for admins
4. **Sections editor shows platform-only sections** — Pricing section appears for store pages where it makes no sense
5. **No title/content translation UI** — The `title_translations` and `content_translations` fields exist in the model but have no admin editor. Only seed data populates them. Store overrides lose translations
6. **Fixed section types** — Only 5-8 section types, can't be extended by modules
7. **No section reordering** — Sections render in a fixed order defined by the template
8. **Everything mixed in one list** — Platform marketing pages, store defaults, and store overrides all in `/admin/content-pages`
### Specific bug found
FASHIONHUB has a store override for `/about` with `title_translations=NULL`. The override was created without translations (no UI to add them), so it always shows "About Fashion Hub" regardless of language. The store default it overrides has full translations (`fr="À propos"`, etc.).
---
## Market Standard (Shopify, WordPress, Squarespace)
| Concept | Market Standard | Our Current State |
|---------|----------------|-------------------|
| **Page types** | "Page" (prose) vs "Landing page" (sections) — clearly distinct | Mixed: same model, hidden `template` field decides rendering |
| **Template** | A starting point you choose when creating a page, pre-populates layout | Hidden field, can't be changed in UI |
| **Sections** | Ordered list, drag-and-drop, add/remove any type | Fixed positions, hardcoded in template |
| **Theme** | Global visual styling (colors, fonts) applied to all templates | Separate system, works but disconnected |
| **Translations** | Per-field translations, always visible when editing | Fields exist but no admin UI for page title/content |
| **Content editing** | Rich text for prose pages, section editor for landing pages | Both shown on same edit page |
| **Storefront management** | Dedicated section (Shopify: "Online Store") | Mixed into Content Management |
---
## Proposed Admin Menu Restructure
### Current
```
Content Management (CMS module, order=70)
├── Content Pages → /admin/content-pages (everything mixed)
└── Store Themes → /admin/store-themes
Platform Admin (Tenancy module)
├── Merchants → /admin/merchants
├── Stores → /admin/stores
└── Platforms → /admin/platforms
```
### Proposed
```
Platform Admin (Tenancy module)
├── Merchants → /admin/merchants
├── Stores → /admin/stores
├── Storefronts → /admin/storefronts ← NEW (card grid per store)
└── Platforms → /admin/platforms
Content Management (CMS module)
├── Platform Pages → /admin/platform-pages (renamed, platform marketing only)
└── Media Library → /admin/media
```
### Storefronts page (`/admin/storefronts`)
Card grid layout (like current `/admin/store-themes` but expanded). Each store card shows:
```
┌─────────────────────────────────────┐
│ 🏪 FashionHub │
│ loyalty platform · active │
│ │
│ [Customize Theme] [Edit Homepage] │
│ [Manage Pages] [Preview →] │
└─────────────────────────────────────┘
```
| Action | What it opens |
|--------|--------------|
| **Customize Theme** | `/admin/stores/{code}/theme` (existing, works well) |
| **Edit Homepage** | Landing page section editor for this store's `slug=home` page |
| **Manage Pages** | List of content pages for this store (about, contact, faq — with translations) |
| **Preview** | Opens storefront in new tab |
This replaces the current **Store Themes** menu item — themes become one tab/action within the broader Storefronts management.
### Platform Pages (`/admin/platform-pages`)
Renamed from "Content Pages". Only shows `is_platform_page=True` pages. Used for platform marketing (homepage, pricing, about, terms, privacy). This is what the admin uses to manage the platform marketing site — not individual store content.
---
## Proposed CMS Changes
### Change 1: Page type selector in admin UI
Add a **Page Type** dropdown at the top of the content page edit form:
| Page Type | Template field | Editor shows | Hides |
|-----------|---------------|-------------|-------|
| **Content Page** | `default` | Title (with translations), content editor (with translations), SEO | Sections editor |
| **Landing Page** | `full` | Title (with translations), section editor, SEO | Content field |
When switching types:
- Content → Landing: initialize empty sections if none exist, hide content field
- Landing → Content: show content field, hide sections editor
- Data is preserved in both cases (no deletion)
### Change 2: Title and content translation UI
Add **language tabs** to the title and content fields — same pattern the sections editor already uses:
```
[FR] [EN] [DE] [LB]
┌────────────────────────────┐
│ Title: À propos │
└────────────────────────────┘
┌────────────────────────────┐
│ Content: │
│ Bienvenue chez ... │
└────────────────────────────┘
```
- Default language tab edits `form.title` / `form.content` directly
- Other language tabs edit `form.title_translations[lang]` / `form.content_translations[lang]`
- When creating a store override from a default, pre-populate translations from the default
### Change 3: Context-aware section editor
Hide irrelevant sections based on page context:
| Section | Platform Homepage | Store Homepage |
|---------|------------------|----------------|
| Hero | Yes | Yes |
| Features | Yes | Yes |
| Pricing | Yes | **No** |
| CTA | Yes | Yes |
| Testimonials | Yes | Yes |
| Gallery | Yes | Yes |
| Contact Info | Yes | Yes |
Implementation: pass `is_platform_page` to the JS component, conditionally show Pricing.
### Change 4: Sections as ordered list (future)
**Current:** Dict with fixed keys (`{"hero": {...}, "features": {...}, "cta": {...}}`)
**Proposed:** Ordered array:
```json
[
{"type": "hero", "enabled": true, "data": {...}},
{"type": "features", "enabled": true, "data": {...}},
{"type": "cta", "enabled": true, "data": {...}}
]
```
Benefits:
- Admin can reorder (drag-and-drop)
- Admin can add/remove section types
- Template iterates array generically
- New section types don't require template changes
Migration: if `sections` is a dict → render in legacy order. If array → render in order.
### Change 5: Module-contributed section types (future)
New contract: `StorefrontSectionProviderProtocol`
- Catalog contributes: `product-showcase`, `category-grid`
- Loyalty contributes: `loyalty-signup`, `rewards-overview`
- Section registry aggregates from enabled modules
- Admin section editor shows available types from enabled modules
---
## What Stays the Same
- **3-tier content hierarchy** (platform → store default → store override) — solid
- **TranslatableText pattern** for sections — well-built
- **Section partials** as Jinja2 macros — reusable, themeable
- **Module-driven menus and widgets** — clean contracts
- **Theme system** (colors, fonts, CSS variables) — works well
- **CMS context providers** for header/footer pages — good pattern
- **ContentPage model** — no schema changes needed for Phase A
---
## Implementation Phases
### Phase A: Quick fixes (immediate, no schema changes)
- [ ] Title + content translation UI (language tabs on edit page)
- [ ] Page type selector (Content Page / Landing Page dropdown)
- [ ] Hide content field when Landing Page selected
- [ ] Hide Pricing section for non-platform pages
- [ ] Fix: FASHIONHUB about page — add translations
- [ ] Fix: store theme API bug (done — `get_store_by_code_or_subdomain`)
### Phase B: Menu restructure + Storefronts page
- [ ] Add "Storefronts" menu item under Platform Admin
- [ ] Build card grid page at `/admin/storefronts`
- [ ] Rename "Content Pages" → "Platform Pages" (filter to `is_platform_page=True`)
- [ ] Move Store Themes into Storefronts
- [ ] "Edit Homepage" action on store card → section editor for store's home page
- [ ] "Manage Pages" action → filtered content page list for that store
### Phase C: Section ordering + add/remove
- [ ] Migrate sections from dict to ordered array
- [ ] Drag-and-drop reordering in admin section editor
- [ ] Add/remove sections from available types
- [ ] Template renders sections from ordered array
- [ ] Backward compatibility for dict-format sections
### Phase D: Module-contributed sections
- [ ] `StorefrontSectionProviderProtocol` contract
- [ ] Catalog: product-showcase section
- [ ] Loyalty: loyalty-signup section
- [ ] Section registry in CMS module
- [ ] Admin section editor shows available types from enabled modules
---
## Relation to Storefront Builder Vision
This proposal covers the CMS foundation. The broader [storefront builder vision](storefront-builder-vision.md) builds on top:
| Builder Vision Phase | CMS Redesign Phase |
|---------------------|-------------------|
| Phase 1: Wire sections to store homepages | ✅ Done |
| Phase 2: Module header actions | ✅ Done |
| Phase 3: Module-contributed sections | Phase D |
| Phase 4: Widget slots | Separate (post Phase D) |
| Phase 5: Per-store menus | Phase B sets up the UI |
| Phase 6: Visual editor | Post Phase C (drag-and-drop foundation) |
---
## Key Files
| Component | File |
|-----------|------|
| Content page edit template | `app/modules/cms/templates/cms/admin/content-page-edit.html` |
| Content page edit JS | `app/modules/cms/static/admin/js/content-page-edit.js` |
| ContentPage model | `app/modules/cms/models/content_page.py` |
| Section schemas | `app/modules/cms/schemas/homepage_sections.py` |
| Section partials | `app/modules/cms/templates/cms/platform/sections/_*.html` |
| CMS definition (admin menu) | `app/modules/cms/definition.py` |
| Tenancy definition (admin menu) | `app/modules/tenancy/definition.py` |
| Store theme page | `app/modules/tenancy/templates/tenancy/admin/store-theme.html` |
| Store themes list | `app/modules/cms/templates/cms/admin/store-themes.html` |
| Storefront landing templates | `app/modules/cms/templates/cms/storefront/landing-*.html` |
| Seed data | `scripts/seed/create_default_content_pages.py` |

View File

@@ -2099,47 +2099,319 @@ SHARED_PLATFORM_PAGES = [
STORE_DEFAULT_HOME = { STORE_DEFAULT_HOME = {
"slug": "home", "slug": "home",
"title": "Welcome to {{store_name}}", "title": "{{store_name}}",
"title_translations": tt( "title_translations": tt(
"Welcome to {{store_name}}", "{{store_name}}",
"Bienvenue chez {{store_name}}", "{{store_name}}",
"Willkommen bei {{store_name}}", "{{store_name}}",
"Wëllkomm bei {{store_name}}", "{{store_name}}",
), ),
"content": """<div class="prose-content"> "content": "",
<h2>Welcome</h2> "template": "full",
<p>{{store_name}} is here to serve you. Browse our offerings and discover what we have for you.</p> "meta_description": "{{store_name}}",
</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_header": False,
"show_in_footer": False, "show_in_footer": False,
"display_order": 0, "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 = [ STORE_DEFAULTS_COMMON = [
{ {
"slug": "about", "slug": "about",
@@ -2796,8 +3068,18 @@ def create_default_pages(db: Session) -> None:
# Only for platforms that host stores (not wizard.lu main) # Only for platforms that host stores (not wizard.lu main)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
if platform.code != "main": if platform.code != "main":
# Store homepage (slug="home") # Store homepage (slug="home") with platform-specific sections
if _create_page(db, platform.id, STORE_DEFAULT_HOME, is_platform_page=False): 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 created_count += 1
else: else:
skipped_count += 1 skipped_count += 1