Compare commits
33 Commits
8cd09f3f89
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 56c94ac2f4 | |||
| 255ac6525e | |||
| 10e37e749b | |||
| f23990a4d9 | |||
| 62b83b46a4 | |||
| f8b2429533 | |||
| 3883927be0 | |||
| 39e02f0d9b | |||
| 29593f4c61 | |||
| 220f7e3a08 | |||
| 258aa6a34b | |||
| 51bcc9f874 | |||
| eafa086c73 | |||
| ab2daf99bd | |||
| 1cf9fea40a | |||
| cd4f83f2cb | |||
| 457350908a | |||
| e759282116 | |||
| 1df1b2bfca | |||
| 51a2114e02 | |||
| 21e4ac5124 | |||
| 3ade1b9354 | |||
| b5bb9415f6 | |||
| bb3d6f0012 | |||
| c92fe1261b | |||
| ca152cd544 | |||
| 914967edcc | |||
| 64fe58c171 | |||
| 3044490a3e | |||
| adc36246b8 | |||
| dd9dc04328 | |||
| 4a60d75a13 | |||
| e98eddc168 |
@@ -1744,3 +1744,39 @@ def get_current_customer_optional(
|
||||
except Exception:
|
||||
# Invalid token, store mismatch, or other error
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STOREFRONT MODULE GATING
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def make_storefront_module_gate(module_code: str):
|
||||
"""
|
||||
Create a FastAPI dependency that gates storefront routes by module enablement.
|
||||
|
||||
Used by main.py at route registration time: each non-core module's storefront
|
||||
router gets this dependency injected automatically. The framework already knows
|
||||
which module owns each route via RouteInfo.module_code — no hardcoded path map.
|
||||
|
||||
Args:
|
||||
module_code: The module code to check (e.g. "catalog", "orders", "loyalty")
|
||||
|
||||
Returns:
|
||||
A FastAPI dependency function
|
||||
"""
|
||||
|
||||
async def _check_module_enabled(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> None:
|
||||
from app.modules.service import module_service
|
||||
|
||||
platform = getattr(request.state, "platform", None)
|
||||
if not platform:
|
||||
return # No platform context — let other middleware handle it
|
||||
|
||||
if not module_service.is_module_enabled(db, platform.id, module_code):
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
|
||||
return _check_module_enabled
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
||||
<span x-html="$icon('location-marker', 'w-6 h-6')"></span>
|
||||
<span x-html="$icon('map-pin', 'w-6 h-6')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="formatNumber(analytics.inventory?.total_locations || 0)"></p>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,5 +44,10 @@
|
||||
"product_not_available": "Product not available",
|
||||
"error_adding": "Error adding item to cart",
|
||||
"error_updating": "Error updating cart"
|
||||
},
|
||||
"storefront": {
|
||||
"actions": {
|
||||
"cart": "Cart"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,5 +44,10 @@
|
||||
"view_desc": "Voir les paniers des clients",
|
||||
"manage": "Gérer les paniers",
|
||||
"manage_desc": "Modifier et gérer les paniers des clients"
|
||||
},
|
||||
"storefront": {
|
||||
"actions": {
|
||||
"cart": "Panier"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,5 +44,10 @@
|
||||
"view_desc": "Clientekuerf kucken",
|
||||
"manage": "Kuerf verwalten",
|
||||
"manage_desc": "Clientekuerf änneren a verwalten"
|
||||
},
|
||||
"storefront": {
|
||||
"actions": {
|
||||
"cart": "Kuerf"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -89,5 +89,13 @@
|
||||
"products_import_desc": "Massenimport von Produkten",
|
||||
"products_export": "Produkte exportieren",
|
||||
"products_export_desc": "Produktdaten exportieren"
|
||||
},
|
||||
"storefront": {
|
||||
"nav": {
|
||||
"products": "Produkte"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Suchen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,5 +107,13 @@
|
||||
"menu": {
|
||||
"products_inventory": "Products & Inventory",
|
||||
"all_products": "All Products"
|
||||
},
|
||||
"storefront": {
|
||||
"nav": {
|
||||
"products": "Products"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Search"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,5 +89,13 @@
|
||||
"products_import_desc": "Importation en masse de produits",
|
||||
"products_export": "Exporter les produits",
|
||||
"products_export_desc": "Exporter les données produits"
|
||||
},
|
||||
"storefront": {
|
||||
"nav": {
|
||||
"products": "Produits"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Rechercher"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,5 +89,13 @@
|
||||
"products_import_desc": "Massenimport vu Produiten",
|
||||
"products_export": "Produiten exportéieren",
|
||||
"products_export_desc": "Produitdaten exportéieren"
|
||||
},
|
||||
"storefront": {
|
||||
"nav": {
|
||||
"products": "Produkter"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Sichen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,10 @@ router = APIRouter()
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||||
@router.get("/products", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def shop_products_page(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Render shop homepage / product catalog.
|
||||
Render product catalog listing.
|
||||
Shows featured products and categories.
|
||||
"""
|
||||
logger.debug(
|
||||
|
||||
@@ -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>
|
||||
@@ -388,5 +388,15 @@
|
||||
},
|
||||
"confirmations": {
|
||||
"delete_file": "Sind Sie sicher, dass Sie diese Datei löschen möchten? Dies kann nicht rückgängig gemacht werden."
|
||||
},
|
||||
"storefront": {
|
||||
"my_account": "Mein Konto",
|
||||
"learn_more": "Mehr erfahren",
|
||||
"explore": "Entdecken",
|
||||
"quick_links": "Schnellzugriff",
|
||||
"information": "Informationen",
|
||||
"about": "Über uns",
|
||||
"contact": "Kontakt",
|
||||
"faq": "FAQ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,5 +388,15 @@
|
||||
"content_pages": "Content Pages",
|
||||
"store_themes": "Store Themes",
|
||||
"media_library": "Media Library"
|
||||
},
|
||||
"storefront": {
|
||||
"my_account": "My Account",
|
||||
"learn_more": "Learn More",
|
||||
"explore": "Explore",
|
||||
"quick_links": "Quick Links",
|
||||
"information": "Information",
|
||||
"about": "About Us",
|
||||
"contact": "Contact",
|
||||
"faq": "FAQ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,5 +388,15 @@
|
||||
},
|
||||
"confirmations": {
|
||||
"delete_file": "Êtes-vous sûr de vouloir supprimer ce fichier ? Cette action est irréversible."
|
||||
},
|
||||
"storefront": {
|
||||
"my_account": "Mon Compte",
|
||||
"learn_more": "En savoir plus",
|
||||
"explore": "Découvrir",
|
||||
"quick_links": "Liens rapides",
|
||||
"information": "Informations",
|
||||
"about": "À propos",
|
||||
"contact": "Contact",
|
||||
"faq": "FAQ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,5 +388,15 @@
|
||||
},
|
||||
"confirmations": {
|
||||
"delete_file": "Sidd Dir sécher datt Dir dëse Fichier läsche wëllt? Dat kann net réckgängeg gemaach ginn."
|
||||
},
|
||||
"storefront": {
|
||||
"my_account": "Mäi Kont",
|
||||
"learn_more": "Méi gewuer ginn",
|
||||
"explore": "Entdecken",
|
||||
"quick_links": "Schnellzougrëff",
|
||||
"information": "Informatiounen",
|
||||
"about": "Iwwer eis",
|
||||
"contact": "Kontakt",
|
||||
"faq": "FAQ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"""
|
||||
CMS Admin Page Routes (HTML rendering).
|
||||
|
||||
Admin pages for managing platform and store content pages.
|
||||
Admin pages for managing platform and store content pages,
|
||||
and store theme customization.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Request
|
||||
@@ -10,6 +11,7 @@ from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_menu_access
|
||||
from app.modules.core.utils.page_context import get_admin_context
|
||||
from app.modules.enums import FrontendType
|
||||
from app.modules.tenancy.models import User
|
||||
from app.templates_config import templates
|
||||
@@ -86,3 +88,49 @@ async def admin_content_page_edit(
|
||||
"page_id": page_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STORE THEMES
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/store-themes", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_store_themes_page(
|
||||
request: Request,
|
||||
current_user: User = Depends(
|
||||
require_menu_access("store-themes", FrontendType.ADMIN)
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render store themes selection page.
|
||||
Allows admins to select a store to customize their theme.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"cms/admin/store-themes.html",
|
||||
get_admin_context(request, db, current_user),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stores/{store_code}/theme",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def admin_store_theme_page(
|
||||
request: Request,
|
||||
store_code: str = Path(..., description="Store code"),
|
||||
current_user: User = Depends(
|
||||
require_menu_access("store-themes", FrontendType.ADMIN)
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render store theme customization page.
|
||||
Allows admins to customize colors, fonts, layout, and branding.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"cms/admin/store-theme.html",
|
||||
get_admin_context(request, db, current_user, store_code=store_code),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -459,5 +459,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script defer src="{{ url_for('tenancy_static', path='admin/js/store-theme.js') }}"></script>
|
||||
<script defer src="{{ url_for('cms_static', path='admin/js/store-theme.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -125,5 +125,5 @@
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/js/tom-select.complete.min.js"></script>
|
||||
<script defer src="{{ url_for('tenancy_static', path='admin/js/store-themes.js') }}"></script>
|
||||
<script defer src="{{ url_for('cms_static', path='admin/js/store-themes.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -3,7 +3,7 @@
|
||||
{% extends "storefront/base.html" %}
|
||||
|
||||
{# Dynamic title from CMS #}
|
||||
{% block title %}{{ page.title }}{% endblock %}
|
||||
{% block title %}{{ page_title or page.title }}{% endblock %}
|
||||
|
||||
{# SEO from CMS #}
|
||||
{% block meta_description %}{{ page.meta_description or page.title }}{% endblock %}
|
||||
@@ -16,13 +16,13 @@
|
||||
<div class="breadcrumb mb-6">
|
||||
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
|
||||
<span>/</span>
|
||||
<span class="text-gray-900 dark:text-gray-200 font-medium">{{ page.title }}</span>
|
||||
<span class="text-gray-900 dark:text-gray-200 font-medium">{{ page_title or page.title }}</span>
|
||||
</div>
|
||||
|
||||
{# Page Header #}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-4">
|
||||
{{ page.title }}
|
||||
{{ page_title or page.title }}
|
||||
</h1>
|
||||
|
||||
{# Optional: Show store override badge for debugging #}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
{# app/templates/store/landing-default.html #}
|
||||
{# standalone #}
|
||||
{# app/modules/cms/templates/cms/storefront/landing-default.html #}
|
||||
{# Default/Minimal Landing Page Template #}
|
||||
{% extends "storefront/base.html" %}
|
||||
|
||||
{% block title %}{{ store.name }}{% endblock %}
|
||||
{% block meta_description %}{{ page.meta_description or store.description or store.name }}{% endblock %}
|
||||
{% block meta_description %}{{ page.meta_description or store.description or store.name if page else store.description or store.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-screen">
|
||||
@@ -24,7 +23,7 @@
|
||||
|
||||
{# Title #}
|
||||
<h1 class="text-4xl md:text-6xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
{{ page.title or store.name }}
|
||||
{{ page_title or store.name }}
|
||||
</h1>
|
||||
|
||||
{# Tagline #}
|
||||
@@ -34,18 +33,31 @@
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{# CTA Button #}
|
||||
{# CTA Buttons — driven by storefront_nav (module-agnostic) #}
|
||||
{% set nav_items = storefront_nav.get('nav', []) %}
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a href="{{ base_url }}"
|
||||
{% if nav_items %}
|
||||
{# Primary CTA: first nav item from enabled modules #}
|
||||
<a href="{{ base_url }}{{ nav_items[0].route }}"
|
||||
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-white bg-primary hover:bg-primary-dark transition-colors shadow-lg hover:shadow-xl"
|
||||
style="background-color: var(--color-primary)">
|
||||
Browse Our Shop
|
||||
{{ _(nav_items[0].label_key) }}
|
||||
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>
|
||||
</a>
|
||||
{% if page.content %}
|
||||
{% else %}
|
||||
{# Fallback: account link when no module nav items #}
|
||||
<a href="{{ base_url }}account/login"
|
||||
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-white bg-primary hover:bg-primary-dark transition-colors shadow-lg hover:shadow-xl"
|
||||
style="background-color: var(--color-primary)">
|
||||
{{ _('cms.storefront.my_account') }}
|
||||
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if page and page.content %}
|
||||
<a href="#about"
|
||||
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-2 border-gray-200 dark:border-gray-600">
|
||||
Learn More
|
||||
{{ _('cms.storefront.learn_more') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -54,73 +66,65 @@
|
||||
</section>
|
||||
|
||||
{# Content Section (if provided) #}
|
||||
{% if page.content %}
|
||||
{% if page_content %}
|
||||
<section id="about" class="py-16 bg-white dark:bg-gray-900">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="prose prose-lg dark:prose-invert max-w-none">
|
||||
{{ page.content | safe }}{# sanitized: CMS content #}
|
||||
{{ page_content | safe }}{# sanitized: CMS content #}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{# Quick Links Section #}
|
||||
{# Quick Links Section — driven by nav items and CMS pages #}
|
||||
{% set account_items = storefront_nav.get('account', []) %}
|
||||
{% set all_links = nav_items + account_items %}
|
||||
{% if all_links or header_pages %}
|
||||
<section class="py-16 bg-gray-50 dark:bg-gray-800">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 class="text-3xl font-bold text-center text-gray-900 dark:text-white mb-12">
|
||||
Explore
|
||||
{{ _('cms.storefront.explore') }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<a href="{{ base_url }}products"
|
||||
{# Module nav items (products, loyalty, etc.) #}
|
||||
{% for item in all_links[:3] %}
|
||||
<a href="{{ base_url }}{{ item.route }}"
|
||||
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
|
||||
<div class="text-4xl mb-4">🛍️</div>
|
||||
<div class="mb-4">
|
||||
<span class="h-10 w-10 text-primary mx-auto" style="color: var(--color-primary)"
|
||||
x-html="$icon('{{ item.icon }}', 'h-10 w-10 mx-auto')"></span>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
|
||||
Shop Products
|
||||
{{ _(item.label_key) }}
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Browse our complete catalog
|
||||
</p>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
{% if header_pages %}
|
||||
{% for page in header_pages[:2] %}
|
||||
{# Fill remaining slots with CMS header pages #}
|
||||
{% set remaining = 3 - all_links[:3]|length %}
|
||||
{% if remaining > 0 and header_pages %}
|
||||
{% for page in header_pages[:remaining] %}
|
||||
<a href="{{ base_url }}{{ page.slug }}"
|
||||
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
|
||||
<div class="text-4xl mb-4">📄</div>
|
||||
<div class="mb-4">
|
||||
<span class="h-10 w-10 text-primary mx-auto" style="color: var(--color-primary)"
|
||||
x-html="$icon('document-text', 'h-10 w-10 mx-auto')"></span>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
|
||||
{{ page.title }}
|
||||
</h3>
|
||||
{% if page.meta_description %}
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
{{ page.meta_description or 'Learn more' }}
|
||||
{{ page.meta_description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<a href="{{ base_url }}about"
|
||||
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
|
||||
<div class="text-4xl mb-4">ℹ️</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
|
||||
About Us
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Learn about our story
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a href="{{ base_url }}contact"
|
||||
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
|
||||
<div class="text-4xl mb-4">📧</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
|
||||
Contact
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Get in touch with us
|
||||
</p>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -80,6 +80,44 @@ class WidgetContext:
|
||||
include_details: bool = False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Storefront Dashboard Card
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class StorefrontDashboardCard:
|
||||
"""
|
||||
A card contributed by a module to the storefront customer dashboard.
|
||||
|
||||
Modules implement get_storefront_dashboard_cards() to provide these.
|
||||
The dashboard template renders them without knowing which module provided them.
|
||||
|
||||
Attributes:
|
||||
key: Unique identifier (e.g. "orders.summary", "loyalty.points")
|
||||
icon: Lucide icon name (e.g. "shopping-bag", "gift")
|
||||
title: Card title (i18n key or plain text)
|
||||
subtitle: Card subtitle / description
|
||||
route: Link destination relative to base_url (e.g. "account/orders")
|
||||
value: Primary display value (e.g. order count, points balance)
|
||||
value_label: Label for the value (e.g. "Total Orders", "Points Balance")
|
||||
order: Sort order (lower = shown first)
|
||||
template: Optional custom template path for complex rendering
|
||||
extra_data: Additional data for custom template rendering
|
||||
"""
|
||||
|
||||
key: str
|
||||
icon: str
|
||||
title: str
|
||||
subtitle: str
|
||||
route: str
|
||||
value: str | int | None = None
|
||||
value_label: str | None = None
|
||||
order: int = 100
|
||||
template: str | None = None
|
||||
extra_data: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Widget Item Types
|
||||
# =============================================================================
|
||||
@@ -330,6 +368,30 @@ class DashboardWidgetProviderProtocol(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
def get_storefront_dashboard_cards(
|
||||
self,
|
||||
db: "Session",
|
||||
store_id: int,
|
||||
customer_id: int,
|
||||
context: WidgetContext | None = None,
|
||||
) -> list["StorefrontDashboardCard"]:
|
||||
"""
|
||||
Get cards for the storefront customer dashboard.
|
||||
|
||||
Called by the customer account dashboard. Each module contributes
|
||||
its own cards (e.g. orders summary, loyalty points).
|
||||
|
||||
Args:
|
||||
db: Database session for queries
|
||||
store_id: ID of the store
|
||||
customer_id: ID of the logged-in customer
|
||||
context: Optional filtering/scoping context
|
||||
|
||||
Returns:
|
||||
List of StorefrontDashboardCard objects
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Context
|
||||
@@ -343,6 +405,8 @@ __all__ = [
|
||||
"WidgetData",
|
||||
# Main envelope
|
||||
"DashboardWidget",
|
||||
# Storefront
|
||||
"StorefrontDashboardCard",
|
||||
# Protocol
|
||||
"DashboardWidgetProviderProtocol",
|
||||
]
|
||||
|
||||
@@ -9,11 +9,13 @@ Store pages for core functionality:
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import (
|
||||
UserContext,
|
||||
get_current_store_from_cookie_or_header,
|
||||
get_current_store_optional,
|
||||
get_db,
|
||||
get_resolved_store_code,
|
||||
)
|
||||
@@ -24,6 +26,21 @@ from app.templates_config import templates
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STORE ROOT REDIRECT
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/", response_class=RedirectResponse, include_in_schema=False)
|
||||
async def store_root(
|
||||
current_user: UserContext | None = Depends(get_current_store_optional),
|
||||
):
|
||||
"""Redirect /store/ based on authentication status."""
|
||||
if current_user:
|
||||
return RedirectResponse(url="/store/dashboard", status_code=302)
|
||||
return RedirectResponse(url="/store/login", status_code=302)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STORE DASHBOARD
|
||||
# ============================================================================
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ from sqlalchemy.orm import Session
|
||||
from app.modules.contracts.widgets import (
|
||||
DashboardWidget,
|
||||
DashboardWidgetProviderProtocol,
|
||||
StorefrontDashboardCard,
|
||||
WidgetContext,
|
||||
)
|
||||
|
||||
@@ -233,6 +234,49 @@ class WidgetAggregatorService:
|
||||
return widget
|
||||
return None
|
||||
|
||||
def get_storefront_dashboard_cards(
|
||||
self,
|
||||
db: Session,
|
||||
store_id: int,
|
||||
customer_id: int,
|
||||
platform_id: int,
|
||||
context: WidgetContext | None = None,
|
||||
) -> list[StorefrontDashboardCard]:
|
||||
"""
|
||||
Get dashboard cards for the storefront customer account page.
|
||||
|
||||
Collects cards from all enabled modules that implement
|
||||
get_storefront_dashboard_cards(), sorted by order.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
store_id: ID of the store
|
||||
customer_id: ID of the logged-in customer
|
||||
platform_id: Platform ID (for module enablement check)
|
||||
context: Optional filtering/scoping context
|
||||
|
||||
Returns:
|
||||
Flat list of StorefrontDashboardCard sorted by order
|
||||
"""
|
||||
providers = self._get_enabled_providers(db, platform_id)
|
||||
cards: list[StorefrontDashboardCard] = []
|
||||
|
||||
for module, provider in providers:
|
||||
if not hasattr(provider, "get_storefront_dashboard_cards"):
|
||||
continue
|
||||
try:
|
||||
module_cards = provider.get_storefront_dashboard_cards(
|
||||
db, store_id, customer_id, context
|
||||
)
|
||||
if module_cards:
|
||||
cards.extend(module_cards)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to get storefront cards from module {module.code}: {e}"
|
||||
)
|
||||
|
||||
return sorted(cards, key=lambda c: c.order)
|
||||
|
||||
def get_available_categories(
|
||||
self, db: Session, platform_id: int
|
||||
) -> list[str]:
|
||||
|
||||
@@ -141,28 +141,28 @@ customers_module = ModuleDefinition(
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="dashboard",
|
||||
label_key="storefront.account.dashboard",
|
||||
label_key="customers.storefront.account.dashboard",
|
||||
icon="home",
|
||||
route="account/dashboard",
|
||||
order=10,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="profile",
|
||||
label_key="storefront.account.profile",
|
||||
label_key="customers.storefront.account.profile",
|
||||
icon="user",
|
||||
route="account/profile",
|
||||
order=20,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="addresses",
|
||||
label_key="storefront.account.addresses",
|
||||
label_key="customers.storefront.account.addresses",
|
||||
icon="map-pin",
|
||||
route="account/addresses",
|
||||
order=30,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="settings",
|
||||
label_key="storefront.account.settings",
|
||||
label_key="customers.storefront.account.settings",
|
||||
icon="cog",
|
||||
route="account/settings",
|
||||
order=90,
|
||||
|
||||
@@ -52,5 +52,13 @@
|
||||
"customers_delete_desc": "Kundendatensätze entfernen",
|
||||
"customers_export": "Kunden exportieren",
|
||||
"customers_export_desc": "Kundendaten exportieren"
|
||||
},
|
||||
"storefront": {
|
||||
"account": {
|
||||
"dashboard": "Dashboard",
|
||||
"profile": "Profil",
|
||||
"addresses": "Adressen",
|
||||
"settings": "Einstellungen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,5 +52,13 @@
|
||||
"customers_section": "Customers",
|
||||
"customers": "Customers",
|
||||
"all_customers": "All Customers"
|
||||
},
|
||||
"storefront": {
|
||||
"account": {
|
||||
"dashboard": "Dashboard",
|
||||
"profile": "Profile",
|
||||
"addresses": "Addresses",
|
||||
"settings": "Settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,5 +52,13 @@
|
||||
"customers_delete_desc": "Supprimer les fiches clients",
|
||||
"customers_export": "Exporter les clients",
|
||||
"customers_export_desc": "Exporter les données clients"
|
||||
},
|
||||
"storefront": {
|
||||
"account": {
|
||||
"dashboard": "Tableau de bord",
|
||||
"profile": "Profil",
|
||||
"addresses": "Adresses",
|
||||
"settings": "Paramètres"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,5 +52,13 @@
|
||||
"customers_delete_desc": "Clientedossieren ewechhuelen",
|
||||
"customers_export": "Clienten exportéieren",
|
||||
"customers_export_desc": "Clientedaten exportéieren"
|
||||
},
|
||||
"storefront": {
|
||||
"account": {
|
||||
"dashboard": "Dashboard",
|
||||
"profile": "Profil",
|
||||
"addresses": "Adressen",
|
||||
"settings": "Astellungen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,9 +195,25 @@ async def shop_account_dashboard_page(
|
||||
},
|
||||
)
|
||||
|
||||
# Collect dashboard cards from enabled modules via widget protocol
|
||||
from app.modules.core.services.widget_aggregator import widget_aggregator
|
||||
|
||||
store = getattr(request.state, "store", None)
|
||||
platform = getattr(request.state, "platform", None)
|
||||
dashboard_cards = []
|
||||
if store and platform:
|
||||
dashboard_cards = widget_aggregator.get_storefront_dashboard_cards(
|
||||
db,
|
||||
store_id=store.id,
|
||||
customer_id=current_customer.id,
|
||||
platform_id=platform.id,
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"customers/storefront/dashboard.html",
|
||||
get_storefront_context(request, db=db, user=current_customer),
|
||||
get_storefront_context(
|
||||
request, db=db, user=current_customer, dashboard_cards=dashboard_cards
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<!-- Empty State -->
|
||||
<div x-show="!loading && !error && addresses.length === 0"
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<span class="mx-auto h-12 w-12 text-gray-400 block" x-html="$icon('location-marker', 'h-12 w-12 mx-auto')"></span>
|
||||
<span class="mx-auto h-12 w-12 text-gray-400 block" x-html="$icon('map-pin', 'h-12 w-12 mx-auto')"></span>
|
||||
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No addresses yet</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Add your first address to speed up checkout.</p>
|
||||
<button @click="openAddModal()"
|
||||
|
||||
@@ -17,25 +17,31 @@
|
||||
<!-- Dashboard Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
|
||||
<!-- Orders Card -->
|
||||
<a href="{{ base_url }}account/orders"
|
||||
{# Module-contributed cards (orders, loyalty, etc.) — rendered via widget protocol #}
|
||||
{% for card in dashboard_cards|default([]) %}
|
||||
<a href="{{ base_url }}{{ card.route }}"
|
||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('shopping-bag', 'h-8 w-8')"></span>
|
||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('{{ card.icon }}', 'h-8 w-8')"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Orders</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">View order history</p>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ card.title }}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ card.subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if card.value is not none %}
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)">{{ user.total_orders }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Total Orders</p>
|
||||
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)">{{ card.value }}</p>
|
||||
{% if card.value_label %}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ card.value_label }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Profile Card -->
|
||||
<!-- Profile Card (always shown — core) -->
|
||||
<a href="{{ base_url }}account/profile"
|
||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center mb-4">
|
||||
@@ -52,12 +58,12 @@
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Addresses Card -->
|
||||
<!-- Addresses Card (always shown — core) -->
|
||||
<a href="{{ base_url }}account/addresses"
|
||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('location-marker', 'h-8 w-8')"></span>
|
||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('map-pin', 'h-8 w-8')"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Addresses</h3>
|
||||
@@ -66,36 +72,7 @@
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{% if 'loyalty' in enabled_modules %}
|
||||
<!-- Loyalty Rewards Card -->
|
||||
<a href="{{ base_url }}account/loyalty"
|
||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700"
|
||||
x-data="{ points: null, loaded: false }"
|
||||
x-init="fetch('/api/v1/storefront/loyalty/card').then(r => r.json()).then(d => { if (d.card) { points = d.card.points_balance; } loaded = true; }).catch(() => { loaded = true; })">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('gift', 'h-8 w-8')"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Loyalty Rewards</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">View your points & rewards</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<template x-if="loaded && points !== null">
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)" x-text="points.toLocaleString()"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Points Balance</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="loaded && points === null">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Join our rewards program</p>
|
||||
</template>
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Messages Card -->
|
||||
<!-- Messages Card (always shown — messaging is core) -->
|
||||
<a href="{{ base_url }}account/messages"
|
||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700"
|
||||
x-data="{ unreadCount: 0 }"
|
||||
@@ -126,10 +103,6 @@
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Customer Since</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.created_at.strftime('%B %Y') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Total Orders</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.total_orders }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Customer Number</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.customer_number }}</p>
|
||||
|
||||
@@ -58,6 +58,13 @@ def _get_feature_provider():
|
||||
return loyalty_feature_provider
|
||||
|
||||
|
||||
def _get_widget_provider():
|
||||
"""Lazy import of widget provider to avoid circular imports."""
|
||||
from app.modules.loyalty.services.loyalty_widgets import loyalty_widget_provider
|
||||
|
||||
return loyalty_widget_provider
|
||||
|
||||
|
||||
def _get_onboarding_provider():
|
||||
"""Lazy import of onboarding provider to avoid circular imports."""
|
||||
from app.modules.loyalty.services.loyalty_onboarding_service import (
|
||||
@@ -289,7 +296,7 @@ loyalty_module = ModuleDefinition(
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="loyalty",
|
||||
label_key="storefront.account.loyalty",
|
||||
label_key="loyalty.storefront.account.loyalty",
|
||||
icon="gift",
|
||||
route="account/loyalty",
|
||||
order=60,
|
||||
@@ -328,6 +335,8 @@ loyalty_module = ModuleDefinition(
|
||||
],
|
||||
# Feature provider for billing feature gating
|
||||
feature_provider=_get_feature_provider,
|
||||
# Widget provider for storefront dashboard cards
|
||||
widget_provider=_get_widget_provider,
|
||||
# Onboarding provider for post-signup checklist
|
||||
onboarding_provider=_get_onboarding_provider,
|
||||
)
|
||||
|
||||
64
app/modules/loyalty/docs/monitoring.md
Normal file
64
app/modules/loyalty/docs/monitoring.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Loyalty Module — Monitoring & Alerting
|
||||
|
||||
## Alert Definitions
|
||||
|
||||
### P0 — Page (immediate action required)
|
||||
|
||||
| Alert | Condition | Action |
|
||||
|-------|-----------|--------|
|
||||
| **Expiration task stale** | `loyalty.expire_points` last success > 26 hours ago | Check Celery worker health, inspect task logs |
|
||||
| **Google Wallet service down** | Wallet sync failure rate > 50% for 2 consecutive runs | Check service account credentials, Google API status |
|
||||
|
||||
### P1 — Warn (investigate within business hours)
|
||||
|
||||
| Alert | Condition | Action |
|
||||
|-------|-----------|--------|
|
||||
| **Wallet sync failures** | `failed_card_ids` count > 5% of total cards synced | Check runbook-wallet-sync.md, inspect failed card IDs |
|
||||
| **Email notification failures** | `loyalty_*` template send failure rate > 1% in 24h | Check SMTP config, EmailLog for errors |
|
||||
| **Rate limit spikes** | 429 responses > 100/min per store | Investigate if legitimate traffic or abuse |
|
||||
|
||||
### P2 — Info (review in next sprint)
|
||||
|
||||
| Alert | Condition | Action |
|
||||
|-------|-----------|--------|
|
||||
| **High churn** | At-risk cards > 20% of active cards | Review re-engagement strategy (future marketing module) |
|
||||
| **Low enrollment** | < 5 new cards in 7 days (per merchant with active program) | Check enrollment page accessibility, QR code placement |
|
||||
|
||||
## Key Metrics to Track
|
||||
|
||||
### Operational
|
||||
|
||||
- Celery task success/failure counts for `loyalty.expire_points` and `loyalty.sync_wallet_passes`
|
||||
- EmailLog status distribution for `loyalty_*` template codes (sent/failed/bounced)
|
||||
- Rate limiter 429 response count per store per hour
|
||||
|
||||
### Business
|
||||
|
||||
- Daily new enrollments (total + per merchant)
|
||||
- Points issued vs redeemed ratio (health indicator: should be > 0.3 redemption rate)
|
||||
- Stamp completion rate (% of cards reaching stamps_target)
|
||||
- Cohort retention at month 3 (target: > 40%)
|
||||
|
||||
## Observability Integration
|
||||
|
||||
The loyalty module logs to the standard Python logger (`app.modules.loyalty.*`). Key log events:
|
||||
|
||||
| Logger | Level | Event |
|
||||
|--------|-------|-------|
|
||||
| `card_service` | INFO | Enrollment, deactivation, GDPR anonymization |
|
||||
| `stamp_service` | INFO | Stamp add/redeem/void with card and store context |
|
||||
| `points_service` | INFO | Points earn/redeem/void/adjust |
|
||||
| `notification_service` | INFO | Email queued (template_code + recipient) |
|
||||
| `point_expiration` | INFO | Chunk processed (cards + points count) |
|
||||
| `wallet_sync` | WARNING | Per-card sync failure with retry count |
|
||||
| `wallet_sync` | ERROR | Card sync exhausted all retries |
|
||||
|
||||
## Dashboard Suggestions
|
||||
|
||||
If using Grafana or similar:
|
||||
|
||||
1. **Enrollment funnel**: Page views → Form starts → Submissions → Success (track drop-off)
|
||||
2. **Transaction volume**: Stamps + Points per hour, grouped by store
|
||||
3. **Wallet adoption**: % of cards with Google/Apple Wallet passes
|
||||
4. **Email delivery**: Sent → Delivered → Opened → Clicked per template
|
||||
5. **Task health**: Celery task execution time + success rate over 24h
|
||||
@@ -100,7 +100,7 @@ All 8 decisions locked. No external blockers.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Notifications Infrastructure *(4d)*
|
||||
### Phase 2A — Notifications Infrastructure *(✅ DONE 2026-04-11)*
|
||||
|
||||
#### 2.1 `LoyaltyNotificationService`
|
||||
- New `app/modules/loyalty/services/notification_service.py` with methods:
|
||||
@@ -144,7 +144,7 @@ All 8 decisions locked. No external blockers.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Task Reliability *(1.5d)*
|
||||
### Phase 3 — Task Reliability *(✅ DONE 2026-04-11)*
|
||||
|
||||
#### 3.1 Batched point expiration
|
||||
- Rewrite `tasks/point_expiration.py:154-185` from per-card loop to set-based SQL:
|
||||
@@ -163,7 +163,7 @@ All 8 decisions locked. No external blockers.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Accessibility & T&C *(2d)*
|
||||
### Phase 4 — Accessibility & T&C *(✅ DONE 2026-04-11)*
|
||||
|
||||
#### 4.1 T&C via store CMS integration
|
||||
- Migration `loyalty_007`: add `terms_cms_page_slug: str | None` to `loyalty_programs`.
|
||||
@@ -182,7 +182,7 @@ All 8 decisions locked. No external blockers.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5 — Google Wallet Production Hardening *(1d)*
|
||||
### Phase 5 — Google Wallet Production Hardening *(✅ UI done 2026-04-11, deploy is manual)*
|
||||
|
||||
#### 5.1 Cert deployment
|
||||
- Place service account JSON at `~/apps/orion/google-wallet-sa.json`, app user, mode 600.
|
||||
@@ -199,7 +199,7 @@ All 8 decisions locked. No external blockers.
|
||||
|
||||
---
|
||||
|
||||
### Phase 6 — Admin UX, GDPR, Bulk *(3d)*
|
||||
### Phase 6 — Admin UX, GDPR, Bulk *(✅ DONE 2026-04-11)*
|
||||
|
||||
#### 6.1 Admin trash UI
|
||||
- Trash tab on programs list and cards list, calling existing `?only_deleted=true` API.
|
||||
@@ -236,7 +236,7 @@ All 8 decisions locked. No external blockers.
|
||||
|
||||
---
|
||||
|
||||
### Phase 7 — Advanced Analytics *(2.5d)*
|
||||
### Phase 7 — Advanced Analytics *(✅ DONE 2026-04-11)*
|
||||
|
||||
#### 7.1 Cohort retention
|
||||
- New `services/analytics_service.py` (or extend `program_service`).
|
||||
@@ -255,7 +255,7 @@ All 8 decisions locked. No external blockers.
|
||||
|
||||
---
|
||||
|
||||
### Phase 8 — Tests, Docs, Observability *(2d)*
|
||||
### Phase 8 — Tests, Docs, Observability *(✅ DONE 2026-04-11)*
|
||||
|
||||
#### 8.1 Coverage enforcement
|
||||
- Loyalty CI job: `pytest app/modules/loyalty/tests --cov=app/modules/loyalty --cov-fail-under=80`.
|
||||
@@ -294,40 +294,120 @@ 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: Google Wallet — already deployed, verify config
|
||||
The Google Wallet integration is already deployed on the Hetzner server (see Step 25 of `hetzner-server-setup.md`):
|
||||
- Service account JSON at `~/apps/orion/google-wallet-sa.json` ✅
|
||||
- Docker volume mount in `docker-compose.yml` (`./google-wallet-sa.json:/app/google-wallet-sa.json:ro`) ✅
|
||||
- Env vars set: `LOYALTY_GOOGLE_ISSUER_ID=3388000000023089598`, `LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/app/google-wallet-sa.json` ✅
|
||||
- Service account linked to Issuer with Admin role ✅
|
||||
|
||||
Verify after deploy:
|
||||
- [ ] Restart app — confirm no startup error (config validator checks file exists)
|
||||
- [ ] `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 (demo mode)
|
||||
Google Wallet currently works in **demo/test mode** — only your Google account and explicitly added test accounts can see passes. This is sufficient for launch testing.
|
||||
- [ ] Enroll a test customer on prod
|
||||
- [ ] Tap "Add to Google Wallet" on success page
|
||||
- [ ] Open Google Wallet on Android device — verify pass renders with merchant branding
|
||||
- [ ] 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
|
||||
|
||||
### Step 9: Google Wallet production access (can be done post-launch)
|
||||
Passes in demo mode only work for test accounts. To make passes available to **all Android users**:
|
||||
- [ ] Go to [pay.google.com/business/console](https://pay.google.com/business/console) → **Google Wallet API** → **Manage**
|
||||
- [ ] Click **"Request production access"**
|
||||
- [ ] Fill in: business name, website URL (`rewardflow.lu`), contact info, pass type (Loyalty)
|
||||
- [ ] Upload 1-2 sample pass screenshots (e.g., Fashion Hub's card with their logo/colors). Google reviews the **Issuer** (your platform), not individual merchants — once approved, all merchants on the platform can issue passes.
|
||||
- [ ] Wait for Google approval (typically 1-3 business days). They check pass design complies with [brand guidelines](https://developers.google.com/wallet/loyalty/brand-guidelines).
|
||||
- [ ] Once approved: **no code or infra changes needed**. Same Issuer ID and service account, passes become visible to all Android users.
|
||||
|
||||
---
|
||||
|
||||
## 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. |
|
||||
|
||||
65
app/modules/loyalty/docs/runbook-expiration-task.md
Normal file
65
app/modules/loyalty/docs/runbook-expiration-task.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Runbook: Point Expiration Task
|
||||
|
||||
## Overview
|
||||
|
||||
The `loyalty.expire_points` Celery task runs daily at 02:00 (configured in `definition.py`). It processes all active programs with `points_expiration_days > 0`.
|
||||
|
||||
## What it does
|
||||
|
||||
1. **Warning emails** (14 days before expiry): finds cards whose last activity is past the warning threshold but not yet past the full expiration threshold. Sends `loyalty_points_expiring` email. Tracked via `last_expiration_warning_at` to prevent duplicates.
|
||||
|
||||
2. **Point expiration**: finds cards with `points_balance > 0` and `last_activity_at` older than `points_expiration_days`. Zeros the balance, creates `POINTS_EXPIRED` transaction, sends `loyalty_points_expired` email.
|
||||
|
||||
Processing is **chunked** (500 cards per batch with `FOR UPDATE SKIP LOCKED`) to avoid long-held row locks.
|
||||
|
||||
## Manual execution
|
||||
|
||||
```bash
|
||||
# Run directly (outside Celery)
|
||||
python -m app.modules.loyalty.tasks.point_expiration
|
||||
|
||||
# Via Celery
|
||||
celery -A app.core.celery_config call loyalty.expire_points
|
||||
```
|
||||
|
||||
## Partial failure handling
|
||||
|
||||
- Each chunk commits independently — if the task crashes mid-run, already-processed chunks are committed
|
||||
- `SKIP LOCKED` means concurrent workers won't block on the same rows
|
||||
- Notification failures are caught per-card and logged but don't stop the expiration
|
||||
|
||||
## Re-run for a specific merchant
|
||||
|
||||
Not currently supported via CLI. To expire points for a single merchant:
|
||||
|
||||
```python
|
||||
from app.core.database import SessionLocal
|
||||
from app.modules.loyalty.services.program_service import program_service
|
||||
from app.modules.loyalty.tasks.point_expiration import _process_program
|
||||
|
||||
db = SessionLocal()
|
||||
program = program_service.get_program_by_merchant(db, merchant_id=2)
|
||||
cards, points, warnings = _process_program(db, program)
|
||||
print(f"Expired {cards} cards, {points} points, {warnings} warnings")
|
||||
db.close()
|
||||
```
|
||||
|
||||
## Manual point restore
|
||||
|
||||
If points were expired incorrectly, use the admin API:
|
||||
|
||||
```
|
||||
POST /api/v1/admin/loyalty/cards/{card_id}/restore-points
|
||||
{
|
||||
"points": 500,
|
||||
"reason": "Incorrectly expired — customer was active"
|
||||
}
|
||||
```
|
||||
|
||||
This creates an `ADMIN_ADJUSTMENT` transaction and restores the balance.
|
||||
|
||||
## Monitoring
|
||||
|
||||
- Alert if `loyalty.expire_points` hasn't succeeded in 26 hours
|
||||
- Check Celery flower for task status and execution time
|
||||
- Expected runtime: < 1 minute for < 10k cards, scales linearly with chunk count
|
||||
51
app/modules/loyalty/docs/runbook-wallet-certs.md
Normal file
51
app/modules/loyalty/docs/runbook-wallet-certs.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Runbook: Wallet Certificate Management
|
||||
|
||||
## Google Wallet
|
||||
|
||||
### Service Account JSON
|
||||
|
||||
**Location (prod):** `~/apps/orion/google-wallet-sa.json` (app user, mode 600)
|
||||
|
||||
**Validation:** The app validates this file at startup via `config.py:google_sa_path_must_exist`. If missing or unreadable, the app fails fast with a clear error message.
|
||||
|
||||
### Rotation
|
||||
|
||||
1. Generate a new service account key in [Google Cloud Console](https://console.cloud.google.com/iam-admin/serviceaccounts)
|
||||
2. Download the JSON key file
|
||||
3. Replace the file at the prod path: `~/apps/orion/google-wallet-sa.json`
|
||||
4. Restart the app to pick up the new key
|
||||
5. Verify: check `GET /api/v1/admin/loyalty/wallet-status` returns `google_configured: true`
|
||||
|
||||
### Expiry Monitoring
|
||||
|
||||
Google service account keys don't expire by default, but Google recommends rotation every 90 days. Set a calendar reminder or monitoring alert.
|
||||
|
||||
### Rollback
|
||||
|
||||
Keep the previous key file as `google-wallet-sa.json.bak`. If the new key fails, restore the backup and restart.
|
||||
|
||||
---
|
||||
|
||||
## Apple Wallet (Phase 9 — not yet configured)
|
||||
|
||||
### Certificates Required
|
||||
|
||||
1. **Pass Type ID** — from Apple Developer portal
|
||||
2. **Team ID** — your Apple Developer team identifier
|
||||
3. **WWDR Certificate** — Apple Worldwide Developer Relations intermediate cert
|
||||
4. **Signer Certificate** — `.pem` for your Pass Type ID
|
||||
5. **Signer Key** — `.key` private key
|
||||
|
||||
### Planned Location
|
||||
|
||||
`~/apps/orion/apple-wallet/` with files: `wwdr.pem`, `signer.pem`, `signer.key`
|
||||
|
||||
### Apple Cert Expiry
|
||||
|
||||
Apple signing certificates typically expire after 1 year. The WWDR intermediate cert expires less frequently. Monitor via:
|
||||
|
||||
```bash
|
||||
openssl x509 -in signer.pem -noout -enddate
|
||||
```
|
||||
|
||||
Add a monitoring alert for < 30 days to expiry.
|
||||
57
app/modules/loyalty/docs/runbook-wallet-sync.md
Normal file
57
app/modules/loyalty/docs/runbook-wallet-sync.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Runbook: Wallet Sync Task
|
||||
|
||||
## Overview
|
||||
|
||||
The `loyalty.sync_wallet_passes` Celery task runs hourly (configured in `definition.py`). It catches cards that missed real-time wallet updates due to transient API errors.
|
||||
|
||||
## What it does
|
||||
|
||||
1. Finds cards with transactions in the last hour that have Google or Apple Wallet integration
|
||||
2. For each card, calls `wallet_service.sync_card_to_wallets(db, card)`
|
||||
3. Uses **exponential backoff** (1s, 4s, 16s) with 4 total attempts per card
|
||||
4. One failing card doesn't block the batch — failures are logged and reported
|
||||
|
||||
## Understanding `failed_card_ids`
|
||||
|
||||
The task returns a `failed_card_ids` list in its result. These are cards where all 4 retry attempts failed.
|
||||
|
||||
**Common failure causes:**
|
||||
- Google Wallet API transient 500/503 errors — usually resolve on next hourly run
|
||||
- Invalid service account credentials — check `wallet-status` endpoint
|
||||
- Card's Google object was deleted externally — needs manual re-creation
|
||||
- Network timeout — check server connectivity to `walletobjects.googleapis.com`
|
||||
|
||||
## Manual re-sync
|
||||
|
||||
```bash
|
||||
# Re-run the entire sync task
|
||||
celery -A app.core.celery_config call loyalty.sync_wallet_passes
|
||||
|
||||
# Re-sync a specific card (Python shell)
|
||||
from app.core.database import SessionLocal
|
||||
from app.modules.loyalty.services import wallet_service
|
||||
from app.modules.loyalty.models import LoyaltyCard
|
||||
|
||||
db = SessionLocal()
|
||||
card = db.query(LoyaltyCard).get(card_id)
|
||||
result = wallet_service.sync_card_to_wallets(db, card)
|
||||
print(result)
|
||||
db.close()
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
- Alert if `loyalty.sync_wallet_passes` failure rate > 5% (more than 5% of cards fail after all retries)
|
||||
- Check Celery flower for task execution time — should be < 30s for typical loads
|
||||
- Large `failed_card_ids` lists (> 10) may indicate a systemic API issue
|
||||
|
||||
## Retry behavior
|
||||
|
||||
| Attempt | Delay before | Total elapsed |
|
||||
|---------|-------------|---------------|
|
||||
| 1 | 0s | 0s |
|
||||
| 2 | 1s | 1s |
|
||||
| 3 | 4s | 5s |
|
||||
| 4 | 16s | 21s |
|
||||
|
||||
After attempt 4 fails, the card is added to `failed_card_ids` and will be retried on the next hourly run.
|
||||
@@ -879,6 +879,9 @@
|
||||
"previous": "Zurück",
|
||||
"next": "Weiter",
|
||||
"page_x_of_y": "Seite {page} von {pages}"
|
||||
},
|
||||
"account": {
|
||||
"loyalty": "Treueprogramm"
|
||||
}
|
||||
},
|
||||
"toasts": {
|
||||
|
||||
@@ -146,13 +146,17 @@
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"activate": "Activate",
|
||||
"active": "Active",
|
||||
"add": "Add",
|
||||
"all_stores": "All Stores",
|
||||
"at": "at",
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"continue": "Continue",
|
||||
"copy": "Copy",
|
||||
"deactivate": "Deactivate",
|
||||
"delete": "Delete",
|
||||
"disabled": "Disabled",
|
||||
"edit": "Edit",
|
||||
@@ -480,6 +484,10 @@
|
||||
"table_points_earned": "Points Earned",
|
||||
"table_points_redeemed": "Points Redeemed",
|
||||
"table_transactions_30d": "Transactions (30d)",
|
||||
"transaction_categories": "Transaction Categories",
|
||||
"select_store": "Select a store...",
|
||||
"no_categories": "No categories configured. Click Add to create one.",
|
||||
"delete_category_message": "Are you sure you want to delete this category? Existing transactions will keep the category reference but it will no longer be available for new transactions.",
|
||||
"admin_policy_settings": "Admin Policy Settings",
|
||||
"staff_pin_policy": "Staff PIN Policy",
|
||||
"self_enrollment": "Self Enrollment",
|
||||
@@ -709,6 +717,7 @@
|
||||
"reward_redeemed": "Reward redeemed: {name}",
|
||||
"card_label": "Card",
|
||||
"confirm": "Confirm",
|
||||
"select_category": "Category (what was sold)",
|
||||
"pin_authorize_text": "Enter your staff PIN to authorize this transaction",
|
||||
"free_item": "Free item",
|
||||
"reward_label": "Reward",
|
||||
@@ -761,7 +770,8 @@
|
||||
"col_location": "Location",
|
||||
"col_notes": "Notes",
|
||||
"no_transactions": "No transactions yet",
|
||||
"card_label": "Card"
|
||||
"card_label": "Card",
|
||||
"page_x_of_y": "Page {page} of {pages}"
|
||||
},
|
||||
"enroll": {
|
||||
"title": "Enroll Customer",
|
||||
@@ -801,7 +811,15 @@
|
||||
"quick_actions": "Quick Actions",
|
||||
"open_terminal": "Open Terminal",
|
||||
"view_members": "View Members",
|
||||
"view_program": "View Program"
|
||||
"view_program": "View Program",
|
||||
"revenue_title": "Points & Customers",
|
||||
"at_risk_title": "At-Risk Members",
|
||||
"cards_at_risk": "members at risk of churn",
|
||||
"no_at_risk": "All members are active!",
|
||||
"cohort_title": "Cohort Retention",
|
||||
"cohort_month": "Enrollment Month",
|
||||
"cohort_enrolled": "Enrolled",
|
||||
"no_data_yet": "Not enough data yet. Analytics will appear as customers enroll and transact."
|
||||
},
|
||||
"program": {
|
||||
"title": "Loyalty Program",
|
||||
@@ -879,6 +897,9 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page_x_of_y": "Page {page} of {pages}"
|
||||
},
|
||||
"account": {
|
||||
"loyalty": "Loyalty Rewards"
|
||||
}
|
||||
},
|
||||
"toasts": {
|
||||
|
||||
@@ -879,6 +879,9 @@
|
||||
"previous": "Précédent",
|
||||
"next": "Suivant",
|
||||
"page_x_of_y": "Page {page} sur {pages}"
|
||||
},
|
||||
"account": {
|
||||
"loyalty": "Récompenses fidélité"
|
||||
}
|
||||
},
|
||||
"toasts": {
|
||||
|
||||
@@ -879,6 +879,9 @@
|
||||
"previous": "Zréck",
|
||||
"next": "Weider",
|
||||
"page_x_of_y": "Säit {page} vun {pages}"
|
||||
},
|
||||
"account": {
|
||||
"loyalty": "Treieprogramm"
|
||||
}
|
||||
},
|
||||
"toasts": {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""loyalty 007 - add transaction categories
|
||||
|
||||
Adds store-scoped product categories (e.g., Men, Women, Accessories)
|
||||
that sellers select when entering loyalty transactions. Also adds
|
||||
category_id FK on loyalty_transactions.
|
||||
|
||||
Revision ID: loyalty_007
|
||||
Revises: loyalty_006
|
||||
Create Date: 2026-04-19
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "loyalty_007"
|
||||
down_revision = "loyalty_006"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"store_transaction_categories",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"store_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("stores.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("display_order", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.UniqueConstraint("store_id", "name", name="uq_store_category_name"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_store_category_store",
|
||||
"store_transaction_categories",
|
||||
["store_id", "is_active"],
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"loyalty_transactions",
|
||||
sa.Column(
|
||||
"category_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("store_transaction_categories.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("loyalty_transactions", "category_id")
|
||||
op.drop_index("idx_store_category_store", table_name="store_transaction_categories")
|
||||
op.drop_table("store_transaction_categories")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""loyalty 008 - add name_translations to transaction categories
|
||||
|
||||
Adds a JSON column for multi-language category names alongside the
|
||||
existing name field (used as fallback/default).
|
||||
|
||||
Revision ID: loyalty_008
|
||||
Revises: loyalty_007
|
||||
Create Date: 2026-04-19
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "loyalty_008"
|
||||
down_revision = "loyalty_007"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"store_transaction_categories",
|
||||
sa.Column(
|
||||
"name_translations",
|
||||
sa.JSON(),
|
||||
nullable=True,
|
||||
comment='Language-keyed name dict, e.g. {"en": "Men", "fr": "Hommes"}',
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("store_transaction_categories", "name_translations")
|
||||
@@ -0,0 +1,43 @@
|
||||
"""loyalty 009 - replace category_id FK with category_ids JSON
|
||||
|
||||
Switches from single-category to multi-category support on transactions.
|
||||
Not live yet so no data migration needed.
|
||||
|
||||
Revision ID: loyalty_009
|
||||
Revises: loyalty_008
|
||||
Create Date: 2026-04-19
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "loyalty_009"
|
||||
down_revision = "loyalty_008"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_column("loyalty_transactions", "category_id")
|
||||
op.add_column(
|
||||
"loyalty_transactions",
|
||||
sa.Column(
|
||||
"category_ids",
|
||||
sa.JSON(),
|
||||
nullable=True,
|
||||
comment="List of category IDs selected for this transaction",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("loyalty_transactions", "category_ids")
|
||||
op.add_column(
|
||||
"loyalty_transactions",
|
||||
sa.Column(
|
||||
"category_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("store_transaction_categories.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
@@ -49,6 +49,10 @@ from app.modules.loyalty.models.staff_pin import (
|
||||
# Model
|
||||
StaffPin,
|
||||
)
|
||||
from app.modules.loyalty.models.transaction_category import (
|
||||
# Model
|
||||
StoreTransactionCategory,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Enums
|
||||
@@ -62,4 +66,5 @@ __all__ = [
|
||||
"StaffPin",
|
||||
"AppleDeviceRegistration",
|
||||
"MerchantLoyaltySettings",
|
||||
"StoreTransactionCategory",
|
||||
]
|
||||
|
||||
@@ -25,6 +25,7 @@ from sqlalchemy import (
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.dialects.sqlite import JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
@@ -104,6 +105,11 @@ class LoyaltyTransaction(Base, TimestampMixin):
|
||||
index=True,
|
||||
comment="Staff PIN used for this operation",
|
||||
)
|
||||
category_ids = Column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
comment="List of category IDs selected for this transaction",
|
||||
)
|
||||
|
||||
# Related transaction (for voids/returns)
|
||||
related_transaction_id = Column(
|
||||
|
||||
56
app/modules/loyalty/models/transaction_category.py
Normal file
56
app/modules/loyalty/models/transaction_category.py
Normal file
@@ -0,0 +1,56 @@
|
||||
# app/modules/loyalty/models/transaction_category.py
|
||||
"""
|
||||
Store-scoped transaction categories.
|
||||
|
||||
Merchants configure 4-10 categories per store (e.g., Men, Women,
|
||||
Accessories, Kids) that sellers select when entering transactions.
|
||||
"""
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.dialects.sqlite import JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class StoreTransactionCategory(Base, TimestampMixin):
|
||||
"""Product category for loyalty transactions."""
|
||||
|
||||
__tablename__ = "store_transaction_categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
store_id = Column(Integer, ForeignKey("stores.id"), nullable=False)
|
||||
name = Column(String(100), nullable=False)
|
||||
name_translations = Column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
comment='Language-keyed name dict, e.g. {"en": "Men", "fr": "Hommes"}',
|
||||
)
|
||||
display_order = Column(Integer, nullable=False, default=0)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
# Relationships
|
||||
store = relationship("Store")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("store_id", "name", name="uq_store_category_name"),
|
||||
Index("idx_store_category_store", "store_id", "is_active"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<StoreTransactionCategory(id={self.id}, store={self.store_id}, name='{self.name}')>"
|
||||
|
||||
def get_translated_name(self, lang: str) -> str:
|
||||
"""Get name in the given language, falling back to self.name."""
|
||||
if self.name_translations and isinstance(self.name_translations, dict):
|
||||
return self.name_translations.get(lang) or self.name
|
||||
return self.name
|
||||
@@ -39,6 +39,7 @@ from app.modules.loyalty.schemas import (
|
||||
TransactionResponse,
|
||||
)
|
||||
from app.modules.loyalty.services import card_service, pin_service, program_service
|
||||
from app.modules.loyalty.services.analytics_service import analytics_service
|
||||
from app.modules.tenancy.models import User # API-007
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -46,6 +47,7 @@ logger = logging.getLogger(__name__)
|
||||
# Admin router with module access control
|
||||
router = APIRouter(
|
||||
prefix="/loyalty",
|
||||
tags=["Loyalty - Admin"],
|
||||
dependencies=[Depends(require_module_access("loyalty", FrontendType.ADMIN))],
|
||||
)
|
||||
|
||||
@@ -495,6 +497,123 @@ def get_platform_stats(
|
||||
return program_service.get_platform_stats(db)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Transaction Categories (admin manages on behalf of stores)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/stores/{store_id}/categories")
|
||||
def list_store_categories(
|
||||
store_id: int = Path(..., gt=0),
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List transaction categories for a store."""
|
||||
from app.modules.loyalty.schemas.category import (
|
||||
CategoryListResponse,
|
||||
CategoryResponse,
|
||||
)
|
||||
from app.modules.loyalty.services.category_service import category_service
|
||||
|
||||
categories = category_service.list_categories(db, store_id)
|
||||
return CategoryListResponse(
|
||||
categories=[CategoryResponse.model_validate(c) for c in categories],
|
||||
total=len(categories),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/stores/{store_id}/categories", status_code=201)
|
||||
def create_store_category(
|
||||
data: dict,
|
||||
store_id: int = Path(..., gt=0),
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Create a transaction category for a store."""
|
||||
from app.modules.loyalty.schemas.category import CategoryCreate, CategoryResponse
|
||||
from app.modules.loyalty.services.category_service import category_service
|
||||
|
||||
category = category_service.create_category(
|
||||
db, store_id, CategoryCreate(**data)
|
||||
)
|
||||
return CategoryResponse.model_validate(category)
|
||||
|
||||
|
||||
@router.patch("/stores/{store_id}/categories/{category_id}")
|
||||
def update_store_category(
|
||||
data: dict,
|
||||
store_id: int = Path(..., gt=0),
|
||||
category_id: int = Path(..., gt=0),
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update a transaction category for a store."""
|
||||
from app.modules.loyalty.schemas.category import CategoryResponse, CategoryUpdate
|
||||
from app.modules.loyalty.services.category_service import category_service
|
||||
|
||||
category = category_service.update_category(
|
||||
db, category_id, store_id, CategoryUpdate(**data)
|
||||
)
|
||||
return CategoryResponse.model_validate(category)
|
||||
|
||||
|
||||
@router.delete("/stores/{store_id}/categories/{category_id}", status_code=204)
|
||||
def delete_store_category(
|
||||
store_id: int = Path(..., gt=0),
|
||||
category_id: int = Path(..., gt=0),
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Delete a transaction category for a store."""
|
||||
from app.modules.loyalty.services.category_service import category_service
|
||||
|
||||
category_service.delete_category(db, category_id, store_id)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Advanced Analytics
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/merchants/{merchant_id}/analytics/cohorts")
|
||||
def get_cohort_retention(
|
||||
merchant_id: int = Path(..., gt=0),
|
||||
months_back: int = Query(6, ge=1, le=24),
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Cohort retention matrix for a merchant's loyalty program."""
|
||||
return analytics_service.get_cohort_retention(
|
||||
db, merchant_id, months_back=months_back
|
||||
)
|
||||
|
||||
|
||||
@router.get("/merchants/{merchant_id}/analytics/churn")
|
||||
def get_at_risk_cards(
|
||||
merchant_id: int = Path(..., gt=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Cards at risk of churn for a merchant."""
|
||||
return analytics_service.get_at_risk_cards(
|
||||
db, merchant_id, limit=limit
|
||||
)
|
||||
|
||||
|
||||
@router.get("/merchants/{merchant_id}/analytics/revenue")
|
||||
def get_revenue_attribution(
|
||||
merchant_id: int = Path(..., gt=0),
|
||||
months_back: int = Query(6, ge=1, le=24),
|
||||
current_user: User = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Revenue attribution from loyalty transactions."""
|
||||
return analytics_service.get_revenue_attribution(
|
||||
db, merchant_id, months_back=months_back
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Wallet Integration Status
|
||||
# =============================================================================
|
||||
|
||||
@@ -69,6 +69,7 @@ logger = logging.getLogger(__name__)
|
||||
# Store router with module access control
|
||||
router = APIRouter(
|
||||
prefix="/loyalty",
|
||||
tags=["Loyalty - Store"],
|
||||
dependencies=[Depends(require_module_access("loyalty", FrontendType.STORE))],
|
||||
)
|
||||
|
||||
@@ -198,6 +199,129 @@ def get_merchant_stats(
|
||||
return MerchantStatsResponse(**stats)
|
||||
|
||||
|
||||
@router.get("/analytics/cohorts")
|
||||
def get_cohort_retention(
|
||||
months_back: int = Query(6, ge=1, le=24),
|
||||
current_user: User = Depends(get_current_store_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Cohort retention matrix for this merchant's loyalty program."""
|
||||
from app.modules.loyalty.services.analytics_service import analytics_service
|
||||
|
||||
merchant_id = get_store_merchant_id(db, current_user.token_store_id)
|
||||
return analytics_service.get_cohort_retention(
|
||||
db, merchant_id, months_back=months_back
|
||||
)
|
||||
|
||||
|
||||
@router.get("/analytics/churn")
|
||||
def get_at_risk_cards(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
current_user: User = Depends(get_current_store_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Cards at risk of churn for this merchant."""
|
||||
from app.modules.loyalty.services.analytics_service import analytics_service
|
||||
|
||||
merchant_id = get_store_merchant_id(db, current_user.token_store_id)
|
||||
return analytics_service.get_at_risk_cards(
|
||||
db, merchant_id, limit=limit
|
||||
)
|
||||
|
||||
|
||||
@router.get("/analytics/revenue")
|
||||
def get_revenue_attribution(
|
||||
months_back: int = Query(6, ge=1, le=24),
|
||||
current_user: User = Depends(get_current_store_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Revenue attribution from loyalty transactions."""
|
||||
from app.modules.loyalty.services.analytics_service import analytics_service
|
||||
|
||||
merchant_id = get_store_merchant_id(db, current_user.token_store_id)
|
||||
return analytics_service.get_revenue_attribution(
|
||||
db, merchant_id, months_back=months_back
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Transaction Categories
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/categories")
|
||||
def list_categories(
|
||||
current_user: User = Depends(get_current_store_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List transaction categories for this store."""
|
||||
from app.modules.loyalty.schemas.category import (
|
||||
CategoryListResponse,
|
||||
CategoryResponse,
|
||||
)
|
||||
from app.modules.loyalty.services.category_service import category_service
|
||||
|
||||
categories = category_service.list_categories(db, current_user.token_store_id)
|
||||
return CategoryListResponse(
|
||||
categories=[CategoryResponse.model_validate(c) for c in categories],
|
||||
total=len(categories),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/categories", status_code=201)
|
||||
def create_category(
|
||||
data: dict,
|
||||
current_user: User = Depends(get_current_store_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Create a transaction category for this store (merchant_owner only)."""
|
||||
if current_user.role != "merchant_owner":
|
||||
raise AuthorizationException("Only merchant owners can manage categories")
|
||||
|
||||
from app.modules.loyalty.schemas.category import CategoryCreate, CategoryResponse
|
||||
from app.modules.loyalty.services.category_service import category_service
|
||||
|
||||
category = category_service.create_category(
|
||||
db, current_user.token_store_id, CategoryCreate(**data)
|
||||
)
|
||||
return CategoryResponse.model_validate(category)
|
||||
|
||||
|
||||
@router.patch("/categories/{category_id}")
|
||||
def update_category(
|
||||
category_id: int,
|
||||
data: dict,
|
||||
current_user: User = Depends(get_current_store_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update a transaction category (merchant_owner only)."""
|
||||
if current_user.role != "merchant_owner":
|
||||
raise AuthorizationException("Only merchant owners can manage categories")
|
||||
|
||||
from app.modules.loyalty.schemas.category import CategoryResponse, CategoryUpdate
|
||||
from app.modules.loyalty.services.category_service import category_service
|
||||
|
||||
category = category_service.update_category(
|
||||
db, category_id, current_user.token_store_id, CategoryUpdate(**data)
|
||||
)
|
||||
return CategoryResponse.model_validate(category)
|
||||
|
||||
|
||||
@router.delete("/categories/{category_id}", status_code=204)
|
||||
def delete_category(
|
||||
category_id: int,
|
||||
current_user: User = Depends(get_current_store_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Delete a transaction category (merchant_owner only)."""
|
||||
if current_user.role != "merchant_owner":
|
||||
raise AuthorizationException("Only merchant owners can manage categories")
|
||||
|
||||
from app.modules.loyalty.services.category_service import category_service
|
||||
|
||||
category_service.delete_category(db, category_id, current_user.token_store_id)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Staff PINs
|
||||
# =============================================================================
|
||||
@@ -374,7 +498,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,
|
||||
@@ -468,6 +592,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,
|
||||
@@ -475,6 +610,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,
|
||||
@@ -503,6 +639,7 @@ def get_card_detail(
|
||||
|
||||
@router.get("/transactions", response_model=TransactionListResponse)
|
||||
def list_store_transactions(
|
||||
request: Request,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_store_api),
|
||||
@@ -511,16 +648,28 @@ def list_store_transactions(
|
||||
"""List recent transactions for this merchant's loyalty program."""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id = get_store_merchant_id(db, store_id)
|
||||
lang = getattr(request.state, "language", "en") or "en"
|
||||
|
||||
transactions, total = card_service.get_store_transactions(
|
||||
db, merchant_id, skip=skip, limit=limit
|
||||
)
|
||||
|
||||
from app.modules.loyalty.services.category_service import category_service
|
||||
|
||||
tx_responses = []
|
||||
for t in transactions:
|
||||
tx = TransactionResponse.model_validate(t)
|
||||
if t.card and t.card.customer:
|
||||
tx.customer_name = t.card.customer.full_name
|
||||
if t.category_ids and isinstance(t.category_ids, list):
|
||||
names = []
|
||||
for cid in t.category_ids:
|
||||
name = category_service.validate_category_for_store(
|
||||
db, cid, t.store_id or 0, lang=lang
|
||||
)
|
||||
if name:
|
||||
names.append(name)
|
||||
tx.category_names = names if names else None
|
||||
tx_responses.append(tx)
|
||||
|
||||
return TransactionListResponse(transactions=tx_responses, total=total)
|
||||
@@ -580,6 +729,7 @@ def enroll_customer(
|
||||
|
||||
@router.get("/cards/{card_id}/transactions", response_model=TransactionListResponse)
|
||||
def get_card_transactions(
|
||||
request: Request,
|
||||
card_id: int = Path(..., gt=0),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
@@ -588,6 +738,7 @@ def get_card_transactions(
|
||||
):
|
||||
"""Get transaction history for a card."""
|
||||
store_id = current_user.token_store_id
|
||||
lang = getattr(request.state, "language", "en") or "en"
|
||||
|
||||
# Verify card belongs to this merchant (raises LoyaltyCardNotFoundException if not found)
|
||||
card_service.lookup_card_for_store(db, store_id, card_id=card_id)
|
||||
@@ -596,10 +747,23 @@ def get_card_transactions(
|
||||
db, card_id, skip=skip, limit=limit
|
||||
)
|
||||
|
||||
return TransactionListResponse(
|
||||
transactions=[TransactionResponse.model_validate(t) for t in transactions],
|
||||
total=total,
|
||||
from app.modules.loyalty.services.category_service import category_service
|
||||
|
||||
tx_responses = []
|
||||
for t in transactions:
|
||||
tx = TransactionResponse.model_validate(t)
|
||||
if t.category_ids and isinstance(t.category_ids, list):
|
||||
names = []
|
||||
for cid in t.category_ids:
|
||||
name = category_service.validate_category_for_store(
|
||||
db, cid, t.store_id or 0, lang=lang
|
||||
)
|
||||
if name:
|
||||
names.append(name)
|
||||
tx.category_names = names if names else None
|
||||
tx_responses.append(tx)
|
||||
|
||||
return TransactionListResponse(transactions=tx_responses, total=total)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -626,6 +790,7 @@ def add_stamp(
|
||||
qr_code=data.qr_code,
|
||||
card_number=data.card_number,
|
||||
staff_pin=data.staff_pin,
|
||||
category_ids=data.category_ids,
|
||||
ip_address=ip,
|
||||
user_agent=user_agent,
|
||||
notes=data.notes,
|
||||
@@ -716,6 +881,7 @@ def earn_points(
|
||||
purchase_amount_cents=data.purchase_amount_cents,
|
||||
order_reference=data.order_reference,
|
||||
staff_pin=data.staff_pin,
|
||||
category_ids=data.category_ids,
|
||||
ip_address=ip,
|
||||
user_agent=user_agent,
|
||||
notes=data.notes,
|
||||
|
||||
@@ -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
|
||||
@@ -187,6 +188,8 @@ class TransactionResponse(BaseModel):
|
||||
order_reference: str | None = None
|
||||
reward_id: str | None = None
|
||||
reward_description: str | None = None
|
||||
category_ids: list[int] | None = None
|
||||
category_names: list[str] | None = None
|
||||
notes: str | None = None
|
||||
|
||||
# Customer
|
||||
|
||||
48
app/modules/loyalty/schemas/category.py
Normal file
48
app/modules/loyalty/schemas/category.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# app/modules/loyalty/schemas/category.py
|
||||
"""Pydantic schemas for transaction categories."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class CategoryCreate(BaseModel):
|
||||
"""Schema for creating a transaction category."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
name_translations: dict[str, str] | None = Field(
|
||||
None,
|
||||
description='Translations keyed by language: {"en": "Men", "fr": "Hommes"}',
|
||||
)
|
||||
display_order: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
"""Schema for updating a transaction category."""
|
||||
|
||||
name: str | None = Field(None, min_length=1, max_length=100)
|
||||
name_translations: dict[str, str] | None = None
|
||||
display_order: int | None = Field(None, ge=0)
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class CategoryResponse(BaseModel):
|
||||
"""Schema for transaction category response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
store_id: int
|
||||
name: str
|
||||
name_translations: dict[str, str] | None = None
|
||||
display_order: int
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class CategoryListResponse(BaseModel):
|
||||
"""Schema for listing categories."""
|
||||
|
||||
categories: list[CategoryResponse]
|
||||
total: int
|
||||
@@ -47,6 +47,12 @@ class PointsEarnRequest(BaseModel):
|
||||
description="Staff PIN for verification",
|
||||
)
|
||||
|
||||
# Categories (what was sold — multi-select)
|
||||
category_ids: list[int] | None = Field(
|
||||
None,
|
||||
description="Transaction category IDs",
|
||||
)
|
||||
|
||||
# Optional metadata
|
||||
notes: str | None = Field(
|
||||
None,
|
||||
|
||||
@@ -37,6 +37,12 @@ class StampRequest(BaseModel):
|
||||
description="Staff PIN for verification",
|
||||
)
|
||||
|
||||
# Categories (what was sold — multi-select)
|
||||
category_ids: list[int] | None = Field(
|
||||
None,
|
||||
description="Transaction category IDs",
|
||||
)
|
||||
|
||||
# Optional metadata
|
||||
notes: str | None = Field(
|
||||
None,
|
||||
|
||||
338
app/modules/loyalty/services/analytics_service.py
Normal file
338
app/modules/loyalty/services/analytics_service.py
Normal file
@@ -0,0 +1,338 @@
|
||||
# app/modules/loyalty/services/analytics_service.py
|
||||
"""
|
||||
Loyalty analytics service.
|
||||
|
||||
Advanced analytics beyond basic stats:
|
||||
- Cohort retention (enrollment month → % active per subsequent month)
|
||||
- Churn detection (at-risk cards based on inactivity)
|
||||
- Revenue attribution (loyalty vs non-loyalty per store)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.loyalty.models import LoyaltyCard, LoyaltyTransaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnalyticsService:
|
||||
"""Advanced loyalty analytics."""
|
||||
|
||||
def get_cohort_retention(
|
||||
self,
|
||||
db: Session,
|
||||
merchant_id: int,
|
||||
months_back: int = 6,
|
||||
) -> dict:
|
||||
"""
|
||||
Cohort retention matrix.
|
||||
|
||||
Groups cards by enrollment month and tracks what % had any
|
||||
transaction in each subsequent month.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"cohorts": [
|
||||
{
|
||||
"month": "2026-01",
|
||||
"enrolled": 50,
|
||||
"retention": [100, 80, 65, 55, ...] # % active per month
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
start_date = now - timedelta(days=months_back * 31)
|
||||
|
||||
# Get enrollment month for each card
|
||||
cards = (
|
||||
db.query(
|
||||
LoyaltyCard.id,
|
||||
func.date_trunc("month", LoyaltyCard.created_at).label(
|
||||
"enrollment_month"
|
||||
),
|
||||
)
|
||||
.filter(
|
||||
LoyaltyCard.merchant_id == merchant_id,
|
||||
LoyaltyCard.created_at >= start_date,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not cards:
|
||||
return {"cohorts": [], "months_back": months_back}
|
||||
|
||||
# Group cards by enrollment month
|
||||
cohort_cards: dict[str, list[int]] = {}
|
||||
for card_id, enrollment_month in cards:
|
||||
month_key = enrollment_month.strftime("%Y-%m")
|
||||
cohort_cards.setdefault(month_key, []).append(card_id)
|
||||
|
||||
# For each cohort, check activity in subsequent months
|
||||
cohorts = []
|
||||
for month_key in sorted(cohort_cards.keys()):
|
||||
card_ids = cohort_cards[month_key]
|
||||
enrolled_count = len(card_ids)
|
||||
|
||||
# Calculate months since enrollment
|
||||
cohort_start = datetime.strptime(month_key, "%Y-%m").replace(
|
||||
tzinfo=UTC
|
||||
)
|
||||
months_since = max(
|
||||
1,
|
||||
(now.year - cohort_start.year) * 12
|
||||
+ (now.month - cohort_start.month),
|
||||
)
|
||||
|
||||
retention = []
|
||||
for month_offset in range(min(months_since, months_back)):
|
||||
period_start = cohort_start + timedelta(days=month_offset * 30)
|
||||
period_end = period_start + timedelta(days=30)
|
||||
|
||||
# Count cards with any transaction in this period
|
||||
active_count = (
|
||||
db.query(func.count(func.distinct(LoyaltyTransaction.card_id)))
|
||||
.filter(
|
||||
LoyaltyTransaction.card_id.in_(card_ids),
|
||||
LoyaltyTransaction.transaction_at >= period_start,
|
||||
LoyaltyTransaction.transaction_at < period_end,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
pct = round(active_count / enrolled_count * 100) if enrolled_count else 0
|
||||
retention.append(pct)
|
||||
|
||||
cohorts.append(
|
||||
{
|
||||
"month": month_key,
|
||||
"enrolled": enrolled_count,
|
||||
"retention": retention,
|
||||
}
|
||||
)
|
||||
|
||||
return {"cohorts": cohorts, "months_back": months_back}
|
||||
|
||||
def get_at_risk_cards(
|
||||
self,
|
||||
db: Session,
|
||||
merchant_id: int,
|
||||
inactivity_multiplier: float = 2.0,
|
||||
limit: int = 50,
|
||||
) -> dict:
|
||||
"""
|
||||
Simple churn detection.
|
||||
|
||||
A card is "at risk" when its inactivity period exceeds
|
||||
`inactivity_multiplier` × its average inter-transaction interval.
|
||||
Falls back to 60 days for cards with fewer than 2 transactions.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"at_risk_count": int,
|
||||
"cards": [
|
||||
{
|
||||
"card_id": int,
|
||||
"card_number": str,
|
||||
"customer_name": str,
|
||||
"days_inactive": int,
|
||||
"avg_interval_days": int,
|
||||
"points_balance": int,
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
default_threshold_days = 60
|
||||
|
||||
# Get active cards with their last activity
|
||||
cards = (
|
||||
db.query(LoyaltyCard)
|
||||
.filter(
|
||||
LoyaltyCard.merchant_id == merchant_id,
|
||||
LoyaltyCard.is_active == True, # noqa: E712
|
||||
LoyaltyCard.last_activity_at.isnot(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
at_risk = []
|
||||
for card in cards:
|
||||
days_inactive = (now - card.last_activity_at).days
|
||||
|
||||
# Calculate average interval from transaction history
|
||||
tx_dates = (
|
||||
db.query(LoyaltyTransaction.transaction_at)
|
||||
.filter(LoyaltyTransaction.card_id == card.id)
|
||||
.order_by(LoyaltyTransaction.transaction_at)
|
||||
.all()
|
||||
)
|
||||
|
||||
if len(tx_dates) >= 2:
|
||||
intervals = [
|
||||
(tx_dates[i + 1][0] - tx_dates[i][0]).days
|
||||
for i in range(len(tx_dates) - 1)
|
||||
]
|
||||
avg_interval = sum(intervals) / len(intervals) if intervals else default_threshold_days
|
||||
else:
|
||||
avg_interval = default_threshold_days
|
||||
|
||||
threshold = avg_interval * inactivity_multiplier
|
||||
|
||||
if days_inactive > threshold:
|
||||
customer_name = None
|
||||
if card.customer:
|
||||
customer_name = card.customer.full_name
|
||||
|
||||
at_risk.append(
|
||||
{
|
||||
"card_id": card.id,
|
||||
"card_number": card.card_number,
|
||||
"customer_name": customer_name,
|
||||
"days_inactive": days_inactive,
|
||||
"avg_interval_days": round(avg_interval),
|
||||
"points_balance": card.points_balance,
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by days_inactive descending
|
||||
at_risk.sort(key=lambda x: x["days_inactive"], reverse=True)
|
||||
|
||||
return {
|
||||
"at_risk_count": len(at_risk),
|
||||
"cards": at_risk[:limit],
|
||||
"total_cards_checked": len(cards),
|
||||
}
|
||||
|
||||
def get_revenue_attribution(
|
||||
self,
|
||||
db: Session,
|
||||
merchant_id: int,
|
||||
months_back: int = 6,
|
||||
) -> dict:
|
||||
"""
|
||||
Revenue attribution from loyalty point-earning transactions.
|
||||
|
||||
Compares revenue from transactions with order references
|
||||
(loyalty customers) against total enrollment metrics.
|
||||
Groups by month and store.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"monthly": [
|
||||
{
|
||||
"month": "2026-01",
|
||||
"transactions_count": int,
|
||||
"total_points_earned": int,
|
||||
"estimated_revenue_cents": int,
|
||||
"unique_customers": int,
|
||||
}
|
||||
],
|
||||
"by_store": [
|
||||
{
|
||||
"store_id": int,
|
||||
"store_name": str,
|
||||
"transactions_count": int,
|
||||
"total_points_earned": int,
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
from app.modules.loyalty.models.loyalty_transaction import TransactionType
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
|
||||
now = datetime.now(UTC)
|
||||
start_date = now - timedelta(days=months_back * 31)
|
||||
|
||||
# Monthly aggregation of point-earning transactions
|
||||
monthly_rows = (
|
||||
db.query(
|
||||
func.date_trunc("month", LoyaltyTransaction.transaction_at).label(
|
||||
"month"
|
||||
),
|
||||
func.count(LoyaltyTransaction.id).label("tx_count"),
|
||||
func.coalesce(
|
||||
func.sum(LoyaltyTransaction.points_delta), 0
|
||||
).label("points_earned"),
|
||||
func.count(
|
||||
func.distinct(LoyaltyTransaction.card_id)
|
||||
).label("unique_cards"),
|
||||
)
|
||||
.filter(
|
||||
LoyaltyTransaction.merchant_id == merchant_id,
|
||||
LoyaltyTransaction.transaction_at >= start_date,
|
||||
LoyaltyTransaction.transaction_type.in_(
|
||||
[
|
||||
TransactionType.POINTS_EARNED.value,
|
||||
TransactionType.STAMP_EARNED.value,
|
||||
]
|
||||
),
|
||||
LoyaltyTransaction.points_delta > 0,
|
||||
)
|
||||
.group_by("month")
|
||||
.order_by("month")
|
||||
.all()
|
||||
)
|
||||
|
||||
monthly = []
|
||||
for row in monthly_rows:
|
||||
monthly.append(
|
||||
{
|
||||
"month": row.month.strftime("%Y-%m"),
|
||||
"transactions_count": row.tx_count,
|
||||
"total_points_earned": row.points_earned,
|
||||
"unique_customers": row.unique_cards,
|
||||
}
|
||||
)
|
||||
|
||||
# Per-store breakdown
|
||||
store_rows = (
|
||||
db.query(
|
||||
LoyaltyTransaction.store_id,
|
||||
func.count(LoyaltyTransaction.id).label("tx_count"),
|
||||
func.coalesce(
|
||||
func.sum(LoyaltyTransaction.points_delta), 0
|
||||
).label("points_earned"),
|
||||
)
|
||||
.filter(
|
||||
LoyaltyTransaction.merchant_id == merchant_id,
|
||||
LoyaltyTransaction.transaction_at >= start_date,
|
||||
LoyaltyTransaction.transaction_type.in_(
|
||||
[
|
||||
TransactionType.POINTS_EARNED.value,
|
||||
TransactionType.STAMP_EARNED.value,
|
||||
]
|
||||
),
|
||||
LoyaltyTransaction.points_delta > 0,
|
||||
LoyaltyTransaction.store_id.isnot(None),
|
||||
)
|
||||
.group_by(LoyaltyTransaction.store_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
by_store = []
|
||||
for row in store_rows:
|
||||
store = store_service.get_store_by_id_optional(db, row.store_id)
|
||||
by_store.append(
|
||||
{
|
||||
"store_id": row.store_id,
|
||||
"store_name": store.name if store else f"Store {row.store_id}",
|
||||
"transactions_count": row.tx_count,
|
||||
"total_points_earned": row.points_earned,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"monthly": monthly,
|
||||
"by_store": by_store,
|
||||
"months_back": months_back,
|
||||
}
|
||||
|
||||
|
||||
# Singleton
|
||||
analytics_service = AnalyticsService()
|
||||
@@ -807,6 +807,7 @@ class CardService:
|
||||
*,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
lang: str = "en",
|
||||
) -> tuple[list[dict], int]:
|
||||
"""
|
||||
Get transaction history for a card with store names resolved.
|
||||
@@ -836,6 +837,8 @@ class CardService:
|
||||
"transaction_at": tx.transaction_at.isoformat() if tx.transaction_at else None,
|
||||
"notes": tx.notes,
|
||||
"store_name": None,
|
||||
"category_ids": tx.category_ids,
|
||||
"category_names": None,
|
||||
}
|
||||
|
||||
if tx.store_id:
|
||||
@@ -843,6 +846,20 @@ class CardService:
|
||||
if store_obj:
|
||||
tx_data["store_name"] = store_obj.name
|
||||
|
||||
if tx.category_ids and isinstance(tx.category_ids, list):
|
||||
from app.modules.loyalty.services.category_service import (
|
||||
category_service,
|
||||
)
|
||||
|
||||
names = []
|
||||
for cid in tx.category_ids:
|
||||
name = category_service.validate_category_for_store(
|
||||
db, cid, tx.store_id or 0, lang=lang
|
||||
)
|
||||
if name:
|
||||
names.append(name)
|
||||
tx_data["category_names"] = names if names else None
|
||||
|
||||
tx_responses.append(tx_data)
|
||||
|
||||
return tx_responses, total
|
||||
|
||||
162
app/modules/loyalty/services/category_service.py
Normal file
162
app/modules/loyalty/services/category_service.py
Normal file
@@ -0,0 +1,162 @@
|
||||
# app/modules/loyalty/services/category_service.py
|
||||
"""
|
||||
Transaction category CRUD service.
|
||||
|
||||
Store-scoped categories (e.g., Men, Women, Accessories) that sellers
|
||||
select when entering loyalty transactions.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.loyalty.models.transaction_category import StoreTransactionCategory
|
||||
from app.modules.loyalty.schemas.category import CategoryCreate, CategoryUpdate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_CATEGORIES_PER_STORE = 10
|
||||
|
||||
|
||||
class CategoryService:
|
||||
"""CRUD operations for store transaction categories."""
|
||||
|
||||
def list_categories(
|
||||
self, db: Session, store_id: int, active_only: bool = False
|
||||
) -> list[StoreTransactionCategory]:
|
||||
"""List categories for a store, ordered by display_order."""
|
||||
query = db.query(StoreTransactionCategory).filter(
|
||||
StoreTransactionCategory.store_id == store_id
|
||||
)
|
||||
if active_only:
|
||||
query = query.filter(StoreTransactionCategory.is_active == True) # noqa: E712
|
||||
return query.order_by(StoreTransactionCategory.display_order).all()
|
||||
|
||||
def create_category(
|
||||
self, db: Session, store_id: int, data: CategoryCreate
|
||||
) -> StoreTransactionCategory:
|
||||
"""Create a new category for a store."""
|
||||
# Check max limit
|
||||
count = (
|
||||
db.query(StoreTransactionCategory)
|
||||
.filter(StoreTransactionCategory.store_id == store_id)
|
||||
.count()
|
||||
)
|
||||
if count >= MAX_CATEGORIES_PER_STORE:
|
||||
from app.modules.loyalty.exceptions import LoyaltyException
|
||||
|
||||
raise LoyaltyException(
|
||||
message=f"Maximum {MAX_CATEGORIES_PER_STORE} categories per store",
|
||||
error_code="MAX_CATEGORIES_REACHED",
|
||||
)
|
||||
|
||||
# Check duplicate name
|
||||
existing = (
|
||||
db.query(StoreTransactionCategory)
|
||||
.filter(
|
||||
StoreTransactionCategory.store_id == store_id,
|
||||
StoreTransactionCategory.name == data.name,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
from app.modules.loyalty.exceptions import LoyaltyException
|
||||
|
||||
raise LoyaltyException(
|
||||
message=f"Category '{data.name}' already exists",
|
||||
error_code="DUPLICATE_CATEGORY",
|
||||
)
|
||||
|
||||
category = StoreTransactionCategory(
|
||||
store_id=store_id,
|
||||
name=data.name,
|
||||
display_order=data.display_order,
|
||||
)
|
||||
db.add(category)
|
||||
db.commit()
|
||||
db.refresh(category)
|
||||
|
||||
logger.info(f"Created category '{data.name}' for store {store_id}")
|
||||
return category
|
||||
|
||||
def update_category(
|
||||
self,
|
||||
db: Session,
|
||||
category_id: int,
|
||||
store_id: int,
|
||||
data: CategoryUpdate,
|
||||
) -> StoreTransactionCategory:
|
||||
"""Update a category (ownership check via store_id)."""
|
||||
category = (
|
||||
db.query(StoreTransactionCategory)
|
||||
.filter(
|
||||
StoreTransactionCategory.id == category_id,
|
||||
StoreTransactionCategory.store_id == store_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not category:
|
||||
from app.modules.loyalty.exceptions import LoyaltyException
|
||||
|
||||
raise LoyaltyException(
|
||||
message="Category not found",
|
||||
error_code="CATEGORY_NOT_FOUND",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(category, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(category)
|
||||
return category
|
||||
|
||||
def delete_category(
|
||||
self, db: Session, category_id: int, store_id: int
|
||||
) -> None:
|
||||
"""Delete a category (ownership check via store_id)."""
|
||||
category = (
|
||||
db.query(StoreTransactionCategory)
|
||||
.filter(
|
||||
StoreTransactionCategory.id == category_id,
|
||||
StoreTransactionCategory.store_id == store_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not category:
|
||||
from app.modules.loyalty.exceptions import LoyaltyException
|
||||
|
||||
raise LoyaltyException(
|
||||
message="Category not found",
|
||||
error_code="CATEGORY_NOT_FOUND",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
db.delete(category)
|
||||
db.commit()
|
||||
logger.info(f"Deleted category {category_id} from store {store_id}")
|
||||
|
||||
def validate_category_for_store(
|
||||
self, db: Session, category_id: int, store_id: int, lang: str = "en"
|
||||
) -> str | None:
|
||||
"""Validate that a category belongs to the store.
|
||||
|
||||
Returns the translated category name if valid, None if not found.
|
||||
"""
|
||||
category = (
|
||||
db.query(StoreTransactionCategory)
|
||||
.filter(
|
||||
StoreTransactionCategory.id == category_id,
|
||||
StoreTransactionCategory.store_id == store_id,
|
||||
StoreTransactionCategory.is_active == True, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not category:
|
||||
return None
|
||||
return category.get_translated_name(lang)
|
||||
|
||||
|
||||
# Singleton
|
||||
category_service = CategoryService()
|
||||
81
app/modules/loyalty/services/loyalty_widgets.py
Normal file
81
app/modules/loyalty/services/loyalty_widgets.py
Normal file
@@ -0,0 +1,81 @@
|
||||
# app/modules/loyalty/services/loyalty_widgets.py
|
||||
"""
|
||||
Loyalty dashboard widget provider.
|
||||
|
||||
Provides storefront dashboard cards for loyalty-related data.
|
||||
Implements get_storefront_dashboard_cards from DashboardWidgetProviderProtocol.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.contracts.widgets import (
|
||||
DashboardWidget,
|
||||
StorefrontDashboardCard,
|
||||
WidgetContext,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoyaltyWidgetProvider:
|
||||
"""Widget provider for loyalty module."""
|
||||
|
||||
@property
|
||||
def widgets_category(self) -> str:
|
||||
return "loyalty"
|
||||
|
||||
def get_store_widgets(
|
||||
self,
|
||||
db: Session,
|
||||
store_id: int,
|
||||
context: WidgetContext | None = None,
|
||||
) -> list[DashboardWidget]:
|
||||
return []
|
||||
|
||||
def get_platform_widgets(
|
||||
self,
|
||||
db: Session,
|
||||
platform_id: int,
|
||||
context: WidgetContext | None = None,
|
||||
) -> list[DashboardWidget]:
|
||||
return []
|
||||
|
||||
def get_storefront_dashboard_cards(
|
||||
self,
|
||||
db: Session,
|
||||
store_id: int,
|
||||
customer_id: int,
|
||||
context: WidgetContext | None = None,
|
||||
) -> list[StorefrontDashboardCard]:
|
||||
"""Provide the Loyalty Rewards card for the customer dashboard."""
|
||||
from app.modules.loyalty.models.loyalty_card import LoyaltyCard
|
||||
|
||||
card = (
|
||||
db.query(LoyaltyCard)
|
||||
.filter(
|
||||
LoyaltyCard.customer_id == customer_id,
|
||||
LoyaltyCard.is_active.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
points = card.points_balance if card else None
|
||||
subtitle = "View your points & rewards" if card else "Join our rewards program"
|
||||
|
||||
return [
|
||||
StorefrontDashboardCard(
|
||||
key="loyalty.rewards",
|
||||
icon="gift",
|
||||
title="Loyalty Rewards",
|
||||
subtitle=subtitle,
|
||||
route="account/loyalty",
|
||||
value=points,
|
||||
value_label="Points Balance" if points is not None else None,
|
||||
order=30,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
loyalty_widget_provider = LoyaltyWidgetProvider()
|
||||
@@ -23,6 +23,7 @@ from app.modules.loyalty.exceptions import (
|
||||
InsufficientPointsException,
|
||||
InvalidRewardException,
|
||||
LoyaltyCardInactiveException,
|
||||
LoyaltyException,
|
||||
LoyaltyProgramInactiveException,
|
||||
OrderReferenceRequiredException,
|
||||
StaffPinRequiredException,
|
||||
@@ -48,6 +49,7 @@ class PointsService:
|
||||
purchase_amount_cents: int,
|
||||
order_reference: str | None = None,
|
||||
staff_pin: str | None = None,
|
||||
category_ids: list[int] | None = None,
|
||||
ip_address: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
notes: str | None = None,
|
||||
@@ -101,6 +103,19 @@ class PointsService:
|
||||
if settings and settings.require_order_reference and not order_reference:
|
||||
raise OrderReferenceRequiredException()
|
||||
|
||||
# Category is mandatory when the store has categories configured
|
||||
if not category_ids:
|
||||
from app.modules.loyalty.services.category_service import category_service
|
||||
|
||||
store_categories = category_service.list_categories(
|
||||
db, store_id, active_only=True
|
||||
)
|
||||
if store_categories:
|
||||
raise LoyaltyException(
|
||||
message="Please select a product category",
|
||||
error_code="CATEGORY_REQUIRED",
|
||||
)
|
||||
|
||||
# Idempotency guard: if same order_reference already earned points on this card, return existing result
|
||||
if order_reference:
|
||||
existing_tx = (
|
||||
@@ -181,6 +196,7 @@ class PointsService:
|
||||
card_id=card.id,
|
||||
store_id=store_id,
|
||||
staff_pin_id=verified_pin.id if verified_pin else None,
|
||||
category_ids=category_ids,
|
||||
transaction_type=TransactionType.POINTS_EARNED.value,
|
||||
points_delta=points_earned,
|
||||
stamps_balance_after=card.stamp_count,
|
||||
|
||||
@@ -46,6 +46,7 @@ class StampService:
|
||||
qr_code: str | None = None,
|
||||
card_number: str | None = None,
|
||||
staff_pin: str | None = None,
|
||||
category_ids: list[int] | None = None,
|
||||
ip_address: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
notes: str | None = None,
|
||||
@@ -143,6 +144,7 @@ class StampService:
|
||||
card_id=card.id,
|
||||
store_id=store_id,
|
||||
staff_pin_id=verified_pin.id if verified_pin else None,
|
||||
category_ids=category_ids,
|
||||
transaction_type=TransactionType.STAMP_EARNED.value,
|
||||
stamps_delta=1,
|
||||
stamps_balance_after=card.stamp_count,
|
||||
|
||||
@@ -33,6 +33,18 @@ function adminLoyaltyMerchantDetail() {
|
||||
settings: null,
|
||||
locations: [],
|
||||
|
||||
// Transaction categories
|
||||
selectedCategoryStoreId: '',
|
||||
storeCategories: [],
|
||||
showAddCategory: false,
|
||||
newCategoryName: '',
|
||||
newCategoryTranslations: { fr: '', de: '', lb: '' },
|
||||
viewingCategoryId: null,
|
||||
editingCategoryId: null,
|
||||
showDeleteCategoryModal: false,
|
||||
categoryToDelete: null,
|
||||
editCategoryData: { name: '', translations: { fr: '', de: '', lb: '' } },
|
||||
|
||||
// State
|
||||
loading: false,
|
||||
error: null,
|
||||
@@ -258,6 +270,103 @@ function adminLoyaltyMerchantDetail() {
|
||||
formatNumber(num) {
|
||||
if (num === null || num === undefined) return '0';
|
||||
return new Intl.NumberFormat('en-US').format(num);
|
||||
},
|
||||
|
||||
// Transaction categories
|
||||
async loadCategoriesForStore() {
|
||||
if (!this.selectedCategoryStoreId) {
|
||||
this.storeCategories = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/loyalty/stores/${this.selectedCategoryStoreId}/categories`);
|
||||
this.storeCategories = response?.categories || [];
|
||||
} catch (error) {
|
||||
loyaltyMerchantDetailLog.warn('Failed to load categories:', error.message);
|
||||
this.storeCategories = [];
|
||||
}
|
||||
},
|
||||
|
||||
async createCategory() {
|
||||
if (!this.newCategoryName || !this.selectedCategoryStoreId) return;
|
||||
try {
|
||||
// Build translations dict (only include non-empty values)
|
||||
const translations = {};
|
||||
if (this.newCategoryName) translations.en = this.newCategoryName;
|
||||
for (const [lang, val] of Object.entries(this.newCategoryTranslations)) {
|
||||
if (val) translations[lang] = val;
|
||||
}
|
||||
|
||||
await apiClient.post(`/admin/loyalty/stores/${this.selectedCategoryStoreId}/categories`, {
|
||||
name: this.newCategoryName,
|
||||
name_translations: Object.keys(translations).length > 0 ? translations : null,
|
||||
display_order: this.storeCategories.length,
|
||||
});
|
||||
this.newCategoryName = '';
|
||||
this.newCategoryTranslations = { fr: '', de: '', lb: '' };
|
||||
this.showAddCategory = false;
|
||||
await this.loadCategoriesForStore();
|
||||
Utils.showToast('Category created', 'success');
|
||||
} catch (error) {
|
||||
Utils.showToast(error.message || 'Failed to create category', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
startEditCategory(cat) {
|
||||
this.editingCategoryId = cat.id;
|
||||
this.editCategoryData = {
|
||||
name: cat.name,
|
||||
translations: {
|
||||
fr: cat.name_translations?.fr || '',
|
||||
de: cat.name_translations?.de || '',
|
||||
lb: cat.name_translations?.lb || '',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async saveEditCategory(catId) {
|
||||
if (!this.editCategoryData.name) return;
|
||||
try {
|
||||
const translations = { en: this.editCategoryData.name };
|
||||
for (const [lang, val] of Object.entries(this.editCategoryData.translations)) {
|
||||
if (val) translations[lang] = val;
|
||||
}
|
||||
|
||||
await apiClient.patch(`/admin/loyalty/stores/${this.selectedCategoryStoreId}/categories/${catId}`, {
|
||||
name: this.editCategoryData.name,
|
||||
name_translations: Object.keys(translations).length > 0 ? translations : null,
|
||||
});
|
||||
this.editingCategoryId = null;
|
||||
await this.loadCategoriesForStore();
|
||||
Utils.showToast('Category updated', 'success');
|
||||
} catch (error) {
|
||||
Utils.showToast(error.message || 'Failed to update category', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async toggleCategoryActive(cat) {
|
||||
try {
|
||||
await apiClient.patch(`/admin/loyalty/stores/${this.selectedCategoryStoreId}/categories/${cat.id}`, {
|
||||
is_active: !cat.is_active,
|
||||
});
|
||||
await this.loadCategoriesForStore();
|
||||
} catch (error) {
|
||||
Utils.showToast(error.message || 'Failed to update category', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async confirmDeleteCategory() {
|
||||
if (!this.categoryToDelete) return;
|
||||
try {
|
||||
await apiClient.delete(`/admin/loyalty/stores/${this.selectedCategoryStoreId}/categories/${this.categoryToDelete}`);
|
||||
await this.loadCategoriesForStore();
|
||||
Utils.showToast('Category deleted', 'success');
|
||||
} catch (error) {
|
||||
Utils.showToast(error.message || 'Failed to delete category', 'error');
|
||||
} finally {
|
||||
this.showDeleteCategoryModal = false;
|
||||
this.categoryToDelete = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -31,6 +31,8 @@ function storeLoyaltyTerminal() {
|
||||
// Transaction inputs
|
||||
earnAmount: null,
|
||||
selectedReward: '',
|
||||
selectedCategories: [],
|
||||
categories: [],
|
||||
|
||||
// PIN entry
|
||||
showPinEntry: false,
|
||||
@@ -63,6 +65,7 @@ function storeLoyaltyTerminal() {
|
||||
}
|
||||
|
||||
await this.loadData();
|
||||
await this.loadCategories();
|
||||
|
||||
loyaltyTerminalLog.info('=== LOYALTY TERMINAL INITIALIZATION COMPLETE ===');
|
||||
},
|
||||
@@ -279,13 +282,25 @@ function storeLoyaltyTerminal() {
|
||||
}
|
||||
},
|
||||
|
||||
// Load categories for this store
|
||||
async loadCategories() {
|
||||
try {
|
||||
const response = await apiClient.get('/store/loyalty/categories');
|
||||
this.categories = (response?.categories || []).filter(c => c.is_active);
|
||||
loyaltyTerminalLog.info(`Loaded ${this.categories.length} categories`);
|
||||
} catch (error) {
|
||||
loyaltyTerminalLog.warn('Failed to load categories:', error.message);
|
||||
}
|
||||
},
|
||||
|
||||
// Add stamp
|
||||
async addStamp() {
|
||||
loyaltyTerminalLog.info('Adding stamp...');
|
||||
|
||||
await apiClient.post('/store/loyalty/stamp', {
|
||||
card_id: this.selectedCard.card_id,
|
||||
staff_pin: this.pinDigits
|
||||
card_id: this.selectedCard.id,
|
||||
staff_pin: this.pinDigits,
|
||||
category_ids: this.selectedCategories.length > 0 ? this.selectedCategories : undefined,
|
||||
});
|
||||
|
||||
Utils.showToast(I18n.t('loyalty.store.terminal.stamp_added'), 'success');
|
||||
@@ -296,8 +311,8 @@ function storeLoyaltyTerminal() {
|
||||
loyaltyTerminalLog.info('Redeeming stamps...');
|
||||
|
||||
await apiClient.post('/store/loyalty/stamp/redeem', {
|
||||
card_id: this.selectedCard.card_id,
|
||||
staff_pin: this.pinDigits
|
||||
card_id: this.selectedCard.id,
|
||||
staff_pin: this.pinDigits,
|
||||
});
|
||||
|
||||
Utils.showToast(I18n.t('loyalty.store.terminal.stamps_redeemed'), 'success');
|
||||
@@ -308,9 +323,10 @@ 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
|
||||
staff_pin: this.pinDigits,
|
||||
category_ids: this.selectedCategories.length > 0 ? this.selectedCategories : undefined,
|
||||
});
|
||||
|
||||
const pointsEarned = response.points_earned || Math.floor(this.earnAmount * (this.program?.points_per_euro || 1));
|
||||
@@ -327,9 +343,9 @@ 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
|
||||
staff_pin: this.pinDigits,
|
||||
});
|
||||
|
||||
Utils.showToast(I18n.t('loyalty.store.terminal.reward_redeemed', {name: reward.name}), 'success');
|
||||
@@ -340,7 +356,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;
|
||||
}
|
||||
@@ -353,9 +369,11 @@ function storeLoyaltyTerminal() {
|
||||
getTransactionLabel(tx) {
|
||||
const type = tx.transaction_type;
|
||||
if (type) {
|
||||
return I18n.t('loyalty.transactions.' + type, {defaultValue: type.replace(/_/g, ' ')});
|
||||
// Use server-rendered labels (no async flicker)
|
||||
if (window._txLabels && window._txLabels[type]) return window._txLabels[type];
|
||||
return type.replace(/_/g, ' ');
|
||||
}
|
||||
return I18n.t('loyalty.common.unknown');
|
||||
return 'Unknown';
|
||||
},
|
||||
|
||||
getTransactionColor(tx) {
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
</a>
|
||||
<a href="/admin/merchants"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-blue-600 bg-blue-100 rounded-lg hover:bg-blue-200 dark:text-blue-300 dark:bg-blue-900/30 dark:hover:bg-blue-900/50">
|
||||
<span x-html="$icon('building-office', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-html="$icon('office-building', 'w-4 h-4 mr-2')"></span>
|
||||
{{ _('loyalty.admin.analytics.manage_merchants') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<a
|
||||
:href="`/admin/merchants/${merchant?.id}?back=/admin/loyalty/merchants/${merchantId}`"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-gray-100 border border-gray-300 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600">
|
||||
<span x-html="$icon('building-office', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-html="$icon('office-building', 'w-4 h-4 mr-2')"></span>
|
||||
{{ _('loyalty.admin.merchant_detail.view_merchant') }}
|
||||
</a>
|
||||
<a x-show="program"
|
||||
@@ -152,7 +152,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<!-- Delete Program Confirmation Modal -->
|
||||
{{ confirm_modal(
|
||||
'deleteProgramModal',
|
||||
_('loyalty.admin.merchant_detail.delete_title'),
|
||||
@@ -164,6 +164,18 @@
|
||||
'danger'
|
||||
) }}
|
||||
|
||||
<!-- Delete Category Confirmation Modal -->
|
||||
{{ confirm_modal(
|
||||
'deleteCategoryModal',
|
||||
_('loyalty.common.delete'),
|
||||
_('loyalty.admin.merchant_detail.delete_category_message'),
|
||||
'confirmDeleteCategory()',
|
||||
'showDeleteCategoryModal',
|
||||
_('loyalty.common.delete'),
|
||||
_('loyalty.common.cancel'),
|
||||
'danger'
|
||||
) }}
|
||||
|
||||
<!-- Location Breakdown -->
|
||||
<div x-show="locations.length > 0" class="px-4 py-3 mb-8 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">
|
||||
@@ -201,6 +213,175 @@
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
<!-- Transaction Categories (per store) -->
|
||||
<div x-show="locations.length > 0" class="px-4 py-3 mb-8 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('tag', 'inline w-5 h-5 mr-2')"></span>
|
||||
{{ _('loyalty.admin.merchant_detail.transaction_categories') }}
|
||||
</h3>
|
||||
|
||||
<!-- Store selector -->
|
||||
<div class="mb-4">
|
||||
<select x-model="selectedCategoryStoreId" @change="loadCategoriesForStore()"
|
||||
class="w-full md:w-auto px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">{{ _('loyalty.admin.merchant_detail.select_store') }}</option>
|
||||
<template x-for="loc in locations" :key="loc.store_id">
|
||||
<option :value="loc.store_id" x-text="loc.store_name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Categories list -->
|
||||
<div x-show="selectedCategoryStoreId">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="storeCategories.length + ' categories'"></p>
|
||||
<button @click="showAddCategory = true" type="button"
|
||||
class="px-3 py-1.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
||||
<span x-html="$icon('plus', 'inline w-4 h-4 mr-1')"></span>
|
||||
{{ _('loyalty.common.add') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add category inline form -->
|
||||
<div x-show="showAddCategory" class="mb-4 p-4 border border-purple-200 dark:border-purple-800 rounded-lg bg-purple-50 dark:bg-purple-900/20">
|
||||
<div class="grid gap-3 md:grid-cols-2 mb-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">English (EN) <span class="text-red-500">*</span></label>
|
||||
<input type="text" x-model="newCategoryName" maxlength="100" placeholder="e.g. Men"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">French (FR) <span class="text-red-500">*</span></label>
|
||||
<input type="text" x-model="newCategoryTranslations.fr" maxlength="100" placeholder="e.g. Hommes"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">German (DE) <span class="text-red-500">*</span></label>
|
||||
<input type="text" x-model="newCategoryTranslations.de" maxlength="100" placeholder="e.g. Herren"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Luxembourgish (LB) <span class="text-red-500">*</span></label>
|
||||
<input type="text" x-model="newCategoryTranslations.lb" maxlength="100" placeholder="e.g. Hären"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="showAddCategory = false; newCategoryName = ''; newCategoryTranslations = {fr:'',de:'',lb:''}" type="button"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600">
|
||||
{{ _('loyalty.common.cancel') }}
|
||||
</button>
|
||||
<button @click="createCategory()" :disabled="!newCategoryName || !newCategoryTranslations.fr || !newCategoryTranslations.de || !newCategoryTranslations.lb"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
{{ _('loyalty.common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Categories table -->
|
||||
<div class="space-y-2">
|
||||
<template x-for="cat in storeCategories" :key="cat.id">
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<!-- List mode -->
|
||||
<div x-show="viewingCategoryId !== cat.id && editingCategoryId !== cat.id" class="flex items-center justify-between p-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="cat.name"></span>
|
||||
<span x-show="!cat.is_active" class="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-500 dark:bg-gray-700">{{ _('loyalty.common.inactive') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="viewingCategoryId = (viewingCategoryId === cat.id ? null : cat.id)" type="button"
|
||||
aria-label="{{ _('loyalty.common.view') }}"
|
||||
class="text-blue-500 hover:text-blue-700">
|
||||
<span x-html="$icon('eye', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button @click="toggleCategoryActive(cat)" type="button"
|
||||
:aria-label="cat.is_active ? '{{ _('loyalty.common.deactivate') }}' : '{{ _('loyalty.common.activate') }}'"
|
||||
class="text-sm" :class="cat.is_active ? 'text-orange-500 hover:text-orange-700' : 'text-green-500 hover:text-green-700'">
|
||||
<span x-html="$icon(cat.is_active ? 'ban' : 'play', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button @click="categoryToDelete = cat.id; showDeleteCategoryModal = true" type="button"
|
||||
aria-label="{{ _('loyalty.common.delete') }}"
|
||||
class="text-red-500 hover:text-red-700">
|
||||
<span x-html="$icon('trash', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- View mode (read-only) -->
|
||||
<div x-show="viewingCategoryId === cat.id && editingCategoryId !== cat.id" class="p-3 bg-gray-50 dark:bg-gray-900/20">
|
||||
<div class="grid gap-2 md:grid-cols-2 mb-3">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">English (EN) <span class="text-red-500">*</span></p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="cat.name || '-'"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">French (FR) <span class="text-red-500">*</span></p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="cat.name_translations?.fr || '-'"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">German (DE) <span class="text-red-500">*</span></p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="cat.name_translations?.de || '-'"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Luxembourgish (LB) <span class="text-red-500">*</span></p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="cat.name_translations?.lb || '-'"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button @click="viewingCategoryId = null" type="button"
|
||||
class="px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600">
|
||||
{{ _('loyalty.common.close') }}
|
||||
</button>
|
||||
<button @click="viewingCategoryId = null; startEditCategory(cat)" type="button"
|
||||
class="px-3 py-1.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
||||
<span x-html="$icon('pencil', 'inline w-3.5 h-3.5 mr-1')"></span>
|
||||
{{ _('loyalty.common.edit') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Edit mode -->
|
||||
<div x-show="editingCategoryId === cat.id" class="p-3 bg-purple-50 dark:bg-purple-900/20">
|
||||
<div class="grid gap-2 md:grid-cols-2 mb-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">English (EN) <span class="text-red-500">*</span></label>
|
||||
<input type="text" x-model="editCategoryData.name" maxlength="100"
|
||||
class="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">French (FR) <span class="text-red-500">*</span></label>
|
||||
<input type="text" x-model="editCategoryData.translations.fr" maxlength="100"
|
||||
class="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">German (DE) <span class="text-red-500">*</span></label>
|
||||
<input type="text" x-model="editCategoryData.translations.de" maxlength="100"
|
||||
class="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Luxembourgish (LB) <span class="text-red-500">*</span></label>
|
||||
<input type="text" x-model="editCategoryData.translations.lb" maxlength="100"
|
||||
class="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button @click="editingCategoryId = null" type="button"
|
||||
class="px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600">
|
||||
{{ _('loyalty.common.cancel') }}
|
||||
</button>
|
||||
<button @click="saveEditCategory(cat.id)" :disabled="!editCategoryData.name || !editCategoryData.translations.fr || !editCategoryData.translations.de || !editCategoryData.translations.lb"
|
||||
class="px-3 py-1.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
{{ _('loyalty.common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="storeCategories.length === 0" class="text-sm text-gray-500 dark:text-gray-400 py-4 text-center">
|
||||
{{ _('loyalty.admin.merchant_detail.no_categories') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Merchant Settings (Admin-controlled) -->
|
||||
<div class="px-4 py-3 mb-8 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">
|
||||
|
||||
@@ -245,7 +245,7 @@
|
||||
:class="program.is_active ? 'text-orange-600 hover:bg-orange-50 dark:text-orange-400 dark:hover:bg-gray-700' : 'text-green-600 hover:bg-green-50 dark:text-green-400 dark:hover:bg-gray-700'"
|
||||
:aria-label="program.is_active ? '{{ _('loyalty.common.deactivate') }}' : '{{ _('loyalty.common.activate') }}'"
|
||||
>
|
||||
<span x-html="$icon(program.is_active ? 'pause' : 'play', 'w-5 h-5')"></span>
|
||||
<span x-html="$icon(program.is_active ? 'ban' : 'play', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
x-text="program?.loyalty_type || 'unknown'"></span>
|
||||
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold leading-tight rounded-full"
|
||||
:class="program?.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700'">
|
||||
<span x-text="program?.is_active ? $t('loyalty.common.active') : $t('loyalty.common.inactive')"></span>
|
||||
<span x-text="program?.is_active ? '{{ _('loyalty.common.active') }}' : '{{ _('loyalty.common.inactive') }}'"></span>
|
||||
</span>
|
||||
{% if show_edit_button is not defined or show_edit_button %}
|
||||
<a href="{{ edit_url }}"
|
||||
@@ -92,22 +92,22 @@
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.shared.program_view.welcome_bonus') }}</p>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
x-text="program?.welcome_bonus_points ? $t('loyalty.shared.program_view.x_points', {count: program.welcome_bonus_points}) : $t('loyalty.common.none')">-</p>
|
||||
x-text="program?.welcome_bonus_points ? '{{ _('loyalty.shared.program_view.x_points') }}'.replace('{count}', program.welcome_bonus_points) : '{{ _('loyalty.common.none') }}'">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.shared.program_view.minimum_redemption') }}</p>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
x-text="program?.minimum_redemption_points ? $t('loyalty.shared.program_view.x_points', {count: program.minimum_redemption_points}) : $t('loyalty.common.none')">-</p>
|
||||
x-text="program?.minimum_redemption_points ? '{{ _('loyalty.shared.program_view.x_points') }}'.replace('{count}', program.minimum_redemption_points) : '{{ _('loyalty.common.none') }}'">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.shared.program_view.minimum_purchase') }}</p>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
x-text="program?.minimum_purchase_cents ? '€' + (program.minimum_purchase_cents / 100).toFixed(2) : $t('loyalty.common.none')">-</p>
|
||||
x-text="program?.minimum_purchase_cents ? '€' + (program.minimum_purchase_cents / 100).toFixed(2) : '{{ _('loyalty.common.none') }}'">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.shared.program_view.points_expiration') }}</p>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
x-text="program?.points_expiration_days ? $t('loyalty.shared.program_view.x_days_inactivity', {days: program.points_expiration_days}) : $t('loyalty.common.never')">-</p>
|
||||
x-text="program?.points_expiration_days ? '{{ _('loyalty.shared.program_view.x_days_inactivity') }}'.replace('{days}', program.points_expiration_days) : '{{ _('loyalty.common.never') }}'">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,7 +153,7 @@
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.shared.program_view.cooldown') }}</p>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
x-text="program?.cooldown_minutes ? $t('loyalty.shared.program_view.x_minutes', {count: program.cooldown_minutes}) : $t('loyalty.common.none')">-</p>
|
||||
x-text="program?.cooldown_minutes ? '{{ _('loyalty.shared.program_view.x_minutes') }}'.replace('{count}', program.cooldown_minutes) : '{{ _('loyalty.common.none') }}'">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.shared.program_view.max_daily_stamps') }}</p>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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 %}
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
<div class="ml-4 flex-1">
|
||||
<p class="font-semibold text-gray-700 dark:text-gray-200" x-text="selectedCard?.customer_name || 'Unknown'"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedCard?.customer_email"></p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500" x-text="$t('loyalty.store.terminal.card_label') + ': ' + selectedCard?.card_number"></p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500" x-text="'{{ _('loyalty.store.terminal.card_label') }}' + ': ' + selectedCard?.card_number"></p>
|
||||
</div>
|
||||
<button @click="clearCustomer()" type="button" aria-label="{{ _('loyalty.common.close') }}" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-html="$icon('x', 'w-5 h-5')"></span>
|
||||
@@ -156,7 +156,7 @@
|
||||
:style="'color: ' + (program?.card_color || '#4F46E5')"
|
||||
x-text="(selectedCard?.stamp_count || 0) + ' / ' + (program?.stamps_target || 10)"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1"
|
||||
x-text="selectedCard?.stamps_until_reward > 0 ? $t('loyalty.store.terminal.more_for_reward', {count: selectedCard.stamps_until_reward}) : $t('loyalty.store.terminal.ready_to_redeem')"></p>
|
||||
x-text="selectedCard?.stamps_until_reward > 0 ? '{{ _('loyalty.store.terminal.more_for_reward') }}'.replace('{count}', selectedCard.stamps_until_reward) : '{{ _('loyalty.store.terminal.ready_to_redeem') }}'"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -168,7 +168,7 @@
|
||||
<template x-if="program?.is_stamps_enabled">
|
||||
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
<span x-html="$icon('plus-circle', 'inline w-4 h-4 mr-1 text-green-500')"></span>
|
||||
<span x-html="$icon('plus', 'inline w-4 h-4 mr-1 text-green-500')"></span>
|
||||
{{ _('loyalty.store.terminal.add_stamp') }}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
@@ -192,7 +192,7 @@
|
||||
{{ _('loyalty.store.terminal.redeem_stamps') }}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3"
|
||||
x-text="selectedCard?.can_redeem_stamps ? $t('loyalty.store.terminal.reward_label') + ': ' + (selectedCard?.stamp_reward_description || program?.stamps_reward_description || $t('loyalty.store.terminal.free_item')) : $t('loyalty.store.terminal.not_enough_stamps')"></p>
|
||||
x-text="selectedCard?.can_redeem_stamps ? '{{ _('loyalty.store.terminal.reward_label') }}' + ': ' + (selectedCard?.stamp_reward_description || program?.stamps_reward_description || '{{ _('loyalty.store.terminal.free_item') }}') : '{{ _('loyalty.store.terminal.not_enough_stamps') }}'"></p>
|
||||
<button @click="showPinModal('redeemStamps')"
|
||||
:disabled="!selectedCard?.can_redeem_stamps"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 disabled:opacity-50">
|
||||
@@ -205,7 +205,7 @@
|
||||
<template x-if="program?.is_points_enabled">
|
||||
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
<span x-html="$icon('plus-circle', 'inline w-4 h-4 mr-1 text-green-500')"></span>
|
||||
<span x-html="$icon('plus', 'inline w-4 h-4 mr-1 text-green-500')"></span>
|
||||
{{ _('loyalty.store.terminal.earn_points') }}
|
||||
</h4>
|
||||
<div class="mb-3">
|
||||
@@ -286,13 +286,14 @@
|
||||
<th class="px-4 py-3">{{ _('loyalty.store.terminal.col_customer') }}</th>
|
||||
<th class="px-4 py-3">{{ _('loyalty.store.terminal.col_type') }}</th>
|
||||
<th class="px-4 py-3 text-right">{{ _('loyalty.store.terminal.col_points') }}</th>
|
||||
<th class="px-4 py-3">{{ _('loyalty.store.terminal.select_category') }}</th>
|
||||
<th class="px-4 py-3">{{ _('loyalty.store.terminal.col_notes') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-if="recentTransactions.length === 0">
|
||||
<tr>
|
||||
<td colspan="5" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
|
||||
<td colspan="6" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
|
||||
{{ _('loyalty.store.terminal.no_recent_transactions') }}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -309,6 +310,7 @@
|
||||
<td class="px-4 py-3 text-sm text-right font-medium"
|
||||
:class="tx.points_delta > 0 ? 'text-green-600' : 'text-orange-600'"
|
||||
x-text="(tx.points_delta > 0 ? '+' : '') + formatNumber(tx.points_delta)"></td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500" x-text="tx.category_names?.join(', ') || '-'"></td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500" x-text="tx.notes || '-'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -324,6 +326,24 @@
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
{{ _('loyalty.store.terminal.pin_authorize_text') }}
|
||||
</p>
|
||||
|
||||
<!-- Category selector (only shown when categories exist and action is stamp/earn) -->
|
||||
<div x-show="categories.length > 0 && (pendingAction === 'stamp' || pendingAction === 'earn')" class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-2">{{ _('loyalty.store.terminal.select_category') }}</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template x-for="cat in categories" :key="cat.id">
|
||||
<button type="button"
|
||||
@click="selectedCategories.includes(cat.id) ? selectedCategories = selectedCategories.filter(id => id !== cat.id) : selectedCategories.push(cat.id)"
|
||||
class="px-3 py-1.5 text-sm font-medium rounded-full border transition-colors"
|
||||
:class="selectedCategories.includes(cat.id)
|
||||
? 'bg-purple-600 text-white border-purple-600'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600'"
|
||||
x-text="(cat.name_translations && cat.name_translations['{{ current_language | default('en') }}']) || cat.name">
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mb-4" aria-live="polite" aria-atomic="true">
|
||||
<div class="flex gap-2" role="status" :aria-label="pinDigits.length + ' of 4 digits entered'">
|
||||
<template x-for="i in 4">
|
||||
@@ -350,7 +370,7 @@
|
||||
</button>
|
||||
<button @click="removePinDigit()" type="button" aria-label="{{ _('loyalty.common.back') }}"
|
||||
class="h-14 text-sm font-medium rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600">
|
||||
<span x-html="$icon('backspace', 'w-6 h-6 mx-auto')"></span>
|
||||
<span x-html="$icon('arrow-left', 'w-6 h-6 mx-auto')"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end gap-3">
|
||||
@@ -359,7 +379,7 @@
|
||||
{{ _('loyalty.common.cancel') }}
|
||||
</button>
|
||||
<button @click="submitTransaction()"
|
||||
:disabled="pinDigits.length !== 4 || processing"
|
||||
:disabled="pinDigits.length !== 4 || processing || (categories.length > 0 && (pendingAction === 'stamp' || pendingAction === 'earn') && selectedCategories.length === 0)"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
<span x-show="processing" x-html="$icon('spinner', 'w-4 h-4 mr-2 inline animate-spin')"></span>
|
||||
<span x-text="processing ? '{{ _('loyalty.store.terminal.processing')|replace("'", "\\'") }}' : '{{ _('loyalty.store.terminal.confirm')|replace("'", "\\'") }}'"></span>
|
||||
@@ -370,5 +390,21 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// Server-rendered transaction type labels (avoids async i18n flicker)
|
||||
window._txLabels = {
|
||||
card_created: {{ _('loyalty.transactions.card_created')|tojson }},
|
||||
welcome_bonus: {{ _('loyalty.transactions.welcome_bonus')|tojson }},
|
||||
stamp_earned: {{ _('loyalty.transactions.stamp_earned')|tojson }},
|
||||
stamp_redeemed: {{ _('loyalty.transactions.stamp_redeemed')|tojson }},
|
||||
stamp_voided: {{ _('loyalty.transactions.stamp_voided')|tojson }},
|
||||
points_earned: {{ _('loyalty.transactions.points_earned')|tojson }},
|
||||
points_redeemed: {{ _('loyalty.transactions.points_redeemed')|tojson }},
|
||||
points_voided: {{ _('loyalty.transactions.points_voided')|tojson }},
|
||||
points_adjustment: {{ _('loyalty.transactions.points_adjustment')|tojson }},
|
||||
points_expired: {{ _('loyalty.transactions.points_expired')|tojson }},
|
||||
reward_redeemed: {{ _('loyalty.transactions.reward_redeemed')|tojson }},
|
||||
};
|
||||
</script>
|
||||
<script defer src="{{ url_for('loyalty_static', path='store/js/loyalty-terminal.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
<!-- Shipping Address -->
|
||||
<div class="min-w-0 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800" x-show="order">
|
||||
<h4 class="mb-4 font-semibold text-gray-600 dark:text-gray-300 flex items-center gap-2">
|
||||
<span x-html="$icon('location-marker', 'w-5 h-5')"></span>
|
||||
<span x-html="$icon('map-pin', 'w-5 h-5')"></span>
|
||||
Shipping Address
|
||||
</h4>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300 space-y-1">
|
||||
|
||||
@@ -192,7 +192,7 @@ messaging_module = ModuleDefinition(
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="messages",
|
||||
label_key="storefront.account.messages",
|
||||
label_key="messaging.storefront.account.messages",
|
||||
icon="chat-bubble-left-right",
|
||||
route="account/messages",
|
||||
order=50,
|
||||
|
||||
@@ -125,5 +125,10 @@
|
||||
"send_messages_desc": "Nachrichten an Kunden senden",
|
||||
"manage_templates": "Vorlagen verwalten",
|
||||
"manage_templates_desc": "Nachrichtenvorlagen erstellen und bearbeiten"
|
||||
},
|
||||
"storefront": {
|
||||
"account": {
|
||||
"messages": "Nachrichten"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,5 +125,10 @@
|
||||
"status_opened": "Opened",
|
||||
"status_clicked": "Clicked",
|
||||
"retention_note": "Email body content is retained for 90 days. Metadata is kept indefinitely."
|
||||
},
|
||||
"storefront": {
|
||||
"account": {
|
||||
"messages": "Messages"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,5 +125,10 @@
|
||||
"send_messages_desc": "Envoyer des messages aux clients",
|
||||
"manage_templates": "Gérer les modèles",
|
||||
"manage_templates_desc": "Créer et modifier les modèles de messages"
|
||||
},
|
||||
"storefront": {
|
||||
"account": {
|
||||
"messages": "Messages"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,5 +125,10 @@
|
||||
"send_messages_desc": "Messagen u Clienten schécken",
|
||||
"manage_templates": "Schablounen verwalten",
|
||||
"manage_templates_desc": "Messageschablounen erstellen an änneren"
|
||||
},
|
||||
"storefront": {
|
||||
"account": {
|
||||
"messages": "Noriichten"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,13 @@ def _get_feature_provider():
|
||||
return order_feature_provider
|
||||
|
||||
|
||||
def _get_widget_provider():
|
||||
"""Lazy import of widget provider to avoid circular imports."""
|
||||
from app.modules.orders.services.order_widgets import order_widget_provider
|
||||
|
||||
return order_widget_provider
|
||||
|
||||
|
||||
# Orders module definition
|
||||
orders_module = ModuleDefinition(
|
||||
code="orders",
|
||||
@@ -146,7 +153,7 @@ orders_module = ModuleDefinition(
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="orders",
|
||||
label_key="storefront.account.orders",
|
||||
label_key="orders.storefront.account.orders",
|
||||
icon="clipboard-list",
|
||||
route="account/orders",
|
||||
order=40,
|
||||
@@ -168,6 +175,7 @@ orders_module = ModuleDefinition(
|
||||
# Metrics provider for dashboard statistics
|
||||
metrics_provider=_get_metrics_provider,
|
||||
feature_provider=_get_feature_provider,
|
||||
widget_provider=_get_widget_provider,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -89,5 +89,10 @@
|
||||
"orders_cancel_desc": "Ausstehende oder in Bearbeitung befindliche Bestellungen stornieren",
|
||||
"orders_refund": "Bestellungen erstatten",
|
||||
"orders_refund_desc": "Rückerstattungen für Bestellungen verarbeiten"
|
||||
},
|
||||
"storefront": {
|
||||
"account": {
|
||||
"orders": "Bestellungen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,5 +89,10 @@
|
||||
"store_operations": "Store Operations",
|
||||
"sales_orders": "Sales & Orders",
|
||||
"orders": "Orders"
|
||||
},
|
||||
"storefront": {
|
||||
"account": {
|
||||
"orders": "Orders"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,5 +89,10 @@
|
||||
"orders_cancel_desc": "Annuler les commandes en attente ou en traitement",
|
||||
"orders_refund": "Rembourser les commandes",
|
||||
"orders_refund_desc": "Traiter les remboursements des commandes"
|
||||
},
|
||||
"storefront": {
|
||||
"account": {
|
||||
"orders": "Commandes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,5 +89,10 @@
|
||||
"orders_cancel_desc": "Aussteesend oder a Veraarbechtung Bestellunge stornéieren",
|
||||
"orders_refund": "Bestellungen erstatten",
|
||||
"orders_refund_desc": "Réckerstattunge fir Bestellunge veraarbechten"
|
||||
},
|
||||
"storefront": {
|
||||
"account": {
|
||||
"orders": "Bestellungen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user