fix: loyalty storefront and store card detail — enrollment, context, and Alpine.js
Some checks failed
CI / ruff (push) Successful in 10s
CI / pytest (push) Failing after 46m41s
CI / validate (push) Successful in 23s
CI / dependency-scanning (push) Successful in 30s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

- Fix storefront enabled_modules always empty (page_context overwrote computed
  set with empty default via extra_context)
- Fix storefront loyalty JS using store's data() instead of shopLayoutData()
- Remove defer from storefront loyalty scripts to prevent Alpine race condition
- Fix enrollment field name mismatch (customer_email → email) in both store
  and storefront JS
- Add self-enrollment customer creation (resolve_customer_id with
  create_if_missing) including hashed_password and customer_number
- Fix card list showing "Unknown" — add customer_name/email to CardResponse
- Add GET /cards/{card_id} detail endpoint for store card detail page
- Fix enroll-success.html using data() instead of shopLayoutData()
- Fix enrollment redirect reading response.card_number instead of
  response.card.card_number

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 14:28:37 +01:00
parent 3de69e55a1
commit 53dfe018c2
13 changed files with 148 additions and 19 deletions

View File

@@ -359,14 +359,12 @@ def get_storefront_context(
"clean_path": clean_path,
"access_method": access_method,
"base_url": base_url,
"enabled_modules": set(),
"storefront_nav": {},
"subscription": subscription,
"subscription_tier": subscription_tier,
"tier_code": subscription_tier.code if subscription_tier else None,
}
# If no db session, return just the base context
# If no db session, return just the base context with safe defaults
if db is None:
context = _build_base_context(
request,
@@ -374,10 +372,13 @@ def get_storefront_context(
getattr(request.state, "language", "en"),
)
context.update(storefront_base)
context["enabled_modules"] = set()
context["storefront_nav"] = {}
context.update(extra_context)
return context
# Full context with module contributions
# (get_context_for_frontend computes enabled_modules and storefront_nav)
return get_context_for_frontend(
FrontendType.STOREFRONT,
request,

View File

@@ -22,6 +22,7 @@ from app.api.deps import get_current_store_api, require_module_access
from app.core.database import get_db
from app.modules.enums import FrontendType
from app.modules.loyalty.schemas import (
CardDetailResponse,
CardEnrollRequest,
CardListResponse,
CardLookupResponse,
@@ -286,6 +287,7 @@ def list_cards(
card_responses = []
for card in cards:
customer = card.customer
response = CardResponse(
id=card.id,
card_number=card.card_number,
@@ -293,6 +295,8 @@ def list_cards(
merchant_id=card.merchant_id,
program_id=card.program_id,
enrolled_at_store_id=card.enrolled_at_store_id,
customer_name=customer.full_name if customer else None,
customer_email=customer.email if customer else None,
stamp_count=card.stamp_count,
stamps_target=program.stamps_target,
stamps_until_reward=max(0, program.stamps_target - card.stamp_count),
@@ -311,6 +315,58 @@ def list_cards(
return CardListResponse(cards=card_responses, total=total)
@store_router.get("/cards/{card_id}", response_model=CardDetailResponse)
def get_card_detail(
card_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_store_api),
db: Session = Depends(get_db),
):
"""Get detailed loyalty card info by ID."""
store_id = current_user.token_store_id
merchant_id = get_store_merchant_id(db, store_id)
card = card_service.get_card(db, card_id)
if not card or card.merchant_id != merchant_id:
from app.modules.loyalty.exceptions import LoyaltyCardNotFoundException
raise LoyaltyCardNotFoundException(str(card_id))
program = card.program
customer = card.customer
return CardDetailResponse(
id=card.id,
card_number=card.card_number,
customer_id=card.customer_id,
merchant_id=card.merchant_id,
program_id=card.program_id,
enrolled_at_store_id=card.enrolled_at_store_id,
customer_name=customer.full_name if customer else None,
customer_email=customer.email if customer else None,
merchant_name=card.merchant.name if card.merchant else None,
qr_code_data=card.qr_code_data or card.card_number,
program_name=program.display_name,
program_type=program.program_type,
reward_description=program.stamps_reward_description,
stamp_count=card.stamp_count,
stamps_target=program.stamps_target,
stamps_until_reward=max(0, program.stamps_target - card.stamp_count),
total_stamps_earned=card.total_stamps_earned,
stamps_redeemed=card.stamps_redeemed,
points_balance=card.points_balance,
total_points_earned=card.total_points_earned,
points_redeemed=card.points_redeemed,
is_active=card.is_active,
created_at=card.created_at,
last_stamp_at=card.last_stamp_at,
last_points_at=card.last_points_at,
last_redemption_at=card.last_redemption_at,
last_activity_at=card.last_activity_at,
has_google_wallet=bool(card.google_object_id),
has_apple_wallet=bool(card.apple_serial_number),
)
def _build_card_lookup_response(card, db=None) -> CardLookupResponse:
"""Build a CardLookupResponse from a card object."""
from datetime import timedelta

View File

@@ -78,12 +78,16 @@ def self_enroll(
# Check if self-enrollment is allowed
program_service.check_self_enrollment_allowed(db, store.merchant_id)
# Resolve customer_id
# Resolve customer_id (create customer if not found for self-enrollment)
customer_id = card_service.resolve_customer_id(
db,
customer_id=data.customer_id,
email=data.email,
store_id=store.id,
create_if_missing=True,
customer_name=data.customer_name,
customer_phone=data.customer_phone,
customer_birthday=data.customer_birthday,
)
logger.info(f"Self-enrollment for customer {customer_id} at store {store.subdomain}")

View File

@@ -24,6 +24,16 @@ class CardEnrollRequest(BaseModel):
None,
description="Customer email (for public enrollment without customer_id)",
)
# Self-enrollment fields (used to create customer if not found)
customer_name: str | None = Field(
None, description="Full name for self-enrollment"
)
customer_phone: str | None = Field(
None, description="Phone number for self-enrollment"
)
customer_birthday: str | None = Field(
None, description="Birthday (YYYY-MM-DD) for self-enrollment"
)
class CardResponse(BaseModel):
@@ -38,6 +48,10 @@ class CardResponse(BaseModel):
program_id: int
enrolled_at_store_id: int | None = None
# Customer info (for list views)
customer_name: str | None = None
customer_email: str | None = None
# Stamps
stamp_count: int
stamps_target: int # From program

View File

@@ -138,6 +138,10 @@ class CardService:
customer_id: int | None,
email: str | None,
store_id: int,
create_if_missing: bool = False,
customer_name: str | None = None,
customer_phone: str | None = None,
customer_birthday: str | None = None,
) -> int:
"""
Resolve a customer ID from either a direct ID or email lookup.
@@ -147,13 +151,18 @@ class CardService:
customer_id: Direct customer ID (used if provided)
email: Customer email to look up
store_id: Store ID for scoping the email lookup
create_if_missing: If True, create customer when email not found
(used for self-enrollment)
customer_name: Full name for customer creation
customer_phone: Phone for customer creation
customer_birthday: Birthday (YYYY-MM-DD) for customer creation
Returns:
Resolved customer ID
Raises:
CustomerIdentifierRequiredException: If neither customer_id nor email provided
CustomerNotFoundByEmailException: If email lookup fails
CustomerNotFoundByEmailException: If email lookup fails and create_if_missing is False
"""
if customer_id:
return customer_id
@@ -166,10 +175,54 @@ class CardService:
.filter(Customer.email == email, Customer.store_id == store_id)
.first()
)
if not customer:
raise CustomerNotFoundByEmailException(email)
if customer:
return customer.id
if create_if_missing:
import secrets
from app.modules.customers.services.customer_service import (
customer_service,
)
from app.modules.tenancy.models.store import Store
store = db.query(Store).filter(Store.id == store_id).first()
store_code = store.store_code if store else "STORE"
# Parse name into first/last
first_name = customer_name or ""
last_name = ""
if customer_name and " " in customer_name:
parts = customer_name.split(" ", 1)
first_name = parts[0]
last_name = parts[1]
# Generate unusable password hash and unique customer number
unusable_hash = f"!loyalty-enroll!{secrets.token_hex(32)}"
cust_number = customer_service._generate_customer_number(
db, store_id, store_code
)
customer = Customer(
email=email,
first_name=first_name,
last_name=last_name,
phone=customer_phone,
hashed_password=unusable_hash,
customer_number=cust_number,
store_id=store_id,
is_active=True,
)
db.add(customer)
db.flush()
logger.info(
f"Created customer {customer.id} ({email}) "
f"number={cust_number} for self-enrollment"
)
return customer.id
raise CustomerNotFoundByEmailException(email)
raise CustomerIdentifierRequiredException()
def lookup_card(

View File

@@ -67,7 +67,7 @@ function storeLoyaltyEnroll() {
loyaltyEnrollLog.info('Enrolling customer:', this.form.email);
const response = await apiClient.post('/store/loyalty/cards/enroll', {
customer_email: this.form.email,
email: this.form.email,
customer_phone: this.form.phone || null,
customer_name: [this.form.first_name, this.form.last_name].filter(Boolean).join(' '),
customer_birthday: this.form.birthday || null,

View File

@@ -3,7 +3,7 @@
function customerLoyaltyDashboard() {
return {
...data(),
...shopLayoutData(),
// Data
card: null,

View File

@@ -3,7 +3,7 @@
function customerLoyaltyEnroll() {
return {
...data(),
...shopLayoutData(),
// Program info
program: null,
@@ -62,7 +62,7 @@ function customerLoyaltyEnroll() {
try {
const response = await apiClient.post('/storefront/loyalty/enroll', {
customer_email: this.form.email,
email: this.form.email,
customer_name: [this.form.first_name, this.form.last_name].filter(Boolean).join(' '),
customer_phone: this.form.phone || null,
customer_birthday: this.form.birthday || null,
@@ -71,12 +71,13 @@ function customerLoyaltyEnroll() {
});
if (response) {
console.log('Enrollment successful:', response.card_number);
const cardNumber = response.card?.card_number || response.card_number;
console.log('Enrollment successful:', cardNumber);
// Redirect to success page - extract base path from current URL
// Current page is at /storefront/loyalty/join, redirect to /storefront/loyalty/join/success
const currentPath = window.location.pathname;
const successUrl = currentPath.replace(/\/join\/?$/, '/join/success') +
'?card=' + encodeURIComponent(response.card_number);
'?card=' + encodeURIComponent(cardNumber);
window.location.href = successUrl;
}
} catch (error) {

View File

@@ -3,7 +3,7 @@
function customerLoyaltyHistory() {
return {
...data(),
...shopLayoutData(),
// Data
card: null,

View File

@@ -228,5 +228,5 @@
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('loyalty_static', path='storefront/js/loyalty-dashboard.js') }}"></script>
<script src="{{ url_for('loyalty_static', path='storefront/js/loyalty-dashboard.js') }}"></script>
{% endblock %}

View File

@@ -86,7 +86,7 @@
<script>
function customerLoyaltyEnrollSuccess() {
return {
...data(),
...shopLayoutData(),
walletUrls: { google_wallet_url: null, apple_wallet_url: null },
async init() {
try {

View File

@@ -131,5 +131,5 @@
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('loyalty_static', path='storefront/js/loyalty-enroll.js') }}"></script>
<script src="{{ url_for('loyalty_static', path='storefront/js/loyalty-enroll.js') }}"></script>
{% endblock %}

View File

@@ -103,5 +103,5 @@
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('loyalty_static', path='storefront/js/loyalty-history.js') }}"></script>
<script src="{{ url_for('loyalty_static', path='storefront/js/loyalty-history.js') }}"></script>
{% endblock %}