feat(loyalty): fix Google Wallet integration and improve enrollment flow
- Fix Google Wallet class creation: add required issuerName field (merchant name), programLogo with default logo fallback, hexBackgroundColor default - Add default loyalty logo assets (200px + 512px) for programs without custom logos - Smart retry: skip retries on 400/401/403/404 client errors (not transient) - Fix enrollment success page: use sessionStorage for wallet URLs instead of authenticated API call (self-enrolled customers have no session) - Hide wallet section on success page when no wallet URLs available - Wire up T&C modal on enrollment page with program.terms_text - Add startup validation for Google/Apple Wallet configs in lifespan - Add admin wallet status dashboard endpoint and UI (moved to service layer) - Fix Apple Wallet push notifications with real APNs HTTP/2 implementation - Fix docs: correct enrollment URLs (port, path segments, /v1 prefix) - Fix test assertion: !loyalty-enroll! → !enrollment! Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,109 @@
|
||||
{% set show_merchants_metric = true %}
|
||||
{% include "loyalty/shared/analytics-stats.html" %}
|
||||
|
||||
<!-- Wallet Integration Status -->
|
||||
<div class="mb-6 px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800" x-show="walletStatus">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
<span x-html="$icon('device-phone-mobile', 'w-5 h-5 inline mr-1')"></span>
|
||||
Wallet Integration Status
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Google Wallet -->
|
||||
<div class="p-4 border rounded-lg dark:border-gray-700" x-show="walletStatus?.google_wallet">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300">Google Wallet</h4>
|
||||
<template x-if="walletStatus?.google_wallet?.credentials_valid">
|
||||
<span class="px-2 py-1 text-xs font-medium text-green-700 bg-green-100 rounded-full dark:text-green-300 dark:bg-green-900/30">Connected</span>
|
||||
</template>
|
||||
<template x-if="walletStatus?.google_wallet?.configured && !walletStatus?.google_wallet?.credentials_valid">
|
||||
<span class="px-2 py-1 text-xs font-medium text-red-700 bg-red-100 rounded-full dark:text-red-300 dark:bg-red-900/30">Error</span>
|
||||
</template>
|
||||
<template x-if="!walletStatus?.google_wallet?.configured">
|
||||
<span class="px-2 py-1 text-xs font-medium text-gray-500 bg-gray-100 rounded-full dark:text-gray-400 dark:bg-gray-700">Not Configured</span>
|
||||
</template>
|
||||
</div>
|
||||
<template x-if="walletStatus?.google_wallet?.configured">
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Issuer ID</span>
|
||||
<span class="text-gray-700 dark:text-gray-300" x-text="walletStatus.google_wallet.issuer_id"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Project</span>
|
||||
<span class="text-gray-700 dark:text-gray-300" x-text="walletStatus.google_wallet.project_id || '-'"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Wallet Objects</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300" x-text="walletStatus.google_wallet.total_objects || 0"></span>
|
||||
</div>
|
||||
<!-- Class statuses -->
|
||||
<template x-if="walletStatus.google_wallet.classes?.length > 0">
|
||||
<div class="mt-2 pt-2 border-t dark:border-gray-700">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Loyalty Classes</p>
|
||||
<template x-for="cls in walletStatus.google_wallet.classes" :key="cls.class_id">
|
||||
<div class="flex justify-between text-xs py-1">
|
||||
<span class="text-gray-600 dark:text-gray-400" x-text="cls.program_name"></span>
|
||||
<span class="px-1.5 py-0.5 rounded"
|
||||
:class="cls.review_status === 'APPROVED' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' : cls.review_status === 'DRAFT' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
|
||||
x-text="cls.review_status"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Errors -->
|
||||
<template x-if="walletStatus.google_wallet.errors?.length > 0">
|
||||
<div class="mt-2 pt-2 border-t dark:border-gray-700">
|
||||
<template x-for="err in walletStatus.google_wallet.errors" :key="err">
|
||||
<p class="text-xs text-red-600 dark:text-red-400" x-text="err"></p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Apple Wallet -->
|
||||
<div class="p-4 border rounded-lg dark:border-gray-700" x-show="walletStatus?.apple_wallet">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300">Apple Wallet</h4>
|
||||
<template x-if="walletStatus?.apple_wallet?.credentials_valid">
|
||||
<span class="px-2 py-1 text-xs font-medium text-green-700 bg-green-100 rounded-full dark:text-green-300 dark:bg-green-900/30">Connected</span>
|
||||
</template>
|
||||
<template x-if="walletStatus?.apple_wallet?.configured && !walletStatus?.apple_wallet?.credentials_valid">
|
||||
<span class="px-2 py-1 text-xs font-medium text-red-700 bg-red-100 rounded-full dark:text-red-300 dark:bg-red-900/30">Error</span>
|
||||
</template>
|
||||
<template x-if="!walletStatus?.apple_wallet?.configured">
|
||||
<span class="px-2 py-1 text-xs font-medium text-gray-500 bg-gray-100 rounded-full dark:text-gray-400 dark:bg-gray-700">Not Configured</span>
|
||||
</template>
|
||||
</div>
|
||||
<template x-if="walletStatus?.apple_wallet?.configured">
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Pass Type ID</span>
|
||||
<span class="text-gray-700 dark:text-gray-300" x-text="walletStatus.apple_wallet.pass_type_id"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Team ID</span>
|
||||
<span class="text-gray-700 dark:text-gray-300" x-text="walletStatus.apple_wallet.team_id"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Active Passes</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300" x-text="walletStatus.apple_wallet.total_passes || 0"></span>
|
||||
</div>
|
||||
<template x-if="walletStatus.apple_wallet.errors?.length > 0">
|
||||
<div class="mt-2 pt-2 border-t dark:border-gray-700">
|
||||
<template x-for="err in walletStatus.apple_wallet.errors" :key="err">
|
||||
<p class="text-xs text-red-600 dark:text-red-400" x-text="err"></p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Quick Actions</h3>
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">Your Card Number</p>
|
||||
<p class="text-2xl font-mono font-bold text-gray-900 dark:text-white">{{ enrolled_card_number or 'Loading...' }}</p>
|
||||
|
||||
<div class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div x-show="walletUrls.apple_wallet_url || walletUrls.google_wallet_url"
|
||||
class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Save your card to your phone for easy access:
|
||||
</p>
|
||||
@@ -88,14 +89,15 @@ function customerLoyaltyEnrollSuccess() {
|
||||
return {
|
||||
...storefrontLayoutData(),
|
||||
walletUrls: { google_wallet_url: null, apple_wallet_url: null },
|
||||
async init() {
|
||||
init() {
|
||||
// Read wallet URLs saved during enrollment (no auth needed)
|
||||
try {
|
||||
const response = await apiClient.get('/storefront/loyalty/card');
|
||||
if (response && response.wallet_urls) {
|
||||
this.walletUrls = response.wallet_urls;
|
||||
const stored = sessionStorage.getItem('loyalty_wallet_urls');
|
||||
if (stored) {
|
||||
this.walletUrls = JSON.parse(stored);
|
||||
sessionStorage.removeItem('loyalty_wallet_urls');
|
||||
}
|
||||
} catch (e) {
|
||||
// Customer may not be authenticated (public enrollment)
|
||||
console.log('Could not load wallet URLs:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,13 @@
|
||||
class="mt-1 w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
style="color: var(--color-primary)">
|
||||
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
I agree to the <a href="#" class="underline" style="color: var(--color-primary)">Terms & Conditions</a>
|
||||
I agree to the
|
||||
<template x-if="program?.terms_text">
|
||||
<a href="#" @click.prevent="showTerms = true" class="underline" style="color: var(--color-primary)">Terms & Conditions</a>
|
||||
</template>
|
||||
<template x-if="!program?.terms_text">
|
||||
<span class="underline" style="color: var(--color-primary)">Terms & Conditions</span>
|
||||
</template>
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-start">
|
||||
@@ -128,6 +134,37 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# TODO: Rework T&C strategy - current approach (small text field on program model) won't scale
|
||||
for full legal T&C. Options: (1) leverage the CMS module to host T&C pages, or
|
||||
(2) create a dedicated T&C page within the loyalty module. Decision pending. #}
|
||||
<!-- Terms & Conditions Modal -->
|
||||
<div x-show="showTerms" x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"
|
||||
@click.self="showTerms = false"
|
||||
@keydown.escape.window="showTerms = false">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] flex flex-col">
|
||||
<div class="flex items-center justify-between p-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Terms & Conditions</h3>
|
||||
<button @click="showTerms = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-html="$icon('x-mark', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-4 overflow-y-auto text-sm text-gray-700 dark:text-gray-300 whitespace-pre-line" x-text="program?.terms_text"></div>
|
||||
<template x-if="program?.privacy_url">
|
||||
<div class="px-4 pb-2">
|
||||
<a :href="program.privacy_url" target="_blank" class="text-sm underline" style="color: var(--color-primary)">Privacy Policy</a>
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 border-t dark:border-gray-700">
|
||||
<button @click="showTerms = false"
|
||||
class="w-full py-2 px-4 text-white font-medium rounded-lg"
|
||||
:style="'background-color: ' + (program?.card_color || 'var(--color-primary)')">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
|
||||
Reference in New Issue
Block a user