Compare commits
40 Commits
f804ff8442
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fa159ff2a | |||
| 143248ff0f | |||
| 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 |
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,53 @@
|
|||||||
{
|
{
|
||||||
"title": "Shopping Cart",
|
"title": "Shopping Cart",
|
||||||
"description": "Shopping cart management for customers",
|
"description": "Shopping cart management for customers",
|
||||||
"cart": {
|
"cart": {
|
||||||
"title": "Your Cart",
|
"title": "Your Cart",
|
||||||
"empty": "Your cart is empty",
|
"empty": "Your cart is empty",
|
||||||
"empty_subtitle": "Add items to start shopping",
|
"empty_subtitle": "Add items to start shopping",
|
||||||
"continue_shopping": "Continue Shopping",
|
"continue_shopping": "Continue Shopping",
|
||||||
"proceed_to_checkout": "Proceed to Checkout"
|
"proceed_to_checkout": "Proceed to Checkout"
|
||||||
},
|
},
|
||||||
"item": {
|
"item": {
|
||||||
"product": "Product",
|
"product": "Product",
|
||||||
"quantity": "Quantity",
|
"quantity": "Quantity",
|
||||||
"price": "Price",
|
"price": "Price",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"update": "Update"
|
"update": "Update"
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"title": "Order Summary",
|
"title": "Order Summary",
|
||||||
"subtotal": "Subtotal",
|
"subtotal": "Subtotal",
|
||||||
"shipping": "Shipping",
|
"shipping": "Shipping",
|
||||||
"estimated_shipping": "Calculated at checkout",
|
"estimated_shipping": "Calculated at checkout",
|
||||||
"tax": "Tax",
|
"tax": "Tax",
|
||||||
"total": "Total"
|
"total": "Total"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"invalid_quantity": "Invalid quantity",
|
"invalid_quantity": "Invalid quantity",
|
||||||
"min_quantity": "Minimum quantity is {min}",
|
"min_quantity": "Minimum quantity is {min}",
|
||||||
"max_quantity": "Maximum quantity is {max}",
|
"max_quantity": "Maximum quantity is {max}",
|
||||||
"insufficient_inventory": "Only {available} available"
|
"insufficient_inventory": "Only {available} available"
|
||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"view": "View Carts",
|
"view": "View Carts",
|
||||||
"view_desc": "View customer shopping carts",
|
"view_desc": "View customer shopping carts",
|
||||||
"manage": "Manage Carts",
|
"manage": "Manage Carts",
|
||||||
"manage_desc": "Modify and manage customer carts"
|
"manage_desc": "Modify and manage customer carts"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"item_added": "Item added to cart",
|
"item_added": "Item added to cart",
|
||||||
"item_updated": "Cart updated",
|
"item_updated": "Cart updated",
|
||||||
"item_removed": "Item removed from cart",
|
"item_removed": "Item removed from cart",
|
||||||
"cart_cleared": "Cart cleared",
|
"cart_cleared": "Cart cleared",
|
||||||
"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>
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
64
app/modules/loyalty/docs/monitoring.md
Normal file
64
app/modules/loyalty/docs/monitoring.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Loyalty Module — Monitoring & Alerting
|
||||||
|
|
||||||
|
## Alert Definitions
|
||||||
|
|
||||||
|
### P0 — Page (immediate action required)
|
||||||
|
|
||||||
|
| Alert | Condition | Action |
|
||||||
|
|-------|-----------|--------|
|
||||||
|
| **Expiration task stale** | `loyalty.expire_points` last success > 26 hours ago | Check Celery worker health, inspect task logs |
|
||||||
|
| **Google Wallet service down** | Wallet sync failure rate > 50% for 2 consecutive runs | Check service account credentials, Google API status |
|
||||||
|
|
||||||
|
### P1 — Warn (investigate within business hours)
|
||||||
|
|
||||||
|
| Alert | Condition | Action |
|
||||||
|
|-------|-----------|--------|
|
||||||
|
| **Wallet sync failures** | `failed_card_ids` count > 5% of total cards synced | Check runbook-wallet-sync.md, inspect failed card IDs |
|
||||||
|
| **Email notification failures** | `loyalty_*` template send failure rate > 1% in 24h | Check SMTP config, EmailLog for errors |
|
||||||
|
| **Rate limit spikes** | 429 responses > 100/min per store | Investigate if legitimate traffic or abuse |
|
||||||
|
|
||||||
|
### P2 — Info (review in next sprint)
|
||||||
|
|
||||||
|
| Alert | Condition | Action |
|
||||||
|
|-------|-----------|--------|
|
||||||
|
| **High churn** | At-risk cards > 20% of active cards | Review re-engagement strategy (future marketing module) |
|
||||||
|
| **Low enrollment** | < 5 new cards in 7 days (per merchant with active program) | Check enrollment page accessibility, QR code placement |
|
||||||
|
|
||||||
|
## Key Metrics to Track
|
||||||
|
|
||||||
|
### Operational
|
||||||
|
|
||||||
|
- Celery task success/failure counts for `loyalty.expire_points` and `loyalty.sync_wallet_passes`
|
||||||
|
- EmailLog status distribution for `loyalty_*` template codes (sent/failed/bounced)
|
||||||
|
- Rate limiter 429 response count per store per hour
|
||||||
|
|
||||||
|
### Business
|
||||||
|
|
||||||
|
- Daily new enrollments (total + per merchant)
|
||||||
|
- Points issued vs redeemed ratio (health indicator: should be > 0.3 redemption rate)
|
||||||
|
- Stamp completion rate (% of cards reaching stamps_target)
|
||||||
|
- Cohort retention at month 3 (target: > 40%)
|
||||||
|
|
||||||
|
## Observability Integration
|
||||||
|
|
||||||
|
The loyalty module logs to the standard Python logger (`app.modules.loyalty.*`). Key log events:
|
||||||
|
|
||||||
|
| Logger | Level | Event |
|
||||||
|
|--------|-------|-------|
|
||||||
|
| `card_service` | INFO | Enrollment, deactivation, GDPR anonymization |
|
||||||
|
| `stamp_service` | INFO | Stamp add/redeem/void with card and store context |
|
||||||
|
| `points_service` | INFO | Points earn/redeem/void/adjust |
|
||||||
|
| `notification_service` | INFO | Email queued (template_code + recipient) |
|
||||||
|
| `point_expiration` | INFO | Chunk processed (cards + points count) |
|
||||||
|
| `wallet_sync` | WARNING | Per-card sync failure with retry count |
|
||||||
|
| `wallet_sync` | ERROR | Card sync exhausted all retries |
|
||||||
|
|
||||||
|
## Dashboard Suggestions
|
||||||
|
|
||||||
|
If using Grafana or similar:
|
||||||
|
|
||||||
|
1. **Enrollment funnel**: Page views → Form starts → Submissions → Success (track drop-off)
|
||||||
|
2. **Transaction volume**: Stamps + Points per hour, grouped by store
|
||||||
|
3. **Wallet adoption**: % of cards with Google/Apple Wallet passes
|
||||||
|
4. **Email delivery**: Sent → Delivered → Opened → Clicked per template
|
||||||
|
5. **Task health**: Celery task execution time + success rate over 24h
|
||||||
@@ -100,7 +100,7 @@ All 8 decisions locked. No external blockers.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 2 — Notifications Infrastructure *(4d)*
|
### Phase 2A — Notifications Infrastructure *(✅ DONE 2026-04-11)*
|
||||||
|
|
||||||
#### 2.1 `LoyaltyNotificationService`
|
#### 2.1 `LoyaltyNotificationService`
|
||||||
- New `app/modules/loyalty/services/notification_service.py` with methods:
|
- New `app/modules/loyalty/services/notification_service.py` with methods:
|
||||||
@@ -144,7 +144,7 @@ All 8 decisions locked. No external blockers.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 3 — Task Reliability *(1.5d)*
|
### Phase 3 — Task Reliability *(✅ DONE 2026-04-11)*
|
||||||
|
|
||||||
#### 3.1 Batched point expiration
|
#### 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:
|
||||||
@@ -163,7 +163,7 @@ All 8 decisions locked. No external blockers.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 4 — Accessibility & T&C *(2d)*
|
### Phase 4 — Accessibility & T&C *(✅ DONE 2026-04-11)*
|
||||||
|
|
||||||
#### 4.1 T&C via store CMS integration
|
#### 4.1 T&C via store CMS integration
|
||||||
- Migration `loyalty_007`: add `terms_cms_page_slug: str | None` to `loyalty_programs`.
|
- Migration `loyalty_007`: add `terms_cms_page_slug: str | None` to `loyalty_programs`.
|
||||||
@@ -182,7 +182,7 @@ All 8 decisions locked. No external blockers.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 5 — Google Wallet Production Hardening *(1d)*
|
### Phase 5 — Google Wallet Production Hardening *(✅ UI done 2026-04-11, deploy is manual)*
|
||||||
|
|
||||||
#### 5.1 Cert deployment
|
#### 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.
|
||||||
@@ -199,7 +199,7 @@ All 8 decisions locked. No external blockers.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 6 — Admin UX, GDPR, Bulk *(3d)*
|
### Phase 6 — Admin UX, GDPR, Bulk *(✅ DONE 2026-04-11)*
|
||||||
|
|
||||||
#### 6.1 Admin trash UI
|
#### 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.
|
||||||
@@ -236,7 +236,7 @@ All 8 decisions locked. No external blockers.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 7 — Advanced Analytics *(2.5d)*
|
### Phase 7 — Advanced Analytics *(✅ DONE 2026-04-11)*
|
||||||
|
|
||||||
#### 7.1 Cohort retention
|
#### 7.1 Cohort retention
|
||||||
- New `services/analytics_service.py` (or extend `program_service`).
|
- New `services/analytics_service.py` (or extend `program_service`).
|
||||||
@@ -255,7 +255,7 @@ All 8 decisions locked. No external blockers.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 8 — Tests, Docs, Observability *(2d)*
|
### Phase 8 — Tests, Docs, Observability *(✅ DONE 2026-04-11)*
|
||||||
|
|
||||||
#### 8.1 Coverage enforcement
|
#### 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`.
|
||||||
@@ -294,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.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -580,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),
|
||||||
@@ -588,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)
|
||||||
@@ -596,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)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -626,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,
|
||||||
@@ -716,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,
|
||||||
@@ -810,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)"}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -624,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)"
|
||||||
@@ -790,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.
|
||||||
@@ -819,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:
|
||||||
@@ -826,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() {
|
||||||
|
|||||||
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()
|
||||||
@@ -6,12 +6,16 @@ Handles expiring points that are older than the configured
|
|||||||
expiration period based on card inactivity.
|
expiration period based on card inactivity.
|
||||||
|
|
||||||
Runs daily at 02:00 via the scheduled task configuration in definition.py.
|
Runs daily at 02:00 via the scheduled task configuration in definition.py.
|
||||||
|
|
||||||
|
Processing is chunked (LIMIT 500 + FOR UPDATE SKIP LOCKED) to avoid
|
||||||
|
holding long-running row locks on the loyalty_cards table.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import SessionLocal
|
from app.core.database import SessionLocal
|
||||||
@@ -20,6 +24,8 @@ from app.modules.loyalty.models.loyalty_transaction import TransactionType
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CHUNK_SIZE = 500
|
||||||
|
|
||||||
|
|
||||||
@shared_task(name="loyalty.expire_points")
|
@shared_task(name="loyalty.expire_points")
|
||||||
def expire_points() -> dict:
|
def expire_points() -> dict:
|
||||||
@@ -27,10 +33,9 @@ def expire_points() -> dict:
|
|||||||
Expire points that are past their expiration date based on card inactivity.
|
Expire points that are past their expiration date based on card inactivity.
|
||||||
|
|
||||||
For each program with points_expiration_days configured:
|
For each program with points_expiration_days configured:
|
||||||
1. Find cards that haven't had activity in the expiration period
|
1. Send 14-day warning emails to cards approaching expiry
|
||||||
2. Expire all points on those cards
|
2. Expire points in chunks of 500, committing after each chunk
|
||||||
3. Create POINTS_EXPIRED transaction records
|
3. Send expired notifications
|
||||||
4. Update card balances
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Summary of expired points
|
Summary of expired points
|
||||||
@@ -40,10 +45,10 @@ def expire_points() -> dict:
|
|||||||
db: Session = SessionLocal()
|
db: Session = SessionLocal()
|
||||||
try:
|
try:
|
||||||
result = _process_point_expiration(db)
|
result = _process_point_expiration(db)
|
||||||
db.commit()
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Point expiration complete: {result['cards_processed']} cards, "
|
f"Point expiration complete: {result['cards_processed']} cards, "
|
||||||
f"{result['points_expired']} points expired"
|
f"{result['points_expired']} points expired, "
|
||||||
|
f"{result['warnings_sent']} warnings sent"
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -54,30 +59,23 @@ def expire_points() -> dict:
|
|||||||
"error": str(e),
|
"error": str(e),
|
||||||
"cards_processed": 0,
|
"cards_processed": 0,
|
||||||
"points_expired": 0,
|
"points_expired": 0,
|
||||||
|
"warnings_sent": 0,
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def _process_point_expiration(db: Session) -> dict:
|
def _process_point_expiration(db: Session) -> dict:
|
||||||
"""
|
"""Process point expiration for all programs."""
|
||||||
Process point expiration for all programs.
|
total_cards = 0
|
||||||
|
total_points = 0
|
||||||
Args:
|
total_warnings = 0
|
||||||
db: Database session
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Summary of expired points
|
|
||||||
"""
|
|
||||||
total_cards_processed = 0
|
|
||||||
total_points_expired = 0
|
|
||||||
programs_processed = 0
|
programs_processed = 0
|
||||||
|
|
||||||
# Find all active programs with point expiration configured
|
|
||||||
programs = (
|
programs = (
|
||||||
db.query(LoyaltyProgram)
|
db.query(LoyaltyProgram)
|
||||||
.filter(
|
.filter(
|
||||||
LoyaltyProgram.is_active == True,
|
LoyaltyProgram.is_active == True, # noqa: E712
|
||||||
LoyaltyProgram.points_expiration_days.isnot(None),
|
LoyaltyProgram.points_expiration_days.isnot(None),
|
||||||
LoyaltyProgram.points_expiration_days > 0,
|
LoyaltyProgram.points_expiration_days > 0,
|
||||||
)
|
)
|
||||||
@@ -87,104 +85,238 @@ def _process_point_expiration(db: Session) -> dict:
|
|||||||
logger.info(f"Found {len(programs)} programs with point expiration configured")
|
logger.info(f"Found {len(programs)} programs with point expiration configured")
|
||||||
|
|
||||||
for program in programs:
|
for program in programs:
|
||||||
cards_count, points_count = _expire_points_for_program(db, program)
|
cards, points, warnings = _process_program(db, program)
|
||||||
total_cards_processed += cards_count
|
total_cards += cards
|
||||||
total_points_expired += points_count
|
total_points += points
|
||||||
|
total_warnings += warnings
|
||||||
programs_processed += 1
|
programs_processed += 1
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f"Program {program.id} (merchant {program.merchant_id}): "
|
|
||||||
f"{cards_count} cards, {points_count} points expired"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"programs_processed": programs_processed,
|
"programs_processed": programs_processed,
|
||||||
"cards_processed": total_cards_processed,
|
"cards_processed": total_cards,
|
||||||
"points_expired": total_points_expired,
|
"points_expired": total_points,
|
||||||
|
"warnings_sent": total_warnings,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _expire_points_for_program(db: Session, program: LoyaltyProgram) -> tuple[int, int]:
|
def _process_program(
|
||||||
"""
|
db: Session, program: LoyaltyProgram
|
||||||
Expire points for a specific loyalty program.
|
) -> tuple[int, int, int]:
|
||||||
|
"""Process warnings + expiration for a single program.
|
||||||
|
|
||||||
Args:
|
Returns (cards_expired, points_expired, warnings_sent).
|
||||||
db: Database session
|
|
||||||
program: Loyalty program to process
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (cards_processed, points_expired)
|
|
||||||
"""
|
"""
|
||||||
if not program.points_expiration_days:
|
if not program.points_expiration_days:
|
||||||
return 0, 0
|
return 0, 0, 0
|
||||||
|
|
||||||
# Calculate expiration threshold
|
now = datetime.now(UTC)
|
||||||
expiration_threshold = datetime.now(UTC) - timedelta(days=program.points_expiration_days)
|
expiration_threshold = now - timedelta(days=program.points_expiration_days)
|
||||||
|
|
||||||
logger.debug(
|
# --- Phase 1: Send 14-day warning emails (chunked) ---
|
||||||
f"Processing program {program.id}: expiration after {program.points_expiration_days} days "
|
warning_days = 14
|
||||||
f"(threshold: {expiration_threshold})"
|
warning_threshold = now - timedelta(
|
||||||
|
days=program.points_expiration_days - warning_days
|
||||||
|
)
|
||||||
|
warnings_sent = _send_expiration_warnings_chunked(
|
||||||
|
db, program, warning_threshold, expiration_threshold, warning_days, now
|
||||||
)
|
)
|
||||||
|
|
||||||
# Find cards with:
|
# --- Phase 2: Expire points (chunked) ---
|
||||||
# - Points balance > 0
|
cards_expired, points_expired = _expire_points_chunked(
|
||||||
# - Last activity before expiration threshold
|
db, program, expiration_threshold, now
|
||||||
# - Belonging to this program's merchant
|
|
||||||
cards_to_expire = (
|
|
||||||
db.query(LoyaltyCard)
|
|
||||||
.filter(
|
|
||||||
LoyaltyCard.merchant_id == program.merchant_id,
|
|
||||||
LoyaltyCard.points_balance > 0,
|
|
||||||
LoyaltyCard.last_activity_at < expiration_threshold,
|
|
||||||
LoyaltyCard.is_active == True,
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not cards_to_expire:
|
return cards_expired, points_expired, warnings_sent
|
||||||
logger.debug(f"No cards to expire for program {program.id}")
|
|
||||||
return 0, 0
|
|
||||||
|
|
||||||
logger.info(f"Found {len(cards_to_expire)} cards to expire for program {program.id}")
|
|
||||||
|
|
||||||
cards_processed = 0
|
# =========================================================================
|
||||||
points_expired = 0
|
# Chunked expiration
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
for card in cards_to_expire:
|
|
||||||
if card.points_balance <= 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
expired_points = card.points_balance
|
def _expire_points_chunked(
|
||||||
|
db: Session,
|
||||||
|
program: LoyaltyProgram,
|
||||||
|
expiration_threshold: datetime,
|
||||||
|
now: datetime,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""Expire points in chunks to avoid long-held row locks.
|
||||||
|
|
||||||
# Create expiration transaction
|
Each chunk:
|
||||||
transaction = LoyaltyTransaction(
|
1. SELECT ... LIMIT 500 FOR UPDATE SKIP LOCKED
|
||||||
card_id=card.id,
|
2. Create POINTS_EXPIRED transactions
|
||||||
merchant_id=program.merchant_id,
|
3. Update card balances
|
||||||
store_id=None, # System action, no store
|
4. Commit (releases locks for this chunk)
|
||||||
transaction_type=TransactionType.POINTS_EXPIRED.value,
|
|
||||||
points_delta=-expired_points,
|
|
||||||
points_balance_after=0,
|
|
||||||
stamps_delta=0,
|
|
||||||
stamps_balance_after=card.stamp_count,
|
|
||||||
notes=f"Points expired after {program.points_expiration_days} days of inactivity",
|
|
||||||
transaction_at=datetime.now(UTC),
|
|
||||||
)
|
|
||||||
db.add(transaction) # noqa: PERF006
|
|
||||||
|
|
||||||
# Update card balance and voided tracking
|
Returns (total_cards, total_points).
|
||||||
card.expire_points(expired_points)
|
"""
|
||||||
# Note: We don't update last_activity_at for expiration
|
total_cards = 0
|
||||||
|
total_points = 0
|
||||||
|
|
||||||
cards_processed += 1
|
while True:
|
||||||
points_expired += expired_points
|
# Fetch next chunk with row-level locks; SKIP LOCKED means
|
||||||
|
# concurrent workers won't block on the same rows.
|
||||||
logger.debug(
|
card_ids_and_balances = (
|
||||||
f"Expired {expired_points} points from card {card.id} "
|
db.query(LoyaltyCard.id, LoyaltyCard.points_balance, LoyaltyCard.stamp_count)
|
||||||
f"(last activity: {card.last_activity_at})"
|
.filter(
|
||||||
|
LoyaltyCard.merchant_id == program.merchant_id,
|
||||||
|
LoyaltyCard.points_balance > 0,
|
||||||
|
LoyaltyCard.last_activity_at < expiration_threshold,
|
||||||
|
LoyaltyCard.is_active == True, # noqa: E712
|
||||||
|
)
|
||||||
|
.limit(CHUNK_SIZE)
|
||||||
|
.with_for_update(skip_locked=True)
|
||||||
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
return cards_processed, points_expired
|
if not card_ids_and_balances:
|
||||||
|
break
|
||||||
|
|
||||||
|
chunk_cards = 0
|
||||||
|
chunk_points = 0
|
||||||
|
|
||||||
|
for card_id, balance, stamp_count in card_ids_and_balances:
|
||||||
|
if balance <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create expiration transaction
|
||||||
|
db.add(
|
||||||
|
LoyaltyTransaction(
|
||||||
|
card_id=card_id,
|
||||||
|
merchant_id=program.merchant_id,
|
||||||
|
store_id=None,
|
||||||
|
transaction_type=TransactionType.POINTS_EXPIRED.value,
|
||||||
|
points_delta=-balance,
|
||||||
|
points_balance_after=0,
|
||||||
|
stamps_delta=0,
|
||||||
|
stamps_balance_after=stamp_count,
|
||||||
|
notes=(
|
||||||
|
f"Points expired after {program.points_expiration_days} "
|
||||||
|
f"days of inactivity"
|
||||||
|
),
|
||||||
|
transaction_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bulk-update the card in the same transaction
|
||||||
|
db.query(LoyaltyCard).filter(LoyaltyCard.id == card_id).update(
|
||||||
|
{
|
||||||
|
LoyaltyCard.points_balance: 0,
|
||||||
|
LoyaltyCard.total_points_voided: (
|
||||||
|
LoyaltyCard.total_points_voided + balance
|
||||||
|
),
|
||||||
|
},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
chunk_cards += 1
|
||||||
|
chunk_points += balance
|
||||||
|
|
||||||
|
# Commit this chunk — releases row locks
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Send notifications AFTER commit (outside the lock window)
|
||||||
|
for card_id, balance, _stamp_count in card_ids_and_balances:
|
||||||
|
if balance <= 0:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
card = db.query(LoyaltyCard).get(card_id)
|
||||||
|
if card:
|
||||||
|
from app.modules.loyalty.services.notification_service import (
|
||||||
|
notification_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
notification_service.send_points_expired(db, card, balance)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to queue expiration notification for card {card_id}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
total_cards += chunk_cards
|
||||||
|
total_points += chunk_points
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Program {program.id}: expired chunk of {chunk_cards} cards "
|
||||||
|
f"({chunk_points} pts), total so far: {total_cards} cards"
|
||||||
|
)
|
||||||
|
|
||||||
|
return total_cards, total_points
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Chunked expiration warnings
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _send_expiration_warnings_chunked(
|
||||||
|
db: Session,
|
||||||
|
program: LoyaltyProgram,
|
||||||
|
warning_threshold: datetime,
|
||||||
|
expiration_threshold: datetime,
|
||||||
|
warning_days: int,
|
||||||
|
now: datetime,
|
||||||
|
) -> int:
|
||||||
|
"""Send expiration warning emails in chunks.
|
||||||
|
|
||||||
|
Only sends one warning per expiration cycle (tracked via
|
||||||
|
last_expiration_warning_at on the card).
|
||||||
|
"""
|
||||||
|
total_warnings = 0
|
||||||
|
expiration_date = (now + timedelta(days=warning_days)).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
cards = (
|
||||||
|
db.query(LoyaltyCard)
|
||||||
|
.filter(
|
||||||
|
LoyaltyCard.merchant_id == program.merchant_id,
|
||||||
|
LoyaltyCard.points_balance > 0,
|
||||||
|
LoyaltyCard.is_active == True, # noqa: E712
|
||||||
|
LoyaltyCard.last_activity_at < warning_threshold,
|
||||||
|
LoyaltyCard.last_activity_at >= expiration_threshold,
|
||||||
|
or_(
|
||||||
|
LoyaltyCard.last_expiration_warning_at.is_(None),
|
||||||
|
LoyaltyCard.last_expiration_warning_at < warning_threshold,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(CHUNK_SIZE)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not cards:
|
||||||
|
break
|
||||||
|
|
||||||
|
chunk_warnings = 0
|
||||||
|
for card in cards:
|
||||||
|
try:
|
||||||
|
from app.modules.loyalty.services.notification_service import (
|
||||||
|
notification_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
notification_service.send_points_expiration_warning(
|
||||||
|
db,
|
||||||
|
card,
|
||||||
|
expiring_points=card.points_balance,
|
||||||
|
days_remaining=warning_days,
|
||||||
|
expiration_date=expiration_date,
|
||||||
|
)
|
||||||
|
card.last_expiration_warning_at = now
|
||||||
|
chunk_warnings += 1
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to queue expiration warning for card {card.id}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
total_warnings += chunk_warnings
|
||||||
|
|
||||||
|
if total_warnings:
|
||||||
|
logger.info(
|
||||||
|
f"Sent {total_warnings} expiration warnings for program {program.id}"
|
||||||
|
)
|
||||||
|
return total_warnings
|
||||||
|
|
||||||
|
|
||||||
# Allow running directly for testing
|
# Allow running directly for testing
|
||||||
|
|||||||
@@ -4,14 +4,22 @@ Wallet synchronization task.
|
|||||||
|
|
||||||
Handles syncing loyalty card data to Google Wallet and Apple Wallet
|
Handles syncing loyalty card data to Google Wallet and Apple Wallet
|
||||||
for cards that may have missed real-time updates.
|
for cards that may have missed real-time updates.
|
||||||
|
|
||||||
|
Uses exponential backoff (1s, 4s, 16s) per card to handle transient
|
||||||
|
API failures without blocking the entire batch.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Exponential backoff delays in seconds: 1s, 4s, 16s
|
||||||
|
_RETRY_DELAYS = [1, 4, 16]
|
||||||
|
_MAX_ATTEMPTS = len(_RETRY_DELAYS) + 1 # 4 total attempts
|
||||||
|
|
||||||
|
|
||||||
@shared_task(name="loyalty.sync_wallet_passes")
|
@shared_task(name="loyalty.sync_wallet_passes")
|
||||||
def sync_wallet_passes() -> dict:
|
def sync_wallet_passes() -> dict:
|
||||||
@@ -35,7 +43,6 @@ def sync_wallet_passes() -> dict:
|
|||||||
# Find cards with transactions in the last hour that have wallet IDs
|
# Find cards with transactions in the last hour that have wallet IDs
|
||||||
one_hour_ago = datetime.now(UTC) - timedelta(hours=1)
|
one_hour_ago = datetime.now(UTC) - timedelta(hours=1)
|
||||||
|
|
||||||
# Get card IDs with recent transactions
|
|
||||||
recent_tx_card_ids = (
|
recent_tx_card_ids = (
|
||||||
db.query(LoyaltyTransaction.card_id)
|
db.query(LoyaltyTransaction.card_id)
|
||||||
.filter(LoyaltyTransaction.transaction_at >= one_hour_ago)
|
.filter(LoyaltyTransaction.transaction_at >= one_hour_ago)
|
||||||
@@ -51,9 +58,9 @@ def sync_wallet_passes() -> dict:
|
|||||||
"cards_checked": 0,
|
"cards_checked": 0,
|
||||||
"google_synced": 0,
|
"google_synced": 0,
|
||||||
"apple_synced": 0,
|
"apple_synced": 0,
|
||||||
|
"failed_card_ids": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get cards with wallet integrations
|
|
||||||
cards = (
|
cards = (
|
||||||
db.query(LoyaltyCard)
|
db.query(LoyaltyCard)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -69,31 +76,21 @@ def sync_wallet_passes() -> dict:
|
|||||||
failed_card_ids = []
|
failed_card_ids = []
|
||||||
|
|
||||||
for card in cards:
|
for card in cards:
|
||||||
synced = False
|
success, google, apple = _sync_card_with_backoff(
|
||||||
for attempt in range(2): # 1 retry
|
wallet_service, db, card
|
||||||
try:
|
)
|
||||||
results = wallet_service.sync_card_to_wallets(db, card)
|
if success:
|
||||||
if results.get("google_wallet"):
|
google_synced += google
|
||||||
google_synced += 1
|
apple_synced += apple
|
||||||
if results.get("apple_wallet"):
|
else:
|
||||||
apple_synced += 1
|
|
||||||
synced = True
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
if attempt == 0:
|
|
||||||
logger.warning(
|
|
||||||
f"Failed to sync card {card.id} (attempt 1/2), "
|
|
||||||
f"retrying in 2s: {e}"
|
|
||||||
)
|
|
||||||
import time
|
|
||||||
time.sleep(2)
|
|
||||||
else:
|
|
||||||
logger.error(
|
|
||||||
f"Failed to sync card {card.id} after 2 attempts: {e}"
|
|
||||||
)
|
|
||||||
if not synced:
|
|
||||||
failed_card_ids.append(card.id)
|
failed_card_ids.append(card.id)
|
||||||
|
|
||||||
|
if failed_card_ids:
|
||||||
|
logger.error(
|
||||||
|
f"Wallet sync: {len(failed_card_ids)} cards failed after "
|
||||||
|
f"{_MAX_ATTEMPTS} attempts each: {failed_card_ids}"
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Wallet sync complete: {len(cards)} cards checked, "
|
f"Wallet sync complete: {len(cards)} cards checked, "
|
||||||
f"{google_synced} Google, {apple_synced} Apple, "
|
f"{google_synced} Google, {apple_synced} Apple, "
|
||||||
@@ -113,6 +110,37 @@ def sync_wallet_passes() -> dict:
|
|||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error": str(e),
|
"error": str(e),
|
||||||
|
"failed_card_ids": [],
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_card_with_backoff(wallet_service, db, card) -> tuple[bool, int, int]:
|
||||||
|
"""Sync a single card with exponential backoff.
|
||||||
|
|
||||||
|
Returns (success, google_count, apple_count).
|
||||||
|
"""
|
||||||
|
last_error = None
|
||||||
|
|
||||||
|
for attempt in range(_MAX_ATTEMPTS):
|
||||||
|
try:
|
||||||
|
results = wallet_service.sync_card_to_wallets(db, card)
|
||||||
|
google = 1 if results.get("google_wallet") else 0
|
||||||
|
apple = 1 if results.get("apple_wallet") else 0
|
||||||
|
return True, google, apple
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
if attempt < len(_RETRY_DELAYS):
|
||||||
|
delay = _RETRY_DELAYS[attempt]
|
||||||
|
logger.warning(
|
||||||
|
f"Card {card.id} sync failed (attempt {attempt + 1}/"
|
||||||
|
f"{_MAX_ATTEMPTS}), retrying in {delay}s: {e}"
|
||||||
|
)
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
f"Card {card.id} sync failed after {_MAX_ATTEMPTS} attempts: "
|
||||||
|
f"{last_error}"
|
||||||
|
)
|
||||||
|
return False, 0, 0
|
||||||
|
|||||||
@@ -166,7 +166,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<a href="/admin/merchants"
|
<a href="/admin/merchants"
|
||||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-blue-600 bg-blue-100 rounded-lg hover:bg-blue-200 dark:text-blue-300 dark:bg-blue-900/30 dark:hover:bg-blue-900/50">
|
class="inline-flex items-center px-4 py-2 text-sm font-medium text-blue-600 bg-blue-100 rounded-lg hover:bg-blue-200 dark:text-blue-300 dark:bg-blue-900/30 dark:hover:bg-blue-900/50">
|
||||||
<span x-html="$icon('building-office', 'w-4 h-4 mr-2')"></span>
|
<span x-html="$icon('office-building', 'w-4 h-4 mr-2')"></span>
|
||||||
{{ _('loyalty.admin.analytics.manage_merchants') }}
|
{{ _('loyalty.admin.analytics.manage_merchants') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<a
|
<a
|
||||||
:href="`/admin/merchants/${merchant?.id}?back=/admin/loyalty/merchants/${merchantId}`"
|
:href="`/admin/merchants/${merchant?.id}?back=/admin/loyalty/merchants/${merchantId}`"
|
||||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-gray-100 border border-gray-300 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600">
|
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-gray-100 border border-gray-300 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600">
|
||||||
<span x-html="$icon('building-office', 'w-4 h-4 mr-2')"></span>
|
<span x-html="$icon('office-building', 'w-4 h-4 mr-2')"></span>
|
||||||
{{ _('loyalty.admin.merchant_detail.view_merchant') }}
|
{{ _('loyalty.admin.merchant_detail.view_merchant') }}
|
||||||
</a>
|
</a>
|
||||||
<a x-show="program"
|
<a x-show="program"
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
<!-- Delete Program Confirmation Modal -->
|
||||||
{{ confirm_modal(
|
{{ confirm_modal(
|
||||||
'deleteProgramModal',
|
'deleteProgramModal',
|
||||||
_('loyalty.admin.merchant_detail.delete_title'),
|
_('loyalty.admin.merchant_detail.delete_title'),
|
||||||
@@ -164,6 +164,18 @@
|
|||||||
'danger'
|
'danger'
|
||||||
) }}
|
) }}
|
||||||
|
|
||||||
|
<!-- Delete Category Confirmation Modal -->
|
||||||
|
{{ confirm_modal(
|
||||||
|
'deleteCategoryModal',
|
||||||
|
_('loyalty.common.delete'),
|
||||||
|
_('loyalty.admin.merchant_detail.delete_category_message'),
|
||||||
|
'confirmDeleteCategory()',
|
||||||
|
'showDeleteCategoryModal',
|
||||||
|
_('loyalty.common.delete'),
|
||||||
|
_('loyalty.common.cancel'),
|
||||||
|
'danger'
|
||||||
|
) }}
|
||||||
|
|
||||||
<!-- Location Breakdown -->
|
<!-- Location Breakdown -->
|
||||||
<div x-show="locations.length > 0" class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
<div x-show="locations.length > 0" class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||||
@@ -201,6 +213,175 @@
|
|||||||
{% endcall %}
|
{% endcall %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Transaction Categories (per store) -->
|
||||||
|
<div x-show="locations.length > 0" class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||||
|
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
<span x-html="$icon('tag', 'inline w-5 h-5 mr-2')"></span>
|
||||||
|
{{ _('loyalty.admin.merchant_detail.transaction_categories') }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Store selector -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<select x-model="selectedCategoryStoreId" @change="loadCategoriesForStore()"
|
||||||
|
class="w-full md:w-auto px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
<option value="">{{ _('loyalty.admin.merchant_detail.select_store') }}</option>
|
||||||
|
<template x-for="loc in locations" :key="loc.store_id">
|
||||||
|
<option :value="loc.store_id" x-text="loc.store_name"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Categories list -->
|
||||||
|
<div x-show="selectedCategoryStoreId">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="storeCategories.length + ' categories'"></p>
|
||||||
|
<button @click="showAddCategory = true" type="button"
|
||||||
|
class="px-3 py-1.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
||||||
|
<span x-html="$icon('plus', 'inline w-4 h-4 mr-1')"></span>
|
||||||
|
{{ _('loyalty.common.add') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add category inline form -->
|
||||||
|
<div x-show="showAddCategory" class="mb-4 p-4 border border-purple-200 dark:border-purple-800 rounded-lg bg-purple-50 dark:bg-purple-900/20">
|
||||||
|
<div class="grid gap-3 md:grid-cols-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">English (EN) <span class="text-red-500">*</span></label>
|
||||||
|
<input type="text" x-model="newCategoryName" maxlength="100" placeholder="e.g. Men"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">French (FR) <span class="text-red-500">*</span></label>
|
||||||
|
<input type="text" x-model="newCategoryTranslations.fr" maxlength="100" placeholder="e.g. Hommes"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">German (DE) <span class="text-red-500">*</span></label>
|
||||||
|
<input type="text" x-model="newCategoryTranslations.de" maxlength="100" placeholder="e.g. Herren"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Luxembourgish (LB) <span class="text-red-500">*</span></label>
|
||||||
|
<input type="text" x-model="newCategoryTranslations.lb" maxlength="100" placeholder="e.g. Hären"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button @click="showAddCategory = false; newCategoryName = ''; newCategoryTranslations = {fr:'',de:'',lb:''}" type="button"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600">
|
||||||
|
{{ _('loyalty.common.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button @click="createCategory()" :disabled="!newCategoryName || !newCategoryTranslations.fr || !newCategoryTranslations.de || !newCategoryTranslations.lb"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||||
|
{{ _('loyalty.common.save') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Categories table -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<template x-for="cat in storeCategories" :key="cat.id">
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
|
<!-- List mode -->
|
||||||
|
<div x-show="viewingCategoryId !== cat.id && editingCategoryId !== cat.id" class="flex items-center justify-between p-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="cat.name"></span>
|
||||||
|
<span x-show="!cat.is_active" class="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-500 dark:bg-gray-700">{{ _('loyalty.common.inactive') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button @click="viewingCategoryId = (viewingCategoryId === cat.id ? null : cat.id)" type="button"
|
||||||
|
aria-label="{{ _('loyalty.common.view') }}"
|
||||||
|
class="text-blue-500 hover:text-blue-700">
|
||||||
|
<span x-html="$icon('eye', 'w-4 h-4')"></span>
|
||||||
|
</button>
|
||||||
|
<button @click="toggleCategoryActive(cat)" type="button"
|
||||||
|
:aria-label="cat.is_active ? '{{ _('loyalty.common.deactivate') }}' : '{{ _('loyalty.common.activate') }}'"
|
||||||
|
class="text-sm" :class="cat.is_active ? 'text-orange-500 hover:text-orange-700' : 'text-green-500 hover:text-green-700'">
|
||||||
|
<span x-html="$icon(cat.is_active ? 'ban' : 'play', 'w-4 h-4')"></span>
|
||||||
|
</button>
|
||||||
|
<button @click="categoryToDelete = cat.id; showDeleteCategoryModal = true" type="button"
|
||||||
|
aria-label="{{ _('loyalty.common.delete') }}"
|
||||||
|
class="text-red-500 hover:text-red-700">
|
||||||
|
<span x-html="$icon('trash', 'w-4 h-4')"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- View mode (read-only) -->
|
||||||
|
<div x-show="viewingCategoryId === cat.id && editingCategoryId !== cat.id" class="p-3 bg-gray-50 dark:bg-gray-900/20">
|
||||||
|
<div class="grid gap-2 md:grid-cols-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">English (EN) <span class="text-red-500">*</span></p>
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="cat.name || '-'"></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">French (FR) <span class="text-red-500">*</span></p>
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="cat.name_translations?.fr || '-'"></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">German (DE) <span class="text-red-500">*</span></p>
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="cat.name_translations?.de || '-'"></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Luxembourgish (LB) <span class="text-red-500">*</span></p>
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="cat.name_translations?.lb || '-'"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button @click="viewingCategoryId = null" type="button"
|
||||||
|
class="px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600">
|
||||||
|
{{ _('loyalty.common.close') }}
|
||||||
|
</button>
|
||||||
|
<button @click="viewingCategoryId = null; startEditCategory(cat)" type="button"
|
||||||
|
class="px-3 py-1.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
||||||
|
<span x-html="$icon('pencil', 'inline w-3.5 h-3.5 mr-1')"></span>
|
||||||
|
{{ _('loyalty.common.edit') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Edit mode -->
|
||||||
|
<div x-show="editingCategoryId === cat.id" class="p-3 bg-purple-50 dark:bg-purple-900/20">
|
||||||
|
<div class="grid gap-2 md:grid-cols-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">English (EN) <span class="text-red-500">*</span></label>
|
||||||
|
<input type="text" x-model="editCategoryData.name" maxlength="100"
|
||||||
|
class="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">French (FR) <span class="text-red-500">*</span></label>
|
||||||
|
<input type="text" x-model="editCategoryData.translations.fr" maxlength="100"
|
||||||
|
class="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">German (DE) <span class="text-red-500">*</span></label>
|
||||||
|
<input type="text" x-model="editCategoryData.translations.de" maxlength="100"
|
||||||
|
class="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Luxembourgish (LB) <span class="text-red-500">*</span></label>
|
||||||
|
<input type="text" x-model="editCategoryData.translations.lb" maxlength="100"
|
||||||
|
class="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button @click="editingCategoryId = null" type="button"
|
||||||
|
class="px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600">
|
||||||
|
{{ _('loyalty.common.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button @click="saveEditCategory(cat.id)" :disabled="!editCategoryData.name || !editCategoryData.translations.fr || !editCategoryData.translations.de || !editCategoryData.translations.lb"
|
||||||
|
class="px-3 py-1.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||||
|
{{ _('loyalty.common.save') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p x-show="storeCategories.length === 0" class="text-sm text-gray-500 dark:text-gray-400 py-4 text-center">
|
||||||
|
{{ _('loyalty.admin.merchant_detail.no_categories') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Merchant Settings (Admin-controlled) -->
|
<!-- Merchant Settings (Admin-controlled) -->
|
||||||
<div class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
<div class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
|||||||
@@ -215,7 +215,7 @@
|
|||||||
<a
|
<a
|
||||||
:href="'/admin/loyalty/merchants/' + program.merchant_id"
|
:href="'/admin/loyalty/merchants/' + program.merchant_id"
|
||||||
class="flex items-center justify-center p-2 text-blue-600 rounded-lg hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
|
class="flex items-center justify-center p-2 text-blue-600 rounded-lg hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
|
||||||
title="{{ _('loyalty.common.view') }}"
|
aria-label="{{ _('loyalty.common.view') }}"
|
||||||
>
|
>
|
||||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||||
</a>
|
</a>
|
||||||
@@ -224,28 +224,28 @@
|
|||||||
<a
|
<a
|
||||||
:href="'/admin/loyalty/merchants/' + program.merchant_id + '/program'"
|
:href="'/admin/loyalty/merchants/' + program.merchant_id + '/program'"
|
||||||
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
|
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
|
||||||
title="{{ _('loyalty.common.edit') }}"
|
aria-label="{{ _('loyalty.common.edit') }}"
|
||||||
>
|
>
|
||||||
<span x-html="$icon('edit', 'w-5 h-5')"></span>
|
<span x-html="$icon('edit', 'w-5 h-5')"></span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Delete Button -->
|
<!-- Delete Button -->
|
||||||
<button
|
<button type="button"
|
||||||
@click="confirmDeleteProgram(program)"
|
@click="confirmDeleteProgram(program)"
|
||||||
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-red-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
|
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-red-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
|
||||||
title="{{ _('loyalty.common.delete') }}"
|
aria-label="{{ _('loyalty.common.delete') }}"
|
||||||
>
|
>
|
||||||
<span x-html="$icon('delete', 'w-5 h-5')"></span>
|
<span x-html="$icon('delete', 'w-5 h-5')"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Activate/Deactivate Toggle -->
|
<!-- Activate/Deactivate Toggle -->
|
||||||
<button
|
<button type="button"
|
||||||
@click="toggleProgramActive(program)"
|
@click="toggleProgramActive(program)"
|
||||||
class="flex items-center justify-center p-2 rounded-lg focus:outline-none transition-colors"
|
class="flex items-center justify-center p-2 rounded-lg focus:outline-none transition-colors"
|
||||||
:class="program.is_active ? 'text-orange-600 hover:bg-orange-50 dark:text-orange-400 dark:hover:bg-gray-700' : 'text-green-600 hover:bg-green-50 dark:text-green-400 dark:hover:bg-gray-700'"
|
:class="program.is_active ? 'text-orange-600 hover:bg-orange-50 dark:text-orange-400 dark:hover:bg-gray-700' : 'text-green-600 hover:bg-green-50 dark:text-green-400 dark:hover:bg-gray-700'"
|
||||||
:title="program.is_active ? 'Deactivate program' : 'Activate program'"
|
:aria-label="program.is_active ? '{{ _('loyalty.common.deactivate') }}' : '{{ _('loyalty.common.activate') }}'"
|
||||||
>
|
>
|
||||||
<span x-html="$icon(program.is_active ? 'pause' : 'play', 'w-5 h-5')"></span>
|
<span x-html="$icon(program.is_active ? 'ban' : 'play', 'w-5 h-5')"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -103,17 +103,19 @@
|
|||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
{% if show_crud %}
|
{% if show_crud %}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button @click="openEditModal(pin)"
|
<button @click="openEditModal(pin)" type="button"
|
||||||
|
aria-label="{{ _('loyalty.common.edit') }}"
|
||||||
class="text-purple-600 hover:text-purple-700 dark:text-purple-400 text-sm">
|
class="text-purple-600 hover:text-purple-700 dark:text-purple-400 text-sm">
|
||||||
<span x-html="$icon('pencil', 'w-4 h-4')"></span>
|
<span x-html="$icon('pencil', 'w-4 h-4')"></span>
|
||||||
</button>
|
</button>
|
||||||
<button @click="openDeleteModal(pin)"
|
<button @click="openDeleteModal(pin)" type="button"
|
||||||
|
aria-label="{{ _('loyalty.common.delete') }}"
|
||||||
class="text-red-600 hover:text-red-700 dark:text-red-400 text-sm">
|
class="text-red-600 hover:text-red-700 dark:text-red-400 text-sm">
|
||||||
<span x-html="$icon('trash', 'w-4 h-4')"></span>
|
<span x-html="$icon('trash', 'w-4 h-4')"></span>
|
||||||
</button>
|
</button>
|
||||||
<button x-show="pin.is_locked" @click="unlockPin(pin)"
|
<button x-show="pin.is_locked" @click="unlockPin(pin)" type="button"
|
||||||
class="text-orange-600 hover:text-orange-700 dark:text-orange-400 text-sm"
|
aria-label="{{ _('loyalty.shared.pins.unlock') }}"
|
||||||
:title="$t('loyalty.shared.pins.unlock')">
|
class="text-orange-600 hover:text-orange-700 dark:text-orange-400 text-sm">
|
||||||
<span x-html="$icon('lock-open', 'w-4 h-4')"></span>
|
<span x-html="$icon('lock-open', 'w-4 h-4')"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -247,9 +247,11 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('loyalty.shared.program_form.terms_conditions') }}</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('loyalty.shared.program_form.terms_cms_page') }}</label>
|
||||||
<textarea x-model="settings.terms_text" rows="3"
|
<input type="text" x-model="settings.terms_cms_page_slug" maxlength="200"
|
||||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"></textarea>
|
placeholder="e.g. terms-and-conditions"
|
||||||
|
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ _('loyalty.shared.program_form.terms_cms_page_hint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('loyalty.shared.program_form.privacy_policy_url') }}</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('loyalty.shared.program_form.privacy_policy_url') }}</label>
|
||||||
@@ -257,6 +259,12 @@
|
|||||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('loyalty.shared.program_form.terms_conditions') }}</label>
|
||||||
|
<textarea x-model="settings.terms_text" rows="3"
|
||||||
|
placeholder="{{ _('loyalty.shared.program_form.terms_fallback_hint') }}"
|
||||||
|
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"></textarea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Program Status -->
|
<!-- Program Status -->
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
x-text="program?.loyalty_type || 'unknown'"></span>
|
x-text="program?.loyalty_type || 'unknown'"></span>
|
||||||
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold leading-tight rounded-full"
|
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold leading-tight rounded-full"
|
||||||
:class="program?.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700'">
|
:class="program?.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700'">
|
||||||
<span x-text="program?.is_active ? $t('loyalty.common.active') : $t('loyalty.common.inactive')"></span>
|
<span x-text="program?.is_active ? '{{ _('loyalty.common.active') }}' : '{{ _('loyalty.common.inactive') }}'"></span>
|
||||||
</span>
|
</span>
|
||||||
{% if show_edit_button is not defined or show_edit_button %}
|
{% if show_edit_button is not defined or show_edit_button %}
|
||||||
<a href="{{ edit_url }}"
|
<a href="{{ edit_url }}"
|
||||||
@@ -92,22 +92,22 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.shared.program_view.welcome_bonus') }}</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.shared.program_view.welcome_bonus') }}</p>
|
||||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
x-text="program?.welcome_bonus_points ? $t('loyalty.shared.program_view.x_points', {count: program.welcome_bonus_points}) : $t('loyalty.common.none')">-</p>
|
x-text="program?.welcome_bonus_points ? '{{ _('loyalty.shared.program_view.x_points') }}'.replace('{count}', program.welcome_bonus_points) : '{{ _('loyalty.common.none') }}'">-</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.shared.program_view.minimum_redemption') }}</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.shared.program_view.minimum_redemption') }}</p>
|
||||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
x-text="program?.minimum_redemption_points ? $t('loyalty.shared.program_view.x_points', {count: program.minimum_redemption_points}) : $t('loyalty.common.none')">-</p>
|
x-text="program?.minimum_redemption_points ? '{{ _('loyalty.shared.program_view.x_points') }}'.replace('{count}', program.minimum_redemption_points) : '{{ _('loyalty.common.none') }}'">-</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.shared.program_view.minimum_purchase') }}</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.shared.program_view.minimum_purchase') }}</p>
|
||||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
x-text="program?.minimum_purchase_cents ? '€' + (program.minimum_purchase_cents / 100).toFixed(2) : $t('loyalty.common.none')">-</p>
|
x-text="program?.minimum_purchase_cents ? '€' + (program.minimum_purchase_cents / 100).toFixed(2) : '{{ _('loyalty.common.none') }}'">-</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.shared.program_view.points_expiration') }}</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.shared.program_view.points_expiration') }}</p>
|
||||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
x-text="program?.points_expiration_days ? $t('loyalty.shared.program_view.x_days_inactivity', {days: program.points_expiration_days}) : $t('loyalty.common.never')">-</p>
|
x-text="program?.points_expiration_days ? '{{ _('loyalty.shared.program_view.x_days_inactivity') }}'.replace('{days}', program.points_expiration_days) : '{{ _('loyalty.common.never') }}'">-</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,7 +153,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.shared.program_view.cooldown') }}</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.shared.program_view.cooldown') }}</p>
|
||||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
x-text="program?.cooldown_minutes ? $t('loyalty.shared.program_view.x_minutes', {count: program.cooldown_minutes}) : $t('loyalty.common.none')">-</p>
|
x-text="program?.cooldown_minutes ? '{{ _('loyalty.shared.program_view.x_minutes') }}'.replace('{count}', program.cooldown_minutes) : '{{ _('loyalty.common.none') }}'">-</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.shared.program_view.max_daily_stamps') }}</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('loyalty.shared.program_view.max_daily_stamps') }}</p>
|
||||||
|
|||||||
@@ -46,6 +46,90 @@
|
|||||||
{% set show_merchants_metric = false %}
|
{% set show_merchants_metric = false %}
|
||||||
{% include "loyalty/shared/analytics-stats.html" %}
|
{% include "loyalty/shared/analytics-stats.html" %}
|
||||||
|
|
||||||
|
<!-- Advanced Analytics Charts -->
|
||||||
|
<div class="grid gap-6 md:grid-cols-2 mb-6">
|
||||||
|
<!-- Revenue Chart -->
|
||||||
|
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||||
|
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
<span x-html="$icon('chart-bar', 'inline w-5 h-5 mr-2')"></span>
|
||||||
|
{{ _('loyalty.store.analytics.revenue_title') }}
|
||||||
|
</h3>
|
||||||
|
<div x-show="revenueData.monthly.length > 0" style="height: 250px;">
|
||||||
|
<canvas id="revenueChart"></canvas>
|
||||||
|
</div>
|
||||||
|
<p x-show="revenueData.monthly.length === 0" class="text-sm text-gray-500 dark:text-gray-400 py-8 text-center">
|
||||||
|
{{ _('loyalty.store.analytics.no_data_yet') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Churn / At-Risk Cards -->
|
||||||
|
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||||
|
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
<span x-html="$icon('exclamation-triangle', 'inline w-5 h-5 mr-2')"></span>
|
||||||
|
{{ _('loyalty.store.analytics.at_risk_title') }}
|
||||||
|
</h3>
|
||||||
|
<div x-show="churnData.at_risk_count > 0">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
<span class="text-2xl font-bold text-orange-600" x-text="churnData.at_risk_count"></span>
|
||||||
|
{{ _('loyalty.store.analytics.cards_at_risk') }}
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2 max-h-48 overflow-y-auto">
|
||||||
|
<template x-for="card in churnData.cards?.slice(0, 10)" :key="card.card_id">
|
||||||
|
<div class="flex items-center justify-between text-sm py-1 border-b border-gray-100 dark:border-gray-700">
|
||||||
|
<span class="text-gray-700 dark:text-gray-300" x-text="card.customer_name || card.card_number"></span>
|
||||||
|
<span class="text-orange-600 font-medium" x-text="card.days_inactive + 'd inactive'"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p x-show="churnData.at_risk_count === 0" class="text-sm text-green-600 dark:text-green-400 py-8 text-center">
|
||||||
|
{{ _('loyalty.store.analytics.no_at_risk') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cohort Retention -->
|
||||||
|
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800 mb-6">
|
||||||
|
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
<span x-html="$icon('table-cells', 'inline w-5 h-5 mr-2')"></span>
|
||||||
|
{{ _('loyalty.store.analytics.cohort_title') }}
|
||||||
|
</h3>
|
||||||
|
<div x-show="cohortData.cohorts?.length > 0" class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 text-left text-gray-600 dark:text-gray-400">{{ _('loyalty.store.analytics.cohort_month') }}</th>
|
||||||
|
<th class="px-3 py-2 text-center text-gray-600 dark:text-gray-400">{{ _('loyalty.store.analytics.cohort_enrolled') }}</th>
|
||||||
|
<template x-for="(_, i) in Array(6)" :key="i">
|
||||||
|
<th class="px-3 py-2 text-center text-gray-600 dark:text-gray-400" x-text="'M' + i"></th>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="cohort in cohortData.cohorts" :key="cohort.month">
|
||||||
|
<tr class="border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<td class="px-3 py-2 font-medium text-gray-900 dark:text-white" x-text="cohort.month"></td>
|
||||||
|
<td class="px-3 py-2 text-center text-gray-600 dark:text-gray-400" x-text="cohort.enrolled"></td>
|
||||||
|
<template x-for="(pct, i) in cohort.retention.slice(0, 6)" :key="i">
|
||||||
|
<td class="px-3 py-2 text-center">
|
||||||
|
<span class="inline-block px-2 py-1 rounded text-xs font-medium"
|
||||||
|
:class="pct >= 60 ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : pct >= 30 ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'"
|
||||||
|
x-text="pct + '%'"></span>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
<template x-for="i in Math.max(0, 6 - cohort.retention.length)" :key="'empty-' + i">
|
||||||
|
<td class="px-3 py-2 text-center text-gray-300">-</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p x-show="!cohortData.cohorts?.length" class="text-sm text-gray-500 dark:text-gray-400 py-8 text-center">
|
||||||
|
{{ _('loyalty.store.analytics.no_data_yet') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
<!-- Quick Actions -->
|
||||||
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">{{ _('loyalty.store.analytics.quick_actions') }}</h3>
|
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">{{ _('loyalty.store.analytics.quick_actions') }}</h3>
|
||||||
@@ -71,5 +155,6 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||||
<script defer src="{{ url_for('loyalty_static', path='store/js/loyalty-analytics.js') }}"></script>
|
<script defer src="{{ url_for('loyalty_static', path='store/js/loyalty-analytics.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user