fix: loyalty storefront and store card detail — enrollment, context, and Alpine.js
Some checks failed
Some checks failed
- 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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
function customerLoyaltyDashboard() {
|
||||
return {
|
||||
...data(),
|
||||
...shopLayoutData(),
|
||||
|
||||
// Data
|
||||
card: null,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
function customerLoyaltyHistory() {
|
||||
return {
|
||||
...data(),
|
||||
...shopLayoutData(),
|
||||
|
||||
// Data
|
||||
card: null,
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
<script>
|
||||
function customerLoyaltyEnrollSuccess() {
|
||||
return {
|
||||
...data(),
|
||||
...shopLayoutData(),
|
||||
walletUrls: { google_wallet_url: null, apple_wallet_url: null },
|
||||
async init() {
|
||||
try {
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user