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,
|
"clean_path": clean_path,
|
||||||
"access_method": access_method,
|
"access_method": access_method,
|
||||||
"base_url": base_url,
|
"base_url": base_url,
|
||||||
"enabled_modules": set(),
|
|
||||||
"storefront_nav": {},
|
|
||||||
"subscription": subscription,
|
"subscription": subscription,
|
||||||
"subscription_tier": subscription_tier,
|
"subscription_tier": subscription_tier,
|
||||||
"tier_code": subscription_tier.code if subscription_tier else None,
|
"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:
|
if db is None:
|
||||||
context = _build_base_context(
|
context = _build_base_context(
|
||||||
request,
|
request,
|
||||||
@@ -374,10 +372,13 @@ def get_storefront_context(
|
|||||||
getattr(request.state, "language", "en"),
|
getattr(request.state, "language", "en"),
|
||||||
)
|
)
|
||||||
context.update(storefront_base)
|
context.update(storefront_base)
|
||||||
|
context["enabled_modules"] = set()
|
||||||
|
context["storefront_nav"] = {}
|
||||||
context.update(extra_context)
|
context.update(extra_context)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
# Full context with module contributions
|
# Full context with module contributions
|
||||||
|
# (get_context_for_frontend computes enabled_modules and storefront_nav)
|
||||||
return get_context_for_frontend(
|
return get_context_for_frontend(
|
||||||
FrontendType.STOREFRONT,
|
FrontendType.STOREFRONT,
|
||||||
request,
|
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.core.database import get_db
|
||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
from app.modules.loyalty.schemas import (
|
from app.modules.loyalty.schemas import (
|
||||||
|
CardDetailResponse,
|
||||||
CardEnrollRequest,
|
CardEnrollRequest,
|
||||||
CardListResponse,
|
CardListResponse,
|
||||||
CardLookupResponse,
|
CardLookupResponse,
|
||||||
@@ -286,6 +287,7 @@ def list_cards(
|
|||||||
|
|
||||||
card_responses = []
|
card_responses = []
|
||||||
for card in cards:
|
for card in cards:
|
||||||
|
customer = card.customer
|
||||||
response = CardResponse(
|
response = CardResponse(
|
||||||
id=card.id,
|
id=card.id,
|
||||||
card_number=card.card_number,
|
card_number=card.card_number,
|
||||||
@@ -293,6 +295,8 @@ def list_cards(
|
|||||||
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,
|
||||||
|
customer_name=customer.full_name if customer else None,
|
||||||
|
customer_email=customer.email if customer else None,
|
||||||
stamp_count=card.stamp_count,
|
stamp_count=card.stamp_count,
|
||||||
stamps_target=program.stamps_target,
|
stamps_target=program.stamps_target,
|
||||||
stamps_until_reward=max(0, program.stamps_target - card.stamp_count),
|
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)
|
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:
|
def _build_card_lookup_response(card, db=None) -> CardLookupResponse:
|
||||||
"""Build a CardLookupResponse from a card object."""
|
"""Build a CardLookupResponse from a card object."""
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|||||||
@@ -78,12 +78,16 @@ def self_enroll(
|
|||||||
# Check if self-enrollment is allowed
|
# Check if self-enrollment is allowed
|
||||||
program_service.check_self_enrollment_allowed(db, store.merchant_id)
|
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(
|
customer_id = card_service.resolve_customer_id(
|
||||||
db,
|
db,
|
||||||
customer_id=data.customer_id,
|
customer_id=data.customer_id,
|
||||||
email=data.email,
|
email=data.email,
|
||||||
store_id=store.id,
|
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}")
|
logger.info(f"Self-enrollment for customer {customer_id} at store {store.subdomain}")
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ class CardEnrollRequest(BaseModel):
|
|||||||
None,
|
None,
|
||||||
description="Customer email (for public enrollment without customer_id)",
|
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):
|
class CardResponse(BaseModel):
|
||||||
@@ -38,6 +48,10 @@ class CardResponse(BaseModel):
|
|||||||
program_id: int
|
program_id: int
|
||||||
enrolled_at_store_id: int | None = None
|
enrolled_at_store_id: int | None = None
|
||||||
|
|
||||||
|
# Customer info (for list views)
|
||||||
|
customer_name: str | None = None
|
||||||
|
customer_email: str | None = None
|
||||||
|
|
||||||
# Stamps
|
# Stamps
|
||||||
stamp_count: int
|
stamp_count: int
|
||||||
stamps_target: int # From program
|
stamps_target: int # From program
|
||||||
|
|||||||
@@ -138,6 +138,10 @@ class CardService:
|
|||||||
customer_id: int | None,
|
customer_id: int | None,
|
||||||
email: str | None,
|
email: str | None,
|
||||||
store_id: int,
|
store_id: int,
|
||||||
|
create_if_missing: bool = False,
|
||||||
|
customer_name: str | None = None,
|
||||||
|
customer_phone: str | None = None,
|
||||||
|
customer_birthday: str | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
Resolve a customer ID from either a direct ID or email lookup.
|
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)
|
customer_id: Direct customer ID (used if provided)
|
||||||
email: Customer email to look up
|
email: Customer email to look up
|
||||||
store_id: Store ID for scoping the email lookup
|
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:
|
Returns:
|
||||||
Resolved customer ID
|
Resolved customer ID
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
CustomerIdentifierRequiredException: If neither customer_id nor email provided
|
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:
|
if customer_id:
|
||||||
return customer_id
|
return customer_id
|
||||||
@@ -166,9 +175,53 @@ class CardService:
|
|||||||
.filter(Customer.email == email, Customer.store_id == store_id)
|
.filter(Customer.email == email, Customer.store_id == store_id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if not customer:
|
if customer:
|
||||||
raise CustomerNotFoundByEmailException(email)
|
return customer.id
|
||||||
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()
|
raise CustomerIdentifierRequiredException()
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function storeLoyaltyEnroll() {
|
|||||||
loyaltyEnrollLog.info('Enrolling customer:', this.form.email);
|
loyaltyEnrollLog.info('Enrolling customer:', this.form.email);
|
||||||
|
|
||||||
const response = await apiClient.post('/store/loyalty/cards/enroll', {
|
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_phone: this.form.phone || null,
|
||||||
customer_name: [this.form.first_name, this.form.last_name].filter(Boolean).join(' '),
|
customer_name: [this.form.first_name, this.form.last_name].filter(Boolean).join(' '),
|
||||||
customer_birthday: this.form.birthday || null,
|
customer_birthday: this.form.birthday || null,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
function customerLoyaltyDashboard() {
|
function customerLoyaltyDashboard() {
|
||||||
return {
|
return {
|
||||||
...data(),
|
...shopLayoutData(),
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
card: null,
|
card: null,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
function customerLoyaltyEnroll() {
|
function customerLoyaltyEnroll() {
|
||||||
return {
|
return {
|
||||||
...data(),
|
...shopLayoutData(),
|
||||||
|
|
||||||
// Program info
|
// Program info
|
||||||
program: null,
|
program: null,
|
||||||
@@ -62,7 +62,7 @@ function customerLoyaltyEnroll() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/storefront/loyalty/enroll', {
|
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_name: [this.form.first_name, this.form.last_name].filter(Boolean).join(' '),
|
||||||
customer_phone: this.form.phone || null,
|
customer_phone: this.form.phone || null,
|
||||||
customer_birthday: this.form.birthday || null,
|
customer_birthday: this.form.birthday || null,
|
||||||
@@ -71,12 +71,13 @@ function customerLoyaltyEnroll() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response) {
|
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
|
// Redirect to success page - extract base path from current URL
|
||||||
// Current page is at /storefront/loyalty/join, redirect to /storefront/loyalty/join/success
|
// Current page is at /storefront/loyalty/join, redirect to /storefront/loyalty/join/success
|
||||||
const currentPath = window.location.pathname;
|
const currentPath = window.location.pathname;
|
||||||
const successUrl = currentPath.replace(/\/join\/?$/, '/join/success') +
|
const successUrl = currentPath.replace(/\/join\/?$/, '/join/success') +
|
||||||
'?card=' + encodeURIComponent(response.card_number);
|
'?card=' + encodeURIComponent(cardNumber);
|
||||||
window.location.href = successUrl;
|
window.location.href = successUrl;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
function customerLoyaltyHistory() {
|
function customerLoyaltyHistory() {
|
||||||
return {
|
return {
|
||||||
...data(),
|
...shopLayoutData(),
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
card: null,
|
card: null,
|
||||||
|
|||||||
@@ -228,5 +228,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@
|
|||||||
<script>
|
<script>
|
||||||
function customerLoyaltyEnrollSuccess() {
|
function customerLoyaltyEnrollSuccess() {
|
||||||
return {
|
return {
|
||||||
...data(),
|
...shopLayoutData(),
|
||||||
walletUrls: { google_wallet_url: null, apple_wallet_url: null },
|
walletUrls: { google_wallet_url: null, apple_wallet_url: null },
|
||||||
async init() {
|
async init() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -131,5 +131,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -103,5 +103,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user