fix: loyalty module end-to-end — merchant route, store menus, sidebar, API error handling
Some checks failed
CI / ruff (push) Successful in 10s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled

- 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:
2026-02-24 13:52:11 +01:00
parent 2833ff1476
commit cfce6c0ca4
8 changed files with 364 additions and 14 deletions

View File

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

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

View File

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

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

View File

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

View File

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