fix(loyalty): cross-store enrollment, card scoping, i18n flicker
Some checks failed
Some checks failed
Fix duplicate card creation when the same email enrolls at different stores under the same merchant, and implement cross-location-aware enrollment behavior. - Cross-location enabled (default): one card per customer per merchant. Re-enrolling at another store returns the existing card with a "works at all our locations" message + store list. - Cross-location disabled: one card per customer per store. Enrolling at a different store creates a separate card for that store. Changes: - Migration loyalty_004: replace (merchant_id, customer_id) unique index with (enrolled_at_store_id, customer_id). Per-merchant uniqueness enforced at application layer when cross-location enabled. - card_service.resolve_customer_id: cross-store email lookup via merchant_id param to find existing cardholders at other stores. - card_service.enroll_customer: branch duplicate check on allow_cross_location_redemption setting. - card_service.search_card_for_store: cross-store email search when cross-location enabled so staff at store2 can find cards from store1. - card_service.get_card_by_customer_and_store: new service method. - storefront enrollment: catch LoyaltyCardAlreadyExistsException, return existing card with already_enrolled flag, locations, and cross-location context. Server-rendered i18n via Jinja2 tojson. - enroll-success.html: conditional cross-store/single-store messaging, server-rendered translations and context, i18n_modules block added. - dashboard.html, history.html: replace $t() with server-side _() to fix i18n flicker across all storefront templates. - Fix device-mobile icon → phone icon. - 4 new i18n keys in 4 locales (en, fr, de, lb). - Docs: updated data-model, business-logic, production-launch-plan, user-journeys with cross-location behavior and E2E test checklist. - 12 new unit tests + 3 new integration tests (334 total pass). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -105,12 +105,12 @@
|
||||
<template x-if="(card?.points_balance || 0) >= reward.points_required">
|
||||
<span class="inline-flex items-center text-sm font-medium text-green-600">
|
||||
<span x-html="$icon('check-circle', 'w-4 h-4 mr-1')"></span>
|
||||
<span x-text="$t('loyalty.storefront.dashboard.ready_to_redeem')"></span>
|
||||
<span>{{ _('loyalty.storefront.dashboard.ready_to_redeem') }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="(card?.points_balance || 0) < reward.points_required">
|
||||
<span class="text-sm text-gray-500"
|
||||
x-text="$t('loyalty.storefront.dashboard.x_more_to_go', {count: reward.points_required - (card?.points_balance || 0)})">
|
||||
x-text="'{{ _('loyalty.storefront.dashboard.x_more_to_go') }}'.replace('{count}', reward.points_required - (card?.points_balance || 0))">
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
@@ -208,14 +208,14 @@
|
||||
<template x-if="walletUrls.apple_wallet_url">
|
||||
<a :href="walletUrls.apple_wallet_url" target="_blank" rel="noopener"
|
||||
class="w-full flex items-center justify-center px-4 py-3 bg-black text-white rounded-lg hover:bg-gray-800 transition-colors">
|
||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||
<span x-html="$icon('phone', 'w-5 h-5 mr-2')"></span>
|
||||
{{ _('loyalty.loyalty.wallet.apple') }}
|
||||
</a>
|
||||
</template>
|
||||
<template x-if="walletUrls.google_wallet_url">
|
||||
<a :href="walletUrls.google_wallet_url" target="_blank" rel="noopener"
|
||||
class="w-full flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||
<span x-html="$icon('phone', 'w-5 h-5 mr-2')"></span>
|
||||
{{ _('loyalty.loyalty.wallet.google') }}
|
||||
</a>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
{% block title %}{{ _('loyalty.enrollment.success.title') }} - {{ store.name }}{% endblock %}
|
||||
|
||||
{% block i18n_modules %}['loyalty']{% endblock %}
|
||||
{% block alpine_data %}customerLoyaltyEnrollSuccess(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -16,7 +17,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">{{ _('loyalty.enrollment.success.title') }}</h1>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2"
|
||||
x-text="enrollContext.already_enrolled ? i18nStrings.already_enrolled_title : i18nStrings.success_title">
|
||||
{{ _('loyalty.enrollment.success.title') }}
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-8">{{ _('loyalty.enrollment.success.message') }}</p>
|
||||
|
||||
<!-- Card Number Display -->
|
||||
@@ -33,14 +37,14 @@
|
||||
<template x-if="walletUrls.apple_wallet_url">
|
||||
<a :href="walletUrls.apple_wallet_url" target="_blank" rel="noopener"
|
||||
class="w-full flex items-center justify-center px-4 py-3 bg-black text-white rounded-lg hover:bg-gray-800 transition-colors">
|
||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||
<span x-html="$icon('phone', 'w-5 h-5 mr-2')"></span>
|
||||
{{ _('loyalty.loyalty.wallet.apple') }}
|
||||
</a>
|
||||
</template>
|
||||
<template x-if="walletUrls.google_wallet_url">
|
||||
<a :href="walletUrls.google_wallet_url" target="_blank" rel="noopener"
|
||||
class="w-full flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||
<span x-html="$icon('phone', 'w-5 h-5 mr-2')"></span>
|
||||
{{ _('loyalty.loyalty.wallet.google') }}
|
||||
</a>
|
||||
</template>
|
||||
@@ -48,6 +52,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cross-location info -->
|
||||
<template x-if="enrollContext.already_enrolled || (enrollContext.merchant_locations?.length > 1 && enrollContext.allow_cross_location)">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-8 text-left">
|
||||
<template x-if="enrollContext.already_enrolled && enrollContext.allow_cross_location">
|
||||
<div>
|
||||
<p class="font-medium text-blue-800 dark:text-blue-200 mb-2" x-text="i18nStrings.cross_location_message"></p>
|
||||
<ul class="space-y-1">
|
||||
<template x-for="loc in enrollContext.merchant_locations" :key="loc.id">
|
||||
<li class="flex items-center text-sm text-blue-700 dark:text-blue-300">
|
||||
<span x-html="$icon('map-pin', 'w-4 h-4 mr-2 flex-shrink-0')"></span>
|
||||
<span x-text="loc.name"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="enrollContext.already_enrolled && !enrollContext.allow_cross_location">
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300" x-text="i18nStrings.single_location_message.replace('{store_name}', enrollContext.enrolled_at_store_name || '')"></p>
|
||||
</template>
|
||||
<template x-if="!enrollContext.already_enrolled && enrollContext.merchant_locations?.length > 1 && enrollContext.allow_cross_location">
|
||||
<div>
|
||||
<p class="font-medium text-blue-800 dark:text-blue-200 mb-2" x-text="i18nStrings.available_locations"></p>
|
||||
<ul class="space-y-1">
|
||||
<template x-for="loc in enrollContext.merchant_locations" :key="loc.id">
|
||||
<li class="flex items-center text-sm text-blue-700 dark:text-blue-300">
|
||||
<span x-html="$icon('map-pin', 'w-4 h-4 mr-2 flex-shrink-0')"></span>
|
||||
<span x-text="loc.name"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Next Steps -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 text-left mb-8">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white mb-4">{{ _('loyalty.enrollment.success.next_steps_title') }}</h2>
|
||||
@@ -89,6 +128,20 @@ function customerLoyaltyEnrollSuccess() {
|
||||
return {
|
||||
...storefrontLayoutData(),
|
||||
walletUrls: { google_wallet_url: null, apple_wallet_url: null },
|
||||
// Server-rendered context — no flicker, survives refreshes
|
||||
enrollContext: {
|
||||
already_enrolled: {{ server_already_enrolled|tojson }},
|
||||
allow_cross_location: {{ server_allow_cross_location|tojson }},
|
||||
enrolled_at_store_name: null,
|
||||
merchant_locations: {{ server_merchant_locations|tojson }},
|
||||
},
|
||||
i18nStrings: {
|
||||
success_title: {{ _('loyalty.enrollment.success.title')|tojson }},
|
||||
already_enrolled_title: {{ _('loyalty.enrollment.already_enrolled_title')|tojson }},
|
||||
cross_location_message: {{ _('loyalty.enrollment.cross_location_message')|tojson }},
|
||||
single_location_message: {{ _('loyalty.enrollment.single_location_message')|tojson }},
|
||||
available_locations: {{ _('loyalty.enrollment.available_locations')|tojson }},
|
||||
},
|
||||
|
||||
init() {
|
||||
// Read wallet URLs saved during enrollment (no auth needed)
|
||||
@@ -101,6 +154,20 @@ function customerLoyaltyEnrollSuccess() {
|
||||
} catch (e) {
|
||||
console.log('Could not load wallet URLs:', e.message);
|
||||
}
|
||||
|
||||
// Merge sessionStorage context (has enrolled_at_store_name from
|
||||
// the enrollment API response) into the server-rendered defaults
|
||||
try {
|
||||
const ctx = sessionStorage.getItem('loyalty_enroll_context');
|
||||
if (ctx) {
|
||||
const parsed = JSON.parse(ctx);
|
||||
if (parsed.enrolled_at_store_name) {
|
||||
this.enrollContext.enrolled_at_store_name = parsed.enrolled_at_store_name;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Could not load enroll context:', e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
{{ _('loyalty.storefront.history.previous') }}
|
||||
</button>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400"
|
||||
x-text="$t('loyalty.storefront.history.page_x_of_y', {page: pagination.page, pages: pagination.pages})">
|
||||
x-text="'{{ _('loyalty.storefront.history.page_x_of_y') }}'.replace('{page}', pagination.page).replace('{pages}', pagination.pages)">
|
||||
</span>
|
||||
<button @click="nextPage()" :disabled="pagination.page >= pagination.pages"
|
||||
class="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50">
|
||||
|
||||
Reference in New Issue
Block a user