Compare commits

..

10 Commits

Author SHA1 Message Date
3ade1b9354 docs(loyalty): rewrite launch plan with step-by-step pre-launch checklist
Some checks failed
CI / pytest (push) Failing after 2h31m6s
CI / validate (push) Successful in 29s
CI / dependency-scanning (push) Successful in 33s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
CI / ruff (push) Successful in 13s
Replace the old effort/critical-path sections with current status:
all dev phases 0-8 marked DONE with dates. Added a clear 8-step
pre-launch checklist (seed templates, deploy wallet certs, migrations,
translations, permissions, E2E testing, device test, go live) and a
post-launch roadmap table (Apple Wallet, marketing module, coverage,
trash UI, bulk PINs, cross-location enforcement).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 22:34:57 +02:00
b5bb9415f6 feat(cms): Phase A — page type selector, translation UI, SEO cleanup
Some checks failed
CI / ruff (push) Successful in 16s
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
Content page editor improvements:
- Page type selector: Content Page / Landing Page dropdown (sets template)
- Title language tabs: translate page titles per language (same pattern as sections)
- Content language tabs: translate page content per language
- Meta description language tabs: translatable SEO descriptions
- Template-driven section palette: template defines which sections are available
  (store landing pages hide Pricing, platform homepages show all)
- Hide content editor when Landing Page selected, hide sections when Content Page

Schema changes (migration cms_003):
- Add meta_description_translations column (JSON) to content_pages
- Drop meta_keywords column (obsolete, ignored by all search engines since 2009)
- Remove meta keywords tag from storefront and platform base templates

API + service updates:
- title_translations, content_translations, meta_description_translations
  added to create/update schemas, route handlers, and service methods

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 22:30:55 +02:00
bb3d6f0012 fix(loyalty): card detail — enrolled store name + copy buttons
Some checks failed
CI / pytest (push) Failing after 2h22m22s
CI / validate (push) Successful in 27s
CI / dependency-scanning (push) Successful in 31s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
CI / ruff (push) Successful in 12s
- Fix "Enrolled at: Unknown" by resolving enrolled_at_store_name from
  the store service and adding it to CardDetailResponse schema.
- Add clipboard-copy buttons next to card number, customer name,
  email, and phone fields using the shared Utils.copyToClipboard()
  utility with toast feedback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:31:53 +02:00
c92fe1261b fix(loyalty): use full pagination macro on card detail (match cards list)
Some checks failed
CI / ruff (push) Successful in 14s
CI / pytest (push) Has started running
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
Switch from pagination_simple to pagination — the same macro used on
the cards list page, with page number buttons and "Showing X-Y of Z".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:25:29 +02:00
ca152cd544 fix(loyalty): use shared pagination macro on card detail transactions
Some checks failed
CI / pytest (push) Has been cancelled
CI / ruff (push) Successful in 14s
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
Replace custom pagination with the shared pagination_simple macro
to match the cards list page pattern. Always shows "Showing X-Y of Z"
with Previous/Next — no longer hidden when only 1 page. Uses standard
Alpine.js pagination interface (pagination.page, totalPages, startIndex,
endIndex, pageNumbers, previousPage, nextPage, goToPage).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:18:59 +02:00
914967edcc feat(loyalty): add paginated transaction history to card detail
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 store card detail page now shows paginated transaction history
instead of a flat list of 50. Uses PlatformSettings.getRowsPerPage()
for the page size (default 20), with Previous/Next navigation and
"Page X of Y" indicator using server-rendered i18n.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:13:00 +02:00
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
adc36246b8 feat(storefront): homepage, module gating, widget protocol, i18n fixes
Some checks failed
CI / ruff (push) Successful in 14s
CI / pytest (push) Failing after 2h32m45s
CI / validate (push) Successful in 30s
CI / dependency-scanning (push) Successful in 31s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
Storefront homepage & module gating:
- CMS owns storefront GET / (slug="home" with 3-tier resolution)
- Catalog loses GET / (keeps /products only)
- Store root redirect (GET / → /store/dashboard or /store/login)
- Route gating: non-core modules return 404 when disabled for platform
- Seed store default homepages per platform

Widget protocol for customer dashboard:
- StorefrontDashboardCard contract in widgets.py
- Widget aggregator get_storefront_dashboard_cards()
- Orders and Loyalty module widget providers
- Dashboard template renders contributed cards (no module names)

Landing template module-agnostic:
- CTAs driven by storefront_nav (not hardcoded module names)
- Header actions check nav item IDs (not enabled_modules)
- Remove hardcoded "Add Product" sidebar button
- Remove all enabled_modules checks from storefront templates

i18n fixes:
- Title placeholder resolution ({{store_name}}) for store default pages
- Storefront nav label_keys prefixed with module code
- Add storefront.account.* keys to 6 modules (en/fr/de/lb)
- Header/footer CMS pages use get_translated_title(current_language)
- Footer labels use i18n keys instead of hardcoded English

Icon cleanup:
- Standardize on map-pin (remove location-marker alias)
- Replace all location-marker references across templates and docs

Docs:
- Storefront builder vision proposal (6 phases)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 22:53:17 +02:00
dd9dc04328 feat(loyalty): add Chart.js visualizations to analytics page
Some checks failed
CI / pytest (push) Failing after 2h21m5s
CI / ruff (push) Successful in 13s
CI / validate (push) Successful in 27s
CI / dependency-scanning (push) Successful in 31s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
Wire the Phase 7 analytics API endpoints into the store analytics
page with interactive visualizations:

- Revenue chart (Chart.js bar+line combo): monthly points earned as
  bars + active customers as line overlay with dual Y axes.
- At-risk members panel: ranked list of churning cards showing
  customer name and days inactive, with count badge.
- Cohort retention table: enrollment month rows × M0-M5 retention
  columns with color-coded percentage cells (green >60%, yellow
  >30%, red <30%).

Chart.js loaded on-demand via existing CDN loader with local fallback.
Data fetched in parallel via Promise.all for the 3 analytics endpoints.
All sections gracefully degrade to "not enough data" message when empty.

7 new i18n keys (EN only — FR/DE/LB translations to be added).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:30:36 +02:00
82 changed files with 5913 additions and 3950 deletions

View File

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

View File

@@ -143,7 +143,7 @@
</div>
<div class="flex items-center">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('location-marker', 'w-6 h-6')"></span>
<span x-html="$icon('map-pin', 'w-6 h-6')"></span>
</div>
<div>
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="formatNumber(analytics.inventory?.total_locations || 0)"></p>

View File

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

View File

@@ -68,10 +68,11 @@ 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,
header_template="cart/storefront/partials/header-cart.html",
),
],
),

View File

@@ -44,5 +44,10 @@
"view_desc": "Warenkörbe der Kunden anzeigen",
"manage": "Warenkörbe verwalten",
"manage_desc": "Warenkörbe der Kunden bearbeiten und verwalten"
},
"storefront": {
"actions": {
"cart": "Warenkorb"
}
}
}

View File

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

View File

@@ -44,5 +44,10 @@
"view_desc": "Voir les paniers des clients",
"manage": "Gérer les paniers",
"manage_desc": "Modifier et gérer les paniers des clients"
},
"storefront": {
"actions": {
"cart": "Panier"
}
}
}

View File

@@ -44,5 +44,10 @@
"view_desc": "Clientekuerf kucken",
"manage": "Kuerf verwalten",
"manage_desc": "Clientekuerf änneren a verwalten"
},
"storefront": {
"actions": {
"cart": "Kuerf"
}
}
}

View File

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

@@ -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,10 +148,11 @@ catalog_module = ModuleDefinition(
items=[
MenuItemDefinition(
id="search",
label_key="storefront.actions.search",
label_key="catalog.storefront.actions.search",
icon="search",
route="",
order=10,
header_template="catalog/storefront/partials/header-search.html",
),
],
),

View File

@@ -89,5 +89,13 @@
"products_import_desc": "Massenimport von Produkten",
"products_export": "Produkte exportieren",
"products_export_desc": "Produktdaten exportieren"
},
"storefront": {
"nav": {
"products": "Produkte"
},
"actions": {
"search": "Suchen"
}
}
}

View File

@@ -107,5 +107,13 @@
"menu": {
"products_inventory": "Products & Inventory",
"all_products": "All Products"
},
"storefront": {
"nav": {
"products": "Products"
},
"actions": {
"search": "Search"
}
}
}

View File

@@ -89,5 +89,13 @@
"products_import_desc": "Importation en masse de produits",
"products_export": "Exporter les produits",
"products_export_desc": "Exporter les données produits"
},
"storefront": {
"nav": {
"products": "Produits"
},
"actions": {
"search": "Rechercher"
}
}
}

View File

@@ -89,5 +89,13 @@
"products_import_desc": "Massenimport vu Produiten",
"products_export": "Produiten exportéieren",
"products_export_desc": "Produitdaten exportéieren"
},
"storefront": {
"nav": {
"products": "Produkter"
},
"actions": {
"search": "Sichen"
}
}
}

View File

@@ -30,11 +30,10 @@ router = APIRouter()
# ============================================================================
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
@router.get("/products", response_class=HTMLResponse, include_in_schema=False)
async def shop_products_page(request: Request, db: Session = Depends(get_db)):
"""
Render shop homepage / product catalog.
Render product catalog listing.
Shows featured products and categories.
"""
logger.debug(

View File

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

@@ -388,5 +388,15 @@
},
"confirmations": {
"delete_file": "Sind Sie sicher, dass Sie diese Datei löschen möchten? Dies kann nicht rückgängig gemacht werden."
},
"storefront": {
"my_account": "Mein Konto",
"learn_more": "Mehr erfahren",
"explore": "Entdecken",
"quick_links": "Schnellzugriff",
"information": "Informationen",
"about": "Über uns",
"contact": "Kontakt",
"faq": "FAQ"
}
}

View File

@@ -388,5 +388,15 @@
"content_pages": "Content Pages",
"store_themes": "Store Themes",
"media_library": "Media Library"
},
"storefront": {
"my_account": "My Account",
"learn_more": "Learn More",
"explore": "Explore",
"quick_links": "Quick Links",
"information": "Information",
"about": "About Us",
"contact": "Contact",
"faq": "FAQ"
}
}

View File

@@ -388,5 +388,15 @@
},
"confirmations": {
"delete_file": "Êtes-vous sûr de vouloir supprimer ce fichier ? Cette action est irréversible."
},
"storefront": {
"my_account": "Mon Compte",
"learn_more": "En savoir plus",
"explore": "Découvrir",
"quick_links": "Liens rapides",
"information": "Informations",
"about": "À propos",
"contact": "Contact",
"faq": "FAQ"
}
}

