Compare commits
40 Commits
4b56eb7ab1
...
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 | |||
| 8cd09f3f89 | |||
| 4c1608f78a | |||
| 24219e4d9a | |||
| fde58bea06 | |||
| 52b78ce346 | |||
| f804ff8442 | |||
| d9abb275a5 |
@@ -1744,3 +1744,39 @@ def get_current_customer_optional(
|
|||||||
except Exception:
|
except Exception:
|
||||||
# Invalid token, store mismatch, or other error
|
# Invalid token, store mismatch, or other error
|
||||||
return None
|
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>
|
||||||
<div class="flex items-center">
|
<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">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="formatNumber(analytics.inventory?.total_locations || 0)"></p>
|
<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
|
requires_permission: str | None = None
|
||||||
badge_source: str | None = None
|
badge_source: str | None = None
|
||||||
is_super_admin_only: bool = False
|
is_super_admin_only: bool = False
|
||||||
|
header_template: str | None = None # Optional partial for custom header rendering
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -68,10 +68,11 @@ cart_module = ModuleDefinition(
|
|||||||
items=[
|
items=[
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="cart",
|
id="cart",
|
||||||
label_key="storefront.actions.cart",
|
label_key="cart.storefront.actions.cart",
|
||||||
icon="shopping-cart",
|
icon="shopping-cart",
|
||||||
route="cart",
|
route="cart",
|
||||||
order=20,
|
order=20,
|
||||||
|
header_template="cart/storefront/partials/header-cart.html",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -44,5 +44,10 @@
|
|||||||
"view_desc": "Warenkörbe der Kunden anzeigen",
|
"view_desc": "Warenkörbe der Kunden anzeigen",
|
||||||
"manage": "Warenkörbe verwalten",
|
"manage": "Warenkörbe verwalten",
|
||||||
"manage_desc": "Warenkörbe der Kunden bearbeiten und verwalten"
|
"manage_desc": "Warenkörbe der Kunden bearbeiten und verwalten"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"actions": {
|
||||||
|
"cart": "Warenkorb"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,5 +44,10 @@
|
|||||||
"product_not_available": "Product not available",
|
"product_not_available": "Product not available",
|
||||||
"error_adding": "Error adding item to cart",
|
"error_adding": "Error adding item to cart",
|
||||||
"error_updating": "Error updating cart"
|
"error_updating": "Error updating cart"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"actions": {
|
||||||
|
"cart": "Cart"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,5 +44,10 @@
|
|||||||
"view_desc": "Voir les paniers des clients",
|
"view_desc": "Voir les paniers des clients",
|
||||||
"manage": "Gérer les paniers",
|
"manage": "Gérer les paniers",
|
||||||
"manage_desc": "Modifier et gérer les paniers des clients"
|
"manage_desc": "Modifier et gérer les paniers des clients"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"actions": {
|
||||||
|
"cart": "Panier"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,5 +44,10 @@
|
|||||||
"view_desc": "Clientekuerf kucken",
|
"view_desc": "Clientekuerf kucken",
|
||||||
"manage": "Kuerf verwalten",
|
"manage": "Kuerf verwalten",
|
||||||
"manage_desc": "Clientekuerf änneren a 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=[
|
items=[
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="products",
|
id="products",
|
||||||
label_key="storefront.nav.products",
|
label_key="catalog.storefront.nav.products",
|
||||||
icon="shopping-bag",
|
icon="shopping-bag",
|
||||||
route="products",
|
route="products",
|
||||||
order=10,
|
order=10,
|
||||||
@@ -148,10 +148,11 @@ catalog_module = ModuleDefinition(
|
|||||||
items=[
|
items=[
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="search",
|
id="search",
|
||||||
label_key="storefront.actions.search",
|
label_key="catalog.storefront.actions.search",
|
||||||
icon="search",
|
icon="search",
|
||||||
route="",
|
route="",
|
||||||
order=10,
|
order=10,
|
||||||
|
header_template="catalog/storefront/partials/header-search.html",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -89,5 +89,13 @@
|
|||||||
"products_import_desc": "Massenimport von Produkten",
|
"products_import_desc": "Massenimport von Produkten",
|
||||||
"products_export": "Produkte exportieren",
|
"products_export": "Produkte exportieren",
|
||||||
"products_export_desc": "Produktdaten exportieren"
|
"products_export_desc": "Produktdaten exportieren"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"nav": {
|
||||||
|
"products": "Produkte"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"search": "Suchen"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,5 +107,13 @@
|
|||||||
"menu": {
|
"menu": {
|
||||||
"products_inventory": "Products & Inventory",
|
"products_inventory": "Products & Inventory",
|
||||||
"all_products": "All Products"
|
"all_products": "All Products"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"nav": {
|
||||||
|
"products": "Products"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"search": "Search"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,5 +89,13 @@
|
|||||||
"products_import_desc": "Importation en masse de produits",
|
"products_import_desc": "Importation en masse de produits",
|
||||||
"products_export": "Exporter les produits",
|
"products_export": "Exporter les produits",
|
||||||
"products_export_desc": "Exporter les données 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_import_desc": "Massenimport vu Produiten",
|
||||||
"products_export": "Produiten exportéieren",
|
"products_export": "Produiten exportéieren",
|
||||||
"products_export_desc": "Produitdaten 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)
|
@router.get("/products", response_class=HTMLResponse, include_in_schema=False)
|
||||||
async def shop_products_page(request: Request, db: Session = Depends(get_db)):
|
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.
|
Shows featured products and categories.
|
||||||
"""
|
"""
|
||||||
logger.debug(
|
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": {
|
"confirmations": {
|
||||||
"delete_file": "Sind Sie sicher, dass Sie diese Datei löschen möchten? Dies kann nicht rückgängig gemacht werden."
|
"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",
|
"content_pages": "Content Pages",
|
||||||
"store_themes": "Store Themes",
|
"store_themes": "Store Themes",
|
||||||
"media_library": "Media Library"
|
"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": {
|
"confirmations": {
|
||||||
"delete_file": "Êtes-vous sûr de vouloir supprimer ce fichier ? Cette action est irréversible."
|
"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": {
|
"confirmations": {
|
||||||
"delete_file": "Sidd Dir sécher datt Dir dëse Fichier läsche wëllt? Dat kann net réckgängeg gemaach ginn."
|
"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
|
# SEO
|
||||||
meta_description = Column(String(300), nullable=True)
|
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
|
# Publishing
|
||||||
is_published = Column(Boolean, default=False, nullable=False)
|
is_published = Column(Boolean, default=False, nullable=False)
|
||||||
@@ -230,6 +235,16 @@ class ContentPage(Base):
|
|||||||
)
|
)
|
||||||
return self.content
|
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):
|
def to_dict(self):
|
||||||
"""Convert to dictionary for API responses."""
|
"""Convert to dictionary for API responses."""
|
||||||
return {
|
return {
|
||||||
@@ -248,7 +263,7 @@ class ContentPage(Base):
|
|||||||
"template": self.template,
|
"template": self.template,
|
||||||
"sections": self.sections,
|
"sections": self.sections,
|
||||||
"meta_description": self.meta_description,
|
"meta_description": self.meta_description,
|
||||||
"meta_keywords": self.meta_keywords,
|
"meta_description_translations": self.meta_description_translations,
|
||||||
"is_published": self.is_published,
|
"is_published": self.is_published,
|
||||||
"published_at": (
|
"published_at": (
|
||||||
self.published_at.isoformat() if self.published_at else None
|
self.published_at.isoformat() if self.published_at else None
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ def create_platform_page(
|
|||||||
content_format=page_data.content_format,
|
content_format=page_data.content_format,
|
||||||
template=page_data.template,
|
template=page_data.template,
|
||||||
meta_description=page_data.meta_description,
|
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,
|
is_published=page_data.is_published,
|
||||||
show_in_footer=page_data.show_in_footer,
|
show_in_footer=page_data.show_in_footer,
|
||||||
show_in_header=page_data.show_in_header,
|
show_in_header=page_data.show_in_header,
|
||||||
@@ -117,7 +117,7 @@ def create_store_page(
|
|||||||
content_format=page_data.content_format,
|
content_format=page_data.content_format,
|
||||||
template=page_data.template,
|
template=page_data.template,
|
||||||
meta_description=page_data.meta_description,
|
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,
|
is_published=page_data.is_published,
|
||||||
show_in_footer=page_data.show_in_footer,
|
show_in_footer=page_data.show_in_footer,
|
||||||
show_in_header=page_data.show_in_header,
|
show_in_header=page_data.show_in_header,
|
||||||
@@ -177,11 +177,13 @@ def update_page(
|
|||||||
db,
|
db,
|
||||||
page_id=page_id,
|
page_id=page_id,
|
||||||
title=page_data.title,
|
title=page_data.title,
|
||||||
|
title_translations=page_data.title_translations,
|
||||||
content=page_data.content,
|
content=page_data.content,
|
||||||
|
content_translations=page_data.content_translations,
|
||||||
content_format=page_data.content_format,
|
content_format=page_data.content_format,
|
||||||
template=page_data.template,
|
template=page_data.template,
|
||||||
meta_description=page_data.meta_description,
|
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,
|
is_published=page_data.is_published,
|
||||||
show_in_footer=page_data.show_in_footer,
|
show_in_footer=page_data.show_in_footer,
|
||||||
show_in_header=page_data.show_in_header,
|
show_in_header=page_data.show_in_header,
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ def create_store_page(
|
|||||||
store_id=current_user.token_store_id,
|
store_id=current_user.token_store_id,
|
||||||
content_format=page_data.content_format,
|
content_format=page_data.content_format,
|
||||||
meta_description=page_data.meta_description,
|
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,
|
is_published=page_data.is_published,
|
||||||
show_in_footer=page_data.show_in_footer,
|
show_in_footer=page_data.show_in_footer,
|
||||||
show_in_header=page_data.show_in_header,
|
show_in_header=page_data.show_in_header,
|
||||||
@@ -241,7 +241,7 @@ def update_store_page(
|
|||||||
content=page_data.content,
|
content=page_data.content,
|
||||||
content_format=page_data.content_format,
|
content_format=page_data.content_format,
|
||||||
meta_description=page_data.meta_description,
|
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,
|
is_published=page_data.is_published,
|
||||||
show_in_footer=page_data.show_in_footer,
|
show_in_footer=page_data.show_in_footer,
|
||||||
show_in_header=page_data.show_in_header,
|
show_in_header=page_data.show_in_header,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
"""
|
"""
|
||||||
CMS Admin Page Routes (HTML rendering).
|
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
|
from fastapi import APIRouter, Depends, Path, Request
|
||||||
@@ -10,6 +11,7 @@ from fastapi.responses import HTMLResponse
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_db, require_menu_access
|
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.enums import FrontendType
|
||||||
from app.modules.tenancy.models import User
|
from app.modules.tenancy.models import User
|
||||||
from app.templates_config import templates
|
from app.templates_config import templates
|
||||||
@@ -86,3 +88,49 @@ async def admin_content_page_edit(
|
|||||||
"page_id": page_id,
|
"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)
|
# DYNAMIC CONTENT PAGES (CMS)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -103,10 +176,13 @@ async def generic_content_page(
|
|||||||
|
|
||||||
# Resolve placeholders in store default pages ({{store_name}}, etc.)
|
# Resolve placeholders in store default pages ({{store_name}}, etc.)
|
||||||
page_content = page.content
|
page_content = page.content
|
||||||
|
page_title = page.title
|
||||||
if page.is_store_default and store:
|
if page.is_store_default and store:
|
||||||
page_content = content_page_service.resolve_placeholders(page.content, 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 = get_storefront_context(request, db=db, page=page)
|
||||||
|
context["page_title"] = page_title
|
||||||
context["page_content"] = page_content
|
context["page_content"] = page_content
|
||||||
|
|
||||||
# Select template based on page.template field
|
# Select template based on page.template field
|
||||||
|
|||||||
@@ -24,19 +24,27 @@ class ContentPageCreate(BaseModel):
|
|||||||
description="URL-safe identifier (about, faq, contact, etc.)",
|
description="URL-safe identifier (about, faq, contact, etc.)",
|
||||||
)
|
)
|
||||||
title: str = Field(..., max_length=200, description="Page title")
|
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: 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(
|
content_format: str = Field(
|
||||||
default="html", description="Content format: html or markdown"
|
default="html", description="Content format: html or markdown"
|
||||||
)
|
)
|
||||||
template: str = Field(
|
template: str = Field(
|
||||||
default="default",
|
default="default",
|
||||||
max_length=50,
|
max_length=50,
|
||||||
description="Template name (default, minimal, modern)",
|
description="Template name (default, minimal, modern, full)",
|
||||||
)
|
)
|
||||||
meta_description: str | None = Field(
|
meta_description: str | None = Field(
|
||||||
None, max_length=300, description="SEO meta description"
|
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")
|
is_published: bool = Field(default=False, description="Publish immediately")
|
||||||
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
|
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
|
||||||
show_in_header: bool = Field(default=False, description="Show in header 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)."""
|
"""Schema for updating a content page (admin)."""
|
||||||
|
|
||||||
title: str | None = Field(None, max_length=200)
|
title: str | None = Field(None, max_length=200)
|
||||||
|
title_translations: dict[str, str] | None = None
|
||||||
content: str | None = None
|
content: str | None = None
|
||||||
|
content_translations: dict[str, str] | None = None
|
||||||
content_format: str | None = None
|
content_format: str | None = None
|
||||||
template: str | None = Field(None, max_length=50)
|
template: str | None = Field(None, max_length=50)
|
||||||
meta_description: str | None = Field(None, max_length=300)
|
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
|
is_published: bool | None = None
|
||||||
show_in_footer: bool | None = None
|
show_in_footer: bool | None = None
|
||||||
show_in_header: bool | None = None
|
show_in_header: bool | None = None
|
||||||
@@ -78,11 +88,13 @@ class ContentPageResponse(BaseModel):
|
|||||||
store_name: str | None
|
store_name: str | None
|
||||||
slug: str
|
slug: str
|
||||||
title: str
|
title: str
|
||||||
|
title_translations: dict[str, str] | None = None
|
||||||
content: str
|
content: str
|
||||||
|
content_translations: dict[str, str] | None = None
|
||||||
content_format: str
|
content_format: str
|
||||||
template: str | None = None
|
template: str | None = None
|
||||||
meta_description: str | None
|
meta_description: str | None
|
||||||
meta_keywords: str | None
|
meta_description_translations: dict[str, str] | None = None
|
||||||
is_published: bool
|
is_published: bool
|
||||||
published_at: str | None
|
published_at: str | None
|
||||||
display_order: int
|
display_order: int
|
||||||
@@ -135,7 +147,6 @@ class StoreContentPageCreate(BaseModel):
|
|||||||
meta_description: str | None = Field(
|
meta_description: str | None = Field(
|
||||||
None, max_length=300, description="SEO meta description"
|
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")
|
is_published: bool = Field(default=False, description="Publish immediately")
|
||||||
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
|
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
|
||||||
show_in_header: bool = Field(default=False, description="Show in header 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: str | None = None
|
||||||
content_format: str | None = None
|
content_format: str | None = None
|
||||||
meta_description: str | None = Field(None, max_length=300)
|
meta_description: str | None = Field(None, max_length=300)
|
||||||
meta_keywords: str | None = Field(None, max_length=300)
|
|
||||||
is_published: bool | None = None
|
is_published: bool | None = None
|
||||||
show_in_footer: bool | None = None
|
show_in_footer: bool | None = None
|
||||||
show_in_header: bool | None = None
|
show_in_header: bool | None = None
|
||||||
@@ -187,7 +197,6 @@ class PublicContentPageResponse(BaseModel):
|
|||||||
content: str
|
content: str
|
||||||
content_format: str
|
content_format: str
|
||||||
meta_description: str | None
|
meta_description: str | None
|
||||||
meta_keywords: str | None
|
|
||||||
published_at: str | None
|
published_at: str | None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Lookup Strategy for Store Storefronts:
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -472,7 +473,7 @@ class ContentPageService:
|
|||||||
content_format: str = "html",
|
content_format: str = "html",
|
||||||
template: str = "default",
|
template: str = "default",
|
||||||
meta_description: str | None = None,
|
meta_description: str | None = None,
|
||||||
meta_keywords: str | None = None,
|
meta_description_translations: str | None = None,
|
||||||
is_published: bool = False,
|
is_published: bool = False,
|
||||||
show_in_footer: bool = True,
|
show_in_footer: bool = True,
|
||||||
show_in_header: bool = False,
|
show_in_header: bool = False,
|
||||||
@@ -494,7 +495,7 @@ class ContentPageService:
|
|||||||
content_format: "html" or "markdown"
|
content_format: "html" or "markdown"
|
||||||
template: Template name for landing pages
|
template: Template name for landing pages
|
||||||
meta_description: SEO description
|
meta_description: SEO description
|
||||||
meta_keywords: SEO keywords
|
meta_description_translations: Meta description translations dict
|
||||||
is_published: Publish immediately
|
is_published: Publish immediately
|
||||||
show_in_footer: Show in footer navigation
|
show_in_footer: Show in footer navigation
|
||||||
show_in_header: Show in header navigation
|
show_in_header: Show in header navigation
|
||||||
@@ -515,7 +516,7 @@ class ContentPageService:
|
|||||||
content_format=content_format,
|
content_format=content_format,
|
||||||
template=template,
|
template=template,
|
||||||
meta_description=meta_description,
|
meta_description=meta_description,
|
||||||
meta_keywords=meta_keywords,
|
meta_description_translations=meta_description_translations,
|
||||||
is_published=is_published,
|
is_published=is_published,
|
||||||
published_at=datetime.now(UTC) if is_published else None,
|
published_at=datetime.now(UTC) if is_published else None,
|
||||||
show_in_footer=show_in_footer,
|
show_in_footer=show_in_footer,
|
||||||
@@ -541,11 +542,13 @@ class ContentPageService:
|
|||||||
db: Session,
|
db: Session,
|
||||||
page_id: int,
|
page_id: int,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
|
title_translations: dict[str, str] | None = None,
|
||||||
content: str | None = None,
|
content: str | None = None,
|
||||||
|
content_translations: dict[str, str] | None = None,
|
||||||
content_format: str | None = None,
|
content_format: str | None = None,
|
||||||
template: str | None = None,
|
template: str | None = None,
|
||||||
meta_description: str | None = None,
|
meta_description: str | None = None,
|
||||||
meta_keywords: str | None = None,
|
meta_description_translations: str | None = None,
|
||||||
is_published: bool | None = None,
|
is_published: bool | None = None,
|
||||||
show_in_footer: bool | None = None,
|
show_in_footer: bool | None = None,
|
||||||
show_in_header: bool | None = None,
|
show_in_header: bool | None = None,
|
||||||
@@ -573,16 +576,20 @@ class ContentPageService:
|
|||||||
# Update fields if provided
|
# Update fields if provided
|
||||||
if title is not None:
|
if title is not None:
|
||||||
page.title = title
|
page.title = title
|
||||||
|
if title_translations is not None:
|
||||||
|
page.title_translations = title_translations
|
||||||
if content is not None:
|
if content is not None:
|
||||||
page.content = content
|
page.content = content
|
||||||
|
if content_translations is not None:
|
||||||
|
page.content_translations = content_translations
|
||||||
if content_format is not None:
|
if content_format is not None:
|
||||||
page.content_format = content_format
|
page.content_format = content_format
|
||||||
if template is not None:
|
if template is not None:
|
||||||
page.template = template
|
page.template = template
|
||||||
if meta_description is not None:
|
if meta_description is not None:
|
||||||
page.meta_description = meta_description
|
page.meta_description = meta_description
|
||||||
if meta_keywords is not None:
|
if meta_description_translations is not None:
|
||||||
page.meta_keywords = meta_keywords
|
page.meta_description_translations = meta_description_translations
|
||||||
if is_published is not None:
|
if is_published is not None:
|
||||||
page.is_published = is_published
|
page.is_published = is_published
|
||||||
if is_published and not page.published_at:
|
if is_published and not page.published_at:
|
||||||
@@ -698,7 +705,7 @@ class ContentPageService:
|
|||||||
content: str | None = None,
|
content: str | None = None,
|
||||||
content_format: str | None = None,
|
content_format: str | None = None,
|
||||||
meta_description: str | None = None,
|
meta_description: str | None = None,
|
||||||
meta_keywords: str | None = None,
|
meta_description_translations: str | None = None,
|
||||||
is_published: bool | None = None,
|
is_published: bool | None = None,
|
||||||
show_in_footer: bool | None = None,
|
show_in_footer: bool | None = None,
|
||||||
show_in_header: bool | None = None,
|
show_in_header: bool | None = None,
|
||||||
@@ -725,7 +732,7 @@ class ContentPageService:
|
|||||||
content=content,
|
content=content,
|
||||||
content_format=content_format,
|
content_format=content_format,
|
||||||
meta_description=meta_description,
|
meta_description=meta_description,
|
||||||
meta_keywords=meta_keywords,
|
meta_description_translations=meta_description_translations,
|
||||||
is_published=is_published,
|
is_published=is_published,
|
||||||
show_in_footer=show_in_footer,
|
show_in_footer=show_in_footer,
|
||||||
show_in_header=show_in_header,
|
show_in_header=show_in_header,
|
||||||
@@ -760,7 +767,7 @@ class ContentPageService:
|
|||||||
content: str,
|
content: str,
|
||||||
content_format: str = "html",
|
content_format: str = "html",
|
||||||
meta_description: str | None = None,
|
meta_description: str | None = None,
|
||||||
meta_keywords: str | None = None,
|
meta_description_translations: str | None = None,
|
||||||
is_published: bool = False,
|
is_published: bool = False,
|
||||||
show_in_footer: bool = True,
|
show_in_footer: bool = True,
|
||||||
show_in_header: bool = False,
|
show_in_header: bool = False,
|
||||||
@@ -791,7 +798,7 @@ class ContentPageService:
|
|||||||
is_platform_page=False,
|
is_platform_page=False,
|
||||||
content_format=content_format,
|
content_format=content_format,
|
||||||
meta_description=meta_description,
|
meta_description=meta_description,
|
||||||
meta_keywords=meta_keywords,
|
meta_description_translations=meta_description_translations,
|
||||||
is_published=is_published,
|
is_published=is_published,
|
||||||
show_in_footer=show_in_footer,
|
show_in_footer=show_in_footer,
|
||||||
show_in_header=show_in_header,
|
show_in_header=show_in_header,
|
||||||
@@ -913,11 +920,13 @@ class ContentPageService:
|
|||||||
db: Session,
|
db: Session,
|
||||||
page_id: int,
|
page_id: int,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
|
title_translations: dict[str, str] | None = None,
|
||||||
content: str | None = None,
|
content: str | None = None,
|
||||||
|
content_translations: dict[str, str] | None = None,
|
||||||
content_format: str | None = None,
|
content_format: str | None = None,
|
||||||
template: str | None = None,
|
template: str | None = None,
|
||||||
meta_description: str | None = None,
|
meta_description: str | None = None,
|
||||||
meta_keywords: str | None = None,
|
meta_description_translations: str | None = None,
|
||||||
is_published: bool | None = None,
|
is_published: bool | None = None,
|
||||||
show_in_footer: bool | None = None,
|
show_in_footer: bool | None = None,
|
||||||
show_in_header: bool | None = None,
|
show_in_header: bool | None = None,
|
||||||
@@ -935,11 +944,13 @@ class ContentPageService:
|
|||||||
db,
|
db,
|
||||||
page_id=page_id,
|
page_id=page_id,
|
||||||
title=title,
|
title=title,
|
||||||
|
title_translations=title_translations,
|
||||||
content=content,
|
content=content,
|
||||||
|
content_translations=content_translations,
|
||||||
content_format=content_format,
|
content_format=content_format,
|
||||||
template=template,
|
template=template,
|
||||||
meta_description=meta_description,
|
meta_description=meta_description,
|
||||||
meta_keywords=meta_keywords,
|
meta_description_translations=meta_description_translations,
|
||||||
is_published=is_published,
|
is_published=is_published,
|
||||||
show_in_footer=show_in_footer,
|
show_in_footer=show_in_footer,
|
||||||
show_in_header=show_in_header,
|
show_in_header=show_in_header,
|
||||||
@@ -991,6 +1002,28 @@ class ContentPageService:
|
|||||||
content = content.replace(placeholder, value)
|
content = content.replace(placeholder, value)
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_placeholders_deep(data, store) -> Any:
|
||||||
|
"""
|
||||||
|
Recursively resolve {{store_name}} etc. in a nested data structure
|
||||||
|
(dicts, lists, strings). Used for sections JSON in store default pages.
|
||||||
|
"""
|
||||||
|
if not data or not store:
|
||||||
|
return data
|
||||||
|
if isinstance(data, str):
|
||||||
|
return ContentPageService.resolve_placeholders(data, store)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return {
|
||||||
|
k: ContentPageService.resolve_placeholders_deep(v, store)
|
||||||
|
for k, v in data.items()
|
||||||
|
}
|
||||||
|
if isinstance(data, list):
|
||||||
|
return [
|
||||||
|
ContentPageService.resolve_placeholders_deep(item, store)
|
||||||
|
for item in data
|
||||||
|
]
|
||||||
|
return data
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Homepage Sections Management
|
# Homepage Sections Management
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class StoreThemeService:
|
|||||||
"""
|
"""
|
||||||
from app.modules.tenancy.services.store_service import store_service
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
store = store_service.get_store_by_code(db, store_code)
|
store = store_service.get_store_by_code_or_subdomain(db, store_code)
|
||||||
|
|
||||||
if not store:
|
if not store:
|
||||||
self.logger.warning(f"Store not found: {store_code}")
|
self.logger.warning(f"Store not found: {store_code}")
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ function contentPageEditor(pageId) {
|
|||||||
form: {
|
form: {
|
||||||
slug: '',
|
slug: '',
|
||||||
title: '',
|
title: '',
|
||||||
|
title_translations: {},
|
||||||
content: '',
|
content: '',
|
||||||
|
content_translations: {},
|
||||||
content_format: 'html',
|
content_format: 'html',
|
||||||
template: 'default',
|
template: 'default',
|
||||||
meta_description: '',
|
meta_description: '',
|
||||||
meta_keywords: '',
|
meta_description_translations: {},
|
||||||
is_published: false,
|
is_published: false,
|
||||||
show_in_header: false,
|
show_in_header: false,
|
||||||
show_in_footer: true,
|
show_in_footer: true,
|
||||||
@@ -42,6 +44,12 @@ function contentPageEditor(pageId) {
|
|||||||
error: null,
|
error: null,
|
||||||
successMessage: null,
|
successMessage: null,
|
||||||
|
|
||||||
|
// Page type: 'content' or 'landing'
|
||||||
|
pageType: 'content',
|
||||||
|
|
||||||
|
// Translation language for title/content
|
||||||
|
titleContentLang: 'fr',
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// HOMEPAGE SECTIONS STATE
|
// HOMEPAGE SECTIONS STATE
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -56,6 +64,13 @@ function contentPageEditor(pageId) {
|
|||||||
de: 'Deutsch',
|
de: 'Deutsch',
|
||||||
lb: 'Lëtzebuergesch'
|
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: {
|
sections: {
|
||||||
hero: {
|
hero: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -108,8 +123,8 @@ function contentPageEditor(pageId) {
|
|||||||
await this.loadPage();
|
await this.loadPage();
|
||||||
contentPageEditLog.groupEnd();
|
contentPageEditLog.groupEnd();
|
||||||
|
|
||||||
// Load sections if this is a homepage
|
// Load sections if this is a landing page
|
||||||
if (this.form.slug === 'home') {
|
if (this.pageType === 'landing') {
|
||||||
await this.loadSections();
|
await this.loadSections();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -120,14 +135,86 @@ function contentPageEditor(pageId) {
|
|||||||
contentPageEditLog.info('=== CONTENT PAGE EDITOR INITIALIZATION COMPLETE ===');
|
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,
|
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() {
|
updateIsHomepage() {
|
||||||
this.isHomepage = this.form.slug === 'home';
|
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
|
// Load platforms for dropdown
|
||||||
async loadPlatforms() {
|
async loadPlatforms() {
|
||||||
this.loadingPlatforms = true;
|
this.loadingPlatforms = true;
|
||||||
@@ -188,11 +275,13 @@ function contentPageEditor(pageId) {
|
|||||||
this.form = {
|
this.form = {
|
||||||
slug: page.slug || '',
|
slug: page.slug || '',
|
||||||
title: page.title || '',
|
title: page.title || '',
|
||||||
|
title_translations: page.title_translations || {},
|
||||||
content: page.content || '',
|
content: page.content || '',
|
||||||
|
content_translations: page.content_translations || {},
|
||||||
content_format: page.content_format || 'html',
|
content_format: page.content_format || 'html',
|
||||||
template: page.template || 'default',
|
template: page.template || 'default',
|
||||||
meta_description: page.meta_description || '',
|
meta_description: page.meta_description || '',
|
||||||
meta_keywords: page.meta_keywords || '',
|
meta_description_translations: page.meta_description_translations || {},
|
||||||
is_published: page.is_published || false,
|
is_published: page.is_published || false,
|
||||||
show_in_header: page.show_in_header || false,
|
show_in_header: page.show_in_header || false,
|
||||||
show_in_footer: page.show_in_footer !== undefined ? page.show_in_footer : true,
|
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
|
store_id: page.store_id
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Set page type from template
|
||||||
|
this.pageType = (this.form.template === 'full') ? 'landing' : 'content';
|
||||||
|
|
||||||
contentPageEditLog.info('Page loaded successfully');
|
contentPageEditLog.info('Page loaded successfully');
|
||||||
|
|
||||||
// Update computed properties after loading
|
// 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() {
|
async loadSections() {
|
||||||
if (!this.pageId || this.form.slug !== 'home') {
|
if (!this.pageId || this.pageType !== 'landing') {
|
||||||
contentPageEditLog.debug('Skipping section load - not a homepage');
|
contentPageEditLog.debug('Skipping section load - not a landing page');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
contentPageEditLog.info('Loading homepage sections...');
|
contentPageEditLog.info('Loading sections...');
|
||||||
const response = await apiClient.get(`/admin/content-pages/${this.pageId}/sections`);
|
const response = await apiClient.get(`/admin/content-pages/${this.pageId}/sections`);
|
||||||
const data = response.data || response;
|
const data = response.data || response;
|
||||||
|
|
||||||
this.supportedLanguages = data.supported_languages || ['fr', 'de', 'en'];
|
this.supportedLanguages = data.supported_languages || ['fr', 'de', 'en'];
|
||||||
this.defaultLanguage = data.default_language || 'fr';
|
this.defaultLanguage = data.default_language || 'fr';
|
||||||
this.currentLang = this.defaultLanguage;
|
this.currentLang = this.defaultLanguage;
|
||||||
|
this.titleContentLang = this.defaultLanguage;
|
||||||
|
|
||||||
if (data.sections) {
|
if (data.sections) {
|
||||||
this.sections = this.mergeWithDefaults(data.sections);
|
this.sections = this.mergeWithDefaults(data.sections);
|
||||||
@@ -277,12 +370,18 @@ function contentPageEditor(pageId) {
|
|||||||
mergeWithDefaults(loadedSections) {
|
mergeWithDefaults(loadedSections) {
|
||||||
const defaults = this.getDefaultSectionStructure();
|
const defaults = this.getDefaultSectionStructure();
|
||||||
|
|
||||||
// Deep merge each section
|
// Deep merge each section that exists in defaults
|
||||||
for (const key of ['hero', 'features', 'pricing', 'cta']) {
|
for (const key of Object.keys(defaults)) {
|
||||||
if (loadedSections[key]) {
|
if (loadedSections[key]) {
|
||||||
defaults[key] = { ...defaults[key], ...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;
|
return defaults;
|
||||||
},
|
},
|
||||||
@@ -375,7 +474,7 @@ function contentPageEditor(pageId) {
|
|||||||
|
|
||||||
// Save sections
|
// Save sections
|
||||||
async saveSections() {
|
async saveSections() {
|
||||||
if (!this.pageId || !this.isHomepage) return;
|
if (!this.pageId || this.pageType !== 'landing') return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
contentPageEditLog.info('Saving sections...');
|
contentPageEditLog.info('Saving sections...');
|
||||||
@@ -401,11 +500,13 @@ function contentPageEditor(pageId) {
|
|||||||
const payload = {
|
const payload = {
|
||||||
slug: this.form.slug,
|
slug: this.form.slug,
|
||||||
title: this.form.title,
|
title: this.form.title,
|
||||||
|
title_translations: this.form.title_translations,
|
||||||
content: this.form.content,
|
content: this.form.content,
|
||||||
|
content_translations: this.form.content_translations,
|
||||||
content_format: this.form.content_format,
|
content_format: this.form.content_format,
|
||||||
template: this.form.template,
|
template: this.form.template,
|
||||||
meta_description: this.form.meta_description,
|
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,
|
is_published: this.form.is_published,
|
||||||
show_in_header: this.form.show_in_header,
|
show_in_header: this.form.show_in_header,
|
||||||
show_in_footer: this.form.show_in_footer,
|
show_in_footer: this.form.show_in_footer,
|
||||||
@@ -422,8 +523,8 @@ function contentPageEditor(pageId) {
|
|||||||
// Update existing page
|
// Update existing page
|
||||||
response = await apiClient.put(`/admin/content-pages/${this.pageId}`, payload);
|
response = await apiClient.put(`/admin/content-pages/${this.pageId}`, payload);
|
||||||
|
|
||||||
// Also save sections if this is a homepage
|
// Also save sections if this is a landing page
|
||||||
if (this.isHomepage && this.sectionsLoaded) {
|
if (this.pageType === 'landing' && this.sectionsLoaded) {
|
||||||
await this.saveSections();
|
await this.saveSections();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,19 +57,23 @@
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<!-- Page Title -->
|
<!-- Page Type -->
|
||||||
<div class="md:col-span-2">
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<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>
|
</label>
|
||||||
<input
|
<select
|
||||||
type="text"
|
x-model="pageType"
|
||||||
x-model="form.title"
|
@change="updatePageType()"
|
||||||
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"
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Slug -->
|
<!-- Slug -->
|
||||||
@@ -133,10 +137,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Title with Language Tabs -->
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
<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">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
Page Content
|
Page Content
|
||||||
|
<span class="text-sm font-normal text-gray-500 ml-2" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<!-- Content Format -->
|
<!-- Content Format -->
|
||||||
@@ -219,9 +267,9 @@
|
|||||||
</div>
|
</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">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
Homepage Sections
|
Homepage Sections
|
||||||
@@ -258,7 +306,7 @@
|
|||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<!-- HERO SECTION -->
|
<!-- 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="openSection = openSection === 'hero' ? null : 'hero'"
|
@click="openSection = openSection === 'hero' ? null : 'hero'"
|
||||||
@@ -341,7 +389,7 @@
|
|||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<!-- FEATURES SECTION -->
|
<!-- 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="openSection = openSection === 'features' ? null : 'features'"
|
@click="openSection = openSection === 'features' ? null : 'features'"
|
||||||
@@ -410,7 +458,7 @@
|
|||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<!-- PRICING SECTION -->
|
<!-- 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="openSection = openSection === 'pricing' ? null : 'pricing'"
|
@click="openSection = openSection === 'pricing' ? null : 'pricing'"
|
||||||
@@ -448,7 +496,7 @@
|
|||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<!-- CTA SECTION -->
|
<!-- 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="openSection = openSection === 'cta' ? null : 'cta'"
|
@click="openSection = openSection === 'cta' ? null : 'cta'"
|
||||||
@@ -525,6 +573,7 @@
|
|||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
<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">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
SEO & Metadata
|
SEO & Metadata
|
||||||
|
<span class="text-sm font-normal text-gray-500 ml-2" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -534,30 +583,17 @@
|
|||||||
Meta Description
|
Meta Description
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
x-model="form.meta_description"
|
:value="getTranslatedMetaDescription()"
|
||||||
|
@input="setTranslatedMetaDescription($event.target.value)"
|
||||||
rows="2"
|
rows="2"
|
||||||
maxlength="300"
|
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"
|
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>
|
></textarea>
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<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>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -459,5 +459,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% 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 %}
|
{% endblock %}
|
||||||
@@ -125,5 +125,5 @@
|
|||||||
|
|
||||||
{% block extra_scripts %}
|
{% 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="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 %}
|
{% endblock %}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
{% extends "storefront/base.html" %}
|
{% extends "storefront/base.html" %}
|
||||||
|
|
||||||
{# Dynamic title from CMS #}
|
{# Dynamic title from CMS #}
|
||||||
{% block title %}{{ page.title }}{% endblock %}
|
{% block title %}{{ page_title or page.title }}{% endblock %}
|
||||||
|
|
||||||
{# SEO from CMS #}
|
{# SEO from CMS #}
|
||||||
{% block meta_description %}{{ page.meta_description or page.title }}{% endblock %}
|
{% block meta_description %}{{ page.meta_description or page.title }}{% endblock %}
|
||||||
@@ -16,13 +16,13 @@
|
|||||||
<div class="breadcrumb mb-6">
|
<div class="breadcrumb mb-6">
|
||||||
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
|
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
|
||||||
<span>/</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
{# Page Header #}
|
{# Page Header #}
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-4">
|
<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>
|
</h1>
|
||||||
|
|
||||||
{# Optional: Show store override badge for debugging #}
|
{# Optional: Show store override badge for debugging #}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
{# app/templates/store/landing-default.html #}
|
{# app/modules/cms/templates/cms/storefront/landing-default.html #}
|
||||||
{# standalone #}
|
|
||||||
{# Default/Minimal Landing Page Template #}
|
{# Default/Minimal Landing Page Template #}
|
||||||
{% extends "storefront/base.html" %}
|
{% extends "storefront/base.html" %}
|
||||||
|
|
||||||
{% block title %}{{ store.name }}{% endblock %}
|
{% 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 %}
|
{% block content %}
|
||||||
<div class="min-h-screen">
|
<div class="min-h-screen">
|
||||||
@@ -24,7 +23,7 @@
|
|||||||
|
|
||||||
{# Title #}
|
{# Title #}
|
||||||
<h1 class="text-4xl md:text-6xl font-bold text-gray-900 dark:text-white mb-6">
|
<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>
|
</h1>
|
||||||
|
|
||||||
{# Tagline #}
|
{# Tagline #}
|
||||||
@@ -34,18 +33,31 @@
|
|||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% 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">
|
<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"
|
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)">
|
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>
|
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>
|
||||||
</a>
|
</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"
|
<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">
|
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>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -54,73 +66,65 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# Content Section (if provided) #}
|
{# Content Section (if provided) #}
|
||||||
{% if page.content %}
|
{% if page_content %}
|
||||||
<section id="about" class="py-16 bg-white dark:bg-gray-900">
|
<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="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="prose prose-lg dark:prose-invert max-w-none">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% 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">
|
<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">
|
<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">
|
<h2 class="text-3xl font-bold text-center text-gray-900 dark:text-white mb-12">
|
||||||
Explore
|
{{ _('cms.storefront.explore') }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<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">
|
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">
|
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
|
||||||
Shop Products
|
{{ _(item.label_key) }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-gray-600 dark:text-gray-400">
|
|
||||||
Browse our complete catalog
|
|
||||||
</p>
|
|
||||||
</a>
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
{% if header_pages %}
|
{# Fill remaining slots with CMS header pages #}
|
||||||
{% for page in header_pages[:2] %}
|
{% 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 }}"
|
<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">
|
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">
|
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
|
||||||
{{ page.title }}
|
{{ page.title }}
|
||||||
</h3>
|
</h3>
|
||||||
|
{% if page.meta_description %}
|
||||||
<p class="text-gray-600 dark:text-gray-400">
|
<p class="text-gray-600 dark:text-gray-400">
|
||||||
{{ page.meta_description or 'Learn more' }}
|
{{ page.meta_description }}
|
||||||
</p>
|
</p>
|
||||||
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
{# SECTION-BASED RENDERING (when page.sections is configured) #}
|
{# SECTION-BASED RENDERING (when page.sections is configured) #}
|
||||||
{# Used by POC builder templates — takes priority over hardcoded HTML #}
|
{# Used by POC builder templates — takes priority over hardcoded HTML #}
|
||||||
{# ═══════════════════════════════════════════════════════════════════ #}
|
{# ═══════════════════════════════════════════════════════════════════ #}
|
||||||
{% if page and page.sections %}
|
{% set sections = page_sections if page_sections is defined and page_sections else (page.sections if page else none) %}
|
||||||
|
{% if sections %}
|
||||||
{% from 'cms/platform/sections/_hero.html' import render_hero %}
|
{% from 'cms/platform/sections/_hero.html' import render_hero %}
|
||||||
{% from 'cms/platform/sections/_features.html' import render_features %}
|
{% from 'cms/platform/sections/_features.html' import render_features %}
|
||||||
{% from 'cms/platform/sections/_testimonials.html' import render_testimonials %}
|
{% from 'cms/platform/sections/_testimonials.html' import render_testimonials %}
|
||||||
@@ -26,12 +27,12 @@
|
|||||||
{% set default_lang = 'fr' %}
|
{% set default_lang = 'fr' %}
|
||||||
|
|
||||||
<div class="min-h-screen">
|
<div class="min-h-screen">
|
||||||
{% if page.sections.hero %}{{ render_hero(page.sections.hero, lang, default_lang) }}{% endif %}
|
{% if sections.hero %}{{ render_hero(sections.hero, lang, default_lang) }}{% endif %}
|
||||||
{% if page.sections.features %}{{ render_features(page.sections.features, lang, default_lang) }}{% endif %}
|
{% if sections.features %}{{ render_features(sections.features, lang, default_lang) }}{% endif %}
|
||||||
{% if page.sections.testimonials %}{{ render_testimonials(page.sections.testimonials, lang, default_lang) }}{% endif %}
|
{% if sections.testimonials %}{{ render_testimonials(sections.testimonials, lang, default_lang) }}{% endif %}
|
||||||
{% if page.sections.gallery %}{{ render_gallery(page.sections.gallery, lang, default_lang) }}{% endif %}
|
{% if sections.gallery %}{{ render_gallery(sections.gallery, lang, default_lang) }}{% endif %}
|
||||||
{% if page.sections.contact_info %}{{ render_contact_info(page.sections.contact_info, lang, default_lang) }}{% endif %}
|
{% if sections.contact_info %}{{ render_contact_info(sections.contact_info, lang, default_lang) }}{% endif %}
|
||||||
{% if page.sections.cta %}{{ render_cta(page.sections.cta, lang, default_lang) }}{% endif %}
|
{% if sections.cta %}{{ render_cta(sections.cta, lang, default_lang) }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -80,6 +80,44 @@ class WidgetContext:
|
|||||||
include_details: bool = False
|
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
|
# 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__ = [
|
__all__ = [
|
||||||
# Context
|
# Context
|
||||||
@@ -343,6 +405,8 @@ __all__ = [
|
|||||||
"WidgetData",
|
"WidgetData",
|
||||||
# Main envelope
|
# Main envelope
|
||||||
"DashboardWidget",
|
"DashboardWidget",
|
||||||
|
# Storefront
|
||||||
|
"StorefrontDashboardCard",
|
||||||
# Protocol
|
# Protocol
|
||||||
"DashboardWidgetProviderProtocol",
|
"DashboardWidgetProviderProtocol",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ Store pages for core functionality:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import (
|
from app.api.deps import (
|
||||||
|
UserContext,
|
||||||
get_current_store_from_cookie_or_header,
|
get_current_store_from_cookie_or_header,
|
||||||
|
get_current_store_optional,
|
||||||
get_db,
|
get_db,
|
||||||
get_resolved_store_code,
|
get_resolved_store_code,
|
||||||
)
|
)
|
||||||
@@ -24,6 +26,21 @@ from app.templates_config import templates
|
|||||||
router = APIRouter()
|
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
|
# STORE DASHBOARD
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ class DiscoveredMenuItem:
|
|||||||
section_order: int
|
section_order: int
|
||||||
is_visible: bool = True
|
is_visible: bool = True
|
||||||
is_module_enabled: bool = True
|
is_module_enabled: bool = True
|
||||||
|
header_template: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -191,6 +192,7 @@ class MenuDiscoveryService:
|
|||||||
section_label_key=section.label_key,
|
section_label_key=section.label_key,
|
||||||
section_order=section.order,
|
section_order=section.order,
|
||||||
is_module_enabled=is_module_enabled,
|
is_module_enabled=is_module_enabled,
|
||||||
|
header_template=item.header_template,
|
||||||
)
|
)
|
||||||
sections_map[section.id].items.append(discovered_item)
|
sections_map[section.id].items.append(discovered_item)
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.modules.contracts.widgets import (
|
from app.modules.contracts.widgets import (
|
||||||
DashboardWidget,
|
DashboardWidget,
|
||||||
DashboardWidgetProviderProtocol,
|
DashboardWidgetProviderProtocol,
|
||||||
|
StorefrontDashboardCard,
|
||||||
WidgetContext,
|
WidgetContext,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -233,6 +234,49 @@ class WidgetAggregatorService:
|
|||||||
return widget
|
return widget
|
||||||
return None
|
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(
|
def get_available_categories(
|
||||||
self, db: Session, platform_id: int
|
self, db: Session, platform_id: int
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
|
|||||||
@@ -141,28 +141,28 @@ customers_module = ModuleDefinition(
|
|||||||
items=[
|
items=[
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="dashboard",
|
id="dashboard",
|
||||||
label_key="storefront.account.dashboard",
|
label_key="customers.storefront.account.dashboard",
|
||||||
icon="home",
|
icon="home",
|
||||||
route="account/dashboard",
|
route="account/dashboard",
|
||||||
order=10,
|
order=10,
|
||||||
),
|
),
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="profile",
|
id="profile",
|
||||||
label_key="storefront.account.profile",
|
label_key="customers.storefront.account.profile",
|
||||||
icon="user",
|
icon="user",
|
||||||
route="account/profile",
|
route="account/profile",
|
||||||
order=20,
|
order=20,
|
||||||
),
|
),
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="addresses",
|
id="addresses",
|
||||||
label_key="storefront.account.addresses",
|
label_key="customers.storefront.account.addresses",
|
||||||
icon="map-pin",
|
icon="map-pin",
|
||||||
route="account/addresses",
|
route="account/addresses",
|
||||||
order=30,
|
order=30,
|
||||||
),
|
),
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="settings",
|
id="settings",
|
||||||
label_key="storefront.account.settings",
|
label_key="customers.storefront.account.settings",
|
||||||
icon="cog",
|
icon="cog",
|
||||||
route="account/settings",
|
route="account/settings",
|
||||||
order=90,
|
order=90,
|
||||||
|
|||||||
@@ -52,5 +52,13 @@
|
|||||||
"customers_delete_desc": "Kundendatensätze entfernen",
|
"customers_delete_desc": "Kundendatensätze entfernen",
|
||||||
"customers_export": "Kunden exportieren",
|
"customers_export": "Kunden exportieren",
|
||||||
"customers_export_desc": "Kundendaten exportieren"
|
"customers_export_desc": "Kundendaten exportieren"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"account": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"profile": "Profil",
|
||||||
|
"addresses": "Adressen",
|
||||||
|
"settings": "Einstellungen"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,5 +52,13 @@
|
|||||||
"customers_section": "Customers",
|
"customers_section": "Customers",
|
||||||
"customers": "Customers",
|
"customers": "Customers",
|
||||||
"all_customers": "All 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_delete_desc": "Supprimer les fiches clients",
|
||||||
"customers_export": "Exporter les clients",
|
"customers_export": "Exporter les clients",
|
||||||
"customers_export_desc": "Exporter les données 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_delete_desc": "Clientedossieren ewechhuelen",
|
||||||
"customers_export": "Clienten exportéieren",
|
"customers_export": "Clienten exportéieren",
|
||||||
"customers_export_desc": "Clientedaten 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(
|
return templates.TemplateResponse(
|
||||||
"customers/storefront/dashboard.html",
|
"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 -->
|
<!-- Empty State -->
|
||||||
<div x-show="!loading && !error && addresses.length === 0"
|
<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">
|
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>
|
<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>
|
<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()"
|
<button @click="openAddModal()"
|
||||||
|
|||||||
@@ -17,25 +17,31 @@
|
|||||||
<!-- Dashboard Grid -->
|
<!-- Dashboard Grid -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||||
|
|
||||||
<!-- Orders Card -->
|
{# Module-contributed cards (orders, loyalty, etc.) — rendered via widget protocol #}
|
||||||
<a href="{{ base_url }}account/orders"
|
{% 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">
|
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 items-center mb-4">
|
||||||
<div class="flex-shrink-0">
|
<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>
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Orders</h3>
|
<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">View order history</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400">{{ card.subtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if card.value is not none %}
|
||||||
<div>
|
<div>
|
||||||
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)">{{ user.total_orders }}</p>
|
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)">{{ card.value }}</p>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Total Orders</p>
|
{% if card.value_label %}
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ card.value_label }}</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
<!-- Profile Card -->
|
<!-- Profile Card (always shown — core) -->
|
||||||
<a href="{{ base_url }}account/profile"
|
<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">
|
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 items-center mb-4">
|
||||||
@@ -52,12 +58,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Addresses Card -->
|
<!-- Addresses Card (always shown — core) -->
|
||||||
<a href="{{ base_url }}account/addresses"
|
<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">
|
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 items-center mb-4">
|
||||||
<div class="flex-shrink-0">
|
<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>
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Addresses</h3>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Addresses</h3>
|
||||||
@@ -66,36 +72,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{% if 'loyalty' in enabled_modules %}
|
<!-- Messages Card (always shown — messaging is core) -->
|
||||||
<!-- 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 -->
|
|
||||||
<a href="{{ base_url }}account/messages"
|
<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"
|
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 }"
|
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-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>
|
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.created_at.strftime('%B %Y') }}</p>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Customer Number</p>
|
<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>
|
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.customer_number }}</p>
|
||||||
|
|||||||
@@ -99,10 +99,9 @@ def execute_query(db: Session, sql: str) -> dict:
|
|||||||
|
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
result = connection.execute(text(sql))
|
result = connection.execute(text(sql))
|
||||||
|
columns = list(result.keys()) if result.returns_rows else []
|
||||||
rows_raw = result.fetchmany(max_rows + 1)
|
rows_raw = result.fetchmany(max_rows + 1)
|
||||||
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
||||||
|
|
||||||
columns = list(result.keys()) if result.returns_rows else []
|
|
||||||
truncated = len(rows_raw) > max_rows
|
truncated = len(rows_raw) > max_rows
|
||||||
rows_raw = rows_raw[:max_rows]
|
rows_raw = rows_raw[:max_rows]
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,14 @@ function sqlQueryTool() {
|
|||||||
// Schema explorer
|
// Schema explorer
|
||||||
showPresets: true,
|
showPresets: true,
|
||||||
expandedCategories: {},
|
expandedCategories: {},
|
||||||
presetQueries: [
|
presetSearch: '',
|
||||||
|
|
||||||
|
// Preset sections — grouped by platform
|
||||||
|
presetSections: [
|
||||||
|
// ── Infrastructure ──
|
||||||
|
{
|
||||||
|
label: 'Infrastructure',
|
||||||
|
groups: [
|
||||||
{
|
{
|
||||||
category: 'Schema',
|
category: 'Schema',
|
||||||
items: [
|
items: [
|
||||||
@@ -58,6 +65,13 @@ function sqlQueryTool() {
|
|||||||
{ name: 'Database size', sql: "SELECT pg_size_pretty(pg_database_size(current_database())) AS db_size,\n current_database() AS db_name,\n version() AS pg_version;" },
|
{ name: 'Database size', sql: "SELECT pg_size_pretty(pg_database_size(current_database())) AS db_size,\n current_database() AS db_name,\n version() AS pg_version;" },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Core ──
|
||||||
|
{
|
||||||
|
label: 'Core',
|
||||||
|
groups: [
|
||||||
{
|
{
|
||||||
category: 'Tenancy',
|
category: 'Tenancy',
|
||||||
items: [
|
items: [
|
||||||
@@ -65,7 +79,8 @@ function sqlQueryTool() {
|
|||||||
{ name: 'Merchants', sql: "SELECT m.id, m.name,\n u.email AS owner_email, m.contact_email,\n m.is_active, m.is_verified, m.created_at\nFROM merchants m\nJOIN users u ON u.id = m.owner_user_id\nORDER BY m.id\nLIMIT 50;" },
|
{ name: 'Merchants', sql: "SELECT m.id, m.name,\n u.email AS owner_email, m.contact_email,\n m.is_active, m.is_verified, m.created_at\nFROM merchants m\nJOIN users u ON u.id = m.owner_user_id\nORDER BY m.id\nLIMIT 50;" },
|
||||||
{ name: 'Stores', sql: "SELECT id, store_code, name, merchant_id,\n subdomain, is_active, is_verified\nFROM stores\nORDER BY id\nLIMIT 50;" },
|
{ name: 'Stores', sql: "SELECT id, store_code, name, merchant_id,\n subdomain, is_active, is_verified\nFROM stores\nORDER BY id\nLIMIT 50;" },
|
||||||
{ name: 'Platforms', sql: "SELECT id, code, name, domain, path_prefix,\n default_language, is_active, is_public\nFROM platforms\nORDER BY id;" },
|
{ name: 'Platforms', sql: "SELECT id, code, name, domain, path_prefix,\n default_language, is_active, is_public\nFROM platforms\nORDER BY id;" },
|
||||||
{ name: 'Customers', sql: "SELECT id, store_id, email, first_name, last_name,\n is_active, created_at\nFROM customers\nORDER BY id\nLIMIT 50;" },
|
{ name: 'Merchant domains', sql: "SELECT md.id, m.name AS merchant_name,\n md.domain, md.is_primary, md.is_active,\n md.ssl_status, md.is_verified\nFROM merchant_domains md\nJOIN merchants m ON m.id = md.merchant_id\nORDER BY m.name, md.domain;" },
|
||||||
|
{ name: 'Store domains', sql: "SELECT sd.id, s.name AS store_name,\n sd.domain, sd.is_primary, sd.is_active,\n sd.ssl_status, sd.is_verified\nFROM store_domains sd\nJOIN stores s ON s.id = sd.store_id\nORDER BY s.name, sd.domain;" },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -79,43 +94,13 @@ function sqlQueryTool() {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: 'System',
|
category: 'Admin & Audit',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Alembic versions', sql: "SELECT * FROM alembic_version\nORDER BY version_num;" },
|
{ name: 'Audit log', sql: "SELECT al.id, u.email AS admin_email,\n al.action, al.target_type, al.target_id,\n al.ip_address, al.created_at\nFROM admin_audit_logs al\nJOIN users u ON u.id = al.admin_user_id\nORDER BY al.created_at DESC\nLIMIT 100;" },
|
||||||
]
|
{ name: 'Active sessions', sql: "SELECT s.id, u.email AS admin_email,\n s.ip_address, s.login_at, s.last_activity_at,\n s.is_active, s.logout_reason\nFROM admin_sessions s\nJOIN users u ON u.id = s.admin_user_id\nORDER BY s.last_activity_at DESC\nLIMIT 50;" },
|
||||||
},
|
{ name: 'Admin settings', sql: "SELECT id, key, value, value_type,\n category, is_encrypted, is_public\nFROM admin_settings\nORDER BY category, key;" },
|
||||||
{
|
{ name: 'Platform alerts', sql: "SELECT id, alert_type, severity, title,\n is_resolved, occurrence_count,\n first_occurred_at, last_occurred_at\nFROM platform_alerts\nORDER BY last_occurred_at DESC\nLIMIT 50;" },
|
||||||
category: 'Loyalty',
|
{ name: 'Application logs', sql: "SELECT id, timestamp, level, logger_name,\n module, message, exception_type,\n request_id\nFROM application_logs\nORDER BY timestamp DESC\nLIMIT 100;" },
|
||||||
items: [
|
|
||||||
{ name: 'Programs', sql: "SELECT lp.id, lp.merchant_id, lp.loyalty_type,\n lp.stamps_target, lp.points_per_euro, lp.is_active,\n lp.activated_at, lp.created_at\nFROM loyalty_programs lp\nORDER BY lp.id DESC;" },
|
|
||||||
{ name: 'Cards', sql: "SELECT lc.id, lc.card_number, c.email AS customer_email,\n lc.stamp_count, lc.points_balance, lc.is_active,\n lc.last_activity_at, lc.created_at\nFROM loyalty_cards lc\nJOIN customers c ON c.id = lc.customer_id\nORDER BY lc.id DESC\nLIMIT 100;" },
|
|
||||||
{ name: 'Transactions', sql: "SELECT lt.id, lt.transaction_type, lc.card_number,\n lt.stamps_delta, lt.points_delta,\n lt.purchase_amount_cents, lt.transaction_at\nFROM loyalty_transactions lt\nJOIN loyalty_cards lc ON lc.id = lt.card_id\nORDER BY lt.transaction_at DESC\nLIMIT 100;" },
|
|
||||||
{ name: 'Staff PINs', sql: "SELECT sp.id, sp.name, sp.staff_id,\n s.name AS store_name, sp.is_active,\n sp.last_used_at, sp.created_at\nFROM staff_pins sp\nJOIN stores s ON s.id = sp.store_id\nORDER BY sp.id DESC;" },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: 'Billing',
|
|
||||||
items: [
|
|
||||||
{ name: 'Subscription tiers', sql: "SELECT st.id, p.code AS platform_code,\n st.code, st.name, st.price_monthly_cents,\n st.price_annual_cents, st.is_active, st.is_public\nFROM subscription_tiers st\nJOIN platforms p ON p.id = st.platform_id\nORDER BY p.code, st.display_order;" },
|
|
||||||
{ name: 'Merchant subscriptions', sql: "SELECT ms.id, m.name AS merchant_name,\n p.code AS platform_code, st.name AS tier_name,\n ms.status, ms.is_annual,\n ms.stripe_customer_id, ms.stripe_subscription_id\nFROM merchant_subscriptions ms\nJOIN merchants m ON m.id = ms.merchant_id\nJOIN platforms p ON p.id = ms.platform_id\nLEFT JOIN subscription_tiers st ON st.id = ms.tier_id\nORDER BY ms.id DESC;" },
|
|
||||||
{ name: 'Billing history', sql: "SELECT bh.id, s.name AS store_name,\n bh.invoice_number, bh.total_cents,\n bh.currency, bh.status, bh.invoice_date\nFROM billing_history bh\nJOIN stores s ON s.id = bh.store_id\nORDER BY bh.invoice_date DESC\nLIMIT 50;" },
|
|
||||||
{ name: 'Add-on products', sql: "SELECT id, code, name, category,\n price_cents, billing_period, is_active\nFROM addon_products\nORDER BY display_order;" },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: 'Orders',
|
|
||||||
items: [
|
|
||||||
{ name: 'Recent orders', sql: "SELECT o.id, s.name AS store_name,\n o.order_number, o.status, o.total_amount_cents,\n o.customer_email, o.order_date\nFROM orders o\nJOIN stores s ON s.id = o.store_id\nORDER BY o.order_date DESC\nLIMIT 50;" },
|
|
||||||
{ name: 'Order items', sql: "SELECT oi.id, o.order_number, oi.product_name,\n oi.product_sku, oi.quantity, oi.unit_price_cents,\n oi.item_state\nFROM order_items oi\nJOIN orders o ON o.id = oi.order_id\nORDER BY oi.id DESC\nLIMIT 100;" },
|
|
||||||
{ name: 'Invoices', sql: "SELECT i.id, s.name AS store_name,\n i.invoice_number, i.status,\n i.total_cents, i.invoice_date\nFROM invoices i\nJOIN stores s ON s.id = i.store_id\nORDER BY i.invoice_date DESC\nLIMIT 50;" },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: 'Catalog',
|
|
||||||
items: [
|
|
||||||
{ name: 'Products', sql: "SELECT p.id, s.name AS store_name,\n p.store_sku, p.gtin, p.price_cents,\n p.availability, p.is_active\nFROM products p\nJOIN stores s ON s.id = p.store_id\nORDER BY p.id DESC\nLIMIT 50;" },
|
|
||||||
{ name: 'Product translations', sql: "SELECT pt.id, p.store_sku,\n pt.language, pt.title\nFROM product_translations pt\nJOIN products p ON p.id = pt.product_id\nORDER BY pt.id DESC\nLIMIT 50;" },
|
|
||||||
{ name: 'Product media', sql: "SELECT pm.id, p.store_sku,\n pm.usage_type, pm.display_order\nFROM product_media pm\nJOIN products p ON p.id = pm.product_id\nORDER BY pm.id DESC\nLIMIT 50;" },
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -123,13 +108,20 @@ function sqlQueryTool() {
|
|||||||
items: [
|
items: [
|
||||||
{ name: 'Customers', sql: "SELECT c.id, s.name AS store_name,\n c.email, c.first_name, c.last_name,\n c.customer_number, c.is_active\nFROM customers c\nJOIN stores s ON s.id = c.store_id\nORDER BY c.id DESC\nLIMIT 50;" },
|
{ name: 'Customers', sql: "SELECT c.id, s.name AS store_name,\n c.email, c.first_name, c.last_name,\n c.customer_number, c.is_active\nFROM customers c\nJOIN stores s ON s.id = c.store_id\nORDER BY c.id DESC\nLIMIT 50;" },
|
||||||
{ name: 'Customer addresses', sql: "SELECT ca.id, c.email AS customer_email,\n ca.address_type, ca.city, ca.country_iso,\n ca.is_default\nFROM customer_addresses ca\nJOIN customers c ON c.id = ca.customer_id\nORDER BY ca.id DESC\nLIMIT 50;" },
|
{ name: 'Customer addresses', sql: "SELECT ca.id, c.email AS customer_email,\n ca.address_type, ca.city, ca.country_iso,\n ca.is_default\nFROM customer_addresses ca\nJOIN customers c ON c.id = ca.customer_id\nORDER BY ca.id DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Password reset tokens', sql: "SELECT prt.id, c.email AS customer_email,\n prt.expires_at, prt.used_at, prt.created_at\nFROM password_reset_tokens prt\nJOIN customers c ON c.id = prt.customer_id\nORDER BY prt.created_at DESC\nLIMIT 50;" },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: 'Inventory',
|
category: 'Messaging',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Stock levels', sql: "SELECT inv.id, s.name AS store_name,\n p.store_sku, inv.quantity,\n inv.reserved_quantity, inv.warehouse\nFROM inventory inv\nJOIN products p ON p.id = inv.product_id\nJOIN stores s ON s.id = inv.store_id\nORDER BY inv.id DESC;" },
|
{ name: 'Email templates', sql: "SELECT id, code, language, name,\n category, is_active\nFROM email_templates\nORDER BY category, code, language;" },
|
||||||
{ name: 'Recent transactions', sql: "SELECT it.id, p.store_sku,\n it.transaction_type, it.quantity_change,\n it.order_number, it.created_at\nFROM inventory_transactions it\nJOIN products p ON p.id = it.product_id\nORDER BY it.created_at DESC\nLIMIT 100;" },
|
{ name: 'Email logs', sql: "SELECT id, recipient_email, subject,\n status, sent_at, provider\nFROM email_logs\nORDER BY sent_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Admin notifications', sql: "SELECT id, type, priority, title,\n is_read, action_required, created_at\nFROM admin_notifications\nORDER BY created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Conversations', sql: "SELECT cv.id, cv.conversation_type, cv.subject,\n s.name AS store_name, cv.is_closed,\n cv.message_count, cv.last_message_at\nFROM conversations cv\nLEFT JOIN stores s ON s.id = cv.store_id\nORDER BY cv.last_message_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Messages', sql: "SELECT m.id, m.conversation_id,\n m.sender_type, m.sender_id,\n LEFT(m.content, 100) AS content_preview,\n m.is_system_message, m.created_at\nFROM messages m\nORDER BY m.created_at DESC\nLIMIT 100;" },
|
||||||
|
{ name: 'Message attachments', sql: "SELECT ma.id, ma.message_id,\n ma.original_filename, ma.mime_type,\n ma.file_size, ma.is_image, ma.created_at\nFROM message_attachments ma\nORDER BY ma.created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Store email templates', sql: "SELECT set_.id, s.name AS store_name,\n set_.template_code, set_.language,\n set_.name, set_.is_active\nFROM store_email_templates set_\nJOIN stores s ON s.id = set_.store_id\nORDER BY s.name, set_.template_code;" },
|
||||||
|
{ name: 'Store email settings', sql: "SELECT ses.id, s.name AS store_name,\n ses.from_email, ses.from_name, ses.provider,\n ses.is_configured, ses.is_verified\nFROM store_email_settings ses\nJOIN stores s ON s.id = ses.store_id\nORDER BY s.name;" },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -141,28 +133,179 @@ function sqlQueryTool() {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: 'Messaging',
|
category: 'Billing',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Email templates', sql: "SELECT id, code, language, name,\n category, is_active\nFROM email_templates\nORDER BY category, code, language;" },
|
{ name: 'Subscription tiers', sql: "SELECT st.id, p.code AS platform_code,\n st.code, st.name, st.price_monthly_cents,\n st.price_annual_cents, st.is_active, st.is_public\nFROM subscription_tiers st\nJOIN platforms p ON p.id = st.platform_id\nORDER BY p.code, st.display_order;" },
|
||||||
{ name: 'Email logs', sql: "SELECT id, recipient_email, subject,\n status, sent_at, provider\nFROM email_logs\nORDER BY sent_at DESC\nLIMIT 50;" },
|
{ name: 'Merchant subscriptions', sql: "SELECT ms.id, m.name AS merchant_name,\n p.code AS platform_code, st.name AS tier_name,\n ms.status, ms.is_annual,\n ms.stripe_customer_id, ms.stripe_subscription_id\nFROM merchant_subscriptions ms\nJOIN merchants m ON m.id = ms.merchant_id\nJOIN platforms p ON p.id = ms.platform_id\nLEFT JOIN subscription_tiers st ON st.id = ms.tier_id\nORDER BY ms.id DESC;" },
|
||||||
{ name: 'Admin notifications', sql: "SELECT id, type, priority, title,\n is_read, action_required, created_at\nFROM admin_notifications\nORDER BY created_at DESC\nLIMIT 50;" },
|
{ name: 'Billing history', sql: "SELECT bh.id, s.name AS store_name,\n bh.invoice_number, bh.total_cents,\n bh.currency, bh.status, bh.invoice_date\nFROM billing_history bh\nJOIN stores s ON s.id = bh.store_id\nORDER BY bh.invoice_date DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Add-on products', sql: "SELECT id, code, name, category,\n price_cents, billing_period, is_active\nFROM addon_products\nORDER BY display_order;" },
|
||||||
|
{ name: 'Tier feature limits', sql: "SELECT tfl.id, st.code AS tier_code,\n st.name AS tier_name, tfl.feature_code,\n tfl.limit_value\nFROM tier_feature_limits tfl\nJOIN subscription_tiers st ON st.id = tfl.tier_id\nORDER BY st.code, tfl.feature_code;" },
|
||||||
|
{ name: 'Merchant feature overrides', sql: "SELECT mfo.id, m.name AS merchant_name,\n p.code AS platform_code, mfo.feature_code,\n mfo.limit_value, mfo.is_enabled, mfo.reason\nFROM merchant_feature_overrides mfo\nJOIN merchants m ON m.id = mfo.merchant_id\nJOIN platforms p ON p.id = mfo.platform_id\nORDER BY m.name, mfo.feature_code;" },
|
||||||
|
{ name: 'Store add-ons', sql: "SELECT sa.id, s.name AS store_name,\n ap.name AS addon_name, sa.status,\n sa.quantity, sa.domain_name,\n sa.period_start, sa.period_end\nFROM store_addons sa\nJOIN stores s ON s.id = sa.store_id\nJOIN addon_products ap ON ap.id = sa.addon_product_id\nORDER BY sa.id DESC;" },
|
||||||
|
{ name: 'Stripe webhook events', sql: "SELECT swe.id, swe.event_id, swe.event_type,\n swe.status, swe.processed_at,\n s.name AS store_name, swe.error_message\nFROM stripe_webhook_events swe\nLEFT JOIN stores s ON s.id = swe.store_id\nORDER BY swe.created_at DESC\nLIMIT 50;" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── OMS ──
|
||||||
|
{
|
||||||
|
label: 'OMS',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
category: 'Orders',
|
||||||
|
items: [
|
||||||
|
{ name: 'Recent orders', sql: "SELECT o.id, s.name AS store_name,\n o.order_number, o.status, o.total_amount_cents,\n o.customer_email, o.order_date\nFROM orders o\nJOIN stores s ON s.id = o.store_id\nORDER BY o.order_date DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Order items', sql: "SELECT oi.id, o.order_number, oi.product_name,\n oi.product_sku, oi.quantity, oi.unit_price_cents,\n oi.item_state\nFROM order_items oi\nJOIN orders o ON o.id = oi.order_id\nORDER BY oi.id DESC\nLIMIT 100;" },
|
||||||
|
{ name: 'Invoices', sql: "SELECT i.id, s.name AS store_name,\n i.invoice_number, i.status,\n i.total_cents, i.invoice_date\nFROM invoices i\nJOIN stores s ON s.id = i.store_id\nORDER BY i.invoice_date DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Order item exceptions', sql: "SELECT oie.id, o.order_number,\n oie.original_product_name, oie.original_gtin,\n oie.exception_type, oie.status,\n oie.resolved_at, oie.created_at\nFROM order_item_exceptions oie\nJOIN order_items oi ON oi.id = oie.order_item_id\nJOIN orders o ON o.id = oi.order_id\nORDER BY oie.created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Invoice settings', sql: "SELECT sis.id, s.name AS store_name,\n sis.merchant_name, sis.vat_number,\n sis.is_vat_registered, sis.invoice_prefix,\n sis.invoice_next_number, sis.default_vat_rate\nFROM store_invoice_settings sis\nJOIN stores s ON s.id = sis.store_id\nORDER BY s.name;" },
|
||||||
|
{ name: 'Customer order stats', sql: "SELECT cos.id, s.name AS store_name,\n c.email AS customer_email, cos.total_orders,\n cos.total_spent_cents, cos.first_order_date,\n cos.last_order_date\nFROM customer_order_stats cos\nJOIN stores s ON s.id = cos.store_id\nJOIN customers c ON c.id = cos.customer_id\nORDER BY cos.total_spent_cents DESC\nLIMIT 50;" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Cart',
|
||||||
|
items: [
|
||||||
|
{ name: 'Cart items', sql: "SELECT ci.id, s.name AS store_name,\n p.store_sku, ci.session_id,\n ci.quantity, ci.price_at_add_cents,\n ci.created_at\nFROM cart_items ci\nJOIN stores s ON s.id = ci.store_id\nJOIN products p ON p.id = ci.product_id\nORDER BY ci.created_at DESC\nLIMIT 50;" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Catalog',
|
||||||
|
items: [
|
||||||
|
{ name: 'Products', sql: "SELECT p.id, s.name AS store_name,\n p.store_sku, p.gtin, p.price_cents,\n p.availability, p.is_active\nFROM products p\nJOIN stores s ON s.id = p.store_id\nORDER BY p.id DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Product translations', sql: "SELECT pt.id, p.store_sku,\n pt.language, pt.title\nFROM product_translations pt\nJOIN products p ON p.id = pt.product_id\nORDER BY pt.id DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Product media', sql: "SELECT pm.id, p.store_sku,\n pm.usage_type, pm.display_order\nFROM product_media pm\nJOIN products p ON p.id = pm.product_id\nORDER BY pm.id DESC\nLIMIT 50;" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Inventory',
|
||||||
|
items: [
|
||||||
|
{ name: 'Stock levels', sql: "SELECT inv.id, s.name AS store_name,\n p.store_sku, inv.quantity,\n inv.reserved_quantity, inv.warehouse\nFROM inventory inv\nJOIN products p ON p.id = inv.product_id\nJOIN stores s ON s.id = inv.store_id\nORDER BY inv.id DESC;" },
|
||||||
|
{ name: 'Recent transactions', sql: "SELECT it.id, p.store_sku,\n it.transaction_type, it.quantity_change,\n it.order_number, it.created_at\nFROM inventory_transactions it\nJOIN products p ON p.id = it.product_id\nORDER BY it.created_at DESC\nLIMIT 100;" },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: 'Marketplace',
|
category: 'Marketplace',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Import jobs', sql: "SELECT mij.id, s.name AS store_name,\n mij.marketplace, mij.status,\n mij.imported_count, mij.error_count,\n mij.created_at\nFROM marketplace_import_jobs mij\nJOIN stores s ON s.id = mij.store_id\nORDER BY mij.created_at DESC\nLIMIT 50;" },
|
{ name: 'Import jobs', sql: "SELECT mij.id, s.name AS store_name,\n mij.marketplace, mij.status,\n mij.imported_count, mij.error_count,\n mij.created_at\nFROM marketplace_import_jobs mij\nJOIN stores s ON s.id = mij.store_id\nORDER BY mij.created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Import errors', sql: "SELECT mie.id, mij.marketplace,\n mie.row_number, mie.identifier,\n mie.error_type, mie.error_message\nFROM marketplace_import_errors mie\nJOIN marketplace_import_jobs mij ON mij.id = mie.import_job_id\nORDER BY mie.created_at DESC\nLIMIT 100;" },
|
||||||
{ name: 'Marketplace products', sql: "SELECT id, marketplace, brand, gtin,\n price_cents, availability, is_active\nFROM marketplace_products\nORDER BY id DESC\nLIMIT 50;" },
|
{ name: 'Marketplace products', sql: "SELECT id, marketplace, brand, gtin,\n price_cents, availability, is_active\nFROM marketplace_products\nORDER BY id DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Product translations', sql: "SELECT mpt.id, mp.gtin,\n mpt.language, mpt.title, mpt.url_slug\nFROM marketplace_product_translations mpt\nJOIN marketplace_products mp ON mp.id = mpt.marketplace_product_id\nORDER BY mpt.id DESC\nLIMIT 50;" },
|
||||||
{ name: 'Fulfillment queue', sql: "SELECT fq.id, s.name AS store_name,\n fq.operation, fq.status, fq.attempts,\n fq.error_message, fq.created_at\nFROM letzshop_fulfillment_queue fq\nJOIN stores s ON s.id = fq.store_id\nORDER BY fq.created_at DESC\nLIMIT 50;" },
|
{ name: 'Fulfillment queue', sql: "SELECT fq.id, s.name AS store_name,\n fq.operation, fq.status, fq.attempts,\n fq.error_message, fq.created_at\nFROM letzshop_fulfillment_queue fq\nJOIN stores s ON s.id = fq.store_id\nORDER BY fq.created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Letzshop credentials', sql: "SELECT slc.id, s.name AS store_name,\n slc.api_endpoint, slc.auto_sync_enabled,\n slc.sync_interval_minutes, slc.last_sync_at,\n slc.last_sync_status\nFROM store_letzshop_credentials slc\nJOIN stores s ON s.id = slc.store_id\nORDER BY s.name;" },
|
||||||
|
{ name: 'Sync logs', sql: "SELECT sl.id, s.name AS store_name,\n sl.operation_type, sl.direction, sl.status,\n sl.records_processed, sl.records_failed,\n sl.duration_seconds, sl.triggered_by\nFROM letzshop_sync_logs sl\nJOIN stores s ON s.id = sl.store_id\nORDER BY sl.created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Historical import jobs', sql: "SELECT hij.id, s.name AS store_name,\n hij.status, hij.current_phase,\n hij.orders_imported, hij.orders_skipped,\n hij.products_matched, hij.products_not_found\nFROM letzshop_historical_import_jobs hij\nJOIN stores s ON s.id = hij.store_id\nORDER BY hij.created_at DESC\nLIMIT 50;" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Loyalty ──
|
||||||
|
{
|
||||||
|
label: 'Loyalty',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
category: 'Loyalty',
|
||||||
|
items: [
|
||||||
|
{ name: 'Programs', sql: "SELECT lp.id, lp.merchant_id, lp.loyalty_type,\n lp.stamps_target, lp.points_per_euro, lp.is_active,\n lp.activated_at, lp.created_at\nFROM loyalty_programs lp\nORDER BY lp.id DESC;" },
|
||||||
|
{ name: 'Cards', sql: "SELECT lc.id, lc.card_number, c.email AS customer_email,\n lc.stamp_count, lc.points_balance, lc.is_active,\n lc.last_activity_at, lc.created_at\nFROM loyalty_cards lc\nJOIN customers c ON c.id = lc.customer_id\nORDER BY lc.id DESC\nLIMIT 100;" },
|
||||||
|
{ name: 'Transactions', sql: "SELECT lt.id, lt.transaction_type, lc.card_number,\n lt.stamps_delta, lt.points_delta,\n lt.purchase_amount_cents, lt.transaction_at\nFROM loyalty_transactions lt\nJOIN loyalty_cards lc ON lc.id = lt.card_id\nORDER BY lt.transaction_at DESC\nLIMIT 100;" },
|
||||||
|
{ name: 'Staff PINs', sql: "SELECT sp.id, sp.name, sp.staff_id,\n s.name AS store_name, sp.is_active,\n sp.last_used_at, sp.created_at\nFROM staff_pins sp\nJOIN stores s ON s.id = sp.store_id\nORDER BY sp.id DESC;" },
|
||||||
|
{ name: 'Apple device registrations', sql: "SELECT adr.id, lc.card_number,\n adr.device_library_identifier,\n adr.push_token, adr.created_at\nFROM apple_device_registrations adr\nJOIN loyalty_cards lc ON lc.id = adr.card_id\nORDER BY adr.created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Merchant loyalty settings', sql: "SELECT mls.id, m.name AS merchant_name,\n mls.staff_pin_policy,\n mls.allow_self_enrollment,\n mls.allow_void_transactions,\n mls.allow_cross_location_redemption,\n mls.require_order_reference\nFROM merchant_loyalty_settings mls\nJOIN merchants m ON m.id = mls.merchant_id\nORDER BY m.name;" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Hosting ──
|
||||||
|
{
|
||||||
|
label: 'Hosting',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
category: 'Hosting',
|
||||||
|
items: [
|
||||||
|
{ name: 'Hosted sites', sql: "SELECT hs.id, s.name AS store_name,\n hs.business_name, hs.status,\n hs.contact_email, hs.live_domain,\n hs.went_live_at, hs.created_at\nFROM hosted_sites hs\nLEFT JOIN stores s ON s.id = hs.store_id\nORDER BY hs.created_at DESC;" },
|
||||||
|
{ name: 'Client services', sql: "SELECT cs.id, hs.business_name,\n cs.service_type, cs.name, cs.status,\n cs.billing_period, cs.price_cents,\n cs.domain_name, cs.expires_at\nFROM client_services cs\nJOIN hosted_sites hs ON hs.id = cs.hosted_site_id\nORDER BY hs.business_name, cs.service_type;" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Prospecting',
|
||||||
|
items: [
|
||||||
|
{ name: 'Prospects', sql: "SELECT id, channel, business_name,\n domain_name, status, source,\n city, country, created_at\nFROM prospects\nORDER BY created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Prospect contacts', sql: "SELECT pc.id, p.business_name,\n pc.contact_type, pc.value, pc.label,\n pc.is_primary, pc.is_validated\nFROM prospect_contacts pc\nJOIN prospects p ON p.id = pc.prospect_id\nORDER BY pc.created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Tech profiles', sql: "SELECT tp.id, p.business_name,\n tp.cms, tp.cms_version, tp.server,\n tp.hosting_provider, tp.ecommerce_platform,\n tp.has_valid_cert\nFROM prospect_tech_profiles tp\nJOIN prospects p ON p.id = tp.prospect_id\nORDER BY tp.created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Performance profiles', sql: "SELECT pp.id, p.business_name,\n pp.performance_score, pp.accessibility_score,\n pp.seo_score, pp.is_mobile_friendly,\n pp.total_bytes, pp.total_requests\nFROM prospect_performance_profiles pp\nJOIN prospects p ON p.id = pp.prospect_id\nORDER BY pp.created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Interactions', sql: "SELECT pi.id, p.business_name,\n pi.interaction_type, pi.subject,\n pi.outcome, pi.next_action,\n pi.next_action_date, pi.created_at\nFROM prospect_interactions pi\nJOIN prospects p ON p.id = pi.prospect_id\nORDER BY pi.created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Scan jobs', sql: "SELECT id, job_type, status,\n total_items, processed_items, failed_items,\n started_at, completed_at\nFROM prospect_scan_jobs\nORDER BY created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Security audits', sql: "SELECT psa.id, p.business_name,\n psa.score, psa.grade,\n psa.findings_count_critical,\n psa.findings_count_high,\n psa.has_https, psa.has_valid_ssl\nFROM prospect_security_audits psa\nJOIN prospects p ON p.id = psa.prospect_id\nORDER BY psa.created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Prospect scores', sql: "SELECT ps.id, p.business_name,\n ps.score, ps.lead_tier,\n ps.technical_health_score,\n ps.modernity_score,\n ps.business_value_score,\n ps.engagement_score\nFROM prospect_scores ps\nJOIN prospects p ON p.id = ps.prospect_id\nORDER BY ps.score DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Campaign templates', sql: "SELECT id, name, lead_type,\n channel, language, is_active\nFROM campaign_templates\nORDER BY lead_type, channel;" },
|
||||||
|
{ name: 'Campaign sends', sql: "SELECT cs.id, ct.name AS template_name,\n p.business_name, cs.channel,\n cs.status, cs.sent_at\nFROM campaign_sends cs\nJOIN campaign_templates ct ON ct.id = cs.template_id\nJOIN prospects p ON p.id = cs.prospect_id\nORDER BY cs.created_at DESC\nLIMIT 50;" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Internal ──
|
||||||
|
{
|
||||||
|
label: 'Internal',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
category: 'System',
|
||||||
|
items: [
|
||||||
|
{ name: 'Alembic versions', sql: "SELECT * FROM alembic_version\nORDER BY version_num;" },
|
||||||
|
{ name: 'Menu configs', sql: "SELECT amc.id, amc.frontend_type,\n p.code AS platform_code, u.email,\n amc.menu_item_id, amc.is_visible\nFROM admin_menu_configs amc\nLEFT JOIN platforms p ON p.id = amc.platform_id\nLEFT JOIN users u ON u.id = amc.user_id\nORDER BY amc.frontend_type, amc.menu_item_id;" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Monitoring',
|
||||||
|
items: [
|
||||||
|
{ name: 'Capacity snapshots', sql: "SELECT id, snapshot_date,\n active_stores, total_products,\n total_orders_month, total_team_members,\n db_size_mb, avg_response_ms,\n peak_cpu_percent, peak_memory_percent\nFROM capacity_snapshots\nORDER BY snapshot_date DESC\nLIMIT 30;" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Dev Tools',
|
||||||
|
items: [
|
||||||
|
{ name: 'Test runs', sql: "SELECT id, timestamp, status,\n total_tests, passed, failed, errors,\n coverage_percent, duration_seconds,\n git_branch\nFROM test_runs\nORDER BY timestamp DESC\nLIMIT 30;" },
|
||||||
|
{ name: 'Architecture scans', sql: "SELECT id, timestamp, validator_type,\n status, total_files, total_violations,\n errors, warnings, duration_seconds\nFROM architecture_scans\nORDER BY timestamp DESC\nLIMIT 30;" },
|
||||||
|
{ name: 'Architecture violations', sql: "SELECT av.id, av.rule_id, av.rule_name,\n av.severity, av.file_path, av.line_number,\n av.status, av.message\nFROM architecture_violations av\nORDER BY av.created_at DESC\nLIMIT 100;" },
|
||||||
|
]
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
|
get filteredPresetSections() {
|
||||||
|
const q = this.presetSearch.toLowerCase().trim();
|
||||||
|
if (!q) return this.presetSections;
|
||||||
|
|
||||||
|
const filtered = [];
|
||||||
|
for (const section of this.presetSections) {
|
||||||
|
const groups = [];
|
||||||
|
for (const group of section.groups) {
|
||||||
|
const items = group.items.filter(
|
||||||
|
item => item.name.toLowerCase().includes(q)
|
||||||
|
|| group.category.toLowerCase().includes(q)
|
||||||
|
|| section.label.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
if (items.length > 0) {
|
||||||
|
groups.push({ ...group, items });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (groups.length > 0) {
|
||||||
|
filtered.push({ ...section, groups });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
},
|
||||||
|
|
||||||
toggleCategory(category) {
|
toggleCategory(category) {
|
||||||
this.expandedCategories[category] = !this.expandedCategories[category];
|
this.expandedCategories[category] = !this.expandedCategories[category];
|
||||||
},
|
},
|
||||||
|
|
||||||
isCategoryExpanded(category) {
|
isCategoryExpanded(category) {
|
||||||
|
if (this.presetSearch.trim()) return true;
|
||||||
return this.expandedCategories[category] || false;
|
return this.expandedCategories[category] || false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,16 @@
|
|||||||
<span x-html="$icon(showPresets ? 'chevron-up' : 'chevron-down', 'w-4 h-4')"></span>
|
<span x-html="$icon(showPresets ? 'chevron-up' : 'chevron-down', 'w-4 h-4')"></span>
|
||||||
</button>
|
</button>
|
||||||
<div x-show="showPresets" x-collapse class="mt-3">
|
<div x-show="showPresets" x-collapse class="mt-3">
|
||||||
<template x-for="group in presetQueries" :key="group.category">
|
<!-- Search filter -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<input type="text" x-model="presetSearch" placeholder="Filter presets..."
|
||||||
|
class="w-full text-xs rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1.5 focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
</div>
|
||||||
|
<template x-for="section in filteredPresetSections" :key="section.label">
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="text-[10px] font-bold text-indigo-500 dark:text-indigo-400 uppercase tracking-widest px-2 py-1"
|
||||||
|
x-text="section.label"></div>
|
||||||
|
<template x-for="group in section.groups" :key="group.category">
|
||||||
<div class="mb-1">
|
<div class="mb-1">
|
||||||
<button @click="toggleCategory(group.category)"
|
<button @click="toggleCategory(group.category)"
|
||||||
class="flex items-center justify-between w-full text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase px-2 py-1 rounded hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
class="flex items-center justify-between w-full text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase px-2 py-1 rounded hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
@@ -43,6 +52,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
<div x-show="presetSearch && filteredPresetSections.length === 0"
|
||||||
|
class="text-xs text-gray-400 px-2 py-2">No matching presets.</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Saved Queries -->
|
<!-- Saved Queries -->
|
||||||
|
|||||||
@@ -58,6 +58,13 @@ def _get_feature_provider():
|
|||||||
return loyalty_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():
|
def _get_onboarding_provider():
|
||||||
"""Lazy import of onboarding provider to avoid circular imports."""
|
"""Lazy import of onboarding provider to avoid circular imports."""
|
||||||
from app.modules.loyalty.services.loyalty_onboarding_service import (
|
from app.modules.loyalty.services.loyalty_onboarding_service import (
|
||||||
@@ -289,7 +296,7 @@ loyalty_module = ModuleDefinition(
|
|||||||
items=[
|
items=[
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="loyalty",
|
id="loyalty",
|
||||||
label_key="storefront.account.loyalty",
|
label_key="loyalty.storefront.account.loyalty",
|
||||||
icon="gift",
|
icon="gift",
|
||||||
route="account/loyalty",
|
route="account/loyalty",
|
||||||
order=60,
|
order=60,
|
||||||
@@ -328,6 +335,8 @@ loyalty_module = ModuleDefinition(
|
|||||||
],
|
],
|
||||||
# Feature provider for billing feature gating
|
# Feature provider for billing feature gating
|
||||||
feature_provider=_get_feature_provider,
|
feature_provider=_get_feature_provider,
|
||||||
|
# Widget provider for storefront dashboard cards
|
||||||
|
widget_provider=_get_widget_provider,
|
||||||
# Onboarding provider for post-signup checklist
|
# Onboarding provider for post-signup checklist
|
||||||
onboarding_provider=_get_onboarding_provider,
|
onboarding_provider=_get_onboarding_provider,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -214,14 +214,25 @@ Uses PKCS#7 signed `.pkpass` files and APNs push notifications.
|
|||||||
|
|
||||||
## Cross-Store Redemption
|
## Cross-Store Redemption
|
||||||
|
|
||||||
When `allow_cross_location_redemption` is enabled in merchant settings:
|
The `allow_cross_location_redemption` merchant setting controls both card scoping and enrollment behavior:
|
||||||
|
|
||||||
- Cards are scoped to the **merchant** (not individual stores)
|
### When enabled (default)
|
||||||
|
|
||||||
|
- **One card per customer per merchant** — enforced at the application layer
|
||||||
- Customer can earn stamps at Store A and redeem at Store B
|
- Customer can earn stamps at Store A and redeem at Store B
|
||||||
- Each transaction records which `store_id` it occurred at
|
- Each transaction records which `store_id` it occurred at
|
||||||
- The `enrolled_at_store_id` field tracks where the customer first enrolled
|
- The `enrolled_at_store_id` field tracks where the customer first enrolled
|
||||||
|
- If a customer tries to enroll at a second store, the system returns their existing card with a message showing all available locations
|
||||||
|
|
||||||
When disabled, stamp/point operations are restricted to the enrollment store.
|
### When disabled
|
||||||
|
|
||||||
|
- **One card per customer per store** — each store under the merchant issues its own card
|
||||||
|
- Stamp/point operations are restricted to the card's enrollment store
|
||||||
|
- A customer can hold separate cards at different stores under the same merchant
|
||||||
|
- Re-enrolling at the **same** store returns the existing card
|
||||||
|
- Enrolling at a **different** store creates a new card scoped to that store
|
||||||
|
|
||||||
|
**Database constraint:** Unique index on `(enrolled_at_store_id, customer_id)` prevents duplicate cards at the same store regardless of the cross-location setting.
|
||||||
|
|
||||||
## Enrollment Flow
|
## Enrollment Flow
|
||||||
|
|
||||||
@@ -229,21 +240,25 @@ When disabled, stamp/point operations are restricted to the enrollment store.
|
|||||||
|
|
||||||
Staff enrolls customer via terminal:
|
Staff enrolls customer via terminal:
|
||||||
1. Enter customer email (and optional name)
|
1. Enter customer email (and optional name)
|
||||||
2. System resolves or creates customer record
|
2. System resolves customer — checks the current store first, then searches across all stores under the merchant for an existing cardholder with the same email
|
||||||
3. Creates loyalty card with unique card number and QR code
|
3. If the customer already has a card (per-merchant or per-store, depending on the cross-location setting), raises `LoyaltyCardAlreadyExistsException`
|
||||||
4. Creates `CARD_CREATED` transaction
|
4. Otherwise creates loyalty card with unique card number and QR code
|
||||||
5. Awards welcome bonus points (if configured) via `WELCOME_BONUS` transaction
|
5. Creates `CARD_CREATED` transaction
|
||||||
6. Creates Google Wallet object and Apple Wallet serial
|
6. Awards welcome bonus points (if configured) via `WELCOME_BONUS` transaction
|
||||||
7. Returns card details with "Add to Wallet" URLs
|
7. Creates Google Wallet object and Apple Wallet serial
|
||||||
|
8. Returns card details with "Add to Wallet" URLs
|
||||||
|
|
||||||
### Self-Enrollment (Public)
|
### Self-Enrollment (Public)
|
||||||
|
|
||||||
Customer enrolls via public page (if `allow_self_enrollment` enabled):
|
Customer enrolls via public page (if `allow_self_enrollment` enabled):
|
||||||
1. Customer visits `/loyalty/join` page
|
1. Customer visits `/loyalty/join` page
|
||||||
2. Enters email and name
|
2. Enters email, name, and optional birthday
|
||||||
3. System creates customer + card
|
3. System resolves customer (cross-store lookup for existing cardholders under the same merchant)
|
||||||
4. Redirected to success page with card number
|
4. If already enrolled: returns existing card with success page showing location info
|
||||||
5. Can add to Google/Apple Wallet from success page
|
- Cross-location enabled: "Your card works at all our locations" + store list
|
||||||
|
- Cross-location disabled: "Your card is registered at {original_store}"
|
||||||
|
5. If new: creates customer + card, redirected to success page with card number
|
||||||
|
6. Can add to Google/Apple Wallet from success page
|
||||||
|
|
||||||
## Scheduled Tasks
|
## Scheduled Tasks
|
||||||
|
|
||||||
|
|||||||
@@ -120,14 +120,21 @@ Merchant-wide loyalty program configuration. One program per merchant, shared ac
|
|||||||
|
|
||||||
### LoyaltyCard
|
### LoyaltyCard
|
||||||
|
|
||||||
Customer loyalty card linking a customer to a merchant's program. One card per customer per merchant.
|
Customer loyalty card linking a customer to a merchant's program.
|
||||||
|
|
||||||
|
**Card uniqueness depends on the `allow_cross_location_redemption` merchant setting:**
|
||||||
|
|
||||||
|
- **Cross-location enabled (default):** One card per customer per merchant. The application layer enforces this by checking all stores under the merchant before creating a card. Re-enrolling at another store returns the existing card.
|
||||||
|
- **Cross-location disabled:** One card per customer per store. A customer can hold separate cards at different stores under the same merchant, each scoped to its enrollment store.
|
||||||
|
|
||||||
|
**Database constraint:** Unique index on `(enrolled_at_store_id, customer_id)` — always enforced. The per-merchant uniqueness (cross-location enabled) is enforced at the application layer in `card_service.enroll_customer`.
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `merchant_id` | FK | Links to program's merchant |
|
| `merchant_id` | FK | Links to program's merchant |
|
||||||
| `customer_id` | FK | Card owner |
|
| `customer_id` | FK | Card owner |
|
||||||
| `program_id` | FK | Associated program |
|
| `program_id` | FK | Associated program |
|
||||||
| `enrolled_at_store_id` | FK | Store where customer enrolled |
|
| `enrolled_at_store_id` | FK | Store where customer enrolled (part of unique constraint) |
|
||||||
| `card_number` | String (unique) | Formatted XXXX-XXXX-XXXX |
|
| `card_number` | String (unique) | Formatted XXXX-XXXX-XXXX |
|
||||||
| `qr_code_data` | String (unique) | URL-safe token for QR codes |
|
| `qr_code_data` | String (unique) | URL-safe token for QR codes |
|
||||||
| `stamp_count` | Integer | Current stamp count |
|
| `stamp_count` | Integer | Current stamp count |
|
||||||
|
|||||||
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
|
||||||
@@ -40,11 +40,14 @@ This is the active execution plan for taking the Loyalty module to production. I
|
|||||||
```
|
```
|
||||||
loyalty_002 (existing)
|
loyalty_002 (existing)
|
||||||
loyalty_003 — CHECK constraints (points_balance >= 0, stamp_count >= 0, etc.)
|
loyalty_003 — CHECK constraints (points_balance >= 0, stamp_count >= 0, etc.)
|
||||||
loyalty_004 — seed 28 notification email templates
|
loyalty_004 — relax card uniqueness: replace (merchant_id, customer_id) unique index
|
||||||
loyalty_005 — add columns: last_expiration_warning_at, last_reengagement_at on cards;
|
with (enrolled_at_store_id, customer_id) for cross-location support
|
||||||
|
loyalty_005 — seed 28 notification email templates
|
||||||
|
loyalty_006 — add columns: last_expiration_warning_at, last_reengagement_at on cards;
|
||||||
acting_admin_id on transactions
|
acting_admin_id on transactions
|
||||||
loyalty_006 — terms_cms_page_slug on programs
|
loyalty_007 — terms_cms_page_slug on programs
|
||||||
loyalty_007 — birth_date on customers (P0 — Phase 1.4 fix, dropped data bug)
|
|
||||||
|
customers_003 — birth_date on customers (Phase 1.4 fix, dropped data bug)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -82,7 +85,7 @@ All 8 decisions locked. No external blockers.
|
|||||||
#### 1.4 Fix dropped birthday data (P0 bug)
|
#### 1.4 Fix dropped birthday data (P0 bug)
|
||||||
**Background:** The enrollment form (`enroll.html:87`) collects a birthday, the schema accepts `customer_birthday` (`schemas/card.py:34`), and `card_service.resolve_customer_id` has the parameter (`card_service.py:172`) — but the call to `customer_service.create_customer_for_enrollment` at `card_service.py:215-222` does not pass it, and the `Customer` model has no `birth_date` column at all. Every enrollment silently loses the birthday. Not live yet, so no backfill needed.
|
**Background:** The enrollment form (`enroll.html:87`) collects a birthday, the schema accepts `customer_birthday` (`schemas/card.py:34`), and `card_service.resolve_customer_id` has the parameter (`card_service.py:172`) — but the call to `customer_service.create_customer_for_enrollment` at `card_service.py:215-222` does not pass it, and the `Customer` model has no `birth_date` column at all. Every enrollment silently loses the birthday. Not live yet, so no backfill needed.
|
||||||
|
|
||||||
- New migration `loyalty_007_add_customer_birth_date.py` (or place under customers module if that's the convention) adds `birth_date: Date | None` to `customers`.
|
- Migration `customers_003_add_birth_date.py` adds `birth_date: Date | None` to `customers`.
|
||||||
- Update `customer_service.create_customer_for_enrollment` to accept and persist `birth_date`.
|
- Update `customer_service.create_customer_for_enrollment` to accept and persist `birth_date`.
|
||||||
- Update `card_service.py:215-222` to pass `customer_birthday` through, with `date.fromisoformat()` parsing and validation (must be a real past date, sensible age range).
|
- Update `card_service.py:215-222` to pass `customer_birthday` through, with `date.fromisoformat()` parsing and validation (must be a real past date, sensible age range).
|
||||||
- Update `customer_service.update_customer` to allow backfill.
|
- Update `customer_service.update_customer` to allow backfill.
|
||||||
@@ -97,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`
|
#### 2.1 `LoyaltyNotificationService`
|
||||||
- New `app/modules/loyalty/services/notification_service.py` with methods:
|
- New `app/modules/loyalty/services/notification_service.py` with methods:
|
||||||
@@ -114,7 +117,7 @@ All 8 decisions locked. No external blockers.
|
|||||||
- New `app/modules/loyalty/tasks/notifications.py` with `@shared_task(name="loyalty.send_notification_email", bind=True, max_retries=3, default_retry_delay=60)`.
|
- New `app/modules/loyalty/tasks/notifications.py` with `@shared_task(name="loyalty.send_notification_email", bind=True, max_retries=3, default_retry_delay=60)`.
|
||||||
- Opens fresh `SessionLocal`, calls `EmailService(db).send_template(...)`, retries on SMTP errors.
|
- Opens fresh `SessionLocal`, calls `EmailService(db).send_template(...)`, retries on SMTP errors.
|
||||||
|
|
||||||
#### 2.3 Seed templates `loyalty_004`
|
#### 2.3 Seed templates `loyalty_005`
|
||||||
- 7 templates × 4 locales (en, fr, de, lb) = **28 rows** in `email_templates`.
|
- 7 templates × 4 locales (en, fr, de, lb) = **28 rows** in `email_templates`.
|
||||||
- Template codes: `loyalty_enrollment`, `loyalty_welcome_bonus`, `loyalty_points_expiring`, `loyalty_points_expired`, `loyalty_reward_ready`, `loyalty_birthday`, `loyalty_reengagement`.
|
- Template codes: `loyalty_enrollment`, `loyalty_welcome_bonus`, `loyalty_points_expiring`, `loyalty_points_expired`, `loyalty_reward_ready`, `loyalty_birthday`, `loyalty_reengagement`.
|
||||||
- **Copywriting needs sign-off** before applying to prod.
|
- **Copywriting needs sign-off** before applying to prod.
|
||||||
@@ -123,7 +126,7 @@ All 8 decisions locked. No external blockers.
|
|||||||
- In `card_service.enroll_customer_for_store` (~lines 480-540), call notification service **after** `db.commit()`.
|
- In `card_service.enroll_customer_for_store` (~lines 480-540), call notification service **after** `db.commit()`.
|
||||||
|
|
||||||
#### 2.5 Wire expiration warning into expiration task
|
#### 2.5 Wire expiration warning into expiration task
|
||||||
- Migration `loyalty_005` adds `last_expiration_warning_at` to prevent duplicates.
|
- Migration `loyalty_006` adds `last_expiration_warning_at` to prevent duplicates.
|
||||||
- In rewritten `tasks/point_expiration.py` (see 3.1), find cards 14 days from expiry, fire warning, stamp timestamp.
|
- In rewritten `tasks/point_expiration.py` (see 3.1), find cards 14 days from expiry, fire warning, stamp timestamp.
|
||||||
- **Validation:** time-mocked test — fires once at 14-day mark.
|
- **Validation:** time-mocked test — fires once at 14-day mark.
|
||||||
|
|
||||||
@@ -137,11 +140,11 @@ All 8 decisions locked. No external blockers.
|
|||||||
|
|
||||||
#### 2.8 Re-engagement Celery beat task
|
#### 2.8 Re-engagement Celery beat task
|
||||||
- Weekly schedule. Finds cards inactive > N days (default 60, configurable).
|
- Weekly schedule. Finds cards inactive > N days (default 60, configurable).
|
||||||
- Throttled via `last_reengagement_at` (added in `loyalty_005`) — once per quarter per card.
|
- Throttled via `last_reengagement_at` (added in `loyalty_006`) — once per quarter per card.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 3 — Task Reliability *(1.5d)*
|
### Phase 3 — Task Reliability *(✅ DONE 2026-04-11)*
|
||||||
|
|
||||||
#### 3.1 Batched point expiration
|
#### 3.1 Batched point expiration
|
||||||
- Rewrite `tasks/point_expiration.py:154-185` from per-card loop to set-based SQL:
|
- Rewrite `tasks/point_expiration.py:154-185` from per-card loop to set-based SQL:
|
||||||
@@ -160,10 +163,10 @@ 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
|
#### 4.1 T&C via store CMS integration
|
||||||
- Migration `loyalty_006`: add `terms_cms_page_slug: str | None` to `loyalty_programs`.
|
- Migration `loyalty_007`: add `terms_cms_page_slug: str | None` to `loyalty_programs`.
|
||||||
- Update `schemas/program.py` (`ProgramCreate`, `ProgramUpdate`).
|
- Update `schemas/program.py` (`ProgramCreate`, `ProgramUpdate`).
|
||||||
- `program-form.html:251` — CMS page picker scoped to the program's owning store.
|
- `program-form.html:251` — CMS page picker scoped to the program's owning store.
|
||||||
- `enroll.html:99-160` — resolve slug to CMS page URL/content; legacy `terms_text` fallback.
|
- `enroll.html:99-160` — resolve slug to CMS page URL/content; legacy `terms_text` fallback.
|
||||||
@@ -179,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
|
#### 5.1 Cert deployment
|
||||||
- Place service account JSON at `~/apps/orion/google-wallet-sa.json`, app user, mode 600.
|
- Place service account JSON at `~/apps/orion/google-wallet-sa.json`, app user, mode 600.
|
||||||
@@ -196,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
|
#### 6.1 Admin trash UI
|
||||||
- Trash tab on programs list and cards list, calling existing `?only_deleted=true` API.
|
- Trash tab on programs list and cards list, calling existing `?only_deleted=true` API.
|
||||||
@@ -224,7 +227,7 @@ All 8 decisions locked. No external blockers.
|
|||||||
- **Admin "act on behalf"** (`routes/api/admin.py`):
|
- **Admin "act on behalf"** (`routes/api/admin.py`):
|
||||||
- `POST /admin/loyalty/merchants/{merchant_id}/cards/bulk/deactivate` (etc.)
|
- `POST /admin/loyalty/merchants/{merchant_id}/cards/bulk/deactivate` (etc.)
|
||||||
- Shared service layer; route stamps `acting_admin_id` in audit log
|
- Shared service layer; route stamps `acting_admin_id` in audit log
|
||||||
- New `loyalty_transactions.acting_admin_id` column in `loyalty_005`.
|
- New `loyalty_transactions.acting_admin_id` column in `loyalty_006`.
|
||||||
|
|
||||||
#### 6.5 Manual override: restore expired points
|
#### 6.5 Manual override: restore expired points
|
||||||
- `POST /admin/loyalty/cards/{card_id}/restore_points` with `{points, reason}`.
|
- `POST /admin/loyalty/cards/{card_id}/restore_points` with `{points, reason}`.
|
||||||
@@ -233,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
|
#### 7.1 Cohort retention
|
||||||
- New `services/analytics_service.py` (or extend `program_service`).
|
- New `services/analytics_service.py` (or extend `program_service`).
|
||||||
@@ -252,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
|
#### 8.1 Coverage enforcement
|
||||||
- Loyalty CI job: `pytest app/modules/loyalty/tests --cov=app/modules/loyalty --cov-fail-under=80`.
|
- Loyalty CI job: `pytest app/modules/loyalty/tests --cov=app/modules/loyalty --cov-fail-under=80`.
|
||||||
@@ -291,40 +294,120 @@ Tracked separately, not blocking launch.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Critical Path
|
## Development Status (as of 2026-04-16)
|
||||||
|
|
||||||
```
|
**All development phases (0-8) are COMPLETE.** 342 automated tests pass.
|
||||||
Phase 0 (done) ──┬─► Phase 1 ──┬─► Phase 3 ──┐
|
|
||||||
├─► Phase 2 ──┤ ├─► Phase 8 ──► LAUNCH
|
|
||||||
└─► Phase 5 ──┘ │
|
|
||||||
│
|
|
||||||
Phase 4, 6, 7 (parallelizable) ───────────┘
|
|
||||||
|
|
||||||
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
|
- Terminal redeem: `card_id` → `id` normalization across schemas/JS
|
||||||
|
- Card detail: enrolled store name resolution, copy buttons, paginated transactions
|
||||||
| Phase | Days |
|
- i18n flicker: server-rendered translations on success page
|
||||||
|---|---|
|
- Icon fix: `device-mobile` → `phone`
|
||||||
| 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 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Open Items Needing Sign-off
|
## Pre-Launch Checklist
|
||||||
|
|
||||||
1. ~~**Rate limit caps**~~ — confirmed.
|
Everything below must be completed before going live. Items are ordered by dependency.
|
||||||
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).
|
### 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.
|
||||||
@@ -792,3 +792,109 @@ flowchart TD
|
|||||||
There is no feature gating on loyalty program creation — you can test them in
|
There is no feature gating on loyalty program creation — you can test them in
|
||||||
either order. Journey 0 is listed second because domain setup is about URL
|
either order. Journey 0 is listed second because domain setup is about URL
|
||||||
presentation, not a functional prerequisite for the loyalty module.
|
presentation, not a functional prerequisite for the loyalty module.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-Launch E2E Test Checklist (Fashion Group)
|
||||||
|
|
||||||
|
Manual end-to-end checklist using Fashion Group (merchant 2: FASHIONHUB + FASHIONOUTLET).
|
||||||
|
Covers all customer-facing flows including the cross-store enrollment and redemption features
|
||||||
|
added in the Phase 1 production launch hardening.
|
||||||
|
|
||||||
|
### Pre-requisite: Program Setup (Journey 1)
|
||||||
|
|
||||||
|
If Fashion Group doesn't have a loyalty program yet:
|
||||||
|
|
||||||
|
1. Login as `jane.owner@fashiongroup.com` at `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/login`
|
||||||
|
2. Navigate to: `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/settings`
|
||||||
|
3. Create program (hybrid or points), set welcome bonus, enable self-enrollment
|
||||||
|
4. Verify Cross-Location Redemption is **enabled** in merchant settings
|
||||||
|
|
||||||
|
### Test 1: Customer Self-Enrollment (Journey 4)
|
||||||
|
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 1.1 | Visit `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/loyalty/join` | Enrollment form loads, no console errors |
|
||||||
|
| 1.2 | Fill in: fresh email, name, **birthday** → Submit | Redirected to success page with card number |
|
||||||
|
| 1.3 | Check DB: `SELECT birth_date FROM customers WHERE email = '...'` | `birth_date` is set (not NULL) |
|
||||||
|
| 1.4 | Enroll **without** birthday (different email) | Success, `birth_date` is NULL (no crash) |
|
||||||
|
|
||||||
|
### Test 2: Cross-Store Re-Enrollment (Cross-Location Enabled)
|
||||||
|
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 2.1 | Visit `http://localhost:8000/platforms/loyalty/storefront/FASHIONOUTLET/loyalty/join` | Enrollment form loads |
|
||||||
|
| 2.2 | Submit with the **same email** from Test 1 | Success page shows **"You're already a member!"** |
|
||||||
|
| 2.3 | Check: store list shown | Blue box: "Your card works at all our locations:" with Fashion Hub + Fashion Outlet listed |
|
||||||
|
| 2.4 | Check: same card number as Test 1 | Card number matches (no duplicate created) |
|
||||||
|
| 2.5 | Check DB: `SELECT COUNT(*) FROM loyalty_cards WHERE customer_id = ...` | Exactly 1 card |
|
||||||
|
| 2.6 | Re-enroll at FASHIONHUB (same store as original) | Same behavior: "already a member" + locations |
|
||||||
|
| 2.7 | Refresh the success page | Message persists, no flicker, no untranslated i18n keys |
|
||||||
|
|
||||||
|
### Test 3: Staff Operations — Stamps/Points (Journeys 2 & 3)
|
||||||
|
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 3.1 | Login as `jane.owner@fashiongroup.com` at FASHIONHUB | Login succeeds |
|
||||||
|
| 3.2 | Open terminal: `.../store/FASHIONHUB/loyalty/terminal` | Terminal loads |
|
||||||
|
| 3.3 | Look up card by card number | Card found, balance displayed |
|
||||||
|
| 3.4 | Look up card by customer email | Card found (same result) |
|
||||||
|
| 3.5 | Add stamp (or earn points with purchase amount) | Count/balance updates |
|
||||||
|
| 3.6 | Add stamp again immediately (within cooldown) | Rejected: cooldown active |
|
||||||
|
|
||||||
|
### Test 4: Cross-Store Redemption (Journey 8)
|
||||||
|
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 4.1 | Staff at FASHIONHUB adds stamps/points to the card | Balance updated |
|
||||||
|
| 4.2 | Login as staff at FASHIONOUTLET (e.g., `diana.stylist@fashiongroup.com` or `jane.owner`) | Login succeeds |
|
||||||
|
| 4.3 | Open terminal: `.../store/FASHIONOUTLET/loyalty/terminal` | Terminal loads |
|
||||||
|
| 4.4 | Look up card **by email** | Card found (cross-store email search) |
|
||||||
|
| 4.5 | Look up card **by card number** | Card found |
|
||||||
|
| 4.6 | Redeem reward (if enough stamps/points) | Redemption succeeds |
|
||||||
|
| 4.7 | View card detail | Transaction history shows entries from both FASHIONHUB and FASHIONOUTLET |
|
||||||
|
|
||||||
|
### Test 5: Customer Views Status (Journey 5)
|
||||||
|
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 5.1 | Login as the customer at storefront | Customer dashboard loads |
|
||||||
|
| 5.2 | Dashboard: `.../storefront/FASHIONHUB/account/loyalty` | Shows balance, available rewards |
|
||||||
|
| 5.3 | History: `.../storefront/FASHIONHUB/account/loyalty/history` | Shows transactions from both stores |
|
||||||
|
|
||||||
|
### Test 6: Void/Return (Journey 7)
|
||||||
|
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 6.1 | Staff at FASHIONHUB opens terminal, looks up card | Card found |
|
||||||
|
| 6.2 | Void a stamp or points transaction | Balance adjusted |
|
||||||
|
| 6.3 | Check transaction history | Void transaction appears, linked to original |
|
||||||
|
|
||||||
|
### Test 7: Admin Oversight (Journey 6)
|
||||||
|
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 7.1 | Login as `samir.boulahtit@gmail.com` (admin) | Admin dashboard loads |
|
||||||
|
| 7.2 | Programs: `.../admin/loyalty/programs` | Fashion Group program visible |
|
||||||
|
| 7.3 | Fashion Group detail: `.../admin/loyalty/merchants/2` | Cards, transactions, stats appear correctly |
|
||||||
|
| 7.4 | Fashion Group settings: `.../admin/loyalty/merchants/2/settings` | Cross-location toggle visible and correct |
|
||||||
|
|
||||||
|
### Test 8: Cross-Location Disabled Behavior
|
||||||
|
|
||||||
|
| Step | Action | Expected Result |
|
||||||
|
|------|--------|-----------------|
|
||||||
|
| 8.1 | Admin disables Cross-Location Redemption for Fashion Group | Setting saved |
|
||||||
|
| 8.2 | Enroll a **new email** at FASHIONHUB | New card created for FASHIONHUB |
|
||||||
|
| 8.3 | Enroll **same email** at FASHIONOUTLET | **New card created** for FASHIONOUTLET (separate card) |
|
||||||
|
| 8.4 | Enroll **same email** at FASHIONHUB again | "Already a member" — shows "Your card is registered at Fashion Hub" (single store, no list) |
|
||||||
|
| 8.5 | Staff at FASHIONOUTLET searches by email | Only finds the FASHIONOUTLET card (no cross-store search) |
|
||||||
|
| 8.6 | Re-enable Cross-Location Redemption when done | Restore default state |
|
||||||
|
|
||||||
|
### Key Things to Watch
|
||||||
|
|
||||||
|
- [ ] Birthday persisted after enrollment (check DB)
|
||||||
|
- [ ] No i18n flicker or console warnings on success page
|
||||||
|
- [ ] Cross-store email search works in the terminal (cross-location enabled)
|
||||||
|
- [ ] "Already a member" message shows correct locations/store based on cross-location setting
|
||||||
|
- [ ] No duplicate cards created under same merchant (when cross-location enabled)
|
||||||
|
- [ ] Rate limiting: rapid-fire stamp calls eventually return 429
|
||||||
|
|||||||
@@ -135,6 +135,10 @@
|
|||||||
"view_dashboard": "Mein Treue-Dashboard anzeigen",
|
"view_dashboard": "Mein Treue-Dashboard anzeigen",
|
||||||
"continue_shopping": "Weiter einkaufen"
|
"continue_shopping": "Weiter einkaufen"
|
||||||
},
|
},
|
||||||
|
"already_enrolled_title": "Sie sind bereits Mitglied!",
|
||||||
|
"cross_location_message": "Ihre Karte gilt an allen unseren Standorten:",
|
||||||
|
"single_location_message": "Ihre Karte ist bei {store_name} registriert",
|
||||||
|
"available_locations": "Nutzen Sie Ihre Karte an allen unseren Standorten:",
|
||||||
"errors": {
|
"errors": {
|
||||||
"load_failed": "Programminformationen konnten nicht geladen werden",
|
"load_failed": "Programminformationen konnten nicht geladen werden",
|
||||||
"email_exists": "Diese E-Mail ist bereits in unserem Treueprogramm registriert.",
|
"email_exists": "Diese E-Mail ist bereits in unserem Treueprogramm registriert.",
|
||||||
@@ -401,7 +405,10 @@
|
|||||||
"logo_url_help": "Erforderlich für Google Wallet-Integration. Muss eine öffentlich zugängliche Bild-URL sein (PNG oder JPG).",
|
"logo_url_help": "Erforderlich für Google Wallet-Integration. Muss eine öffentlich zugängliche Bild-URL sein (PNG oder JPG).",
|
||||||
"hero_image_url": "Hintergrundbild-URL",
|
"hero_image_url": "Hintergrundbild-URL",
|
||||||
"terms_privacy": "AGB & Datenschutz",
|
"terms_privacy": "AGB & Datenschutz",
|
||||||
"terms_conditions": "Allgemeine Geschäftsbedingungen",
|
"terms_cms_page": "CMS-Seiten-Slug",
|
||||||
|
"terms_cms_page_hint": "Geben Sie einen CMS-Seiten-Slug ein (z.B. agb) um die vollständigen AGB aus dem CMS-Modul anzuzeigen",
|
||||||
|
"terms_conditions": "Allgemeine Geschäftsbedingungen (Fallback)",
|
||||||
|
"terms_fallback_hint": "Wird verwendet wenn kein CMS-Slug gesetzt ist",
|
||||||
"privacy_policy_url": "Datenschutzrichtlinien-URL",
|
"privacy_policy_url": "Datenschutzrichtlinien-URL",
|
||||||
"program_status": "Programmstatus",
|
"program_status": "Programmstatus",
|
||||||
"program_active": "Programm aktiv",
|
"program_active": "Programm aktiv",
|
||||||
@@ -872,6 +879,9 @@
|
|||||||
"previous": "Zurück",
|
"previous": "Zurück",
|
||||||
"next": "Weiter",
|
"next": "Weiter",
|
||||||
"page_x_of_y": "Seite {page} von {pages}"
|
"page_x_of_y": "Seite {page} von {pages}"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"loyalty": "Treueprogramm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
|
|||||||
@@ -135,6 +135,10 @@
|
|||||||
"view_dashboard": "View My Loyalty Dashboard",
|
"view_dashboard": "View My Loyalty Dashboard",
|
||||||
"continue_shopping": "Continue Shopping"
|
"continue_shopping": "Continue Shopping"
|
||||||
},
|
},
|
||||||
|
"already_enrolled_title": "You're already a member!",
|
||||||
|
"cross_location_message": "Your card works at all our locations:",
|
||||||
|
"single_location_message": "Your card is registered at {store_name}",
|
||||||
|
"available_locations": "Use your card at all our locations:",
|
||||||
"errors": {
|
"errors": {
|
||||||
"load_failed": "Failed to load program information",
|
"load_failed": "Failed to load program information",
|
||||||
"email_exists": "This email is already registered in our loyalty program.",
|
"email_exists": "This email is already registered in our loyalty program.",
|
||||||
@@ -142,13 +146,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"activate": "Activate",
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
|
"add": "Add",
|
||||||
"all_stores": "All Stores",
|
"all_stores": "All Stores",
|
||||||
"at": "at",
|
"at": "at",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
|
"copy": "Copy",
|
||||||
|
"deactivate": "Deactivate",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
@@ -401,7 +409,10 @@
|
|||||||
"logo_url_help": "Required for Google Wallet integration. Must be a publicly accessible image URL (PNG or JPG).",
|
"logo_url_help": "Required for Google Wallet integration. Must be a publicly accessible image URL (PNG or JPG).",
|
||||||
"hero_image_url": "Hero Image URL",
|
"hero_image_url": "Hero Image URL",
|
||||||
"terms_privacy": "Terms & Privacy",
|
"terms_privacy": "Terms & Privacy",
|
||||||
"terms_conditions": "Terms & Conditions",
|
"terms_cms_page": "CMS Page Slug",
|
||||||
|
"terms_cms_page_hint": "Enter a CMS page slug (e.g. terms-and-conditions) to display full T&C from the CMS module",
|
||||||
|
"terms_conditions": "Terms & Conditions (fallback)",
|
||||||
|
"terms_fallback_hint": "Used when no CMS page slug is set",
|
||||||
"privacy_policy_url": "Privacy Policy URL",
|
"privacy_policy_url": "Privacy Policy URL",
|
||||||
"program_status": "Program Status",
|
"program_status": "Program Status",
|
||||||
"program_active": "Program Active",
|
"program_active": "Program Active",
|
||||||
@@ -473,6 +484,10 @@
|
|||||||
"table_points_earned": "Points Earned",
|
"table_points_earned": "Points Earned",
|
||||||
"table_points_redeemed": "Points Redeemed",
|
"table_points_redeemed": "Points Redeemed",
|
||||||
"table_transactions_30d": "Transactions (30d)",
|
"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",
|
"admin_policy_settings": "Admin Policy Settings",
|
||||||
"staff_pin_policy": "Staff PIN Policy",
|
"staff_pin_policy": "Staff PIN Policy",
|
||||||
"self_enrollment": "Self Enrollment",
|
"self_enrollment": "Self Enrollment",
|
||||||
@@ -702,6 +717,7 @@
|
|||||||
"reward_redeemed": "Reward redeemed: {name}",
|
"reward_redeemed": "Reward redeemed: {name}",
|
||||||
"card_label": "Card",
|
"card_label": "Card",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
|
"select_category": "Category (what was sold)",
|
||||||
"pin_authorize_text": "Enter your staff PIN to authorize this transaction",
|
"pin_authorize_text": "Enter your staff PIN to authorize this transaction",
|
||||||
"free_item": "Free item",
|
"free_item": "Free item",
|
||||||
"reward_label": "Reward",
|
"reward_label": "Reward",
|
||||||
@@ -754,7 +770,8 @@
|
|||||||
"col_location": "Location",
|
"col_location": "Location",
|
||||||
"col_notes": "Notes",
|
"col_notes": "Notes",
|
||||||
"no_transactions": "No transactions yet",
|
"no_transactions": "No transactions yet",
|
||||||
"card_label": "Card"
|
"card_label": "Card",
|
||||||
|
"page_x_of_y": "Page {page} of {pages}"
|
||||||
},
|
},
|
||||||
"enroll": {
|
"enroll": {
|
||||||
"title": "Enroll Customer",
|
"title": "Enroll Customer",
|
||||||
@@ -794,7 +811,15 @@
|
|||||||
"quick_actions": "Quick Actions",
|
"quick_actions": "Quick Actions",
|
||||||
"open_terminal": "Open Terminal",
|
"open_terminal": "Open Terminal",
|
||||||
"view_members": "View Members",
|
"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": {
|
"program": {
|
||||||
"title": "Loyalty Program",
|
"title": "Loyalty Program",
|
||||||
@@ -872,6 +897,9 @@
|
|||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"page_x_of_y": "Page {page} of {pages}"
|
"page_x_of_y": "Page {page} of {pages}"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"loyalty": "Loyalty Rewards"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
|
|||||||
@@ -135,6 +135,10 @@
|
|||||||
"view_dashboard": "Voir mon tableau de bord fidélité",
|
"view_dashboard": "Voir mon tableau de bord fidélité",
|
||||||
"continue_shopping": "Continuer mes achats"
|
"continue_shopping": "Continuer mes achats"
|
||||||
},
|
},
|
||||||
|
"already_enrolled_title": "Vous êtes déjà membre !",
|
||||||
|
"cross_location_message": "Votre carte est valable dans tous nos points de vente :",
|
||||||
|
"single_location_message": "Votre carte est enregistrée chez {store_name}",
|
||||||
|
"available_locations": "Utilisez votre carte dans tous nos points de vente :",
|
||||||
"errors": {
|
"errors": {
|
||||||
"load_failed": "Impossible de charger les informations du programme",
|
"load_failed": "Impossible de charger les informations du programme",
|
||||||
"email_exists": "Cet e-mail est déjà inscrit dans notre programme de fidélité.",
|
"email_exists": "Cet e-mail est déjà inscrit dans notre programme de fidélité.",
|
||||||
@@ -401,7 +405,10 @@
|
|||||||
"logo_url_help": "Requis pour l'intégration Google Wallet. Doit être une URL d'image publique (PNG ou JPG).",
|
"logo_url_help": "Requis pour l'intégration Google Wallet. Doit être une URL d'image publique (PNG ou JPG).",
|
||||||
"hero_image_url": "URL de l'image principale",
|
"hero_image_url": "URL de l'image principale",
|
||||||
"terms_privacy": "Conditions & Confidentialité",
|
"terms_privacy": "Conditions & Confidentialité",
|
||||||
"terms_conditions": "Conditions Générales",
|
"terms_cms_page": "Slug de page CMS",
|
||||||
|
"terms_cms_page_hint": "Entrez un slug de page CMS (ex. conditions-generales) pour afficher les CGV complètes depuis le module CMS",
|
||||||
|
"terms_conditions": "Conditions Générales (secours)",
|
||||||
|
"terms_fallback_hint": "Utilisé quand aucun slug CMS n'est défini",
|
||||||
"privacy_policy_url": "URL politique de confidentialité",
|
"privacy_policy_url": "URL politique de confidentialité",
|
||||||
"program_status": "Statut du programme",
|
"program_status": "Statut du programme",
|
||||||
"program_active": "Programme actif",
|
"program_active": "Programme actif",
|
||||||
@@ -872,6 +879,9 @@
|
|||||||
"previous": "Précédent",
|
"previous": "Précédent",
|
||||||
"next": "Suivant",
|
"next": "Suivant",
|
||||||
"page_x_of_y": "Page {page} sur {pages}"
|
"page_x_of_y": "Page {page} sur {pages}"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"loyalty": "Récompenses fidélité"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
|
|||||||
@@ -135,6 +135,10 @@
|
|||||||
"view_dashboard": "Mäin Treie-Dashboard kucken",
|
"view_dashboard": "Mäin Treie-Dashboard kucken",
|
||||||
"continue_shopping": "Weider akafen"
|
"continue_shopping": "Weider akafen"
|
||||||
},
|
},
|
||||||
|
"already_enrolled_title": "Dir sidd schonn Member!",
|
||||||
|
"cross_location_message": "Är Kaart gëllt an all eise Standuerter:",
|
||||||
|
"single_location_message": "Är Kaart ass bei {store_name} registréiert",
|
||||||
|
"available_locations": "Benotzt Är Kaart an all eise Standuerter:",
|
||||||
"errors": {
|
"errors": {
|
||||||
"load_failed": "Programminformatiounen konnten net gelueden ginn",
|
"load_failed": "Programminformatiounen konnten net gelueden ginn",
|
||||||
"email_exists": "Dës E-Mail ass schonn an eisem Treieprogramm registréiert.",
|
"email_exists": "Dës E-Mail ass schonn an eisem Treieprogramm registréiert.",
|
||||||
@@ -401,7 +405,10 @@
|
|||||||
"logo_url_help": "Erfuerderlech fir Google Wallet-Integratioun. Muss eng ëffentlech zougänglech Bild-URL sinn (PNG oder JPG).",
|
"logo_url_help": "Erfuerderlech fir Google Wallet-Integratioun. Muss eng ëffentlech zougänglech Bild-URL sinn (PNG oder JPG).",
|
||||||
"hero_image_url": "Hannergrondbild-URL",
|
"hero_image_url": "Hannergrondbild-URL",
|
||||||
"terms_privacy": "AGB & Dateschutz",
|
"terms_privacy": "AGB & Dateschutz",
|
||||||
"terms_conditions": "Allgemeng Geschäftsbedingungen",
|
"terms_cms_page": "CMS-Säiten-Slug",
|
||||||
|
"terms_cms_page_hint": "Gitt e CMS-Säiten-Slug an (z.B. agb) fir déi komplett AGB vum CMS-Modul unzeweisen",
|
||||||
|
"terms_conditions": "Allgemeng Geschäftsbedingungen (Fallback)",
|
||||||
|
"terms_fallback_hint": "Gëtt benotzt wann keen CMS-Slug gesat ass",
|
||||||
"privacy_policy_url": "Dateschutzrichtlinn-URL",
|
"privacy_policy_url": "Dateschutzrichtlinn-URL",
|
||||||
"program_status": "Programmstatus",
|
"program_status": "Programmstatus",
|
||||||
"program_active": "Programm aktiv",
|
"program_active": "Programm aktiv",
|
||||||
@@ -872,6 +879,9 @@
|
|||||||
"previous": "Zréck",
|
"previous": "Zréck",
|
||||||
"next": "Weider",
|
"next": "Weider",
|
||||||
"page_x_of_y": "Säit {page} vun {pages}"
|
"page_x_of_y": "Säit {page} vun {pages}"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"loyalty": "Treieprogramm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""loyalty 004 - relax card uniqueness for cross-location support
|
||||||
|
|
||||||
|
Replace the (merchant_id, customer_id) and (customer_id, program_id)
|
||||||
|
unique indexes with (enrolled_at_store_id, customer_id). This allows
|
||||||
|
merchants with cross-location redemption DISABLED to issue one card per
|
||||||
|
store per customer, while merchants with it ENABLED enforce the
|
||||||
|
per-merchant constraint in the application layer.
|
||||||
|
|
||||||
|
Revision ID: loyalty_004
|
||||||
|
Revises: loyalty_003
|
||||||
|
Create Date: 2026-04-10
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "loyalty_004"
|
||||||
|
down_revision = "loyalty_003"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Drop the old per-merchant unique indexes
|
||||||
|
op.drop_index("idx_loyalty_card_merchant_customer", table_name="loyalty_cards")
|
||||||
|
op.drop_index("idx_loyalty_card_customer_program", table_name="loyalty_cards")
|
||||||
|
|
||||||
|
# Keep a non-unique index on (merchant_id, customer_id) for lookups
|
||||||
|
op.create_index(
|
||||||
|
"idx_loyalty_card_merchant_customer",
|
||||||
|
"loyalty_cards",
|
||||||
|
["merchant_id", "customer_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# New unique constraint: one card per customer per store (always valid)
|
||||||
|
op.create_index(
|
||||||
|
"idx_loyalty_card_store_customer",
|
||||||
|
"loyalty_cards",
|
||||||
|
["enrolled_at_store_id", "customer_id"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("idx_loyalty_card_store_customer", table_name="loyalty_cards")
|
||||||
|
op.drop_index("idx_loyalty_card_merchant_customer", table_name="loyalty_cards")
|
||||||
|
|
||||||
|
# Restore original unique indexes
|
||||||
|
op.create_index(
|
||||||
|
"idx_loyalty_card_merchant_customer",
|
||||||
|
"loyalty_cards",
|
||||||
|
["merchant_id", "customer_id"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_loyalty_card_customer_program",
|
||||||
|
"loyalty_cards",
|
||||||
|
["customer_id", "program_id"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""loyalty 005 - add last_expiration_warning_at to loyalty_cards
|
||||||
|
|
||||||
|
Tracks when the last expiration warning email was sent to prevent
|
||||||
|
duplicate notifications. The expiration task checks this timestamp
|
||||||
|
and only sends a warning once per expiration cycle.
|
||||||
|
|
||||||
|
Revision ID: loyalty_005
|
||||||
|
Revises: loyalty_004
|
||||||
|
Create Date: 2026-04-11
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "loyalty_005"
|
||||||
|
down_revision = "loyalty_004"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"loyalty_cards",
|
||||||
|
sa.Column("last_expiration_warning_at", sa.DateTime(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("loyalty_cards", "last_expiration_warning_at")
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""loyalty 006 - add terms_cms_page_slug to loyalty_programs
|
||||||
|
|
||||||
|
Allows linking a loyalty program's T&C to a CMS page instead of
|
||||||
|
using the simple terms_text field. When set, the enrollment page
|
||||||
|
resolves the slug to a full CMS page. The legacy terms_text is
|
||||||
|
kept as a fallback for existing programs.
|
||||||
|
|
||||||
|
Revision ID: loyalty_006
|
||||||
|
Revises: loyalty_005
|
||||||
|
Create Date: 2026-04-11
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "loyalty_006"
|
||||||
|
down_revision = "loyalty_005"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"loyalty_programs",
|
||||||
|
sa.Column(
|
||||||
|
"terms_cms_page_slug",
|
||||||
|
sa.String(200),
|
||||||
|
nullable=True,
|
||||||
|
comment="CMS page slug for full T&C content (overrides terms_text when set)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("loyalty_programs", "terms_cms_page_slug")
|
||||||
@@ -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
|
# Model
|
||||||
StaffPin,
|
StaffPin,
|
||||||
)
|
)
|
||||||
|
from app.modules.loyalty.models.transaction_category import (
|
||||||
|
# Model
|
||||||
|
StoreTransactionCategory,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Enums
|
# Enums
|
||||||
@@ -62,4 +66,5 @@ __all__ = [
|
|||||||
"StaffPin",
|
"StaffPin",
|
||||||
"AppleDeviceRegistration",
|
"AppleDeviceRegistration",
|
||||||
"MerchantLoyaltySettings",
|
"MerchantLoyaltySettings",
|
||||||
|
"StoreTransactionCategory",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -223,6 +223,13 @@ class LoyaltyCard(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
comment="Any activity (for expiration calculation)",
|
comment="Any activity (for expiration calculation)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Notification tracking
|
||||||
|
last_expiration_warning_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="When last expiration warning email was sent",
|
||||||
|
)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Status
|
# Status
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -255,11 +262,14 @@ class LoyaltyCard(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Indexes - one card per customer per merchant
|
# Indexes
|
||||||
|
# One card per customer per store (always enforced at DB level).
|
||||||
|
# Per-merchant uniqueness (when cross-location is enabled) is enforced
|
||||||
|
# by the application layer in enroll_customer().
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("idx_loyalty_card_merchant_customer", "merchant_id", "customer_id", unique=True),
|
Index("idx_loyalty_card_store_customer", "enrolled_at_store_id", "customer_id", unique=True),
|
||||||
|
Index("idx_loyalty_card_merchant_customer", "merchant_id", "customer_id"),
|
||||||
Index("idx_loyalty_card_merchant_active", "merchant_id", "is_active"),
|
Index("idx_loyalty_card_merchant_active", "merchant_id", "is_active"),
|
||||||
Index("idx_loyalty_card_customer_program", "customer_id", "program_id", unique=True),
|
|
||||||
# Balances must never go negative — guards against direct SQL writes
|
# Balances must never go negative — guards against direct SQL writes
|
||||||
# bypassing the service layer's clamping logic.
|
# bypassing the service layer's clamping logic.
|
||||||
CheckConstraint("points_balance >= 0", name="ck_loyalty_cards_points_balance_nonneg"),
|
CheckConstraint("points_balance >= 0", name="ck_loyalty_cards_points_balance_nonneg"),
|
||||||
|
|||||||
@@ -227,7 +227,12 @@ class LoyaltyProgram(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
terms_text = Column(
|
terms_text = Column(
|
||||||
Text,
|
Text,
|
||||||
nullable=True,
|
nullable=True,
|
||||||
comment="Loyalty program terms and conditions",
|
comment="Loyalty program terms and conditions (legacy — use terms_cms_page_slug when available)",
|
||||||
|
)
|
||||||
|
terms_cms_page_slug = Column(
|
||||||
|
String(200),
|
||||||
|
nullable=True,
|
||||||
|
comment="CMS page slug for full T&C content (overrides terms_text when set)",
|
||||||
)
|
)
|
||||||
privacy_url = Column(
|
privacy_url = Column(
|
||||||
String(500),
|
String(500),
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from sqlalchemy import (
|
|||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
)
|
)
|
||||||
|
from sqlalchemy.dialects.sqlite import JSON
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -104,6 +105,11 @@ class LoyaltyTransaction(Base, TimestampMixin):
|
|||||||
index=True,
|
index=True,
|
||||||
comment="Staff PIN used for this operation",
|
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 (for voids/returns)
|
||||||
related_transaction_id = Column(
|
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
|
||||||
@@ -11,6 +11,7 @@ Platform admin endpoints for:
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Path, Query
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import (
|
from app.api.deps import (
|
||||||
@@ -38,6 +39,7 @@ from app.modules.loyalty.schemas import (
|
|||||||
TransactionResponse,
|
TransactionResponse,
|
||||||
)
|
)
|
||||||
from app.modules.loyalty.services import card_service, pin_service, program_service
|
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
|
from app.modules.tenancy.models import User # API-007
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -45,6 +47,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# Admin router with module access control
|
# Admin router with module access control
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/loyalty",
|
prefix="/loyalty",
|
||||||
|
tags=["Loyalty - Admin"],
|
||||||
dependencies=[Depends(require_module_access("loyalty", FrontendType.ADMIN))],
|
dependencies=[Depends(require_module_access("loyalty", FrontendType.ADMIN))],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -494,6 +497,123 @@ def get_platform_stats(
|
|||||||
return program_service.get_platform_stats(db)
|
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
|
# Wallet Integration Status
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -765,3 +885,153 @@ def debug_recent_enrollments(
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {"enrollments": results}
|
return {"enrollments": results}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Phase 6: Admin Operations (GDPR, Bulk, Point Restore)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class GDPRAnonymizeResponse(BaseModel):
|
||||||
|
"""Response for GDPR customer anonymization."""
|
||||||
|
|
||||||
|
cards_anonymized: int
|
||||||
|
customer_id: int
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class BulkDeactivateRequest(BaseModel):
|
||||||
|
"""Request for bulk card deactivation."""
|
||||||
|
|
||||||
|
card_ids: list[int] = Field(..., min_length=1, max_length=1000)
|
||||||
|
reason: str = Field(..., min_length=1, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkDeactivateResponse(BaseModel):
|
||||||
|
|
||||||
|
cards_deactivated: int
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class PointsRestoreRequest(BaseModel):
|
||||||
|
"""Request for admin point restore."""
|
||||||
|
|
||||||
|
points: int = Field(..., gt=0)
|
||||||
|
reason: str = Field(..., min_length=1, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class PointsRestoreResponse(BaseModel):
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
card_id: int
|
||||||
|
points_restored: int
|
||||||
|
new_balance: int
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/cards/customer/{customer_id}",
|
||||||
|
response_model=GDPRAnonymizeResponse,
|
||||||
|
)
|
||||||
|
def gdpr_anonymize_customer(
|
||||||
|
customer_id: int = Path(..., gt=0),
|
||||||
|
current_user: User = Depends(get_current_admin_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
GDPR: Anonymize a customer's loyalty cards.
|
||||||
|
|
||||||
|
Nulls customer_id on all cards, deactivates them, and scrubs PII
|
||||||
|
from transaction notes. Keeps transaction rows for aggregate reporting.
|
||||||
|
"""
|
||||||
|
count = card_service.anonymize_cards_for_customer(
|
||||||
|
db, customer_id, admin_user_id=current_user.id
|
||||||
|
)
|
||||||
|
return GDPRAnonymizeResponse(
|
||||||
|
cards_anonymized=count,
|
||||||
|
customer_id=customer_id,
|
||||||
|
message=f"Anonymized {count} card(s) for customer {customer_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/merchants/{merchant_id}/cards/bulk/deactivate",
|
||||||
|
response_model=BulkDeactivateResponse,
|
||||||
|
)
|
||||||
|
def bulk_deactivate_cards(
|
||||||
|
data: BulkDeactivateRequest,
|
||||||
|
merchant_id: int = Path(..., gt=0),
|
||||||
|
current_user: User = Depends(get_current_admin_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Bulk deactivate multiple loyalty cards for a merchant."""
|
||||||
|
count = card_service.bulk_deactivate_cards(
|
||||||
|
db,
|
||||||
|
card_ids=data.card_ids,
|
||||||
|
merchant_id=merchant_id,
|
||||||
|
reason=data.reason,
|
||||||
|
)
|
||||||
|
return BulkDeactivateResponse(
|
||||||
|
cards_deactivated=count,
|
||||||
|
message=f"Deactivated {count} card(s)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/cards/{card_id}/restore-points",
|
||||||
|
response_model=PointsRestoreResponse,
|
||||||
|
)
|
||||||
|
def restore_points(
|
||||||
|
data: PointsRestoreRequest,
|
||||||
|
card_id: int = Path(..., gt=0),
|
||||||
|
current_user: User = Depends(get_current_admin_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Admin: Restore expired or voided points to a card.
|
||||||
|
|
||||||
|
Creates an ADMIN_ADJUSTMENT transaction with a positive delta.
|
||||||
|
"""
|
||||||
|
from app.modules.loyalty.services.points_service import points_service
|
||||||
|
|
||||||
|
result = points_service.adjust_points(
|
||||||
|
db,
|
||||||
|
card_id=card_id,
|
||||||
|
points_delta=data.points,
|
||||||
|
reason=f"Admin restore: {data.reason}",
|
||||||
|
)
|
||||||
|
return PointsRestoreResponse(
|
||||||
|
success=True,
|
||||||
|
card_id=card_id,
|
||||||
|
points_restored=data.points,
|
||||||
|
new_balance=result["points_balance"],
|
||||||
|
message=f"Restored {data.points} points",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/merchants/{merchant_id}/restore-deleted",
|
||||||
|
)
|
||||||
|
def restore_deleted_merchant_data(
|
||||||
|
merchant_id: int = Path(..., gt=0),
|
||||||
|
current_user: User = Depends(get_current_admin_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Cascade restore: restore all soft-deleted programs and cards
|
||||||
|
for a merchant.
|
||||||
|
"""
|
||||||
|
programs_restored = program_service.restore_deleted_programs(db, merchant_id)
|
||||||
|
cards_restored = card_service.restore_deleted_cards(db, merchant_id)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Cascade restore for merchant {merchant_id}: "
|
||||||
|
f"{programs_restored} programs, {cards_restored} cards"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"merchant_id": merchant_id,
|
||||||
|
"programs_restored": programs_restored,
|
||||||
|
"cards_restored": cards_restored,
|
||||||
|
"message": f"Restored {programs_restored} program(s) and {cards_restored} card(s)",
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# Store router with module access control
|
# Store router with module access control
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/loyalty",
|
prefix="/loyalty",
|
||||||
|
tags=["Loyalty - Store"],
|
||||||
dependencies=[Depends(require_module_access("loyalty", FrontendType.STORE))],
|
dependencies=[Depends(require_module_access("loyalty", FrontendType.STORE))],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -198,6 +199,129 @@ def get_merchant_stats(
|
|||||||
return MerchantStatsResponse(**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
|
# Staff PINs
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -374,7 +498,7 @@ def _build_card_lookup_response(card, db=None) -> CardLookupResponse:
|
|||||||
available_rewards.append(reward)
|
available_rewards.append(reward)
|
||||||
|
|
||||||
return CardLookupResponse(
|
return CardLookupResponse(
|
||||||
card_id=card.id,
|
id=card.id,
|
||||||
card_number=card.card_number,
|
card_number=card.card_number,
|
||||||
customer_id=card.customer_id,
|
customer_id=card.customer_id,
|
||||||
customer_name=card.customer.full_name if card.customer else None,
|
customer_name=card.customer.full_name if card.customer else None,
|
||||||
@@ -468,6 +592,17 @@ def get_card_detail(
|
|||||||
program = card.program
|
program = card.program
|
||||||
customer = card.customer
|
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(
|
return CardDetailResponse(
|
||||||
id=card.id,
|
id=card.id,
|
||||||
card_number=card.card_number,
|
card_number=card.card_number,
|
||||||
@@ -475,6 +610,7 @@ def get_card_detail(
|
|||||||
merchant_id=card.merchant_id,
|
merchant_id=card.merchant_id,
|
||||||
program_id=card.program_id,
|
program_id=card.program_id,
|
||||||
enrolled_at_store_id=card.enrolled_at_store_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_name=customer.full_name if customer else None,
|
||||||
customer_email=customer.email if customer else None,
|
customer_email=customer.email if customer else None,
|
||||||
merchant_name=card.merchant.name if card.merchant 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)
|
@router.get("/transactions", response_model=TransactionListResponse)
|
||||||
def list_store_transactions(
|
def list_store_transactions(
|
||||||
|
request: Request,
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(10, ge=1, le=100),
|
limit: int = Query(10, ge=1, le=100),
|
||||||
current_user: User = Depends(get_current_store_api),
|
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."""
|
"""List recent transactions for this merchant's loyalty program."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id = get_store_merchant_id(db, 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(
|
transactions, total = card_service.get_store_transactions(
|
||||||
db, merchant_id, skip=skip, limit=limit
|
db, merchant_id, skip=skip, limit=limit
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from app.modules.loyalty.services.category_service import category_service
|
||||||
|
|
||||||
tx_responses = []
|
tx_responses = []
|
||||||
for t in transactions:
|
for t in transactions:
|
||||||
tx = TransactionResponse.model_validate(t)
|
tx = TransactionResponse.model_validate(t)
|
||||||
if t.card and t.card.customer:
|
if t.card and t.card.customer:
|
||||||
tx.customer_name = t.card.customer.full_name
|
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)
|
tx_responses.append(tx)
|
||||||
|
|
||||||
return TransactionListResponse(transactions=tx_responses, total=total)
|
return TransactionListResponse(transactions=tx_responses, total=total)
|
||||||
@@ -540,11 +689,18 @@ def enroll_customer(
|
|||||||
"""
|
"""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
|
|
||||||
|
# Resolve merchant_id for cross-store customer lookup
|
||||||
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
|
store = store_service.get_store_by_id_optional(db, store_id)
|
||||||
|
merchant_id = store.merchant_id if store else None
|
||||||
|
|
||||||
customer_id = card_service.resolve_customer_id(
|
customer_id = card_service.resolve_customer_id(
|
||||||
db,
|
db,
|
||||||
customer_id=data.customer_id,
|
customer_id=data.customer_id,
|
||||||
email=data.email,
|
email=data.email,
|
||||||
store_id=store_id,
|
store_id=store_id,
|
||||||
|
merchant_id=merchant_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
card = card_service.enroll_customer_for_store(db, customer_id, store_id)
|
card = card_service.enroll_customer_for_store(db, customer_id, store_id)
|
||||||
@@ -573,6 +729,7 @@ def enroll_customer(
|
|||||||
|
|
||||||
@router.get("/cards/{card_id}/transactions", response_model=TransactionListResponse)
|
@router.get("/cards/{card_id}/transactions", response_model=TransactionListResponse)
|
||||||
def get_card_transactions(
|
def get_card_transactions(
|
||||||
|
request: Request,
|
||||||
card_id: int = Path(..., gt=0),
|
card_id: int = Path(..., gt=0),
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(50, ge=1, le=100),
|
limit: int = Query(50, ge=1, le=100),
|
||||||
@@ -581,6 +738,7 @@ def get_card_transactions(
|
|||||||
):
|
):
|
||||||
"""Get transaction history for a card."""
|
"""Get transaction history for a card."""
|
||||||
store_id = current_user.token_store_id
|
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)
|
# Verify card belongs to this merchant (raises LoyaltyCardNotFoundException if not found)
|
||||||
card_service.lookup_card_for_store(db, store_id, card_id=card_id)
|
card_service.lookup_card_for_store(db, store_id, card_id=card_id)
|
||||||
@@ -589,10 +747,23 @@ def get_card_transactions(
|
|||||||
db, card_id, skip=skip, limit=limit
|
db, card_id, skip=skip, limit=limit
|
||||||
)
|
)
|
||||||
|
|
||||||
return TransactionListResponse(
|
from app.modules.loyalty.services.category_service import category_service
|
||||||
transactions=[TransactionResponse.model_validate(t) for t in transactions],
|
|
||||||
total=total,
|
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)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -619,6 +790,7 @@ def add_stamp(
|
|||||||
qr_code=data.qr_code,
|
qr_code=data.qr_code,
|
||||||
card_number=data.card_number,
|
card_number=data.card_number,
|
||||||
staff_pin=data.staff_pin,
|
staff_pin=data.staff_pin,
|
||||||
|
category_ids=data.category_ids,
|
||||||
ip_address=ip,
|
ip_address=ip,
|
||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
notes=data.notes,
|
notes=data.notes,
|
||||||
@@ -709,6 +881,7 @@ def earn_points(
|
|||||||
purchase_amount_cents=data.purchase_amount_cents,
|
purchase_amount_cents=data.purchase_amount_cents,
|
||||||
order_reference=data.order_reference,
|
order_reference=data.order_reference,
|
||||||
staff_pin=data.staff_pin,
|
staff_pin=data.staff_pin,
|
||||||
|
category_ids=data.category_ids,
|
||||||
ip_address=ip,
|
ip_address=ip,
|
||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
notes=data.notes,
|
notes=data.notes,
|
||||||
@@ -803,3 +976,39 @@ def adjust_points(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return PointsAdjustResponse(**result)
|
return PointsAdjustResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Bulk Operations (Merchant Owner only)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cards/bulk/deactivate")
|
||||||
|
@rate_limit(max_requests=10, window_seconds=60)
|
||||||
|
def bulk_deactivate_cards(
|
||||||
|
request: Request,
|
||||||
|
data: dict,
|
||||||
|
current_user: User = Depends(get_current_store_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Bulk deactivate multiple loyalty cards (merchant_owner only)."""
|
||||||
|
if current_user.role != "merchant_owner":
|
||||||
|
raise AuthorizationException("Only merchant owners can bulk deactivate cards")
|
||||||
|
|
||||||
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
|
store = store_service.get_store_by_id_optional(db, current_user.token_store_id)
|
||||||
|
if not store:
|
||||||
|
raise AuthorizationException("Store not found")
|
||||||
|
|
||||||
|
card_ids = data.get("card_ids", [])
|
||||||
|
reason = data.get("reason", "Merchant bulk deactivation")
|
||||||
|
|
||||||
|
count = card_service.bulk_deactivate_cards(
|
||||||
|
db,
|
||||||
|
card_ids=card_ids,
|
||||||
|
merchant_id=store.merchant_id,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"cards_deactivated": count, "message": f"Deactivated {count} card(s)"}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.api.deps import get_current_customer_api
|
from app.api.deps import get_current_customer_api
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.modules.customers.schemas import CustomerContext
|
from app.modules.customers.schemas import CustomerContext
|
||||||
|
from app.modules.loyalty.exceptions import LoyaltyCardAlreadyExistsException
|
||||||
from app.modules.loyalty.schemas import (
|
from app.modules.loyalty.schemas import (
|
||||||
CardEnrollRequest,
|
CardEnrollRequest,
|
||||||
CardResponse,
|
CardResponse,
|
||||||
@@ -88,6 +89,7 @@ def self_enroll(
|
|||||||
customer_id=data.customer_id,
|
customer_id=data.customer_id,
|
||||||
email=data.email,
|
email=data.email,
|
||||||
store_id=store.id,
|
store_id=store.id,
|
||||||
|
merchant_id=store.merchant_id,
|
||||||
create_if_missing=True,
|
create_if_missing=True,
|
||||||
customer_name=data.customer_name,
|
customer_name=data.customer_name,
|
||||||
customer_phone=data.customer_phone,
|
customer_phone=data.customer_phone,
|
||||||
@@ -96,10 +98,45 @@ def self_enroll(
|
|||||||
|
|
||||||
logger.info(f"Self-enrollment for customer {customer_id} at store {store.subdomain}")
|
logger.info(f"Self-enrollment for customer {customer_id} at store {store.subdomain}")
|
||||||
|
|
||||||
|
# Build merchant context for the response (locations, cross-location flag)
|
||||||
|
settings = program_service.get_merchant_settings(db, store.merchant_id)
|
||||||
|
allow_cross_location = (
|
||||||
|
settings.allow_cross_location_redemption if settings else True
|
||||||
|
)
|
||||||
|
locations = program_service.get_merchant_locations(db, store.merchant_id)
|
||||||
|
location_list = [
|
||||||
|
{"id": loc.id, "name": loc.name}
|
||||||
|
for loc in locations
|
||||||
|
]
|
||||||
|
|
||||||
|
already_enrolled = False
|
||||||
|
try:
|
||||||
card = card_service.enroll_customer_for_store(db, customer_id, store.id)
|
card = card_service.enroll_customer_for_store(db, customer_id, store.id)
|
||||||
|
except LoyaltyCardAlreadyExistsException:
|
||||||
|
# Customer already has a card — return it instead of erroring out.
|
||||||
|
# For cross-location=true this is the normal re-enroll-at-another-store
|
||||||
|
# path; for cross-location=false this is a same-store re-enroll.
|
||||||
|
already_enrolled = True
|
||||||
|
if allow_cross_location:
|
||||||
|
card = card_service.get_card_by_customer_and_merchant(
|
||||||
|
db, customer_id, store.merchant_id
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
card = card_service.get_card_by_customer_and_store(
|
||||||
|
db, customer_id, store.id
|
||||||
|
)
|
||||||
|
|
||||||
program = card.program
|
program = card.program
|
||||||
wallet_urls = wallet_service.get_add_to_wallet_urls(db, card)
|
wallet_urls = wallet_service.get_add_to_wallet_urls(db, card)
|
||||||
|
|
||||||
|
# Resolve the name of the original enrollment store
|
||||||
|
enrolled_at_store_name = None
|
||||||
|
if card.enrolled_at_store_id:
|
||||||
|
for loc in locations:
|
||||||
|
if loc.id == card.enrolled_at_store_id:
|
||||||
|
enrolled_at_store_name = loc.name
|
||||||
|
break
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"card": CardResponse(
|
"card": CardResponse(
|
||||||
id=card.id,
|
id=card.id,
|
||||||
@@ -122,6 +159,10 @@ def self_enroll(
|
|||||||
has_apple_wallet=bool(card.apple_serial_number),
|
has_apple_wallet=bool(card.apple_serial_number),
|
||||||
),
|
),
|
||||||
"wallet_urls": wallet_urls,
|
"wallet_urls": wallet_urls,
|
||||||
|
"already_enrolled": already_enrolled,
|
||||||
|
"allow_cross_location": allow_cross_location,
|
||||||
|
"enrolled_at_store_name": enrolled_at_store_name,
|
||||||
|
"merchant_locations": location_list,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ async def loyalty_self_enrollment(
|
|||||||
async def loyalty_enrollment_success(
|
async def loyalty_enrollment_success(
|
||||||
request: Request,
|
request: Request,
|
||||||
card: str = Query(None, description="Card number"),
|
card: str = Query(None, description="Card number"),
|
||||||
|
already: str = Query(None, description="Already enrolled flag"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -149,6 +150,27 @@ async def loyalty_enrollment_success(
|
|||||||
|
|
||||||
context = get_storefront_context(request, db=db)
|
context = get_storefront_context(request, db=db)
|
||||||
context["enrolled_card_number"] = card
|
context["enrolled_card_number"] = card
|
||||||
|
|
||||||
|
# Provide merchant locations and cross-location flag server-side so
|
||||||
|
# the template doesn't depend on sessionStorage surviving refreshes.
|
||||||
|
store = getattr(request.state, "store", None)
|
||||||
|
if store:
|
||||||
|
from app.modules.loyalty.services import program_service
|
||||||
|
|
||||||
|
settings = program_service.get_merchant_settings(db, store.merchant_id)
|
||||||
|
locations = program_service.get_merchant_locations(db, store.merchant_id)
|
||||||
|
context["server_already_enrolled"] = already == "1"
|
||||||
|
context["server_allow_cross_location"] = (
|
||||||
|
settings.allow_cross_location_redemption if settings else True
|
||||||
|
)
|
||||||
|
context["server_merchant_locations"] = [
|
||||||
|
{"id": loc.id, "name": loc.name} for loc in locations
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
context["server_already_enrolled"] = False
|
||||||
|
context["server_allow_cross_location"] = True
|
||||||
|
context["server_merchant_locations"] = []
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"loyalty/storefront/enroll-success.html",
|
"loyalty/storefront/enroll-success.html",
|
||||||
context,
|
context,
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ class CardDetailResponse(CardResponse):
|
|||||||
|
|
||||||
# Merchant info
|
# Merchant info
|
||||||
merchant_name: str | None = None
|
merchant_name: str | None = None
|
||||||
|
enrolled_at_store_name: str | None = None
|
||||||
|
|
||||||
# Program info
|
# Program info
|
||||||
program_name: str
|
program_name: str
|
||||||
@@ -128,7 +129,7 @@ class CardLookupResponse(BaseModel):
|
|||||||
"""Schema for card lookup by QR code or card number."""
|
"""Schema for card lookup by QR code or card number."""
|
||||||
|
|
||||||
# Card info
|
# Card info
|
||||||
card_id: int
|
id: int
|
||||||
card_number: str
|
card_number: str
|
||||||
|
|
||||||
# Customer
|
# Customer
|
||||||
@@ -187,6 +188,8 @@ class TransactionResponse(BaseModel):
|
|||||||
order_reference: str | None = None
|
order_reference: str | None = None
|
||||||
reward_id: str | None = None
|
reward_id: str | None = None
|
||||||
reward_description: str | None = None
|
reward_description: str | None = None
|
||||||
|
category_ids: list[int] | None = None
|
||||||
|
category_names: list[str] | None = None
|
||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
|
|
||||||
# Customer
|
# 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",
|
description="Staff PIN for verification",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Categories (what was sold — multi-select)
|
||||||
|
category_ids: list[int] | None = Field(
|
||||||
|
None,
|
||||||
|
description="Transaction category IDs",
|
||||||
|
)
|
||||||
|
|
||||||
# Optional metadata
|
# Optional metadata
|
||||||
notes: str | None = Field(
|
notes: str | None = Field(
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -110,7 +110,8 @@ class ProgramCreate(BaseModel):
|
|||||||
hero_image_url: str | None = Field(None, max_length=500, description="Hero image URL")
|
hero_image_url: str | None = Field(None, max_length=500, description="Hero image URL")
|
||||||
|
|
||||||
# Terms
|
# Terms
|
||||||
terms_text: str | None = Field(None, description="Terms and conditions")
|
terms_text: str | None = Field(None, description="Terms and conditions (legacy)")
|
||||||
|
terms_cms_page_slug: str | None = Field(None, max_length=200, description="CMS page slug for T&C")
|
||||||
privacy_url: str | None = Field(None, max_length=500, description="Privacy policy URL")
|
privacy_url: str | None = Field(None, max_length=500, description="Privacy policy URL")
|
||||||
|
|
||||||
|
|
||||||
@@ -155,6 +156,7 @@ class ProgramUpdate(BaseModel):
|
|||||||
|
|
||||||
# Terms
|
# Terms
|
||||||
terms_text: str | None = None
|
terms_text: str | None = None
|
||||||
|
terms_cms_page_slug: str | None = Field(None, max_length=200)
|
||||||
privacy_url: str | None = Field(None, max_length=500)
|
privacy_url: str | None = Field(None, max_length=500)
|
||||||
|
|
||||||
# Wallet integration
|
# Wallet integration
|
||||||
@@ -202,6 +204,7 @@ class ProgramResponse(BaseModel):
|
|||||||
|
|
||||||
# Terms
|
# Terms
|
||||||
terms_text: str | None = None
|
terms_text: str | None = None
|
||||||
|
terms_cms_page_slug: str | None = None
|
||||||
privacy_url: str | None = None
|
privacy_url: str | None = None
|
||||||
|
|
||||||
# Wallet
|
# Wallet
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ class StampRequest(BaseModel):
|
|||||||
description="Staff PIN for verification",
|
description="Staff PIN for verification",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Categories (what was sold — multi-select)
|
||||||
|
category_ids: list[int] | None = Field(
|
||||||
|
None,
|
||||||
|
description="Transaction category IDs",
|
||||||
|
)
|
||||||
|
|
||||||
# Optional metadata
|
# Optional metadata
|
||||||
notes: str | None = Field(
|
notes: str | None = Field(
|
||||||
None,
|
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()
|
||||||
@@ -119,6 +119,23 @@ class CardService:
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_card_by_customer_and_store(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
customer_id: int,
|
||||||
|
store_id: int,
|
||||||
|
) -> LoyaltyCard | None:
|
||||||
|
"""Get a customer's card for a specific store."""
|
||||||
|
return (
|
||||||
|
db.query(LoyaltyCard)
|
||||||
|
.options(joinedload(LoyaltyCard.program))
|
||||||
|
.filter(
|
||||||
|
LoyaltyCard.customer_id == customer_id,
|
||||||
|
LoyaltyCard.enrolled_at_store_id == store_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
def get_card_by_customer_and_program(
|
def get_card_by_customer_and_program(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
@@ -166,6 +183,7 @@ class CardService:
|
|||||||
customer_id: int | None,
|
customer_id: int | None,
|
||||||
email: str | None,
|
email: str | None,
|
||||||
store_id: int,
|
store_id: int,
|
||||||
|
merchant_id: int | None = None,
|
||||||
create_if_missing: bool = False,
|
create_if_missing: bool = False,
|
||||||
customer_name: str | None = None,
|
customer_name: str | None = None,
|
||||||
customer_phone: str | None = None,
|
customer_phone: str | None = None,
|
||||||
@@ -179,6 +197,7 @@ class CardService:
|
|||||||
customer_id: Direct customer ID (used if provided)
|
customer_id: Direct customer ID (used if provided)
|
||||||
email: Customer email to look up
|
email: Customer email to look up
|
||||||
store_id: Store ID for scoping the email lookup
|
store_id: Store ID for scoping the email lookup
|
||||||
|
merchant_id: Merchant ID for cross-store loyalty card lookup
|
||||||
create_if_missing: If True, create customer when email not found
|
create_if_missing: If True, create customer when email not found
|
||||||
(used for self-enrollment)
|
(used for self-enrollment)
|
||||||
customer_name: Full name for customer creation
|
customer_name: Full name for customer creation
|
||||||
@@ -196,6 +215,9 @@ class CardService:
|
|||||||
return customer_id
|
return customer_id
|
||||||
|
|
||||||
if email:
|
if email:
|
||||||
|
from app.modules.customers.models.customer import (
|
||||||
|
Customer as CustomerModel,
|
||||||
|
)
|
||||||
from app.modules.customers.services.customer_service import (
|
from app.modules.customers.services.customer_service import (
|
||||||
customer_service,
|
customer_service,
|
||||||
)
|
)
|
||||||
@@ -210,6 +232,29 @@ class CardService:
|
|||||||
db.flush()
|
db.flush()
|
||||||
return customer.id
|
return customer.id
|
||||||
|
|
||||||
|
# Customers are store-scoped, but loyalty cards are merchant-scoped.
|
||||||
|
# Check if this email already has a card under the same merchant at
|
||||||
|
# a different store — if so, reuse that customer_id so the duplicate
|
||||||
|
# check in enroll_customer() fires correctly.
|
||||||
|
if merchant_id:
|
||||||
|
existing_cardholder = (
|
||||||
|
db.query(CustomerModel)
|
||||||
|
.join(
|
||||||
|
LoyaltyCard,
|
||||||
|
CustomerModel.id == LoyaltyCard.customer_id,
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
CustomerModel.email == email.lower(),
|
||||||
|
LoyaltyCard.merchant_id == merchant_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing_cardholder:
|
||||||
|
if customer_birthday and not existing_cardholder.birth_date:
|
||||||
|
existing_cardholder.birth_date = customer_birthday
|
||||||
|
db.flush()
|
||||||
|
return existing_cardholder.id
|
||||||
|
|
||||||
if create_if_missing:
|
if create_if_missing:
|
||||||
# Parse name into first/last
|
# Parse name into first/last
|
||||||
first_name = customer_name or ""
|
first_name = customer_name or ""
|
||||||
@@ -347,18 +392,45 @@ class CardService:
|
|||||||
|
|
||||||
merchant_id = store.merchant_id
|
merchant_id = store.merchant_id
|
||||||
|
|
||||||
# Try card number
|
# Try card number — always merchant-scoped
|
||||||
card = self.get_card_by_number(db, query)
|
card = self.get_card_by_number(db, query)
|
||||||
if card and card.merchant_id == merchant_id:
|
if card and card.merchant_id == merchant_id:
|
||||||
return card
|
return card
|
||||||
|
|
||||||
# Try customer email
|
# Try customer email — first at this store
|
||||||
customer = customer_service.get_customer_by_email(db, store_id, query)
|
customer = customer_service.get_customer_by_email(db, store_id, query)
|
||||||
if customer:
|
if customer:
|
||||||
card = self.get_card_by_customer_and_merchant(db, customer.id, merchant_id)
|
card = self.get_card_by_customer_and_merchant(db, customer.id, merchant_id)
|
||||||
if card:
|
if card:
|
||||||
return card
|
return card
|
||||||
|
|
||||||
|
# Cross-store email search: the customer may have enrolled at a
|
||||||
|
# different store under the same merchant. Only search when
|
||||||
|
# cross-location redemption is enabled.
|
||||||
|
from app.modules.customers.models.customer import Customer as CustomerModel
|
||||||
|
from app.modules.loyalty.services.program_service import program_service
|
||||||
|
|
||||||
|
settings = program_service.get_merchant_settings(db, merchant_id)
|
||||||
|
cross_location_enabled = (
|
||||||
|
settings.allow_cross_location_redemption if settings else True
|
||||||
|
)
|
||||||
|
if cross_location_enabled:
|
||||||
|
cross_store_customer = (
|
||||||
|
db.query(CustomerModel)
|
||||||
|
.join(LoyaltyCard, CustomerModel.id == LoyaltyCard.customer_id)
|
||||||
|
.filter(
|
||||||
|
CustomerModel.email == query.lower(),
|
||||||
|
LoyaltyCard.merchant_id == merchant_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if cross_store_customer:
|
||||||
|
card = self.get_card_by_customer_and_merchant(
|
||||||
|
db, cross_store_customer.id, merchant_id
|
||||||
|
)
|
||||||
|
if card:
|
||||||
|
return card
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def list_cards(
|
def list_cards(
|
||||||
@@ -479,8 +551,30 @@ class CardService:
|
|||||||
if not program.is_active:
|
if not program.is_active:
|
||||||
raise LoyaltyProgramInactiveException(program.id)
|
raise LoyaltyProgramInactiveException(program.id)
|
||||||
|
|
||||||
# Check if customer already has a card
|
# Check for duplicate enrollment — the scope depends on whether
|
||||||
existing = self.get_card_by_customer_and_merchant(db, customer_id, merchant_id)
|
# cross-location redemption is enabled for this merchant.
|
||||||
|
from app.modules.loyalty.services.program_service import program_service
|
||||||
|
|
||||||
|
settings = program_service.get_merchant_settings(db, merchant_id)
|
||||||
|
if settings and not settings.allow_cross_location_redemption:
|
||||||
|
# Per-store cards: only block if the customer already has a card
|
||||||
|
# at THIS specific store. Cards at other stores are allowed.
|
||||||
|
if enrolled_at_store_id:
|
||||||
|
existing = (
|
||||||
|
db.query(LoyaltyCard)
|
||||||
|
.filter(
|
||||||
|
LoyaltyCard.customer_id == customer_id,
|
||||||
|
LoyaltyCard.enrolled_at_store_id == enrolled_at_store_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise LoyaltyCardAlreadyExistsException(customer_id, program.id)
|
||||||
|
else:
|
||||||
|
# Cross-location enabled (default): one card per merchant
|
||||||
|
existing = self.get_card_by_customer_and_merchant(
|
||||||
|
db, customer_id, merchant_id
|
||||||
|
)
|
||||||
if existing:
|
if existing:
|
||||||
raise LoyaltyCardAlreadyExistsException(customer_id, program.id)
|
raise LoyaltyCardAlreadyExistsException(customer_id, program.id)
|
||||||
|
|
||||||
@@ -530,6 +624,23 @@ class CardService:
|
|||||||
|
|
||||||
wallet_service.create_wallet_objects(db, card)
|
wallet_service.create_wallet_objects(db, card)
|
||||||
|
|
||||||
|
# Send notification emails (async via Celery)
|
||||||
|
try:
|
||||||
|
from app.modules.loyalty.services.notification_service import (
|
||||||
|
notification_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
notification_service.send_enrollment_confirmation(db, card)
|
||||||
|
if program.welcome_bonus_points > 0:
|
||||||
|
notification_service.send_welcome_bonus(
|
||||||
|
db, card, program.welcome_bonus_points
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to queue enrollment notification for card {card.id}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Enrolled customer {customer_id} in merchant {merchant_id} loyalty program "
|
f"Enrolled customer {customer_id} in merchant {merchant_id} loyalty program "
|
||||||
f"(card: {card.card_number}, bonus: {program.welcome_bonus_points} pts)"
|
f"(card: {card.card_number}, bonus: {program.welcome_bonus_points} pts)"
|
||||||
@@ -696,6 +807,7 @@ class CardService:
|
|||||||
*,
|
*,
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
|
lang: str = "en",
|
||||||
) -> tuple[list[dict], int]:
|
) -> tuple[list[dict], int]:
|
||||||
"""
|
"""
|
||||||
Get transaction history for a card with store names resolved.
|
Get transaction history for a card with store names resolved.
|
||||||
@@ -725,6 +837,8 @@ class CardService:
|
|||||||
"transaction_at": tx.transaction_at.isoformat() if tx.transaction_at else None,
|
"transaction_at": tx.transaction_at.isoformat() if tx.transaction_at else None,
|
||||||
"notes": tx.notes,
|
"notes": tx.notes,
|
||||||
"store_name": None,
|
"store_name": None,
|
||||||
|
"category_ids": tx.category_ids,
|
||||||
|
"category_names": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
if tx.store_id:
|
if tx.store_id:
|
||||||
@@ -732,10 +846,166 @@ class CardService:
|
|||||||
if store_obj:
|
if store_obj:
|
||||||
tx_data["store_name"] = store_obj.name
|
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)
|
tx_responses.append(tx_data)
|
||||||
|
|
||||||
return tx_responses, total
|
return tx_responses, total
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Admin Operations
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def anonymize_cards_for_customer(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
customer_id: int,
|
||||||
|
admin_user_id: int,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
GDPR anonymization: null out customer_id and scrub PII on all
|
||||||
|
loyalty cards belonging to this customer.
|
||||||
|
|
||||||
|
Transaction rows are kept for aggregate reporting but notes
|
||||||
|
containing PII are scrubbed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_id: Customer to anonymize
|
||||||
|
admin_user_id: Admin performing the action (for audit)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of cards anonymized
|
||||||
|
"""
|
||||||
|
cards = (
|
||||||
|
db.query(LoyaltyCard)
|
||||||
|
.filter(LoyaltyCard.customer_id == customer_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not cards:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
for card in cards:
|
||||||
|
# Create audit transaction before nulling customer_id
|
||||||
|
db.add(
|
||||||
|
LoyaltyTransaction(
|
||||||
|
card_id=card.id,
|
||||||
|
merchant_id=card.merchant_id,
|
||||||
|
transaction_type=TransactionType.ADMIN_ADJUSTMENT.value,
|
||||||
|
notes=f"GDPR anonymization by admin {admin_user_id}",
|
||||||
|
transaction_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Null the customer reference
|
||||||
|
card.customer_id = None
|
||||||
|
card.is_active = False
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
# Scrub notes on existing transactions that might contain PII
|
||||||
|
db.query(LoyaltyTransaction).filter(
|
||||||
|
LoyaltyTransaction.card_id.in_([c.id for c in cards]),
|
||||||
|
LoyaltyTransaction.notes.isnot(None),
|
||||||
|
).update(
|
||||||
|
{LoyaltyTransaction.notes: "GDPR scrubbed"},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"GDPR: anonymized {count} cards for customer {customer_id} "
|
||||||
|
f"by admin {admin_user_id}"
|
||||||
|
)
|
||||||
|
return count
|
||||||
|
|
||||||
|
def bulk_deactivate_cards(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
card_ids: list[int],
|
||||||
|
merchant_id: int,
|
||||||
|
reason: str,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Deactivate multiple cards at once.
|
||||||
|
|
||||||
|
Only deactivates cards belonging to the specified merchant.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of cards deactivated
|
||||||
|
"""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
cards = (
|
||||||
|
db.query(LoyaltyCard)
|
||||||
|
.filter(
|
||||||
|
LoyaltyCard.id.in_(card_ids),
|
||||||
|
LoyaltyCard.merchant_id == merchant_id,
|
||||||
|
LoyaltyCard.is_active == True, # noqa: E712
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
for card in cards:
|
||||||
|
card.is_active = False
|
||||||
|
db.add(
|
||||||
|
LoyaltyTransaction(
|
||||||
|
card_id=card.id,
|
||||||
|
merchant_id=merchant_id,
|
||||||
|
transaction_type=TransactionType.ADMIN_ADJUSTMENT.value,
|
||||||
|
notes=f"Bulk deactivation: {reason}",
|
||||||
|
transaction_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Bulk deactivated {len(cards)} cards for merchant {merchant_id}: {reason}"
|
||||||
|
)
|
||||||
|
return len(cards)
|
||||||
|
|
||||||
|
def restore_deleted_cards(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
merchant_id: int,
|
||||||
|
) -> int:
|
||||||
|
"""Restore all soft-deleted cards for a merchant.
|
||||||
|
|
||||||
|
Returns number of cards restored.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
result = db.execute(
|
||||||
|
update(LoyaltyCard)
|
||||||
|
.where(
|
||||||
|
LoyaltyCard.merchant_id == merchant_id,
|
||||||
|
LoyaltyCard.deleted_at.isnot(None),
|
||||||
|
)
|
||||||
|
.values(deleted_at=None, deleted_by_id=None)
|
||||||
|
.execution_options(include_deleted=True)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
count = result.rowcount
|
||||||
|
if count:
|
||||||
|
logger.info(f"Restored {count} soft-deleted cards for merchant {merchant_id}")
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
# Singleton instance
|
||||||
card_service = CardService()
|
card_service = CardService()
|
||||||
|
|||||||
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()
|
||||||
137
app/modules/loyalty/services/notification_service.py
Normal file
137
app/modules/loyalty/services/notification_service.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# app/modules/loyalty/services/notification_service.py
|
||||||
|
"""
|
||||||
|
Loyalty notification service.
|
||||||
|
|
||||||
|
Thin wrapper that resolves customer/card/program data into template
|
||||||
|
variables and dispatches emails asynchronously via the Celery task.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.modules.loyalty.models import LoyaltyCard
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LoyaltyNotificationService:
|
||||||
|
"""Dispatches loyalty email notifications."""
|
||||||
|
|
||||||
|
def _resolve_context(self, db: Session, card: LoyaltyCard) -> dict | None:
|
||||||
|
"""Load customer, store, and program info for a card.
|
||||||
|
|
||||||
|
Returns None if the customer has no email (can't send).
|
||||||
|
"""
|
||||||
|
from app.modules.customers.services.customer_service import customer_service
|
||||||
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
|
customer = customer_service.get_customer_by_id(db, card.customer_id)
|
||||||
|
if not customer or not customer.email:
|
||||||
|
return None
|
||||||
|
|
||||||
|
store = store_service.get_store_by_id_optional(db, card.enrolled_at_store_id)
|
||||||
|
program = card.program
|
||||||
|
|
||||||
|
return {
|
||||||
|
"customer": customer,
|
||||||
|
"store": store,
|
||||||
|
"program": program,
|
||||||
|
"to_email": customer.email,
|
||||||
|
"to_name": customer.full_name,
|
||||||
|
"store_id": card.enrolled_at_store_id,
|
||||||
|
"customer_id": customer.id,
|
||||||
|
"customer_name": customer.full_name,
|
||||||
|
"program_name": program.display_name if program else "Loyalty Program",
|
||||||
|
"store_name": store.name if store else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _dispatch(
|
||||||
|
self, template_code: str, ctx: dict, extra_vars: dict | None = None
|
||||||
|
):
|
||||||
|
"""Enqueue a notification email via Celery."""
|
||||||
|
from app.modules.loyalty.tasks.notifications import send_notification_email
|
||||||
|
|
||||||
|
variables = {
|
||||||
|
"customer_name": ctx["customer_name"],
|
||||||
|
"program_name": ctx["program_name"],
|
||||||
|
"store_name": ctx["store_name"],
|
||||||
|
}
|
||||||
|
if extra_vars:
|
||||||
|
variables.update(extra_vars)
|
||||||
|
|
||||||
|
send_notification_email.delay(
|
||||||
|
template_code=template_code,
|
||||||
|
to_email=ctx["to_email"],
|
||||||
|
to_name=ctx["to_name"],
|
||||||
|
variables=variables,
|
||||||
|
store_id=ctx["store_id"],
|
||||||
|
customer_id=ctx["customer_id"],
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Queued {template_code} for {ctx['to_email']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def send_enrollment_confirmation(self, db: Session, card: LoyaltyCard):
|
||||||
|
"""Send enrollment confirmation email."""
|
||||||
|
ctx = self._resolve_context(db, card)
|
||||||
|
if not ctx:
|
||||||
|
return
|
||||||
|
self._dispatch("loyalty_enrollment", ctx, {
|
||||||
|
"card_number": card.card_number,
|
||||||
|
})
|
||||||
|
|
||||||
|
def send_welcome_bonus(self, db: Session, card: LoyaltyCard, points: int):
|
||||||
|
"""Send welcome bonus notification (only if points > 0)."""
|
||||||
|
if points <= 0:
|
||||||
|
return
|
||||||
|
ctx = self._resolve_context(db, card)
|
||||||
|
if not ctx:
|
||||||
|
return
|
||||||
|
self._dispatch("loyalty_welcome_bonus", ctx, {
|
||||||
|
"points": str(points),
|
||||||
|
})
|
||||||
|
|
||||||
|
def send_points_expiration_warning(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
card: LoyaltyCard,
|
||||||
|
expiring_points: int,
|
||||||
|
days_remaining: int,
|
||||||
|
expiration_date: str,
|
||||||
|
):
|
||||||
|
"""Send points expiring warning email."""
|
||||||
|
ctx = self._resolve_context(db, card)
|
||||||
|
if not ctx:
|
||||||
|
return
|
||||||
|
self._dispatch("loyalty_points_expiring", ctx, {
|
||||||
|
"points": str(expiring_points),
|
||||||
|
"days_remaining": str(days_remaining),
|
||||||
|
"expiration_date": expiration_date,
|
||||||
|
})
|
||||||
|
|
||||||
|
def send_points_expired(
|
||||||
|
self, db: Session, card: LoyaltyCard, expired_points: int
|
||||||
|
):
|
||||||
|
"""Send points expired notification email."""
|
||||||
|
ctx = self._resolve_context(db, card)
|
||||||
|
if not ctx:
|
||||||
|
return
|
||||||
|
self._dispatch("loyalty_points_expired", ctx, {
|
||||||
|
"expired_points": str(expired_points),
|
||||||
|
})
|
||||||
|
|
||||||
|
def send_reward_available(
|
||||||
|
self, db: Session, card: LoyaltyCard, reward_name: str
|
||||||
|
):
|
||||||
|
"""Send reward earned notification email."""
|
||||||
|
ctx = self._resolve_context(db, card)
|
||||||
|
if not ctx:
|
||||||
|
return
|
||||||
|
self._dispatch("loyalty_reward_ready", ctx, {
|
||||||
|
"reward_name": reward_name,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton
|
||||||
|
notification_service = LoyaltyNotificationService()
|
||||||
@@ -23,6 +23,7 @@ from app.modules.loyalty.exceptions import (
|
|||||||
InsufficientPointsException,
|
InsufficientPointsException,
|
||||||
InvalidRewardException,
|
InvalidRewardException,
|
||||||
LoyaltyCardInactiveException,
|
LoyaltyCardInactiveException,
|
||||||
|
LoyaltyException,
|
||||||
LoyaltyProgramInactiveException,
|
LoyaltyProgramInactiveException,
|
||||||
OrderReferenceRequiredException,
|
OrderReferenceRequiredException,
|
||||||
StaffPinRequiredException,
|
StaffPinRequiredException,
|
||||||
@@ -48,6 +49,7 @@ class PointsService:
|
|||||||
purchase_amount_cents: int,
|
purchase_amount_cents: int,
|
||||||
order_reference: str | None = None,
|
order_reference: str | None = None,
|
||||||
staff_pin: str | None = None,
|
staff_pin: str | None = None,
|
||||||
|
category_ids: list[int] | None = None,
|
||||||
ip_address: str | None = None,
|
ip_address: str | None = None,
|
||||||
user_agent: str | None = None,
|
user_agent: str | None = None,
|
||||||
notes: str | None = None,
|
notes: str | None = None,
|
||||||
@@ -101,6 +103,19 @@ class PointsService:
|
|||||||
if settings and settings.require_order_reference and not order_reference:
|
if settings and settings.require_order_reference and not order_reference:
|
||||||
raise OrderReferenceRequiredException()
|
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
|
# Idempotency guard: if same order_reference already earned points on this card, return existing result
|
||||||
if order_reference:
|
if order_reference:
|
||||||
existing_tx = (
|
existing_tx = (
|
||||||
@@ -181,6 +196,7 @@ class PointsService:
|
|||||||
card_id=card.id,
|
card_id=card.id,
|
||||||
store_id=store_id,
|
store_id=store_id,
|
||||||
staff_pin_id=verified_pin.id if verified_pin else None,
|
staff_pin_id=verified_pin.id if verified_pin else None,
|
||||||
|
category_ids=category_ids,
|
||||||
transaction_type=TransactionType.POINTS_EARNED.value,
|
transaction_type=TransactionType.POINTS_EARNED.value,
|
||||||
points_delta=points_earned,
|
points_delta=points_earned,
|
||||||
stamps_balance_after=card.stamp_count,
|
stamps_balance_after=card.stamp_count,
|
||||||
|
|||||||
@@ -1127,5 +1127,30 @@ class ProgramService:
|
|||||||
return stats
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def restore_deleted_programs(self, db: Session, merchant_id: int) -> int:
|
||||||
|
"""Restore all soft-deleted programs for a merchant.
|
||||||
|
|
||||||
|
Returns number of programs restored.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
result = db.execute(
|
||||||
|
update(LoyaltyProgram)
|
||||||
|
.where(
|
||||||
|
LoyaltyProgram.merchant_id == merchant_id,
|
||||||
|
LoyaltyProgram.deleted_at.isnot(None),
|
||||||
|
)
|
||||||
|
.values(deleted_at=None, deleted_by_id=None)
|
||||||
|
.execution_options(include_deleted=True)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
count = result.rowcount
|
||||||
|
if count:
|
||||||
|
logger.info(
|
||||||
|
f"Restored {count} soft-deleted programs for merchant {merchant_id}"
|
||||||
|
)
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
# Singleton instance
|
||||||
program_service = ProgramService()
|
program_service = ProgramService()
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class StampService:
|
|||||||
qr_code: str | None = None,
|
qr_code: str | None = None,
|
||||||
card_number: str | None = None,
|
card_number: str | None = None,
|
||||||
staff_pin: str | None = None,
|
staff_pin: str | None = None,
|
||||||
|
category_ids: list[int] | None = None,
|
||||||
ip_address: str | None = None,
|
ip_address: str | None = None,
|
||||||
user_agent: str | None = None,
|
user_agent: str | None = None,
|
||||||
notes: str | None = None,
|
notes: str | None = None,
|
||||||
@@ -143,6 +144,7 @@ class StampService:
|
|||||||
card_id=card.id,
|
card_id=card.id,
|
||||||
store_id=store_id,
|
store_id=store_id,
|
||||||
staff_pin_id=verified_pin.id if verified_pin else None,
|
staff_pin_id=verified_pin.id if verified_pin else None,
|
||||||
|
category_ids=category_ids,
|
||||||
transaction_type=TransactionType.STAMP_EARNED.value,
|
transaction_type=TransactionType.STAMP_EARNED.value,
|
||||||
stamps_delta=1,
|
stamps_delta=1,
|
||||||
stamps_balance_after=card.stamp_count,
|
stamps_balance_after=card.stamp_count,
|
||||||
@@ -162,6 +164,22 @@ class StampService:
|
|||||||
|
|
||||||
wallet_service.sync_card_to_wallets(db, card)
|
wallet_service.sync_card_to_wallets(db, card)
|
||||||
|
|
||||||
|
# Notify customer when they've earned a reward
|
||||||
|
if reward_earned:
|
||||||
|
try:
|
||||||
|
from app.modules.loyalty.services.notification_service import (
|
||||||
|
notification_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
notification_service.send_reward_available(
|
||||||
|
db, card, program.stamps_reward_description or "Reward"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to queue reward notification for card {card.id}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
stamps_today += 1
|
stamps_today += 1
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ function adminLoyaltyMerchantDetail() {
|
|||||||
settings: null,
|
settings: null,
|
||||||
locations: [],
|
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
|
// State
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -258,6 +270,103 @@ function adminLoyaltyMerchantDetail() {
|
|||||||
formatNumber(num) {
|
formatNumber(num) {
|
||||||
if (num === null || num === undefined) return '0';
|
if (num === null || num === undefined) return '0';
|
||||||
return new Intl.NumberFormat('en-US').format(num);
|
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,
|
estimated_liability_cents: 0,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Advanced analytics
|
||||||
|
cohortData: { cohorts: [] },
|
||||||
|
churnData: { at_risk_count: 0, cards: [] },
|
||||||
|
revenueData: { monthly: [], by_store: [] },
|
||||||
|
revenueChart: null,
|
||||||
|
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
@@ -56,6 +62,7 @@ function storeLoyaltyAnalytics() {
|
|||||||
await this.loadProgram();
|
await this.loadProgram();
|
||||||
if (this.program) {
|
if (this.program) {
|
||||||
await this.loadStats();
|
await this.loadStats();
|
||||||
|
this.loadAdvancedAnalytics();
|
||||||
}
|
}
|
||||||
loyaltyAnalyticsLog.info('=== LOYALTY ANALYTICS PAGE INITIALIZATION COMPLETE ===');
|
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) {
|
formatNumber(num) {
|
||||||
if (num === null || num === undefined) return '0';
|
if (num === null || num === undefined) return '0';
|
||||||
return new Intl.NumberFormat('en-US').format(num);
|
return new Intl.NumberFormat('en-US').format(num);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ function storeLoyaltyCardDetail() {
|
|||||||
cardId: null,
|
cardId: null,
|
||||||
card: null,
|
card: null,
|
||||||
transactions: [],
|
transactions: [],
|
||||||
|
pagination: { page: 1, per_page: 20, total: 0 },
|
||||||
|
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -38,6 +39,13 @@ function storeLoyaltyCardDetail() {
|
|||||||
return;
|
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();
|
await this.loadData();
|
||||||
loyaltyCardDetailLog.info('=== LOYALTY CARD DETAIL PAGE INITIALIZATION COMPLETE ===');
|
loyaltyCardDetailLog.info('=== LOYALTY CARD DETAIL PAGE INITIALIZATION COMPLETE ===');
|
||||||
},
|
},
|
||||||
@@ -67,18 +75,49 @@ function storeLoyaltyCardDetail() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadTransactions() {
|
async loadTransactions(page = 1) {
|
||||||
try {
|
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) {
|
if (response && response.transactions) {
|
||||||
this.transactions = 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) {
|
} catch (error) {
|
||||||
loyaltyCardDetailLog.warn('Failed to load transactions:', error.message);
|
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) {
|
formatNumber(num) {
|
||||||
return num == null ? '0' : new Intl.NumberFormat('en-US').format(num);
|
return num == null ? '0' : new Intl.NumberFormat('en-US').format(num);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ function storeLoyaltyTerminal() {
|
|||||||
// Transaction inputs
|
// Transaction inputs
|
||||||
earnAmount: null,
|
earnAmount: null,
|
||||||
selectedReward: '',
|
selectedReward: '',
|
||||||
|
selectedCategories: [],
|
||||||
|
categories: [],
|
||||||
|
|
||||||
// PIN entry
|
// PIN entry
|
||||||
showPinEntry: false,
|
showPinEntry: false,
|
||||||
@@ -63,6 +65,7 @@ function storeLoyaltyTerminal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.loadData();
|
await this.loadData();
|
||||||
|
await this.loadCategories();
|
||||||
|
|
||||||
loyaltyTerminalLog.info('=== LOYALTY TERMINAL INITIALIZATION COMPLETE ===');
|
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
|
// Add stamp
|
||||||
async addStamp() {
|
async addStamp() {
|
||||||
loyaltyTerminalLog.info('Adding stamp...');
|
loyaltyTerminalLog.info('Adding stamp...');
|
||||||
|
|
||||||
await apiClient.post('/store/loyalty/stamp', {
|
await apiClient.post('/store/loyalty/stamp', {
|
||||||
card_id: this.selectedCard.card_id,
|
card_id: this.selectedCard.id,
|
||||||
staff_pin: this.pinDigits
|
staff_pin: this.pinDigits,
|
||||||
|
category_ids: this.selectedCategories.length > 0 ? this.selectedCategories : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
Utils.showToast(I18n.t('loyalty.store.terminal.stamp_added'), 'success');
|
Utils.showToast(I18n.t('loyalty.store.terminal.stamp_added'), 'success');
|
||||||
@@ -296,8 +311,8 @@ function storeLoyaltyTerminal() {
|
|||||||
loyaltyTerminalLog.info('Redeeming stamps...');
|
loyaltyTerminalLog.info('Redeeming stamps...');
|
||||||
|
|
||||||
await apiClient.post('/store/loyalty/stamp/redeem', {
|
await apiClient.post('/store/loyalty/stamp/redeem', {
|
||||||
card_id: this.selectedCard.card_id,
|
card_id: this.selectedCard.id,
|
||||||
staff_pin: this.pinDigits
|
staff_pin: this.pinDigits,
|
||||||
});
|
});
|
||||||
|
|
||||||
Utils.showToast(I18n.t('loyalty.store.terminal.stamps_redeemed'), 'success');
|
Utils.showToast(I18n.t('loyalty.store.terminal.stamps_redeemed'), 'success');
|
||||||
@@ -308,9 +323,10 @@ function storeLoyaltyTerminal() {
|
|||||||
loyaltyTerminalLog.info('Earning points...', { amount: this.earnAmount });
|
loyaltyTerminalLog.info('Earning points...', { amount: this.earnAmount });
|
||||||
|
|
||||||
const response = await apiClient.post('/store/loyalty/points/earn', {
|
const response = await apiClient.post('/store/loyalty/points/earn', {
|
||||||
card_id: this.selectedCard.card_id,
|
card_id: this.selectedCard.id,
|
||||||
purchase_amount_cents: Math.round(this.earnAmount * 100),
|
purchase_amount_cents: Math.round(this.earnAmount * 100),
|
||||||
staff_pin: this.pinDigits
|
staff_pin: this.pinDigits,
|
||||||
|
category_ids: this.selectedCategories.length > 0 ? this.selectedCategories : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pointsEarned = response.points_earned || Math.floor(this.earnAmount * (this.program?.points_per_euro || 1));
|
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 });
|
loyaltyTerminalLog.info('Redeeming reward...', { reward: reward.name });
|
||||||
|
|
||||||
await apiClient.post('/store/loyalty/points/redeem', {
|
await apiClient.post('/store/loyalty/points/redeem', {
|
||||||
card_id: this.selectedCard.card_id,
|
card_id: this.selectedCard.id,
|
||||||
reward_id: this.selectedReward,
|
reward_id: this.selectedReward,
|
||||||
staff_pin: this.pinDigits
|
staff_pin: this.pinDigits,
|
||||||
});
|
});
|
||||||
|
|
||||||
Utils.showToast(I18n.t('loyalty.store.terminal.reward_redeemed', {name: reward.name}), 'success');
|
Utils.showToast(I18n.t('loyalty.store.terminal.reward_redeemed', {name: reward.name}), 'success');
|
||||||
@@ -340,7 +356,7 @@ function storeLoyaltyTerminal() {
|
|||||||
// Refresh card data
|
// Refresh card data
|
||||||
async refreshCard() {
|
async refreshCard() {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/store/loyalty/cards/${this.selectedCard.card_id}`);
|
const response = await apiClient.get(`/store/loyalty/cards/${this.selectedCard.id}`);
|
||||||
if (response) {
|
if (response) {
|
||||||
this.selectedCard = response;
|
this.selectedCard = response;
|
||||||
}
|
}
|
||||||
@@ -353,9 +369,11 @@ function storeLoyaltyTerminal() {
|
|||||||
getTransactionLabel(tx) {
|
getTransactionLabel(tx) {
|
||||||
const type = tx.transaction_type;
|
const type = tx.transaction_type;
|
||||||
if (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) {
|
getTransactionColor(tx) {
|
||||||
|
|||||||
@@ -27,10 +27,26 @@ function customerLoyaltyEnroll() {
|
|||||||
enrolledCard: null,
|
enrolledCard: null,
|
||||||
error: null,
|
error: null,
|
||||||
showTerms: false,
|
showTerms: false,
|
||||||
|
termsHtml: null,
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
loyaltyEnrollLog.info('Customer loyalty enroll initializing...');
|
loyaltyEnrollLog.info('Customer loyalty enroll initializing...');
|
||||||
await this.loadProgram();
|
await this.loadProgram();
|
||||||
|
// Load CMS T&C content if a page slug is configured
|
||||||
|
if (this.program?.terms_cms_page_slug) {
|
||||||
|
this.loadTermsFromCms(this.program.terms_cms_page_slug);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadTermsFromCms(slug) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/storefront/cms/pages/${slug}`);
|
||||||
|
if (response?.content_html) {
|
||||||
|
this.termsHtml = response.content_html;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loyaltyEnrollLog.warn('Could not load CMS T&C page:', e.message);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadProgram() {
|
async loadProgram() {
|
||||||
@@ -81,10 +97,21 @@ function customerLoyaltyEnroll() {
|
|||||||
sessionStorage.setItem('loyalty_wallet_urls', JSON.stringify(response.wallet_urls));
|
sessionStorage.setItem('loyalty_wallet_urls', JSON.stringify(response.wallet_urls));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to success page
|
// Store enrollment context for the success page
|
||||||
|
sessionStorage.setItem('loyalty_enroll_context', JSON.stringify({
|
||||||
|
already_enrolled: response.already_enrolled || false,
|
||||||
|
allow_cross_location: response.allow_cross_location ?? true,
|
||||||
|
enrolled_at_store_name: response.enrolled_at_store_name || null,
|
||||||
|
merchant_locations: response.merchant_locations || [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Redirect to success page — pass already_enrolled in the
|
||||||
|
// URL so the message survives page refreshes (sessionStorage
|
||||||
|
// is supplementary for the location list).
|
||||||
const currentPath = window.location.pathname;
|
const currentPath = window.location.pathname;
|
||||||
|
const alreadyFlag = response.already_enrolled ? '&already=1' : '';
|
||||||
const successUrl = currentPath.replace(/\/join\/?$/, '/join/success') +
|
const successUrl = currentPath.replace(/\/join\/?$/, '/join/success') +
|
||||||
'?card=' + encodeURIComponent(cardNumber);
|
'?card=' + encodeURIComponent(cardNumber) + alreadyFlag;
|
||||||
window.location.href = successUrl;
|
window.location.href = successUrl;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
72
app/modules/loyalty/tasks/notifications.py
Normal file
72
app/modules/loyalty/tasks/notifications.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# app/modules/loyalty/tasks/notifications.py
|
||||||
|
"""
|
||||||
|
Async email notification dispatch for loyalty events.
|
||||||
|
|
||||||
|
All loyalty notification emails are sent asynchronously via this Celery
|
||||||
|
task to avoid blocking request handlers. The task opens its own DB
|
||||||
|
session and calls EmailService.send_template() which handles language
|
||||||
|
resolution, store overrides, Jinja2 rendering, and EmailLog creation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(
|
||||||
|
name="loyalty.send_notification_email",
|
||||||
|
bind=True,
|
||||||
|
max_retries=3,
|
||||||
|
default_retry_delay=60,
|
||||||
|
)
|
||||||
|
def send_notification_email(
|
||||||
|
self,
|
||||||
|
template_code: str,
|
||||||
|
to_email: str,
|
||||||
|
to_name: str | None = None,
|
||||||
|
variables: dict | None = None,
|
||||||
|
store_id: int | None = None,
|
||||||
|
customer_id: int | None = None,
|
||||||
|
language: str | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Send a loyalty notification email asynchronously.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template_code: Email template code (e.g. 'loyalty_enrollment')
|
||||||
|
to_email: Recipient email address
|
||||||
|
to_name: Recipient display name
|
||||||
|
variables: Template variables dict
|
||||||
|
store_id: Store ID for branding and template overrides
|
||||||
|
customer_id: Customer ID for language resolution
|
||||||
|
language: Explicit language override (otherwise auto-resolved)
|
||||||
|
"""
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.modules.messaging.services.email_service import EmailService
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
email_service = EmailService(db)
|
||||||
|
email_log = email_service.send_template(
|
||||||
|
template_code=template_code,
|
||||||
|
to_email=to_email,
|
||||||
|
to_name=to_name,
|
||||||
|
language=language,
|
||||||
|
variables=variables or {},
|
||||||
|
store_id=store_id,
|
||||||
|
customer_id=customer_id,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Loyalty notification sent: {template_code} to {to_email} "
|
||||||
|
f"(log_id={email_log.id if email_log else 'none'})"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
f"Loyalty notification failed: {template_code} to {to_email}: {exc}"
|
||||||
|
)
|
||||||
|
db.rollback()
|
||||||
|
raise self.retry(exc=exc)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user