fix: loyalty module end-to-end — merchant route, store menus, sidebar, API error handling
Some checks failed
Some checks failed
- Add merchant loyalty overview route and template (was 404)
- Fix store loyalty route paths to match menu URLs (/{store_code}/loyalty/...)
- Add loyalty rewards card to storefront account dashboard
- Fix merchant overview to resolve merchant via get_merchant_for_current_user_page
- Fix store login to use store's primary platform for JWT token (interim fix)
- Fix apiClient to attach status/errorCode to thrown errors (fixes error.status
checks in 12+ JS files — loyalty settings, terminal, email templates, etc.)
- Hide "Add Product" sidebar button when catalog module is not enabled
- Add proposal doc for proper platform detection in store login flow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -66,6 +66,35 @@
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{% if 'loyalty' in enabled_modules %}
|
||||
<!-- Loyalty Rewards Card -->
|
||||
<a href="{{ base_url }}account/loyalty"
|
||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700"
|
||||
x-data="{ points: null, loaded: false }"
|
||||
x-init="fetch('/api/v1/storefront/loyalty/card').then(r => r.json()).then(d => { if (d.card) { points = d.card.points_balance; } loaded = true; }).catch(() => { loaded = true; })">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('gift', 'h-8 w-8')"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Loyalty Rewards</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">View your points & rewards</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<template x-if="loaded && points !== null">
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)" x-text="points.toLocaleString()"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Points Balance</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="loaded && points === null">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Join our rewards program</p>
|
||||
</template>
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Messages Card -->
|
||||
<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"
|
||||
|
||||
100
app/modules/loyalty/routes/pages/merchant.py
Normal file
100
app/modules/loyalty/routes/pages/merchant.py
Normal file
@@ -0,0 +1,100 @@
|
||||
# app/modules/loyalty/routes/pages/merchant.py
|
||||
"""
|
||||
Loyalty Merchant Page Routes (HTML rendering).
|
||||
|
||||
Merchant portal pages for:
|
||||
- Loyalty overview (aggregate stats across all stores)
|
||||
|
||||
Authentication: merchant_token cookie or Authorization header.
|
||||
|
||||
Auto-discovered by the route system (merchant.py in routes/pages/ triggers
|
||||
registration under /merchants/loyalty/*).
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import (
|
||||
get_current_merchant_from_cookie_or_header,
|
||||
get_merchant_for_current_user_page,
|
||||
)
|
||||
from app.core.database import get_db
|
||||
from app.modules.core.utils.page_context import get_context_for_frontend
|
||||
from app.modules.enums import FrontendType
|
||||
from app.modules.loyalty.services import program_service
|
||||
from app.modules.tenancy.models import Merchant
|
||||
from app.templates_config import templates
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ROUTE_CONFIG = {
|
||||
"prefix": "/loyalty",
|
||||
}
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _get_merchant_context(
|
||||
request: Request,
|
||||
db: Session,
|
||||
current_user: UserContext,
|
||||
**extra_context,
|
||||
) -> dict:
|
||||
"""Build template context for merchant loyalty pages."""
|
||||
return get_context_for_frontend(
|
||||
FrontendType.MERCHANT,
|
||||
request,
|
||||
db,
|
||||
user=current_user,
|
||||
**extra_context,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# LOYALTY OVERVIEW
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/overview", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def merchant_loyalty_overview(
|
||||
request: Request,
|
||||
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
|
||||
merchant: Merchant = Depends(get_merchant_for_current_user_page),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render merchant loyalty overview page.
|
||||
|
||||
Shows aggregate loyalty program stats across all merchant stores.
|
||||
"""
|
||||
# Get merchant stats server-side
|
||||
merchant_id = merchant.id
|
||||
stats = {}
|
||||
try:
|
||||
stats = program_service.get_merchant_stats(db, merchant_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"Failed to load loyalty stats for merchant {merchant_id}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
context = _get_merchant_context(
|
||||
request,
|
||||
db,
|
||||
current_user,
|
||||
loyalty_stats=stats,
|
||||
merchant_id=merchant_id,
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
"loyalty/merchant/overview.html",
|
||||
context,
|
||||
)
|
||||
@@ -7,12 +7,15 @@ Store pages for:
|
||||
- Loyalty members management
|
||||
- Program settings
|
||||
- Stats dashboard
|
||||
|
||||
Routes follow the standard store convention: /{store_code}/loyalty/...
|
||||
so they match the menu URLs in definition.py.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_store_from_cookie_or_header, get_db
|
||||
@@ -27,8 +30,9 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# Route configuration for module route discovery
|
||||
# No custom prefix — routes include /loyalty/ in their paths to follow
|
||||
# the standard store convention: /store/{store_code}/loyalty/...
|
||||
ROUTE_CONFIG = {
|
||||
"prefix": "/loyalty",
|
||||
"tags": ["store-loyalty"],
|
||||
}
|
||||
|
||||
@@ -76,13 +80,37 @@ def get_store_context(
|
||||
return context
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# LOYALTY ROOT (Redirect to Terminal)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{store_code}/loyalty",
|
||||
response_class=RedirectResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def store_loyalty_root(
|
||||
store_code: str = Path(..., description="Store code"),
|
||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||
):
|
||||
"""
|
||||
Redirect loyalty root to the terminal (primary daily interface).
|
||||
Menu item "Dashboard" points here.
|
||||
"""
|
||||
return RedirectResponse(
|
||||
url=f"/store/{store_code}/loyalty/terminal",
|
||||
status_code=302,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# LOYALTY TERMINAL (Primary Daily Interface)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{store_code}/terminal",
|
||||
"/{store_code}/loyalty/terminal",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
@@ -108,7 +136,7 @@ async def store_loyalty_terminal(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{store_code}/cards",
|
||||
"/{store_code}/loyalty/cards",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
@@ -129,7 +157,7 @@ async def store_loyalty_cards(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{store_code}/cards/{card_id}",
|
||||
"/{store_code}/loyalty/cards/{card_id}",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
@@ -156,7 +184,7 @@ async def store_loyalty_card_detail(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{store_code}/settings",
|
||||
"/{store_code}/loyalty/settings",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
@@ -182,7 +210,7 @@ async def store_loyalty_settings(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{store_code}/stats",
|
||||
"/{store_code}/loyalty/stats",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
@@ -208,7 +236,7 @@ async def store_loyalty_stats(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{store_code}/enroll",
|
||||
"/{store_code}/loyalty/enroll",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
|
||||
116
app/modules/loyalty/templates/loyalty/merchant/overview.html
Normal file
116
app/modules/loyalty/templates/loyalty/merchant/overview.html
Normal file
@@ -0,0 +1,116 @@
|
||||
{# app/modules/loyalty/templates/loyalty/merchant/overview.html #}
|
||||
{% extends "merchant/base.html" %}
|
||||
|
||||
{% block title %}Loyalty Overview{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="merchantLoyaltyOverview()">
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8 mt-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Loyalty Overview</h2>
|
||||
<p class="mt-1 text-gray-500 dark:text-gray-400">Loyalty program statistics across all your stores.</p>
|
||||
</div>
|
||||
|
||||
<!-- No Program State -->
|
||||
<template x-if="!stats.program_id && !loading">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<span x-html="$icon('gift', 'w-12 h-12 mx-auto text-gray-400')"></span>
|
||||
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No Loyalty Program</h3>
|
||||
<p class="mt-2 text-gray-500 dark:text-gray-400">
|
||||
Your loyalty program hasn't been set up yet. Contact the platform administrator or set it up from your store dashboard.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<template x-if="stats.program_id || loading">
|
||||
<div>
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<!-- Total Cards -->
|
||||
<div class="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
|
||||
<span x-html="$icon('identification', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Total Cards</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_cards || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Cards -->
|
||||
<div class="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Active Cards</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.active_cards || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Points Issued (30d) -->
|
||||
<div class="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
|
||||
<span x-html="$icon('arrow-trending-up', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Points Issued (30d)</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="(stats.points_issued_30d || 0).toLocaleString()"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transactions (30d) -->
|
||||
<div class="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<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('receipt-percent', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Transactions (30d)</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.transactions_30d || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- All Time Stats -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">All-Time Statistics</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Total Points Issued</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white" x-text="(stats.total_points_issued || 0).toLocaleString()"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Total Points Redeemed</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white" x-text="(stats.total_points_redeemed || 0).toLocaleString()"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Points Redeemed (30d)</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white" x-text="(stats.points_redeemed_30d || 0).toLocaleString()"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Outstanding Liability</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white"
|
||||
x-text="'€' + ((stats.estimated_liability_cents || 0) / 100).toFixed(2)"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function merchantLoyaltyOverview() {
|
||||
return {
|
||||
loading: false,
|
||||
stats: {{ loyalty_stats | tojson }},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -111,10 +111,27 @@ def store_login(
|
||||
f"for store {store.store_code} as {store_role}"
|
||||
)
|
||||
|
||||
# Get platform from URL context (middleware-detected)
|
||||
platform = get_current_platform(request)
|
||||
platform_id = platform.id if platform else None
|
||||
platform_code = platform.code if platform else None
|
||||
# Resolve platform from the store's primary platform link.
|
||||
# Middleware-detected platform is unreliable for API paths on localhost
|
||||
# (e.g., /api/v1/store/auth/login defaults to "main" instead of the store's platform).
|
||||
platform_id = None
|
||||
platform_code = None
|
||||
if store:
|
||||
from app.modules.core.services.menu_service import menu_service
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
|
||||
primary_pid = menu_service.get_store_primary_platform_id(db, store.id)
|
||||
if primary_pid:
|
||||
plat = platform_service.get_platform_by_id(db, primary_pid)
|
||||
if plat:
|
||||
platform_id = plat.id
|
||||
platform_code = plat.code
|
||||
|
||||
if platform_id is None:
|
||||
# Fallback to middleware-detected platform
|
||||
platform = get_current_platform(request)
|
||||
platform_id = platform.id if platform else None
|
||||
platform_code = platform.code if platform else None
|
||||
|
||||
# Create store-scoped access token with store information
|
||||
token_data = auth_service.auth_manager.create_access_token(
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if 'catalog' in enabled_modules %}
|
||||
<!-- Quick Actions (static, outside dynamic menu) -->
|
||||
<div class="px-6 my-6">
|
||||
<button class="flex items-center justify-between w-full px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
|
||||
@@ -114,6 +115,7 @@
|
||||
<span class="ml-2">Add Product</span>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user