View File

@@ -388,5 +388,15 @@
},
"confirmations": {
"delete_file": "Sidd Dir sécher datt Dir dëse Fichier läsche wëllt? Dat kann net réckgängeg gemaach ginn."
},
"storefront": {
"my_account": "Mäi Kont",
"learn_more": "Méi gewuer ginn",
"explore": "Entdecken",
"quick_links": "Schnellzougrëff",
"information": "Informatiounen",
"about": "Iwwer eis",
"contact": "Kontakt",
"faq": "FAQ"
}
}

View File

@@ -0,0 +1,36 @@
"""add meta_description_translations and drop meta_keywords from content_pages
Revision ID: cms_003
Revises: cms_002
Create Date: 2026-04-15
"""
import sqlalchemy as sa
from alembic import op
revision = "cms_003"
down_revision = "cms_002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"content_pages",
sa.Column(
"meta_description_translations",
sa.JSON(),
nullable=True,
comment="Language-keyed meta description dict for multi-language SEO",
),
)
op.drop_column("content_pages", "meta_keywords")
def downgrade() -> None:
op.add_column(
"content_pages",
sa.Column("meta_keywords", sa.String(300), nullable=True),
)
op.drop_column("content_pages", "meta_description_translations")

View File

@@ -135,7 +135,12 @@ class ContentPage(Base):
# SEO
meta_description = Column(String(300), nullable=True)
meta_keywords = Column(String(300), nullable=True)
meta_description_translations = Column(
JSON,
nullable=True,
default=None,
comment="Language-keyed meta description dict for multi-language SEO",
)
# Publishing
is_published = Column(Boolean, default=False, nullable=False)
@@ -230,6 +235,16 @@ class ContentPage(Base):
)
return self.content
def get_translated_meta_description(self, lang: str, default_lang: str = "fr") -> str:
"""Get meta description in the given language, falling back to default_lang then self.meta_description."""
if self.meta_description_translations:
return (
self.meta_description_translations.get(lang)
or self.meta_description_translations.get(default_lang)
or self.meta_description or ""
)
return self.meta_description or ""
def to_dict(self):
"""Convert to dictionary for API responses."""
return {
@@ -248,7 +263,7 @@ class ContentPage(Base):
"template": self.template,
"sections": self.sections,
"meta_description": self.meta_description,
"meta_keywords": self.meta_keywords,
"meta_description_translations": self.meta_description_translations,
"is_published": self.is_published,
"published_at": (
self.published_at.isoformat() if self.published_at else None

View File

@@ -73,7 +73,7 @@ def create_platform_page(
content_format=page_data.content_format,
template=page_data.template,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
meta_description_translations=page_data.meta_description_translations,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
@@ -117,7 +117,7 @@ def create_store_page(
content_format=page_data.content_format,
template=page_data.template,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
meta_description_translations=page_data.meta_description_translations,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
@@ -177,11 +177,13 @@ def update_page(
db,
page_id=page_id,
title=page_data.title,
title_translations=page_data.title_translations,
content=page_data.content,
content_translations=page_data.content_translations,
content_format=page_data.content_format,
template=page_data.template,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
meta_description_translations=page_data.meta_description_translations,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,

View File

@@ -207,7 +207,7 @@ def create_store_page(
store_id=current_user.token_store_id,
content_format=page_data.content_format,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
meta_description_translations=getattr(page_data, "meta_description_translations", None),
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
@@ -241,7 +241,7 @@ def update_store_page(
content=page_data.content,
content_format=page_data.content_format,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
meta_description_translations=getattr(page_data, "meta_description_translations", None),
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,

View File

@@ -28,6 +28,79 @@ 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, 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
)
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 = {
"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 +176,13 @@ async def generic_content_page(
# Resolve placeholders in store default pages ({{store_name}}, etc.)
page_content = page.content
page_title = page.title
if page.is_store_default and store:
page_content = content_page_service.resolve_placeholders(page.content, store)
page_title = content_page_service.resolve_placeholders(page.title, store)
context = get_storefront_context(request, db=db, page=page)
context["page_title"] = page_title
context["page_content"] = page_content
# Select template based on page.template field

View File

@@ -24,19 +24,27 @@ class ContentPageCreate(BaseModel):
description="URL-safe identifier (about, faq, contact, etc.)",
)
title: str = Field(..., max_length=200, description="Page title")
title_translations: dict[str, str] | None = Field(
None, description="Title translations keyed by language code"
)
content: str = Field(..., description="HTML or Markdown content")
content_translations: dict[str, str] | None = Field(
None, description="Content translations keyed by language code"
)
content_format: str = Field(
default="html", description="Content format: html or markdown"
)
template: str = Field(
default="default",
max_length=50,
description="Template name (default, minimal, modern)",
description="Template name (default, minimal, modern, full)",
)
meta_description: str | None = Field(
None, max_length=300, description="SEO meta description"
)
meta_keywords: str | None = Field(None, max_length=300, description="SEO keywords")
meta_description_translations: dict[str, str] | None = Field(
None, description="Meta description translations keyed by language code"
)
is_published: bool = Field(default=False, description="Publish immediately")
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
show_in_header: bool = Field(default=False, description="Show in header navigation")
@@ -53,11 +61,13 @@ class ContentPageUpdate(BaseModel):
"""Schema for updating a content page (admin)."""
title: str | None = Field(None, max_length=200)
title_translations: dict[str, str] | None = None
content: str | None = None
content_translations: dict[str, str] | None = None
content_format: str | None = None
template: str | None = Field(None, max_length=50)
meta_description: str | None = Field(None, max_length=300)
meta_keywords: str | None = Field(None, max_length=300)
meta_description_translations: dict[str, str] | None = None
is_published: bool | None = None
show_in_footer: bool | None = None
show_in_header: bool | None = None
@@ -78,11 +88,13 @@ class ContentPageResponse(BaseModel):
store_name: str | None
slug: str
title: str
title_translations: dict[str, str] | None = None
content: str
content_translations: dict[str, str] | None = None
content_format: str
template: str | None = None
meta_description: str | None
meta_keywords: str | None
meta_description_translations: dict[str, str] | None = None
is_published: bool
published_at: str | None
display_order: int
@@ -135,7 +147,6 @@ class StoreContentPageCreate(BaseModel):
meta_description: str | None = Field(
None, max_length=300, description="SEO meta description"
)
meta_keywords: str | None = Field(None, max_length=300, description="SEO keywords")
is_published: bool = Field(default=False, description="Publish immediately")
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
show_in_header: bool = Field(default=False, description="Show in header navigation")
@@ -152,7 +163,6 @@ class StoreContentPageUpdate(BaseModel):
content: str | None = None
content_format: str | None = None
meta_description: str | None = Field(None, max_length=300)
meta_keywords: str | None = Field(None, max_length=300)
is_published: bool | None = None
show_in_footer: bool | None = None
show_in_header: bool | None = None
@@ -187,7 +197,6 @@ class PublicContentPageResponse(BaseModel):
content: str
content_format: str
meta_description: str | None
meta_keywords: str | None
published_at: str | None

View File

@@ -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
@@ -472,7 +473,7 @@ class ContentPageService:
content_format: str = "html",
template: str = "default",
meta_description: str | None = None,
meta_keywords: str | None = None,
meta_description_translations: str | None = None,
is_published: bool = False,
show_in_footer: bool = True,
show_in_header: bool = False,
@@ -494,7 +495,7 @@ class ContentPageService:
content_format: "html" or "markdown"
template: Template name for landing pages
meta_description: SEO description
meta_keywords: SEO keywords
meta_description_translations: Meta description translations dict
is_published: Publish immediately
show_in_footer: Show in footer navigation
show_in_header: Show in header navigation
@@ -515,7 +516,7 @@ class ContentPageService:
content_format=content_format,
template=template,
meta_description=meta_description,
meta_keywords=meta_keywords,
meta_description_translations=meta_description_translations,
is_published=is_published,
published_at=datetime.now(UTC) if is_published else None,
show_in_footer=show_in_footer,
@@ -541,11 +542,13 @@ class ContentPageService:
db: Session,
page_id: int,
title: str | None = None,
title_translations: dict[str, str] | None = None,
content: str | None = None,
content_translations: dict[str, str] | None = None,
content_format: str | None = None,
template: str | None = None,
meta_description: str | None = None,
meta_keywords: str | None = None,
meta_description_translations: str | None = None,
is_published: bool | None = None,
show_in_footer: bool | None = None,
show_in_header: bool | None = None,
@@ -573,16 +576,20 @@ class ContentPageService:
# Update fields if provided
if title is not None:
page.title = title
if title_translations is not None:
page.title_translations = title_translations
if content is not None:
page.content = content
if content_translations is not None:
page.content_translations = content_translations
if content_format is not None:
page.content_format = content_format
if template is not None:
page.template = template
if meta_description is not None:
page.meta_description = meta_description
if meta_keywords is not None:
page.meta_keywords = meta_keywords
if meta_description_translations is not None:
page.meta_description_translations = meta_description_translations
if is_published is not None:
page.is_published = is_published
if is_published and not page.published_at:
@@ -698,7 +705,7 @@ class ContentPageService:
content: str | None = None,
content_format: str | None = None,
meta_description: str | None = None,
meta_keywords: str | None = None,
meta_description_translations: str | None = None,
is_published: bool | None = None,
show_in_footer: bool | None = None,
show_in_header: bool | None = None,
@@ -725,7 +732,7 @@ class ContentPageService:
content=content,
content_format=content_format,
meta_description=meta_description,
meta_keywords=meta_keywords,
meta_description_translations=meta_description_translations,
is_published=is_published,
show_in_footer=show_in_footer,
show_in_header=show_in_header,
@@ -760,7 +767,7 @@ class ContentPageService:
content: str,
content_format: str = "html",
meta_description: str | None = None,
meta_keywords: str | None = None,
meta_description_translations: str | None = None,
is_published: bool = False,
show_in_footer: bool = True,
show_in_header: bool = False,
@@ -791,7 +798,7 @@ class ContentPageService:
is_platform_page=False,
content_format=content_format,
meta_description=meta_description,
meta_keywords=meta_keywords,
meta_description_translations=meta_description_translations,
is_published=is_published,
show_in_footer=show_in_footer,
show_in_header=show_in_header,
@@ -913,11 +920,13 @@ class ContentPageService:
db: Session,
page_id: int,
title: str | None = None,
title_translations: dict[str, str] | None = None,
content: str | None = None,
content_translations: dict[str, str] | None = None,
content_format: str | None = None,
template: str | None = None,
meta_description: str | None = None,
meta_keywords: str | None = None,
meta_description_translations: str | None = None,
is_published: bool | None = None,
show_in_footer: bool | None = None,
show_in_header: bool | None = None,
@@ -935,11 +944,13 @@ class ContentPageService:
db,
page_id=page_id,
title=title,
title_translations=title_translations,
content=content,
content_translations=content_translations,
content_format=content_format,
template=template,
meta_description=meta_description,
meta_keywords=meta_keywords,
meta_description_translations=meta_description_translations,
is_published=is_published,
show_in_footer=show_in_footer,
show_in_header=show_in_header,
@@ -991,6 +1002,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
# =========================================================================

View File

@@ -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}")

View File

@@ -20,11 +20,13 @@ function contentPageEditor(pageId) {
form: {
slug: '',
title: '',
title_translations: {},
content: '',
content_translations: {},
content_format: 'html',
template: 'default',
meta_description: '',
meta_keywords: '',
meta_description_translations: {},
is_published: false,
show_in_header: false,
show_in_footer: true,
@@ -42,6 +44,12 @@ function contentPageEditor(pageId) {
error: null,
successMessage: null,
// Page type: 'content' or 'landing'
pageType: 'content',
// Translation language for title/content
titleContentLang: 'fr',
// ========================================
// HOMEPAGE SECTIONS STATE
// ========================================
@@ -56,6 +64,13 @@ function contentPageEditor(pageId) {
de: 'Deutsch',
lb: 'Lëtzebuergesch'
},
// Template-driven section palette
sectionPalette: {
'default': ['hero', 'features', 'products', 'pricing', 'testimonials', 'gallery', 'contact_info', 'cta'],
'full': ['hero', 'features', 'testimonials', 'gallery', 'contact_info', 'cta'],
},
sections: {
hero: {
enabled: true,
@@ -108,8 +123,8 @@ function contentPageEditor(pageId) {
await this.loadPage();
contentPageEditLog.groupEnd();
// Load sections if this is a homepage
if (this.form.slug === 'home') {
// Load sections if this is a landing page
if (this.pageType === 'landing') {
await this.loadSections();
}
} else {
@@ -120,14 +135,86 @@ function contentPageEditor(pageId) {
contentPageEditLog.info('=== CONTENT PAGE EDITOR INITIALIZATION COMPLETE ===');
},
// Check if we should show section editor (property, not getter for Alpine compatibility)
// Check if we should show section editor
isHomepage: false,
// Update isHomepage when slug changes
// Is a section available for the current template?
isSectionAvailable(sectionName) {
const palette = this.sectionPalette[this.form.template] || this.sectionPalette['full'];
return palette.includes(sectionName);
},
// Update homepage state
updateIsHomepage() {
this.isHomepage = this.form.slug === 'home';
},
// Update template when page type changes
updatePageType() {
if (this.pageType === 'landing') {
this.form.template = 'full';
// Load sections if editing and not yet loaded
if (this.pageId && !this.sectionsLoaded) {
this.loadSections();
}
} else {
this.form.template = 'default';
}
this.updateIsHomepage();
},
// ========================================
// TITLE/CONTENT TRANSLATION HELPERS
// ========================================
getTranslatedTitle() {
if (this.titleContentLang === this.defaultLanguage) {
return this.form.title;
}
return (this.form.title_translations || {})[this.titleContentLang] || '';
},
setTranslatedTitle(value) {
if (this.titleContentLang === this.defaultLanguage) {
this.form.title = value;
} else {
if (!this.form.title_translations) this.form.title_translations = {};
this.form.title_translations[this.titleContentLang] = value;
}
},
getTranslatedContent() {
if (this.titleContentLang === this.defaultLanguage) {
return this.form.content;
}
return (this.form.content_translations || {})[this.titleContentLang] || '';
},
setTranslatedContent(value) {
if (this.titleContentLang === this.defaultLanguage) {
this.form.content = value;
} else {
if (!this.form.content_translations) this.form.content_translations = {};
this.form.content_translations[this.titleContentLang] = value;
}
},
getTranslatedMetaDescription() {
if (this.titleContentLang === this.defaultLanguage) {
return this.form.meta_description;
}
return (this.form.meta_description_translations || {})[this.titleContentLang] || '';
},
setTranslatedMetaDescription(value) {
if (this.titleContentLang === this.defaultLanguage) {
this.form.meta_description = value;
} else {
if (!this.form.meta_description_translations) this.form.meta_description_translations = {};
this.form.meta_description_translations[this.titleContentLang] = value;
}
},
// Load platforms for dropdown
async loadPlatforms() {
this.loadingPlatforms = true;
@@ -188,11 +275,13 @@ function contentPageEditor(pageId) {
this.form = {
slug: page.slug || '',
title: page.title || '',
title_translations: page.title_translations || {},
content: page.content || '',
content_translations: page.content_translations || {},
content_format: page.content_format || 'html',
template: page.template || 'default',
meta_description: page.meta_description || '',
meta_keywords: page.meta_keywords || '',
meta_description_translations: page.meta_description_translations || {},
is_published: page.is_published || false,
show_in_header: page.show_in_header || false,
show_in_footer: page.show_in_footer !== undefined ? page.show_in_footer : true,
@@ -202,6 +291,9 @@ function contentPageEditor(pageId) {
store_id: page.store_id
};
// Set page type from template
this.pageType = (this.form.template === 'full') ? 'landing' : 'content';
contentPageEditLog.info('Page loaded successfully');
// Update computed properties after loading
@@ -240,24 +332,25 @@ function contentPageEditor(pageId) {
},
// ========================================
// HOMEPAGE SECTIONS METHODS
// SECTIONS METHODS
// ========================================
// Load sections for homepage
// Load sections for landing pages
async loadSections() {
if (!this.pageId || this.form.slug !== 'home') {
contentPageEditLog.debug('Skipping section load - not a homepage');
if (!this.pageId || this.pageType !== 'landing') {
contentPageEditLog.debug('Skipping section load - not a landing page');
return;
}
try {
contentPageEditLog.info('Loading homepage sections...');
contentPageEditLog.info('Loading sections...');
const response = await apiClient.get(`/admin/content-pages/${this.pageId}/sections`);
const data = response.data || response;
this.supportedLanguages = data.supported_languages || ['fr', 'de', 'en'];
this.defaultLanguage = data.default_language || 'fr';
this.currentLang = this.defaultLanguage;
this.titleContentLang = this.defaultLanguage;
if (data.sections) {
this.sections = this.mergeWithDefaults(data.sections);
@@ -277,12 +370,18 @@ function contentPageEditor(pageId) {
mergeWithDefaults(loadedSections) {
const defaults = this.getDefaultSectionStructure();
// Deep merge each section
for (const key of ['hero', 'features', 'pricing', 'cta']) {
// Deep merge each section that exists in defaults
for (const key of Object.keys(defaults)) {
if (loadedSections[key]) {
defaults[key] = { ...defaults[key], ...loadedSections[key] };
}
}
// Also preserve any extra sections from loaded data
for (const key of Object.keys(loadedSections)) {
if (!defaults[key]) {
defaults[key] = loadedSections[key];
}
}
return defaults;
},
@@ -375,7 +474,7 @@ function contentPageEditor(pageId) {
// Save sections
async saveSections() {
if (!this.pageId || !this.isHomepage) return;
if (!this.pageId || this.pageType !== 'landing') return;
try {
contentPageEditLog.info('Saving sections...');
@@ -401,11 +500,13 @@ function contentPageEditor(pageId) {
const payload = {
slug: this.form.slug,
title: this.form.title,
title_translations: this.form.title_translations,
content: this.form.content,
content_translations: this.form.content_translations,
content_format: this.form.content_format,
template: this.form.template,
meta_description: this.form.meta_description,
meta_keywords: this.form.meta_keywords,
meta_description_translations: this.form.meta_description_translations,
is_published: this.form.is_published,
show_in_header: this.form.show_in_header,
show_in_footer: this.form.show_in_footer,
@@ -422,8 +523,8 @@ function contentPageEditor(pageId) {
// Update existing page
response = await apiClient.put(`/admin/content-pages/${this.pageId}`, payload);
// Also save sections if this is a homepage
if (this.isHomepage && this.sectionsLoaded) {
// Also save sections if this is a landing page
if (this.pageType === 'landing' && this.sectionsLoaded) {
await this.saveSections();
}

View File

@@ -57,19 +57,23 @@
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Page Title -->
<div class="md:col-span-2">
<!-- Page Type -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Page Title <span class="text-red-500">*</span>
Page Type
</label>
<input
type="text"
x-model="form.title"
required
maxlength="200"
<select
x-model="pageType"
@change="updatePageType()"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
placeholder="About Us"
>
<option value="content">Content Page</option>
<option value="landing">Landing Page</option>
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<span x-show="pageType === 'content'">Standard page with rich text content (About, FAQ, Privacy...)</span>
<span x-show="pageType === 'landing'">Section-based page with hero, features, CTA blocks</span>
</p>
</div>
<!-- Slug -->
@@ -133,10 +137,54 @@
</div>
</div>
<!-- Content -->
<!-- Title with Language Tabs -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Page Title
<span class="text-sm font-normal text-gray-500 ml-2">(Multi-language)</span>
</h3>
<!-- Language Tabs for Title/Content -->
<div class="mb-4">
<div class="border-b border-gray-200 dark:border-gray-700">
<nav class="flex -mb-px space-x-4">
<template x-for="lang in supportedLanguages" :key="'tc-' + lang">
<button
type="button"
@click="titleContentLang = lang"
:class="titleContentLang === lang ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
class="py-2 px-4 border-b-2 font-medium text-sm transition-colors"
>
<span x-text="languageNames[lang] || lang.toUpperCase()"></span>
<span x-show="lang === defaultLanguage" class="ml-1 text-xs text-gray-400">(default)</span>
</button>
</template>
</nav>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Title <span class="text-red-500">*</span>
<span class="font-normal text-gray-400 ml-1" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
</label>
<input
type="text"
:value="getTranslatedTitle()"
@input="setTranslatedTitle($event.target.value)"
required
maxlength="200"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
:placeholder="'Page title in ' + (languageNames[titleContentLang] || titleContentLang)"
>
</div>
</div>
<!-- Content (only for Content Page type) -->
<div x-show="pageType === 'content'" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Page Content
<span class="text-sm font-normal text-gray-500 ml-2" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
</h3>
<!-- Content Format -->
@@ -219,9 +267,9 @@
</div>
<!-- ══════════════════════════════════════════════════════════════════ -->
<!-- HOMEPAGE SECTIONS EDITOR (only for slug='home') -->
<!-- SECTIONS EDITOR (for Landing Page type) -->
<!-- ══════════════════════════════════════════════════════════════════ -->
<div x-show="isHomepage" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
<div x-show="pageType === 'landing'" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Homepage Sections
@@ -258,7 +306,7 @@
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- HERO SECTION -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div x-show="isSectionAvailable('hero')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
type="button"
@click="openSection = openSection === 'hero' ? null : 'hero'"
@@ -341,7 +389,7 @@
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- FEATURES SECTION -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div x-show="isSectionAvailable('features')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
type="button"
@click="openSection = openSection === 'features' ? null : 'features'"
@@ -410,7 +458,7 @@
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- PRICING SECTION -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div x-show="isSectionAvailable('pricing')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
type="button"
@click="openSection = openSection === 'pricing' ? null : 'pricing'"
@@ -448,7 +496,7 @@
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- CTA SECTION -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div x-show="isSectionAvailable('cta')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
type="button"
@click="openSection = openSection === 'cta' ? null : 'cta'"
@@ -525,6 +573,7 @@
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
SEO & Metadata
<span class="text-sm font-normal text-gray-500 ml-2" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
</h3>
<div class="space-y-4">
@@ -534,30 +583,17 @@
Meta Description
</label>
<textarea
x-model="form.meta_description"
:value="getTranslatedMetaDescription()"
@input="setTranslatedMetaDescription($event.target.value)"
rows="2"
maxlength="300"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
placeholder="A brief description for search engines"
:placeholder="'Meta description in ' + (languageNames[titleContentLang] || titleContentLang)"
></textarea>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<span x-text="(form.meta_description || '').length"></span>/300 characters (150-160 recommended)
150-160 characters recommended for search engines
</p>
</div>
<!-- Meta Keywords -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Meta Keywords
</label>
<input
type="text"
x-model="form.meta_keywords"
maxlength="300"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
placeholder="keyword1, keyword2, keyword3"
>
</div>
</div>
</div>

View File

@@ -3,7 +3,7 @@
{% extends "storefront/base.html" %}
{# Dynamic title from CMS #}
{% block title %}{{ page.title }}{% endblock %}
{% block title %}{{ page_title or page.title }}{% endblock %}
{# SEO from CMS #}
{% block meta_description %}{{ page.meta_description or page.title }}{% endblock %}
@@ -16,13 +16,13 @@
<div class="breadcrumb mb-6">
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
<span>/</span>
<span class="text-gray-900 dark:text-gray-200 font-medium">{{ page.title }}</span>
<span class="text-gray-900 dark:text-gray-200 font-medium">{{ page_title or page.title }}</span>
</div>
{# Page Header #}
<div class="mb-8">
<h1 class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-4">
{{ page.title }}
{{ page_title or page.title }}
</h1>
{# Optional: Show store override badge for debugging #}

View File

@@ -1,10 +1,9 @@
{# app/templates/store/landing-default.html #}
{# standalone #}
{# app/modules/cms/templates/cms/storefront/landing-default.html #}
{# Default/Minimal Landing Page Template #}
{% extends "storefront/base.html" %}
{% block title %}{{ store.name }}{% endblock %}
{% block meta_description %}{{ page.meta_description or store.description or store.name }}{% endblock %}
{% block meta_description %}{{ page.meta_description or store.description or store.name if page else store.description or store.name }}{% endblock %}
{% block content %}
<div class="min-h-screen">
@@ -24,7 +23,7 @@
{# Title #}
<h1 class="text-4xl md:text-6xl font-bold text-gray-900 dark:text-white mb-6">
{{ page.title or store.name }}
{{ page_title or store.name }}
</h1>
{# Tagline #}
@@ -34,18 +33,31 @@
</p>
{% endif %}
{# CTA Button #}
{# CTA Buttons — driven by storefront_nav (module-agnostic) #}
{% set nav_items = storefront_nav.get('nav', []) %}
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="{{ base_url }}"
{% if nav_items %}
{# Primary CTA: first nav item from enabled modules #}
<a href="{{ base_url }}{{ nav_items[0].route }}"
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-white bg-primary hover:bg-primary-dark transition-colors shadow-lg hover:shadow-xl"
style="background-color: var(--color-primary)">
Browse Our Shop
{{ _(nav_items[0].label_key) }}
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>
</a>
{% if page.content %}
{% else %}
{# Fallback: account link when no module nav items #}
<a href="{{ base_url }}account/login"
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-white bg-primary hover:bg-primary-dark transition-colors shadow-lg hover:shadow-xl"
style="background-color: var(--color-primary)">
{{ _('cms.storefront.my_account') }}
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>
</a>
{% endif %}
{% if page and page.content %}
<a href="#about"
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-2 border-gray-200 dark:border-gray-600">
Learn More
{{ _('cms.storefront.learn_more') }}
</a>
{% endif %}
</div>
@@ -54,73 +66,65 @@
</section>
{# Content Section (if provided) #}
{% if page.content %}
{% if page_content %}
<section id="about" class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="prose prose-lg dark:prose-invert max-w-none">
{{ page.content | safe }}{# sanitized: CMS content #}
{{ page_content | safe }}{# sanitized: CMS content #}
</div>
</div>
</section>
{% endif %}
{# Quick Links Section #}
{# Quick Links Section — driven by nav items and CMS pages #}
{% set account_items = storefront_nav.get('account', []) %}
{% set all_links = nav_items + account_items %}
{% if all_links or header_pages %}
<section class="py-16 bg-gray-50 dark:bg-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-3xl font-bold text-center text-gray-900 dark:text-white mb-12">
Explore
{{ _('cms.storefront.explore') }}
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<a href="{{ base_url }}products"
{# Module nav items (products, loyalty, etc.) #}
{% for item in all_links[:3] %}
<a href="{{ base_url }}{{ item.route }}"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4">🛍️</div>
<div class="mb-4">
<span class="h-10 w-10 text-primary mx-auto" style="color: var(--color-primary)"
x-html="$icon('{{ item.icon }}', 'h-10 w-10 mx-auto')"></span>
</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
Shop Products
{{ _(item.label_key) }}
</h3>
<p class="text-gray-600 dark:text-gray-400">
Browse our complete catalog
</p>
</a>
{% endfor %}
{% if header_pages %}
{% for page in header_pages[:2] %}
{# Fill remaining slots with CMS header pages #}
{% set remaining = 3 - all_links[:3]|length %}
{% if remaining > 0 and header_pages %}
{% for page in header_pages[:remaining] %}
<a href="{{ base_url }}{{ page.slug }}"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4">📄</div>
<div class="mb-4">
<span class="h-10 w-10 text-primary mx-auto" style="color: var(--color-primary)"
x-html="$icon('document-text', 'h-10 w-10 mx-auto')"></span>
</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
{{ page.title }}
</h3>
{% if page.meta_description %}
<p class="text-gray-600 dark:text-gray-400">
{{ page.meta_description or 'Learn more' }}
{{ page.meta_description }}
</p>
{% endif %}
</a>
{% endfor %}
{% else %}
<a href="{{ base_url }}about"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4"></div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
About Us
</h3>
<p class="text-gray-600 dark:text-gray-400">
Learn about our story
</p>
</a>
<a href="{{ base_url }}contact"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4">📧</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
Contact
</h3>
<p class="text-gray-600 dark:text-gray-400">
Get in touch with us
</p>
</a>
{% endif %}
</div>
</div>
</section>
{% endif %}
</div>
{% endblock %}

View File

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

View File

@@ -80,6 +80,44 @@ class WidgetContext:
include_details: bool = False
# =============================================================================
# Storefront Dashboard Card
# =============================================================================
@dataclass
class StorefrontDashboardCard:
"""
A card contributed by a module to the storefront customer dashboard.
Modules implement get_storefront_dashboard_cards() to provide these.
The dashboard template renders them without knowing which module provided them.
Attributes:
key: Unique identifier (e.g. "orders.summary", "loyalty.points")
icon: Lucide icon name (e.g. "shopping-bag", "gift")
title: Card title (i18n key or plain text)
subtitle: Card subtitle / description
route: Link destination relative to base_url (e.g. "account/orders")
value: Primary display value (e.g. order count, points balance)
value_label: Label for the value (e.g. "Total Orders", "Points Balance")
order: Sort order (lower = shown first)
template: Optional custom template path for complex rendering
extra_data: Additional data for custom template rendering
"""
key: str
icon: str
title: str
subtitle: str
route: str
value: str | int | None = None
value_label: str | None = None
order: int = 100
template: str | None = None
extra_data: dict[str, Any] = field(default_factory=dict)
# =============================================================================
# Widget Item Types
# =============================================================================
@@ -330,6 +368,30 @@ class DashboardWidgetProviderProtocol(Protocol):
"""
...
def get_storefront_dashboard_cards(
self,
db: "Session",
store_id: int,
customer_id: int,
context: WidgetContext | None = None,
) -> list["StorefrontDashboardCard"]:
"""
Get cards for the storefront customer dashboard.
Called by the customer account dashboard. Each module contributes
its own cards (e.g. orders summary, loyalty points).
Args:
db: Database session for queries
store_id: ID of the store
customer_id: ID of the logged-in customer
context: Optional filtering/scoping context
Returns:
List of StorefrontDashboardCard objects
"""
...
__all__ = [
# Context
@@ -343,6 +405,8 @@ __all__ = [
"WidgetData",
# Main envelope
"DashboardWidget",
# Storefront
"StorefrontDashboardCard",
# Protocol
"DashboardWidgetProviderProtocol",
]

View File

@@ -9,11 +9,13 @@ Store pages for core functionality:
"""
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.api.deps import (
UserContext,
get_current_store_from_cookie_or_header,
get_current_store_optional,
get_db,
get_resolved_store_code,
)
@@ -24,6 +26,21 @@ from app.templates_config import templates
router = APIRouter()
# ============================================================================
# STORE ROOT REDIRECT
# ============================================================================
@router.get("/", response_class=RedirectResponse, include_in_schema=False)
async def store_root(
current_user: UserContext | None = Depends(get_current_store_optional),
):
"""Redirect /store/ based on authentication status."""
if current_user:
return RedirectResponse(url="/store/dashboard", status_code=302)
return RedirectResponse(url="/store/login", status_code=302)
# ============================================================================
# STORE DASHBOARD
# ============================================================================

View File

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

View File

@@ -34,6 +34,7 @@ from sqlalchemy.orm import Session
from app.modules.contracts.widgets import (
DashboardWidget,
DashboardWidgetProviderProtocol,
StorefrontDashboardCard,
WidgetContext,
)
@@ -233,6 +234,49 @@ class WidgetAggregatorService:
return widget
return None
def get_storefront_dashboard_cards(
self,
db: Session,
store_id: int,
customer_id: int,
platform_id: int,
context: WidgetContext | None = None,
) -> list[StorefrontDashboardCard]:
"""
Get dashboard cards for the storefront customer account page.
Collects cards from all enabled modules that implement
get_storefront_dashboard_cards(), sorted by order.
Args:
db: Database session
store_id: ID of the store
customer_id: ID of the logged-in customer
platform_id: Platform ID (for module enablement check)
context: Optional filtering/scoping context
Returns:
Flat list of StorefrontDashboardCard sorted by order
"""
providers = self._get_enabled_providers(db, platform_id)
cards: list[StorefrontDashboardCard] = []
for module, provider in providers:
if not hasattr(provider, "get_storefront_dashboard_cards"):
continue
try:
module_cards = provider.get_storefront_dashboard_cards(
db, store_id, customer_id, context
)
if module_cards:
cards.extend(module_cards)
except Exception as e:
logger.warning(
f"Failed to get storefront cards from module {module.code}: {e}"
)
return sorted(cards, key=lambda c: c.order)
def get_available_categories(
self, db: Session, platform_id: int
) -> list[str]:

View File

@@ -141,28 +141,28 @@ customers_module = ModuleDefinition(
items=[
MenuItemDefinition(
id="dashboard",
label_key="storefront.account.dashboard",
label_key="customers.storefront.account.dashboard",
icon="home",
route="account/dashboard",
order=10,
),
MenuItemDefinition(
id="profile",
label_key="storefront.account.profile",
label_key="customers.storefront.account.profile",
icon="user",
route="account/profile",
order=20,
),
MenuItemDefinition(
id="addresses",
label_key="storefront.account.addresses",
label_key="customers.storefront.account.addresses",
icon="map-pin",
route="account/addresses",
order=30,
),
MenuItemDefinition(
id="settings",
label_key="storefront.account.settings",
label_key="customers.storefront.account.settings",
icon="cog",
route="account/settings",
order=90,

View File

@@ -52,5 +52,13 @@
"customers_delete_desc": "Kundendatensätze entfernen",
"customers_export": "Kunden exportieren",
"customers_export_desc": "Kundendaten exportieren"
},
"storefront": {
"account": {
"dashboard": "Dashboard",
"profile": "Profil",
"addresses": "Adressen",
"settings": "Einstellungen"
}
}
}

View File

@@ -52,5 +52,13 @@
"customers_section": "Customers",
"customers": "Customers",
"all_customers": "All Customers"
},
"storefront": {
"account": {
"dashboard": "Dashboard",
"profile": "Profile",
"addresses": "Addresses",
"settings": "Settings"
}
}
}

View File

@@ -52,5 +52,13 @@
"customers_delete_desc": "Supprimer les fiches clients",
"customers_export": "Exporter les clients",
"customers_export_desc": "Exporter les données clients"
},
"storefront": {
"account": {
"dashboard": "Tableau de bord",
"profile": "Profil",
"addresses": "Adresses",
"settings": "Paramètres"
}
}
}

View File

@@ -52,5 +52,13 @@
"customers_delete_desc": "Clientedossieren ewechhuelen",
"customers_export": "Clienten exportéieren",
"customers_export_desc": "Clientedaten exportéieren"
},
"storefront": {
"account": {
"dashboard": "Dashboard",
"profile": "Profil",
"addresses": "Adressen",
"settings": "Astellungen"
}
}
}

View File

@@ -195,9 +195,25 @@ async def shop_account_dashboard_page(
},
)
# Collect dashboard cards from enabled modules via widget protocol
from app.modules.core.services.widget_aggregator import widget_aggregator
store = getattr(request.state, "store", None)
platform = getattr(request.state, "platform", None)
dashboard_cards = []
if store and platform:
dashboard_cards = widget_aggregator.get_storefront_dashboard_cards(
db,
store_id=store.id,
customer_id=current_customer.id,
platform_id=platform.id,
)
return templates.TemplateResponse(
"customers/storefront/dashboard.html",
get_storefront_context(request, db=db, user=current_customer),
get_storefront_context(
request, db=db, user=current_customer, dashboard_cards=dashboard_cards
),
)

View File

@@ -37,7 +37,7 @@
<!-- Empty State -->
<div x-show="!loading && !error && addresses.length === 0"
class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-12 text-center">
<span class="mx-auto h-12 w-12 text-gray-400 block" x-html="$icon('location-marker', 'h-12 w-12 mx-auto')"></span>
<span class="mx-auto h-12 w-12 text-gray-400 block" x-html="$icon('map-pin', 'h-12 w-12 mx-auto')"></span>
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No addresses yet</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Add your first address to speed up checkout.</p>
<button @click="openAddModal()"

View File

@@ -17,25 +17,31 @@
<!-- Dashboard Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<!-- Orders Card -->
<a href="{{ base_url }}account/orders"
{# Module-contributed cards (orders, loyalty, etc.) — rendered via widget protocol #}
{% for card in dashboard_cards|default([]) %}
<a href="{{ base_url }}{{ card.route }}"
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center mb-4">
<div class="flex-shrink-0">
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('shopping-bag', 'h-8 w-8')"></span>
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('{{ card.icon }}', 'h-8 w-8')"></span>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Orders</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">View order history</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ card.title }}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ card.subtitle }}</p>
</div>
</div>
{% if card.value is not none %}
<div>
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)">{{ user.total_orders }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Total Orders</p>
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)">{{ card.value }}</p>
{% if card.value_label %}
<p class="text-sm text-gray-500 dark:text-gray-400">{{ card.value_label }}</p>
{% endif %}
</div>
{% endif %}
</a>
{% endfor %}
<!-- Profile Card -->
<!-- Profile Card (always shown — core) -->
<a href="{{ base_url }}account/profile"
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center mb-4">
@@ -52,12 +58,12 @@
</div>
</a>
<!-- Addresses Card -->
<!-- Addresses Card (always shown — core) -->
<a href="{{ base_url }}account/addresses"
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center mb-4">
<div class="flex-shrink-0">
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('location-marker', 'h-8 w-8')"></span>
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('map-pin', 'h-8 w-8')"></span>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Addresses</h3>
@@ -66,36 +72,7 @@
</div>
</a>
{% if 'loyalty' in enabled_modules %}
<!-- Loyalty Rewards Card -->
<a href="{{ base_url }}account/loyalty"
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700"
x-data="{ points: null, loaded: false }"
x-init="fetch('/api/v1/storefront/loyalty/card').then(r => r.json()).then(d => { if (d.card) { points = d.card.points_balance; } loaded = true; }).catch(() => { loaded = true; })">
<div class="flex items-center mb-4">
<div class="flex-shrink-0">
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('gift', 'h-8 w-8')"></span>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Loyalty Rewards</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">View your points & rewards</p>
</div>
</div>
<div>
<template x-if="loaded && points !== null">
<div>
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)" x-text="points.toLocaleString()"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">Points Balance</p>
</div>
</template>
<template x-if="loaded && points === null">
<p class="text-sm text-gray-500 dark:text-gray-400">Join our rewards program</p>
</template>
</div>
</a>
{% endif %}
<!-- Messages Card -->
<!-- Messages Card (always shown — messaging is core) -->
<a href="{{ base_url }}account/messages"
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700"
x-data="{ unreadCount: 0 }"
@@ -126,10 +103,6 @@
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Customer Since</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.created_at.strftime('%B %Y') }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Total Orders</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.total_orders }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Customer Number</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.customer_number }}</p>

View File

@@ -58,6 +58,13 @@ def _get_feature_provider():
return loyalty_feature_provider
def _get_widget_provider():
"""Lazy import of widget provider to avoid circular imports."""
from app.modules.loyalty.services.loyalty_widgets import loyalty_widget_provider
return loyalty_widget_provider
def _get_onboarding_provider():
"""Lazy import of onboarding provider to avoid circular imports."""
from app.modules.loyalty.services.loyalty_onboarding_service import (
@@ -289,7 +296,7 @@ loyalty_module = ModuleDefinition(
items=[
MenuItemDefinition(
id="loyalty",
label_key="storefront.account.loyalty",
label_key="loyalty.storefront.account.loyalty",
icon="gift",
route="account/loyalty",
order=60,
@@ -328,6 +335,8 @@ loyalty_module = ModuleDefinition(
],
# Feature provider for billing feature gating
feature_provider=_get_feature_provider,
# Widget provider for storefront dashboard cards
widget_provider=_get_widget_provider,
# Onboarding provider for post-signup checklist
onboarding_provider=_get_onboarding_provider,
)

View File

@@ -294,40 +294,106 @@ Tracked separately, not blocking launch.
---
## Critical Path
## Development Status (as of 2026-04-16)
```
Phase 0 (done) ──┬─► Phase 1 ──┬─► Phase 3 ──┐
├─► Phase 2 ──┤ ├─► Phase 8 ──► LAUNCH
└─► Phase 5 ──┘ │
Phase 4, 6, 7 (parallelizable) ───────────┘
**All development phases (0-8) are COMPLETE.** 342 automated tests pass.
Phase 9 — post-launch
```
| Phase | Status | Completed |
|---|---|---|
| Phase 0 — Decisions | ✅ Done | 2026-04-09 |
| Phase 1 — Config & Security | ✅ Done | 2026-04-09 |
| Phase 1.x — Cross-store enrollment fix | ✅ Done | 2026-04-10 |
| Phase 2A — Transactional notifications (5 templates) | ✅ Done | 2026-04-11 |
| Phase 3 — Task reliability (batched expiration + wallet backoff) | ✅ Done | 2026-04-11 |
| Phase 4.1 — T&C via CMS | ✅ Done | 2026-04-11 |
| Phase 4.2 — Accessibility audit | ✅ Done | 2026-04-11 |
| Phase 5 — Wallet UI flags | ✅ Done (already handled) | 2026-04-11 |
| Phase 6 — GDPR, bulk ops, point restore, cascade restore | ✅ Done | 2026-04-11 |
| Phase 7 — Analytics (cohort, churn, revenue + Chart.js) | ✅ Done | 2026-04-11 |
| Phase 8 — Runbooks, monitoring docs, OpenAPI tags | ✅ Done | 2026-04-11 |
Phases 4, 6, 7 can run in parallel with 2/3/5 if multiple developers are available.
**Additional bugfixes during manual testing (2026-04-15):**
## Effort Summary
| Phase | Days |
|---|---|
| 0 — Decisions | done |
| 1 — Config & security | 2 |
| 2 — Notifications | 4 |
| 3 — Task reliability | 1.5 |
| 4 — A11y + CMS T&C | 2 |
| 5 — Google Wallet hardening | 1 |
| 6 — Admin / GDPR / bulk | 3 |
| 7 — Analytics | 2.5 |
| 8 — Tests / docs / observability | 2 |
| **Launch total** | **~18 days sequential, ~10 with 2 parallel tracks** |
| 9 — Apple Wallet (post-launch) | 3 |
- Terminal redeem: `card_id``id` normalization across schemas/JS
- Card detail: enrolled store name resolution, copy buttons, paginated transactions
- i18n flicker: server-rendered translations on success page
- Icon fix: `device-mobile``phone`
---
## Open Items Needing Sign-off
## Pre-Launch Checklist
1. ~~**Rate limit caps**~~ — confirmed.
2. **Email copywriting** for the 7 templates × 4 locales (Phase 2.3) — flow: I draft EN, Samir reviews, then translate.
3. ~~**`birth_date` column**~~ — confirmed missing; addressed in Phase 1.4. No backfill needed (not yet live).
Everything below must be completed before going live. Items are ordered by dependency.
### Step 1: Seed email templates on prod DB
- [ ] SSH into prod server
- [ ] Run: `python scripts/seed/seed_email_templates_loyalty.py`
- [ ] Verify: 20 rows created (5 templates × 4 locales)
- [ ] Review EN email copy — adjust subject lines/body if needed via admin UI at `/admin/email-templates`
### Step 2: Deploy Google Wallet service account
- [ ] Place service account JSON at `~/apps/orion/google-wallet-sa.json` (app user, mode 600)
- [ ] Set `LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=~/apps/orion/google-wallet-sa.json` in prod `.env`
- [ ] Set `LOYALTY_GOOGLE_ISSUER_ID=<your issuer ID>` in prod `.env`
- [ ] Restart app — verify no startup error (validator checks file exists)
- [ ] Verify: `GET /api/v1/admin/loyalty/wallet-status` returns `google_configured: true`
### Step 3: Apply database migrations
- [ ] Run: `alembic upgrade heads`
- [ ] Verify migrations applied: `loyalty_003` through `loyalty_006`, `customers_003`
### Step 4: FR/DE/LB translations for new analytics i18n keys
- [ ] Add translations for 7 keys in `app/modules/loyalty/locales/{fr,de,lb}.json`:
- `store.analytics.revenue_title`
- `store.analytics.at_risk_title`
- `store.analytics.cards_at_risk`
- `store.analytics.no_at_risk`
- `store.analytics.cohort_title`
- `store.analytics.cohort_month`
- `store.analytics.cohort_enrolled`
- `store.analytics.no_data_yet`
### Step 5: Investigate email template menu visibility
- [ ] Check if `messaging.manage_templates` permission is assigned to `merchant_owner` role
- [ ] If not, add it to permission discovery or default role assignments
- [ ] Verify menu appears at `/store/{store_code}/email-templates`
- [ ] Verify admin menu at `/admin/email-templates` shows loyalty templates
### Step 6: Manual E2E testing (user journeys)
Follow the **Pre-Launch E2E Test Checklist** at the bottom of `user-journeys.md`:
- [ ] **Test 1:** Customer self-enrollment (with birthday)
- [ ] **Test 2:** Cross-store re-enrollment (cross-location enabled)
- [ ] **Test 3:** Staff operations — stamps/points via terminal
- [ ] **Test 4:** Cross-store redemption (earn at store1, redeem at store2)
- [ ] **Test 5:** Customer views dashboard + transaction history
- [ ] **Test 6:** Void/return flow
- [ ] **Test 7:** Admin oversight (programs, merchants, analytics)
- [ ] **Test 8:** Cross-location disabled behavior (separate cards per store)
### Step 7: Google Wallet real-device test
- [ ] Enroll a test customer on prod
- [ ] Tap "Add to Google Wallet" on success page
- [ ] Open Google Wallet on Android device — verify pass renders
- [ ] Trigger a stamp/points transaction — verify pass auto-updates within 60s
### Step 8: Go live
- [ ] Remove any test data from prod DB (test customers, test cards)
- [ ] Verify Celery workers are running (`loyalty.expire_points`, `loyalty.sync_wallet_passes`)
- [ ] Verify SMTP is configured and test email sends work
- [ ] Enable the loyalty platform for production stores
- [ ] Monitor first 24h: check email logs, wallet sync, expiration task
---
## Post-Launch Roadmap
| Item | Priority | Effort | Notes |
|---|---|---|---|
| **Phase 9 — Apple Wallet** | P1 | 3d | Requires Apple Developer certs. See `runbook-wallet-certs.md`. |
| **Phase 2B — Marketing module** | P2 | 4d | Birthday + re-engagement emails. Cross-platform (OMS, loyalty, hosting). |
| **Coverage to 80%** | P2 | 2d | Needs Celery task mocking infrastructure for task-level tests. |
| **Admin trash UI** | P3 | 2d | Trash tab on programs/cards pages using existing `?only_deleted=true` API. The cascade restore API exists but has no UI. |
| **Bulk PIN assignment** | P3 | 1d | Batch create staff PINs. API exists for single PIN; needs bulk endpoint + UI. |
| **Cross-location enforcement** | P3 | 2d | `allow_cross_location_redemption` controls enrollment behavior but stamp/point operations don't enforce it yet. |
| **Email template menu** | P2 | 0.5d | Investigate and fix `messaging.manage_templates` permission for store owners. |

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -420,7 +420,7 @@ def _build_card_lookup_response(card, db=None) -> CardLookupResponse:
available_rewards.append(reward)
return CardLookupResponse(
card_id=card.id,
id=card.id,
card_number=card.card_number,
customer_id=card.customer_id,
customer_name=card.customer.full_name if card.customer else None,
@@ -514,6 +514,17 @@ def get_card_detail(
program = card.program
customer = card.customer
# Resolve enrolled store name
enrolled_store_name = None
if card.enrolled_at_store_id:
from app.modules.tenancy.services.store_service import store_service
enrolled_store = store_service.get_store_by_id_optional(
db, card.enrolled_at_store_id
)
if enrolled_store:
enrolled_store_name = enrolled_store.name
return CardDetailResponse(
id=card.id,
card_number=card.card_number,
@@ -521,6 +532,7 @@ def get_card_detail(
merchant_id=card.merchant_id,
program_id=card.program_id,
enrolled_at_store_id=card.enrolled_at_store_id,
enrolled_at_store_name=enrolled_store_name,
customer_name=customer.full_name if customer else None,
customer_email=customer.email if customer else None,
merchant_name=card.merchant.name if card.merchant else None,

View File

@@ -100,6 +100,7 @@ class CardDetailResponse(CardResponse):
# Merchant info
merchant_name: str | None = None
enrolled_at_store_name: str | None = None
# Program info
program_name: str
@@ -128,7 +129,7 @@ class CardLookupResponse(BaseModel):
"""Schema for card lookup by QR code or card number."""
# Card info
card_id: int
id: int
card_number: str
# Customer

View File

@@ -0,0 +1,81 @@
# app/modules/loyalty/services/loyalty_widgets.py
"""
Loyalty dashboard widget provider.
Provides storefront dashboard cards for loyalty-related data.
Implements get_storefront_dashboard_cards from DashboardWidgetProviderProtocol.
"""
import logging
from sqlalchemy.orm import Session
from app.modules.contracts.widgets import (
DashboardWidget,
StorefrontDashboardCard,
WidgetContext,
)
logger = logging.getLogger(__name__)
class LoyaltyWidgetProvider:
"""Widget provider for loyalty module."""
@property
def widgets_category(self) -> str:
return "loyalty"
def get_store_widgets(
self,
db: Session,
store_id: int,
context: WidgetContext | None = None,
) -> list[DashboardWidget]:
return []
def get_platform_widgets(
self,
db: Session,
platform_id: int,
context: WidgetContext | None = None,
) -> list[DashboardWidget]:
return []
def get_storefront_dashboard_cards(
self,
db: Session,
store_id: int,
customer_id: int,
context: WidgetContext | None = None,
) -> list[StorefrontDashboardCard]:
"""Provide the Loyalty Rewards card for the customer dashboard."""
from app.modules.loyalty.models.loyalty_card import LoyaltyCard
card = (
db.query(LoyaltyCard)
.filter(
LoyaltyCard.customer_id == customer_id,
LoyaltyCard.is_active.is_(True),
)
.first()
)
points = card.points_balance if card else None
subtitle = "View your points & rewards" if card else "Join our rewards program"
return [
StorefrontDashboardCard(
key="loyalty.rewards",
icon="gift",
title="Loyalty Rewards",
subtitle=subtitle,
route="account/loyalty",
value=points,
value_label="Points Balance" if points is not None else None,
order=30,
),
]
loyalty_widget_provider = LoyaltyWidgetProvider()

View File

@@ -24,6 +24,12 @@ function storeLoyaltyAnalytics() {
estimated_liability_cents: 0,
},
// Advanced analytics
cohortData: { cohorts: [] },
churnData: { at_risk_count: 0, cards: [] },
revenueData: { monthly: [], by_store: [] },
revenueChart: null,
loading: false,
error: null,
@@ -56,6 +62,7 @@ function storeLoyaltyAnalytics() {
await this.loadProgram();
if (this.program) {
await this.loadStats();
this.loadAdvancedAnalytics();
}
loyaltyAnalyticsLog.info('=== LOYALTY ANALYTICS PAGE INITIALIZATION COMPLETE ===');
},
@@ -99,6 +106,72 @@ function storeLoyaltyAnalytics() {
}
},
async loadAdvancedAnalytics() {
try {
const [cohort, churn, revenue] = await Promise.all([
apiClient.get('/store/loyalty/analytics/cohorts'),
apiClient.get('/store/loyalty/analytics/churn'),
apiClient.get('/store/loyalty/analytics/revenue'),
]);
if (cohort) this.cohortData = cohort;
if (churn) this.churnData = churn;
if (revenue) {
this.revenueData = revenue;
this.$nextTick(() => this.renderRevenueChart());
}
loyaltyAnalyticsLog.info('Advanced analytics loaded');
} catch (error) {
loyaltyAnalyticsLog.warn('Advanced analytics failed:', error.message);
}
},
renderRevenueChart() {
const canvas = document.getElementById('revenueChart');
if (!canvas || !window.Chart || !this.revenueData.monthly.length) return;
if (this.revenueChart) this.revenueChart.destroy();
const labels = this.revenueData.monthly.map(m => m.month);
const pointsData = this.revenueData.monthly.map(m => m.total_points_earned);
const customersData = this.revenueData.monthly.map(m => m.unique_customers);
this.revenueChart = new Chart(canvas, {
type: 'bar',
data: {
labels,
datasets: [
{
label: 'Points Earned',
data: pointsData,
backgroundColor: 'rgba(99, 102, 241, 0.7)',
borderRadius: 4,
yAxisID: 'y',
},
{
label: 'Active Customers',
data: customersData,
type: 'line',
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
fill: true,
tension: 0.3,
yAxisID: 'y1',
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { position: 'bottom' } },
scales: {
y: { position: 'left', title: { display: true, text: 'Points' } },
y1: { position: 'right', title: { display: true, text: 'Customers' }, grid: { drawOnChartArea: false } },
},
},
});
},
formatNumber(num) {
if (num === null || num === undefined) return '0';
return new Intl.NumberFormat('en-US').format(num);

View File

@@ -11,6 +11,7 @@ function storeLoyaltyCardDetail() {
cardId: null,
card: null,
transactions: [],
pagination: { page: 1, per_page: 20, total: 0 },
loading: false,
error: null,
@@ -38,6 +39,13 @@ function storeLoyaltyCardDetail() {
return;
}
// Use platform pagination setting if available
if (window.PlatformSettings) {
try {
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
} catch (e) { /* use default */ }
}
await this.loadData();
loyaltyCardDetailLog.info('=== LOYALTY CARD DETAIL PAGE INITIALIZATION COMPLETE ===');
},
@@ -67,18 +75,49 @@ function storeLoyaltyCardDetail() {
}
},
async loadTransactions() {
async loadTransactions(page = 1) {
try {
const response = await apiClient.get(`/store/loyalty/cards/${this.cardId}/transactions?limit=50`);
const skip = (page - 1) * this.pagination.per_page;
const response = await apiClient.get(
`/store/loyalty/cards/${this.cardId}/transactions?skip=${skip}&limit=${this.pagination.per_page}`
);
if (response && response.transactions) {
this.transactions = response.transactions;
loyaltyCardDetailLog.info(`Loaded ${this.transactions.length} transactions`);
this.pagination.total = response.total || 0;
this.pagination.page = page;
loyaltyCardDetailLog.info(`Loaded ${this.transactions.length} of ${this.pagination.total} transactions (page ${page})`);
}
} catch (error) {
loyaltyCardDetailLog.warn('Failed to load transactions:', error.message);
}
},
// Standard pagination interface (matches shared pagination macro)
get totalPages() {
return Math.max(1, Math.ceil(this.pagination.total / this.pagination.per_page));
},
get startIndex() {
if (this.pagination.total === 0) return 0;
return (this.pagination.page - 1) * this.pagination.per_page + 1;
},
get endIndex() {
return Math.min(this.pagination.page * this.pagination.per_page, this.pagination.total);
},
get pageNumbers() {
const pages = [];
for (let i = 1; i <= this.totalPages; i++) {
if (i === 1 || i === this.totalPages || Math.abs(i - this.pagination.page) <= 1) {
pages.push(i);
} else if (pages[pages.length - 1] !== '...') {
pages.push('...');
}
}
return pages;
},
previousPage() { if (this.pagination.page > 1) this.loadTransactions(this.pagination.page - 1); },
nextPage() { if (this.pagination.page < this.totalPages) this.loadTransactions(this.pagination.page + 1); },
goToPage(p) { if (p >= 1 && p <= this.totalPages) this.loadTransactions(p); },
formatNumber(num) {
return num == null ? '0' : new Intl.NumberFormat('en-US').format(num);
},

View File

@@ -284,7 +284,7 @@ function storeLoyaltyTerminal() {
loyaltyTerminalLog.info('Adding stamp...');
await apiClient.post('/store/loyalty/stamp', {
card_id: this.selectedCard.card_id,
card_id: this.selectedCard.id,
staff_pin: this.pinDigits
});
@@ -296,7 +296,7 @@ function storeLoyaltyTerminal() {
loyaltyTerminalLog.info('Redeeming stamps...');
await apiClient.post('/store/loyalty/stamp/redeem', {
card_id: this.selectedCard.card_id,
card_id: this.selectedCard.id,
staff_pin: this.pinDigits
});
@@ -308,7 +308,7 @@ function storeLoyaltyTerminal() {
loyaltyTerminalLog.info('Earning points...', { amount: this.earnAmount });
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),
staff_pin: this.pinDigits
});
@@ -327,7 +327,7 @@ function storeLoyaltyTerminal() {
loyaltyTerminalLog.info('Redeeming reward...', { reward: reward.name });
await apiClient.post('/store/loyalty/points/redeem', {
card_id: this.selectedCard.card_id,
card_id: this.selectedCard.id,
reward_id: this.selectedReward,
staff_pin: this.pinDigits
});
@@ -340,7 +340,7 @@ function storeLoyaltyTerminal() {
// Refresh card data
async refreshCard() {
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) {
this.selectedCard = response;
}

View File

@@ -46,6 +46,90 @@
{% set show_merchants_metric = false %}
{% include "loyalty/shared/analytics-stats.html" %}
<!-- Advanced Analytics Charts -->
<div class="grid gap-6 md:grid-cols-2 mb-6">
<!-- Revenue Chart -->
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('chart-bar', 'inline w-5 h-5 mr-2')"></span>
{{ _('loyalty.store.analytics.revenue_title') }}
</h3>
<div x-show="revenueData.monthly.length > 0" style="height: 250px;">
<canvas id="revenueChart"></canvas>
</div>
<p x-show="revenueData.monthly.length === 0" class="text-sm text-gray-500 dark:text-gray-400 py-8 text-center">
{{ _('loyalty.store.analytics.no_data_yet') }}
</p>
</div>
<!-- Churn / At-Risk Cards -->
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('exclamation-triangle', 'inline w-5 h-5 mr-2')"></span>
{{ _('loyalty.store.analytics.at_risk_title') }}
</h3>
<div x-show="churnData.at_risk_count > 0">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
<span class="text-2xl font-bold text-orange-600" x-text="churnData.at_risk_count"></span>
{{ _('loyalty.store.analytics.cards_at_risk') }}
</p>
<div class="space-y-2 max-h-48 overflow-y-auto">
<template x-for="card in churnData.cards?.slice(0, 10)" :key="card.card_id">
<div class="flex items-center justify-between text-sm py-1 border-b border-gray-100 dark:border-gray-700">
<span class="text-gray-700 dark:text-gray-300" x-text="card.customer_name || card.card_number"></span>
<span class="text-orange-600 font-medium" x-text="card.days_inactive + 'd inactive'"></span>
</div>
</template>
</div>
</div>
<p x-show="churnData.at_risk_count === 0" class="text-sm text-green-600 dark:text-green-400 py-8 text-center">
{{ _('loyalty.store.analytics.no_at_risk') }}
</p>
</div>
</div>
<!-- Cohort Retention -->
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800 mb-6">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('table-cells', 'inline w-5 h-5 mr-2')"></span>
{{ _('loyalty.store.analytics.cohort_title') }}
</h3>
<div x-show="cohortData.cohorts?.length > 0" class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr>
<th class="px-3 py-2 text-left text-gray-600 dark:text-gray-400">{{ _('loyalty.store.analytics.cohort_month') }}</th>
<th class="px-3 py-2 text-center text-gray-600 dark:text-gray-400">{{ _('loyalty.store.analytics.cohort_enrolled') }}</th>
<template x-for="(_, i) in Array(6)" :key="i">
<th class="px-3 py-2 text-center text-gray-600 dark:text-gray-400" x-text="'M' + i"></th>
</template>
</tr>
</thead>
<tbody>
<template x-for="cohort in cohortData.cohorts" :key="cohort.month">
<tr class="border-t border-gray-100 dark:border-gray-700">
<td class="px-3 py-2 font-medium text-gray-900 dark:text-white" x-text="cohort.month"></td>
<td class="px-3 py-2 text-center text-gray-600 dark:text-gray-400" x-text="cohort.enrolled"></td>
<template x-for="(pct, i) in cohort.retention.slice(0, 6)" :key="i">
<td class="px-3 py-2 text-center">
<span class="inline-block px-2 py-1 rounded text-xs font-medium"
:class="pct >= 60 ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : pct >= 30 ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'"
x-text="pct + '%'"></span>
</td>
</template>
<template x-for="i in Math.max(0, 6 - cohort.retention.length)" :key="'empty-' + i">
<td class="px-3 py-2 text-center text-gray-300">-</td>
</template>
</tr>
</template>
</tbody>
</table>
</div>
<p x-show="!cohortData.cohorts?.length" class="text-sm text-gray-500 dark:text-gray-400 py-8 text-center">
{{ _('loyalty.store.analytics.no_data_yet') }}
</p>
</div>
<!-- Quick Actions -->
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">{{ _('loyalty.store.analytics.quick_actions') }}</h3>
@@ -71,5 +155,6 @@
{% endblock %}
{% block extra_scripts %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script defer src="{{ url_for('loyalty_static', path='store/js/loyalty-analytics.js') }}"></script>
{% endblock %}

View File

@@ -3,6 +3,7 @@
{% from 'shared/macros/headers.html' import detail_page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% from 'shared/macros/pagination.html' import pagination %}
{% block title %}{{ _('loyalty.store.card_detail.title') }}{% endblock %}
@@ -69,15 +70,30 @@
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.name') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_name || '-'">-</p>
<div class="flex items-center gap-2">
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_name || '-'">-</p>
<button x-show="card?.customer_name" @click="Utils.copyToClipboard(card.customer_name)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
</button>
</div>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.email') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_email || '-'">-</p>
<div class="flex items-center gap-2">
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_email || '-'">-</p>
<button x-show="card?.customer_email" @click="Utils.copyToClipboard(card.customer_email)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
</button>
</div>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.phone') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_phone || '-'">-</p>
<div class="flex items-center gap-2">
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_phone || '-'">-</p>
<button x-show="card?.customer_phone" @click="Utils.copyToClipboard(card.customer_phone)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
</button>
</div>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.birthday') }}</p>
@@ -95,7 +111,12 @@
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.card_number') }}</p>
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="card?.card_number">-</p>
<div class="flex items-center gap-2">
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="card?.card_number">-</p>
<button x-show="card?.card_number" @click="Utils.copyToClipboard(card.card_number)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
</button>
</div>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.status') }}</p>
@@ -151,6 +172,8 @@
</template>
</tbody>
{% endcall %}
{{ pagination() }}
</div>
</div>
{% endblock %}

View File

@@ -115,7 +115,7 @@
<!-- Shipping Address -->
<div class="min-w-0 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800" x-show="order">
<h4 class="mb-4 font-semibold text-gray-600 dark:text-gray-300 flex items-center gap-2">
<span x-html="$icon('location-marker', 'w-5 h-5')"></span>
<span x-html="$icon('map-pin', 'w-5 h-5')"></span>
Shipping Address
</h4>
<div class="text-sm text-gray-700 dark:text-gray-300 space-y-1">

View File

@@ -192,7 +192,7 @@ messaging_module = ModuleDefinition(
items=[
MenuItemDefinition(
id="messages",
label_key="storefront.account.messages",
label_key="messaging.storefront.account.messages",
icon="chat-bubble-left-right",
route="account/messages",
order=50,

View File

@@ -125,5 +125,10 @@
"send_messages_desc": "Nachrichten an Kunden senden",
"manage_templates": "Vorlagen verwalten",
"manage_templates_desc": "Nachrichtenvorlagen erstellen und bearbeiten"
},
"storefront": {
"account": {
"messages": "Nachrichten"
}
}
}

View File

@@ -125,5 +125,10 @@
"status_opened": "Opened",
"status_clicked": "Clicked",
"retention_note": "Email body content is retained for 90 days. Metadata is kept indefinitely."
},
"storefront": {
"account": {
"messages": "Messages"
}
}
}

View File

@@ -125,5 +125,10 @@
"send_messages_desc": "Envoyer des messages aux clients",
"manage_templates": "Gérer les modèles",
"manage_templates_desc": "Créer et modifier les modèles de messages"
},
"storefront": {
"account": {
"messages": "Messages"
}
}
}

View File

@@ -125,5 +125,10 @@
"send_messages_desc": "Messagen u Clienten schécken",
"manage_templates": "Schablounen verwalten",
"manage_templates_desc": "Messageschablounen erstellen an änneren"
},
"storefront": {
"account": {
"messages": "Noriichten"
}
}
}

View File

@@ -43,6 +43,13 @@ def _get_feature_provider():
return order_feature_provider
def _get_widget_provider():
"""Lazy import of widget provider to avoid circular imports."""
from app.modules.orders.services.order_widgets import order_widget_provider
return order_widget_provider
# Orders module definition
orders_module = ModuleDefinition(
code="orders",
@@ -146,7 +153,7 @@ orders_module = ModuleDefinition(
items=[
MenuItemDefinition(
id="orders",
label_key="storefront.account.orders",
label_key="orders.storefront.account.orders",
icon="clipboard-list",
route="account/orders",
order=40,
@@ -168,6 +175,7 @@ orders_module = ModuleDefinition(
# Metrics provider for dashboard statistics
metrics_provider=_get_metrics_provider,
feature_provider=_get_feature_provider,
widget_provider=_get_widget_provider,
)

View File

@@ -89,5 +89,10 @@
"orders_cancel_desc": "Ausstehende oder in Bearbeitung befindliche Bestellungen stornieren",
"orders_refund": "Bestellungen erstatten",
"orders_refund_desc": "Rückerstattungen für Bestellungen verarbeiten"
},
"storefront": {
"account": {
"orders": "Bestellungen"
}
}
}

View File

@@ -89,5 +89,10 @@
"store_operations": "Store Operations",
"sales_orders": "Sales & Orders",
"orders": "Orders"
},
"storefront": {
"account": {
"orders": "Orders"
}
}
}

View File

@@ -89,5 +89,10 @@
"orders_cancel_desc": "Annuler les commandes en attente ou en traitement",
"orders_refund": "Rembourser les commandes",
"orders_refund_desc": "Traiter les remboursements des commandes"
},
"storefront": {
"account": {
"orders": "Commandes"
}
}
}

View File

@@ -89,5 +89,10 @@
"orders_cancel_desc": "Aussteesend oder a Veraarbechtung Bestellunge stornéieren",
"orders_refund": "Bestellungen erstatten",
"orders_refund_desc": "Réckerstattunge fir Bestellunge veraarbechten"
},
"storefront": {
"account": {
"orders": "Bestellungen"
}
}
}

View File

@@ -0,0 +1,80 @@
# app/modules/orders/services/order_widgets.py
"""
Orders dashboard widget provider.
Provides storefront dashboard cards for order-related data.
Implements get_storefront_dashboard_cards from DashboardWidgetProviderProtocol.
"""
import logging
from sqlalchemy.orm import Session
from app.modules.contracts.widgets import (
DashboardWidget,
StorefrontDashboardCard,
WidgetContext,
)
logger = logging.getLogger(__name__)
class OrderWidgetProvider:
"""Widget provider for orders module."""
@property
def widgets_category(self) -> str:
return "orders"
def get_store_widgets(
self,
db: Session,
store_id: int,
context: WidgetContext | None = None,
) -> list[DashboardWidget]:
return []
def get_platform_widgets(
self,
db: Session,
platform_id: int,
context: WidgetContext | None = None,
) -> list[DashboardWidget]:
return []
def get_storefront_dashboard_cards(
self,
db: Session,
store_id: int,
customer_id: int,
context: WidgetContext | None = None,
) -> list[StorefrontDashboardCard]:
"""Provide the Orders card for the customer dashboard."""
from app.modules.orders.models.customer_order_stats import CustomerOrderStats
stats = (
db.query(CustomerOrderStats)
.filter(
CustomerOrderStats.store_id == store_id,
CustomerOrderStats.customer_id == customer_id,
)
.first()
)
total_orders = stats.total_orders if stats else 0
return [
StorefrontDashboardCard(
key="orders.summary",
icon="shopping-bag",
title="Orders",
subtitle="View order history",
route="account/orders",
value=total_orders,
value_label="Total Orders",
order=10,
),
]
order_widget_provider = OrderWidgetProvider()

View File

@@ -178,7 +178,7 @@
<!-- Shipping Address -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<span class="h-5 w-5 mr-2 text-gray-400" x-html="$icon('location-marker', 'h-5 w-5')"></span>
<span class="h-5 w-5 mr-2 text-gray-400" x-html="$icon('map-pin', 'h-5 w-5')"></span>
Shipping Address
</h3>
<div class="text-sm text-gray-600 dark:text-gray-300 space-y-1">
@@ -302,7 +302,7 @@
rel="noopener noreferrer"
class="mt-3 w-full inline-flex justify-center items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white transition-colors"
style="background-color: var(--color-primary)">
<span class="h-4 w-4 mr-2" x-html="$icon('location-marker', 'h-4 w-4')"></span>
<span class="h-4 w-4 mr-2" x-html="$icon('map-pin', 'h-4 w-4')"></span>
Track Package
</a>
</div>

View File

@@ -105,7 +105,7 @@
<button type="button" @click="getLocation()"
:disabled="gettingLocation"
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:shadow-outline-gray disabled:opacity-50">
<span x-html="$icon('location-marker', 'w-4 h-4 mr-2')"></span>
<span x-html="$icon('map-pin', 'w-4 h-4 mr-2')"></span>
<span x-text="gettingLocation ? 'Getting location...' : (form.location_lat ? 'Location saved' : 'Get Location')"></span>
</button>
<span x-show="form.location_lat" class="text-xs text-green-600 dark:text-green-400 self-center">

View File

@@ -11,7 +11,6 @@
{# SEO Meta Tags #}
<meta name="description" content="{% block meta_description %}{{ platform.get_translated_description(current_language|default('fr'), platform.default_language|default('fr')) if platform else '' }}{% endblock %}">
<meta name="keywords" content="{% block meta_keywords %}letzshop, order management, oms, luxembourg, e-commerce, invoicing, inventory{% endblock %}">
{# Favicon #}
<link rel="icon" type="image/x-icon" href="{{ url_for('static', path='favicon.ico') }}">

View File

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

View File

@@ -14,7 +14,6 @@
{# SEO Meta Tags #}
<meta name="description" content="{% block meta_description %}{{ store.description or 'Shop at ' + store.name }}{% endblock %}">
<meta name="keywords" content="{% block meta_keywords %}{{ store.name }}, online shop{% endblock %}">
{# Favicon - store-specific or default #}
{% if theme.branding.favicon %}
@@ -100,38 +99,22 @@
{# 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>
{# Right side actions #}
{# Right side actions — module-provided via header_template #}
<div class="flex items-center space-x-4">
{% if 'catalog' in enabled_modules|default([]) %}
{# 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 enabled_modules|default([]) %}
{# 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 %}
{% 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()"
@@ -272,7 +255,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 +315,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 +326,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 +337,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 %}

View File

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

View File

@@ -0,0 +1,248 @@
# 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: Template-driven section palette
The **template** (page type) defines which sections are available — not a hardcoded list filtered by context. The admin section editor loads the available section types from a template config.
| Template | Available Sections |
|----------|-------------------|
| `default` (platform homepage) | hero, features, products, pricing, testimonials, gallery, contact_info, cta |
| `full` (store landing page) | hero, features, testimonials, gallery, contact_info, cta |
Implementation: a `TEMPLATE_SECTION_PALETTE` dict mapping template name → list of allowed section types. The route handler passes the palette to the editor JS, which only renders sections in the palette. This keeps the logic in one place and sets up Phase C/D — when sections become an ordered array with add/remove, the template defines the palette of available types, and modules can extend that palette.
### 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
- [ ] Template-driven section palette (template defines which sections are available)
- [ ] 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

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

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

View File

@@ -2097,6 +2097,321 @@ SHARED_PLATFORM_PAGES = [
# ============================================================================
STORE_DEFAULT_HOME = {
"slug": "home",
"title": "{{store_name}}",
"title_translations": tt(
"{{store_name}}",
"{{store_name}}",
"{{store_name}}",
"{{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",
@@ -2753,6 +3068,22 @@ 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") 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
for page_data in STORE_DEFAULTS_COMMON:
if _create_page(db, platform.id, page_data, is_platform_page=False):
created_count += 1

View File

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