Some checks failed
Test 5 (storefront password reset + customer dashboard) surfaced five
issues that all traced back to missing i18n plumbing:
- Forgot-password email arrived in EN regardless of storefront locale —
handler now prefers request.state.language over customer.preferred_language,
and loyalty self-enrollment backfills preferred_language for new + returning
customers so future locale-sensitive flows hit the right language without
being told twice.
- reset-password.html rendered "undefined" icon boxes because $icon magic
wasn't loaded in the standalone page — replaced with inline SVGs matching
the forgot-password.html convention.
- reset-password.html was hardcoded English: added lang attr, full _()
sweep (22 new auth.* keys × 4 locales), language selector, and JS
validation strings exposed via tojson.
- "Continue shopping" CTA renamed to "Back to Home" (auth.back_to_home,
4 locales) on login + forgot + reset — loyalty storefronts have no
catalog to continue to, mirroring the earlier enroll-success rename.
- /account dashboard, profile, addresses were hardcoded English in the
body (menu was FR because base layout uses _()). New customers.storefront
.pages.{dashboard,profile,addresses}.* namespace (~80 keys × 4 locales),
templates updated, Alpine JS strings injected via window.__*I18n.
18 files, 18 changed; arch validation: 126 warnings before = 126 after,
mkdocs --strict clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
281 lines
9.6 KiB
Python
281 lines
9.6 KiB
Python
# app/modules/loyalty/routes/api/storefront.py
|
|
"""
|
|
Loyalty Module - Storefront API Routes
|
|
|
|
Customer-facing endpoints for:
|
|
- View loyalty card and balance
|
|
- View transaction history
|
|
- Self-service enrollment
|
|
- Get program information
|
|
|
|
Uses store from middleware context (StoreContextMiddleware).
|
|
"""
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, Query, Request
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api.deps import get_current_customer_api
|
|
from app.core.database import get_db
|
|
from app.modules.customers.schemas import CustomerContext
|
|
from app.modules.loyalty.exceptions import LoyaltyCardAlreadyExistsException
|
|
from app.modules.loyalty.schemas import (
|
|
CardEnrollRequest,
|
|
CardResponse,
|
|
ProgramResponse,
|
|
)
|
|
from app.modules.loyalty.schemas.program import StorefrontProgramResponse
|
|
from app.modules.loyalty.services import card_service, program_service, wallet_service
|
|
from app.modules.tenancy.exceptions import StoreNotFoundException
|
|
from middleware.decorators import rate_limit
|
|
|
|
storefront_router = APIRouter()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# =============================================================================
|
|
# Public Endpoints (No Authentication Required)
|
|
# =============================================================================
|
|
|
|
|
|
@storefront_router.get("/loyalty/program")
|
|
@rate_limit(max_requests=30, window_seconds=60)
|
|
def get_program_info(
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get loyalty program information for current store.
|
|
Public endpoint - no authentication required.
|
|
"""
|
|
store = getattr(request.state, "store", None)
|
|
if not store:
|
|
raise StoreNotFoundException("context", identifier_type="subdomain")
|
|
|
|
program = program_service.get_program_by_store(db, store.id)
|
|
if not program:
|
|
return None
|
|
|
|
response = StorefrontProgramResponse.model_validate(program)
|
|
response.is_stamps_enabled = program.is_stamps_enabled
|
|
response.is_points_enabled = program.is_points_enabled
|
|
response.display_name = program.display_name
|
|
|
|
return response
|
|
|
|
|
|
@storefront_router.post("/loyalty/enroll")
|
|
@rate_limit(max_requests=10, window_seconds=60)
|
|
def self_enroll(
|
|
request: Request,
|
|
data: CardEnrollRequest,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Self-service enrollment.
|
|
Public endpoint - customers can enroll via QR code without authentication.
|
|
"""
|
|
store = getattr(request.state, "store", None)
|
|
if not store:
|
|
raise StoreNotFoundException("context", identifier_type="subdomain")
|
|
|
|
# Check if self-enrollment is allowed
|
|
program_service.check_self_enrollment_allowed(db, store.merchant_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,
|
|
merchant_id=store.merchant_id,
|
|
create_if_missing=True,
|
|
customer_name=data.customer_name,
|
|
customer_phone=data.customer_phone,
|
|
customer_birthday=data.customer_birthday,
|
|
customer_language=getattr(request.state, "language", None),
|
|
)
|
|
|
|
logger.info(f"Self-enrollment for customer {customer_id} at store {store.subdomain}")
|
|
|
|
# Build merchant context for the response (locations, cross-location flag)
|
|
settings = program_service.get_merchant_settings(db, store.merchant_id)
|
|
allow_cross_location = (
|
|
settings.allow_cross_location_redemption if settings else True
|
|
)
|
|
locations = program_service.get_merchant_locations(db, store.merchant_id)
|
|
location_list = [
|
|
{"id": loc.id, "name": loc.name}
|
|
for loc in locations
|
|
]
|
|
|
|
already_enrolled = False
|
|
try:
|
|
card = card_service.enroll_customer_for_store(db, customer_id, store.id)
|
|
except LoyaltyCardAlreadyExistsException:
|
|
# Customer already has a card — return it instead of erroring out.
|
|
# For cross-location=true this is the normal re-enroll-at-another-store
|
|
# path; for cross-location=false this is a same-store re-enroll.
|
|
already_enrolled = True
|
|
if allow_cross_location:
|
|
card = card_service.get_card_by_customer_and_merchant(
|
|
db, customer_id, store.merchant_id
|
|
)
|
|
else:
|
|
card = card_service.get_card_by_customer_and_store(
|
|
db, customer_id, store.id
|
|
)
|
|
|
|
program = card.program
|
|
wallet_urls = wallet_service.get_add_to_wallet_urls(db, card)
|
|
|
|
# Resolve the name of the original enrollment store
|
|
enrolled_at_store_name = None
|
|
if card.enrolled_at_store_id:
|
|
for loc in locations:
|
|
if loc.id == card.enrolled_at_store_id:
|
|
enrolled_at_store_name = loc.name
|
|
break
|
|
|
|
return {
|
|
"card": CardResponse(
|
|
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,
|
|
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,
|
|
has_google_wallet=bool(card.google_object_id),
|
|
has_apple_wallet=bool(card.apple_serial_number),
|
|
),
|
|
"wallet_urls": wallet_urls,
|
|
"already_enrolled": already_enrolled,
|
|
"allow_cross_location": allow_cross_location,
|
|
"enrolled_at_store_name": enrolled_at_store_name,
|
|
"merchant_locations": location_list,
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# Authenticated Endpoints
|
|
# =============================================================================
|
|
|
|
|
|
@storefront_router.get("/loyalty/card")
|
|
def get_my_card(
|
|
request: Request,
|
|
customer: CustomerContext = Depends(get_current_customer_api),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get customer's loyalty card and program info.
|
|
Returns card details, program info, and available rewards.
|
|
"""
|
|
store = getattr(request.state, "store", None)
|
|
if not store:
|
|
raise StoreNotFoundException("context", identifier_type="subdomain")
|
|
|
|
logger.debug(f"Getting loyalty card for customer {customer.id}")
|
|
|
|
# Get program
|
|
program = program_service.get_program_by_store(db, store.id)
|
|
if not program:
|
|
return {"card": None, "program": None, "locations": []}
|
|
|
|
# Look up card by customer ID and merchant
|
|
card = card_service.get_card_by_customer_and_merchant(
|
|
db,
|
|
customer_id=customer.id,
|
|
merchant_id=program.merchant_id,
|
|
)
|
|
|
|
if not card:
|
|
return {"card": None, "program": None, "locations": []}
|
|
|
|
# Get merchant locations
|
|
locations = program_service.get_merchant_locations(db, program.merchant_id)
|
|
|
|
program_response = ProgramResponse.model_validate(program)
|
|
program_response.is_stamps_enabled = program.is_stamps_enabled
|
|
program_response.is_points_enabled = program.is_points_enabled
|
|
program_response.display_name = program.display_name
|
|
|
|
wallet_urls = wallet_service.get_add_to_wallet_urls(db, card)
|
|
|
|
return {
|
|
"card": CardResponse(
|
|
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,
|
|
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,
|
|
has_google_wallet=bool(card.google_object_id),
|
|
has_apple_wallet=bool(card.apple_serial_number),
|
|
),
|
|
"program": program_response,
|
|
"locations": [{"id": v.id, "name": v.name} for v in locations],
|
|
"wallet_urls": wallet_urls,
|
|
}
|
|
|
|
|
|
@storefront_router.get("/loyalty/transactions")
|
|
def get_my_transactions(
|
|
request: Request,
|
|
skip: int = Query(0, ge=0),
|
|
limit: int = Query(20, ge=1, le=100),
|
|
customer: CustomerContext = Depends(get_current_customer_api),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get customer's loyalty transaction history.
|
|
"""
|
|
store = getattr(request.state, "store", None)
|
|
if not store:
|
|
raise StoreNotFoundException("context", identifier_type="subdomain")
|
|
|
|
logger.debug(f"Getting transactions for customer {customer.id}")
|
|
|
|
# Get program
|
|
program = program_service.get_program_by_store(db, store.id)
|
|
if not program:
|
|
return {"transactions": [], "total": 0}
|
|
|
|
# Get card
|
|
card = card_service.get_card_by_customer_and_merchant(
|
|
db,
|
|
customer_id=customer.id,
|
|
merchant_id=program.merchant_id,
|
|
)
|
|
|
|
if not card:
|
|
return {"transactions": [], "total": 0}
|
|
|
|
# Get transactions with store names
|
|
tx_responses, total = card_service.get_customer_transactions_with_store_names(
|
|
db, card.id, skip=skip, limit=limit
|
|
)
|
|
|
|
return {"transactions": tx_responses, "total": total}
|