From 53dfe018c2bacd5e887909c8e66dbc5b167c835e Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Tue, 24 Feb 2026 14:28:37 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20loyalty=20storefront=20and=20store=20car?= =?UTF-8?q?d=20detail=20=E2=80=94=20enrollment,=20context,=20and=20Alpine.?= =?UTF-8?q?js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/modules/core/utils/page_context.py | 7 ++- app/modules/loyalty/routes/api/store.py | 56 +++++++++++++++++ app/modules/loyalty/routes/api/storefront.py | 6 +- app/modules/loyalty/schemas/card.py | 14 +++++ app/modules/loyalty/services/card_service.py | 61 +++++++++++++++++-- .../loyalty/static/store/js/loyalty-enroll.js | 2 +- .../static/storefront/js/loyalty-dashboard.js | 2 +- .../static/storefront/js/loyalty-enroll.js | 9 +-- .../static/storefront/js/loyalty-history.js | 2 +- .../loyalty/storefront/dashboard.html | 2 +- .../loyalty/storefront/enroll-success.html | 2 +- .../templates/loyalty/storefront/enroll.html | 2 +- .../templates/loyalty/storefront/history.html | 2 +- 13 files changed, 148 insertions(+), 19 deletions(-) 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 %}