diff --git a/app/modules/core/utils/page_context.py b/app/modules/core/utils/page_context.py
index 4c7c9b9a..66f8e92b 100644
--- a/app/modules/core/utils/page_context.py
+++ b/app/modules/core/utils/page_context.py
@@ -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,
diff --git a/app/modules/loyalty/routes/api/store.py b/app/modules/loyalty/routes/api/store.py
index f3cfedf2..8795effe 100644
--- a/app/modules/loyalty/routes/api/store.py
+++ b/app/modules/loyalty/routes/api/store.py
@@ -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
diff --git a/app/modules/loyalty/routes/api/storefront.py b/app/modules/loyalty/routes/api/storefront.py
index 0268c935..1387549b 100644
--- a/app/modules/loyalty/routes/api/storefront.py
+++ b/app/modules/loyalty/routes/api/storefront.py
@@ -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}")
diff --git a/app/modules/loyalty/schemas/card.py b/app/modules/loyalty/schemas/card.py
index 54c2c908..684c8aa2 100644
--- a/app/modules/loyalty/schemas/card.py
+++ b/app/modules/loyalty/schemas/card.py
@@ -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
diff --git a/app/modules/loyalty/services/card_service.py b/app/modules/loyalty/services/card_service.py
index 30c2b8d3..167c604e 100644
--- a/app/modules/loyalty/services/card_service.py
+++ b/app/modules/loyalty/services/card_service.py
@@ -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,9 +175,53 @@ class CardService:
.filter(Customer.email == email, Customer.store_id == store_id)
.first()
)
- if not customer:
- raise CustomerNotFoundByEmailException(email)
- return customer.id
+ 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()
diff --git a/app/modules/loyalty/static/store/js/loyalty-enroll.js b/app/modules/loyalty/static/store/js/loyalty-enroll.js
index cc14cf3a..778b6154 100644
--- a/app/modules/loyalty/static/store/js/loyalty-enroll.js
+++ b/app/modules/loyalty/static/store/js/loyalty-enroll.js
@@ -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,
diff --git a/app/modules/loyalty/static/storefront/js/loyalty-dashboard.js b/app/modules/loyalty/static/storefront/js/loyalty-dashboard.js
index 1b5441ea..f430b008 100644
--- a/app/modules/loyalty/static/storefront/js/loyalty-dashboard.js
+++ b/app/modules/loyalty/static/storefront/js/loyalty-dashboard.js
@@ -3,7 +3,7 @@
function customerLoyaltyDashboard() {
return {
- ...data(),
+ ...shopLayoutData(),
// Data
card: null,
diff --git a/app/modules/loyalty/static/storefront/js/loyalty-enroll.js b/app/modules/loyalty/static/storefront/js/loyalty-enroll.js
index c48e6b33..ef5c9c24 100644
--- a/app/modules/loyalty/static/storefront/js/loyalty-enroll.js
+++ b/app/modules/loyalty/static/storefront/js/loyalty-enroll.js
@@ -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) {
diff --git a/app/modules/loyalty/static/storefront/js/loyalty-history.js b/app/modules/loyalty/static/storefront/js/loyalty-history.js
index d53a881d..a9b3c831 100644
--- a/app/modules/loyalty/static/storefront/js/loyalty-history.js
+++ b/app/modules/loyalty/static/storefront/js/loyalty-history.js
@@ -3,7 +3,7 @@
function customerLoyaltyHistory() {
return {
- ...data(),
+ ...shopLayoutData(),
// Data
card: null,
diff --git a/app/modules/loyalty/templates/loyalty/storefront/dashboard.html b/app/modules/loyalty/templates/loyalty/storefront/dashboard.html
index 50824a62..064f73e0 100644
--- a/app/modules/loyalty/templates/loyalty/storefront/dashboard.html
+++ b/app/modules/loyalty/templates/loyalty/storefront/dashboard.html
@@ -228,5 +228,5 @@
{% endblock %}
{% block extra_scripts %}
-
+
{% endblock %}
diff --git a/app/modules/loyalty/templates/loyalty/storefront/enroll-success.html b/app/modules/loyalty/templates/loyalty/storefront/enroll-success.html
index 20108e70..1d930f32 100644
--- a/app/modules/loyalty/templates/loyalty/storefront/enroll-success.html
+++ b/app/modules/loyalty/templates/loyalty/storefront/enroll-success.html
@@ -86,7 +86,7 @@
+
{% endblock %}
diff --git a/app/modules/loyalty/templates/loyalty/storefront/history.html b/app/modules/loyalty/templates/loyalty/storefront/history.html
index 30cf6246..16dce88e 100644
--- a/app/modules/loyalty/templates/loyalty/storefront/history.html
+++ b/app/modules/loyalty/templates/loyalty/storefront/history.html
@@ -103,5 +103,5 @@
{% endblock %}
{% block extra_scripts %}
-
+
{% endblock %}