feat(loyalty): wallet debug page, Google Wallet fixes, and module config env_file standardization
Some checks failed
Some checks failed
- Add wallet diagnostics page at /admin/loyalty/wallet-debug (super admin only) with explorer-sidebar pattern: config validation, class status, card inspector, save URL tester, recent enrollments, and Apple Wallet status panels - Fix Google Wallet fat JWT: include both loyaltyClasses and loyaltyObjects in payload, use UNDER_REVIEW instead of DRAFT for class reviewStatus - Fix StorefrontProgramResponse schema: accept google_class_id values while keeping exclude=True (was rejecting non-None values) - Standardize all module configs to read from .env file directly (env_file=".env", extra="ignore") matching core Settings pattern - Add MOD-026 architecture rule enforcing env_file in module configs - Add SVC-005 noqa support in architecture validator - Add test files for dev_tools domain_health and isolation_audit services - Add google_wallet_status.py script for querying Google Wallet API - Use table_wrapper macro in wallet-debug.html (FE-005 compliance) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
905
app/modules/loyalty/templates/loyalty/admin/wallet-debug.html
Normal file
905
app/modules/loyalty/templates/loyalty/admin/wallet-debug.html
Normal file
@@ -0,0 +1,905 @@
|
||||
{# app/modules/loyalty/templates/loyalty/admin/wallet-debug.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/headers.html' import page_header %}
|
||||
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
|
||||
|
||||
{% block title %}Wallet Debug{% endblock %}
|
||||
|
||||
{% block alpine_data %}walletDebug(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header('Wallet Diagnostics', back_url='/admin/loyalty/programs', back_label='Back to Programs') }}
|
||||
|
||||
<div class="flex gap-6">
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<!-- Left sidebar — Diagnostic Tools explorer -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<div class="w-72 flex-shrink-0">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<div class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3 flex items-center gap-1.5">
|
||||
<span x-html="$icon('beaker', 'w-4 h-4')"></span>
|
||||
Wallet Tools
|
||||
</div>
|
||||
<template x-for="group in toolGroups" :key="group.category">
|
||||
<div class="mb-1">
|
||||
<button @click="toggleCategory(group.category)"
|
||||
class="flex items-center justify-between w-full text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase px-2 py-1 rounded hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
<span x-text="group.category"></span>
|
||||
<span class="text-[10px] font-mono leading-none" x-text="isCategoryExpanded(group.category) ? '−' : '+'"></span>
|
||||
</button>
|
||||
<ul x-show="isCategoryExpanded(group.category)" x-collapse class="space-y-0.5 mt-0.5">
|
||||
<template x-for="tool in group.items" :key="tool.id">
|
||||
<li @click="selectTool(tool.id)"
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm cursor-pointer transition-colors"
|
||||
:class="activeTool === tool.id
|
||||
? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-200'">
|
||||
<span x-html="$icon(tool.icon, 'w-3.5 h-3.5 flex-shrink-0')"></span>
|
||||
<span class="truncate" x-text="tool.label"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<!-- Main area — tool content panels -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
||||
<div class="flex-1 min-w-0">
|
||||
|
||||
<!-- ─────────────────────────────────────────────────────────── -->
|
||||
<!-- Tool: Config Validation -->
|
||||
<!-- ─────────────────────────────────────────────────────────── -->
|
||||
<div x-show="activeTool === 'config-check'" x-cloak>
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Config Validation</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Validates Google Wallet configuration: service account, credentials, issuer ID, origins, and logo URL reachability.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<button @click="loadConfig()" :disabled="configLoading"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 text-sm font-medium">
|
||||
<span x-show="!configLoading">Run Validation</span>
|
||||
<span x-show="configLoading">Checking...</span>
|
||||
</button>
|
||||
<template x-if="configData">
|
||||
<button @click="copyConfigResults()"
|
||||
class="px-3 py-2 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 font-medium">
|
||||
Copy Results
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template x-if="configData">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-6 space-y-3">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase mb-4">Google Wallet</h3>
|
||||
|
||||
<!-- Checklist items -->
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span x-text="configData.configured ? '✅' : '❌'"></span>
|
||||
<span class="text-gray-700 dark:text-gray-300">Configured:</span>
|
||||
<span class="font-mono text-gray-500 dark:text-gray-400" x-text="configData.configured ? 'Yes' : 'No'"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span x-text="configData.service_account_path ? '✅' : '❌'"></span>
|
||||
<span class="text-gray-700 dark:text-gray-300">Service Account File:</span>
|
||||
<span class="font-mono text-xs text-gray-500 dark:text-gray-400" x-text="configData.service_account_path || 'Not set'"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span x-text="configData.credentials_valid ? '✅' : '❌'"></span>
|
||||
<span class="text-gray-700 dark:text-gray-300">Credentials Valid:</span>
|
||||
<span class="font-mono text-gray-500 dark:text-gray-400" x-text="configData.credentials_valid ? 'Yes' : 'No'"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span x-text="configData.issuer_id ? '✅' : '❌'"></span>
|
||||
<span class="text-gray-700 dark:text-gray-300">Issuer ID:</span>
|
||||
<span class="font-mono text-gray-500 dark:text-gray-400" x-text="configData.issuer_id || 'Not set'"></span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span x-text="configData.origins && configData.origins.length > 0 ? '✅' : '⚠️'"></span>
|
||||
<span class="text-gray-700 dark:text-gray-300">Wallet Origins:</span>
|
||||
<div>
|
||||
<template x-if="configData.origins && configData.origins.length > 0">
|
||||
<ul class="font-mono text-xs text-gray-500 dark:text-gray-400">
|
||||
<template x-for="origin in configData.origins" :key="origin">
|
||||
<li x-text="origin"></li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
<template x-if="!configData.origins || configData.origins.length === 0">
|
||||
<span class="text-xs text-amber-600 dark:text-amber-400">Empty (wallet button may not work)</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span x-text="configData.default_logo_url ? '✅' : '⚠️'"></span>
|
||||
<span class="text-gray-700 dark:text-gray-300">Default Logo URL:</span>
|
||||
<span class="font-mono text-xs text-gray-500 dark:text-gray-400 truncate max-w-md" x-text="configData.default_logo_url || 'Not set'"></span>
|
||||
</div>
|
||||
<template x-if="configData.service_account_email">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>📧</span>
|
||||
<span class="text-gray-700 dark:text-gray-300">Service Account Email:</span>
|
||||
<span class="font-mono text-xs text-gray-500 dark:text-gray-400" x-text="configData.service_account_email"></span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="configData.project_id">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>📂</span>
|
||||
<span class="text-gray-700 dark:text-gray-300">Project ID:</span>
|
||||
<span class="font-mono text-xs text-gray-500 dark:text-gray-400" x-text="configData.project_id"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Errors -->
|
||||
<template x-if="configData.errors && configData.errors.length > 0">
|
||||
<div class="mt-4 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<h4 class="text-sm font-semibold text-red-700 dark:text-red-400 mb-2">Errors</h4>
|
||||
<ul class="text-sm text-red-600 dark:text-red-400 space-y-1">
|
||||
<template x-for="err in configData.errors" :key="err">
|
||||
<li class="flex items-start gap-1">
|
||||
<span>❌</span>
|
||||
<span x-text="err"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Warnings -->
|
||||
<template x-if="configData.warnings && configData.warnings.length > 0">
|
||||
<div class="mt-4 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg">
|
||||
<h4 class="text-sm font-semibold text-amber-700 dark:text-amber-400 mb-2">Warnings</h4>
|
||||
<ul class="text-sm text-amber-600 dark:text-amber-400 space-y-1">
|
||||
<template x-for="w in configData.warnings" :key="w">
|
||||
<li class="flex items-start gap-1">
|
||||
<span>⚠️</span>
|
||||
<span x-text="w"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Apple Wallet section -->
|
||||
<div class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase mb-4">Apple Wallet</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span x-text="configData.apple && configData.apple.configured ? '✅' : '❌'"></span>
|
||||
<span class="text-gray-700 dark:text-gray-300">Configured:</span>
|
||||
<span class="font-mono text-gray-500 dark:text-gray-400" x-text="configData.apple && configData.apple.configured ? 'Yes' : 'No'"></span>
|
||||
</div>
|
||||
<template x-if="configData.apple && configData.apple.pass_type_id">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>🎫</span>
|
||||
<span class="text-gray-700 dark:text-gray-300">Pass Type ID:</span>
|
||||
<span class="font-mono text-xs text-gray-500 dark:text-gray-400" x-text="configData.apple.pass_type_id"></span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="configData.apple && configData.apple.team_id">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>🏢</span>
|
||||
<span class="text-gray-700 dark:text-gray-300">Team ID:</span>
|
||||
<span class="font-mono text-xs text-gray-500 dark:text-gray-400" x-text="configData.apple.team_id"></span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="configData.apple && !configData.apple.configured">
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-900 rounded text-xs text-gray-500 dark:text-gray-400">
|
||||
Apple Wallet is not configured. Required env vars: LOYALTY_APPLE_PASS_TYPE_ID, LOYALTY_APPLE_TEAM_ID, LOYALTY_APPLE_WWDR_CERT_PATH, LOYALTY_APPLE_SIGNER_CERT_PATH, LOYALTY_APPLE_SIGNER_KEY_PATH
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="configError">
|
||||
<div class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg text-sm text-red-600 dark:text-red-400" x-text="configError"></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ─────────────────────────────────────────────────────────── -->
|
||||
<!-- Tool: Class Status -->
|
||||
<!-- ─────────────────────────────────────────────────────────── -->
|
||||
<div x-show="activeTool === 'class-status'" x-cloak>
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Class Status</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Shows Google Wallet class review status for each loyalty program. Classes must be APPROVED for wallet buttons to work.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<button @click="loadClasses()" :disabled="classesLoading"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 text-sm font-medium">
|
||||
<span x-show="!classesLoading">Check Status</span>
|
||||
<span x-show="classesLoading">Loading...</span>
|
||||
</button>
|
||||
<template x-if="classesData">
|
||||
<button @click="copyClassesResults()"
|
||||
class="px-3 py-2 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 font-medium">
|
||||
Copy Results
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template x-if="classesData">
|
||||
{% call table_wrapper() %}
|
||||
{{ table_header(['Program', 'Merchant', 'Class ID', 'Review Status', 'Actions']) }}
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-for="cls in classesData.programs" :key="cls.program_id">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white" x-text="cls.program_name"></td>
|
||||
<td class="px-4 py-3" x-text="cls.merchant_name || '—'"></td>
|
||||
<td class="px-4 py-3 font-mono text-xs" x-text="cls.google_class_id || '—'"></td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
:class="{
|
||||
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300': cls.review_status === 'APPROVED',
|
||||
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300': cls.review_status === 'DRAFT' || cls.review_status === 'UNDER_REVIEW',
|
||||
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300': cls.review_status === 'REJECTED',
|
||||
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300': cls.review_status === 'NOT_CREATED' || cls.review_status === 'UNKNOWN'
|
||||
}" x-text="cls.review_status"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<template x-if="cls.review_status === 'NOT_CREATED'">
|
||||
<button @click="createClass(cls.program_id)" :disabled="classCreating === cls.program_id"
|
||||
class="px-3 py-1 text-xs bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:opacity-50">
|
||||
<span x-show="classCreating !== cls.program_id">Create Class</span>
|
||||
<span x-show="classCreating === cls.program_id">Creating...</span>
|
||||
</button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
</template>
|
||||
|
||||
<template x-if="classesError">
|
||||
<div class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg text-sm text-red-600 dark:text-red-400" x-text="classesError"></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ─────────────────────────────────────────────────────────── -->
|
||||
<!-- Tool: Card Inspector -->
|
||||
<!-- ─────────────────────────────────────────────────────────── -->
|
||||
<div x-show="activeTool === 'card-inspector'" x-cloak>
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Card Inspector</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Inspect a loyalty card's wallet integration state, including Google Wallet object status and JWT details.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs">
|
||||
<div class="flex gap-3 items-end flex-wrap">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Card Number or ID</label>
|
||||
<input x-model="cardSearch" type="text" placeholder="XXXX-XXXX-XXXX or card ID"
|
||||
@keydown.enter="inspectCard()"
|
||||
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-64">
|
||||
</div>
|
||||
<button @click="inspectCard()" :disabled="cardLoading"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 text-sm font-medium">
|
||||
<span x-show="!cardLoading">Inspect</span>
|
||||
<span x-show="cardLoading">Loading...</span>
|
||||
</button>
|
||||
<template x-if="cardData && !cardData.error">
|
||||
<button @click="copyCardResults()"
|
||||
class="px-3 py-2 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 font-medium">
|
||||
Copy Results
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="cardData && !cardData.error">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-6 space-y-3">
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Card Number:</span>
|
||||
<span class="ml-2 font-mono text-gray-900 dark:text-white" x-text="cardData.card_number"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Card ID:</span>
|
||||
<span class="ml-2 font-mono text-gray-900 dark:text-white" x-text="cardData.card_id"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Customer:</span>
|
||||
<span class="ml-2 text-gray-900 dark:text-white" x-text="cardData.customer_email || '—'"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Program:</span>
|
||||
<span class="ml-2 text-gray-900 dark:text-white" x-text="cardData.program_name || '—'"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Active:</span>
|
||||
<span class="ml-2" x-text="cardData.is_active ? '✅ Yes' : '❌ No'"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Created:</span>
|
||||
<span class="ml-2 text-xs text-gray-900 dark:text-white" x-text="cardData.created_at || '—'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Google Wallet</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span x-text="cardData.google_object_id ? '✅' : '❌'"></span>
|
||||
<span class="text-gray-700 dark:text-gray-300">Object ID:</span>
|
||||
<span class="font-mono text-xs text-gray-500 dark:text-gray-400" x-text="cardData.google_object_id || 'Not created'"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span x-text="cardData.google_object_jwt && cardData.google_object_jwt.present ? '✅' : '❌'"></span>
|
||||
<span class="text-gray-700 dark:text-gray-300">JWT:</span>
|
||||
<template x-if="cardData.google_object_jwt && cardData.google_object_jwt.present">
|
||||
<span class="text-xs">
|
||||
<span class="px-2 py-0.5 rounded-full font-medium"
|
||||
:class="cardData.google_object_jwt.type === 'reference'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-300'"
|
||||
x-text="cardData.google_object_jwt.type"></span>
|
||||
<span class="ml-2 font-mono text-gray-400" x-text="'iss: ' + (cardData.google_object_jwt.iss || '?')"></span>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="!cardData.google_object_jwt || !cardData.google_object_jwt.present">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Not present</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span x-text="cardData.google_object_exists_in_api === true ? '✅' : (cardData.google_object_exists_in_api === false ? '❌' : '⚪')"></span>
|
||||
<span class="text-gray-700 dark:text-gray-300">Live API Check:</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400"
|
||||
x-text="cardData.google_object_exists_in_api === true ? 'Object exists in Google'
|
||||
: cardData.google_object_exists_in_api === false ? 'Object NOT found in Google'
|
||||
: 'Not checked'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Apple Wallet</h4>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span x-text="cardData.has_apple_wallet ? '✅' : '❌'"></span>
|
||||
<span class="text-gray-700 dark:text-gray-300">Serial Number:</span>
|
||||
<span class="font-mono text-xs text-gray-500 dark:text-gray-400" x-text="cardData.apple_serial_number || 'Not created'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 flex gap-3">
|
||||
<button @click="generateUrlFromInspector(cardData.card_id)"
|
||||
class="px-3 py-1.5 text-xs bg-indigo-600 text-white rounded hover:bg-indigo-700">
|
||||
Generate Save URL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="cardData && cardData.error">
|
||||
<div class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg text-sm text-red-600 dark:text-red-400" x-text="cardData.error"></div>
|
||||
</template>
|
||||
|
||||
<template x-if="cardError">
|
||||
<div class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg text-sm text-red-600 dark:text-red-400" x-text="cardError"></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ─────────────────────────────────────────────────────────── -->
|
||||
<!-- Tool: Save URL Tester -->
|
||||
<!-- ─────────────────────────────────────────────────────────── -->
|
||||
<div x-show="activeTool === 'url-tester'" x-cloak>
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Save URL Tester</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Generate a fresh Google Wallet save URL for a card and inspect the JWT.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs">
|
||||
<div class="flex gap-3 items-end flex-wrap">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Card ID</label>
|
||||
<input x-model="urlCardId" type="number" placeholder="Card ID"
|
||||
@keydown.enter="generateUrl()"
|
||||
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-40">
|
||||
</div>
|
||||
<button @click="generateUrl()" :disabled="urlLoading"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 text-sm font-medium">
|
||||
<span x-show="!urlLoading">Generate URL</span>
|
||||
<span x-show="urlLoading">Generating...</span>
|
||||
</button>
|
||||
<template x-if="urlData && urlData.success">
|
||||
<button @click="copyUrlResults()"
|
||||
class="px-3 py-2 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 font-medium">
|
||||
Copy Results
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="urlData && urlData.success">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-6 space-y-4">
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Generated URL</h4>
|
||||
<div class="flex items-center gap-2">
|
||||
<a :href="urlData.url" target="_blank" rel="noopener"
|
||||
class="text-sm text-indigo-600 dark:text-indigo-400 hover:underline font-mono break-all" x-text="urlData.url.substring(0, 100) + '...'"></a>
|
||||
<button @click="navigator.clipboard.writeText(urlData.url)"
|
||||
class="flex-shrink-0 px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
|
||||
Copy
|
||||
</button>
|
||||
<a :href="urlData.url" target="_blank" rel="noopener"
|
||||
class="flex-shrink-0 px-2 py-1 text-xs bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded hover:bg-green-200 dark:hover:bg-green-800">
|
||||
Open
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="urlData.jwt_preview">
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">JWT Details</h4>
|
||||
<div class="space-y-1 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-500 dark:text-gray-400">Type:</span>
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="urlData.jwt_preview.type === 'reference'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-300'"
|
||||
x-text="urlData.jwt_preview.type"></span>
|
||||
<span class="text-xs text-gray-400" x-text="urlData.jwt_preview.type === 'fat' ? '(object data embedded in JWT — class may not be approved)' : '(references existing object by ID)'"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Issuer:</span>
|
||||
<span class="ml-2 font-mono text-xs text-gray-900 dark:text-white" x-text="urlData.jwt_preview.iss || '—'"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Audience:</span>
|
||||
<span class="ml-2 font-mono text-xs text-gray-900 dark:text-white" x-text="urlData.jwt_preview.aud || '—'"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Expires:</span>
|
||||
<span class="ml-2 font-mono text-xs text-gray-900 dark:text-white" x-text="urlData.jwt_preview.exp ? new Date(urlData.jwt_preview.exp * 1000).toISOString() : '—'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="urlData && !urlData.success">
|
||||
<div class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg text-sm text-red-600 dark:text-red-400" x-text="urlData.error"></div>
|
||||
</template>
|
||||
|
||||
<template x-if="urlError">
|
||||
<div class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg text-sm text-red-600 dark:text-red-400" x-text="urlError"></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ─────────────────────────────────────────────────────────── -->
|
||||
<!-- Tool: Recent Enrollments -->
|
||||
<!-- ─────────────────────────────────────────────────────────── -->
|
||||
<div x-show="activeTool === 'recent-enrollments'" x-cloak>
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Recent Enrollments</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Shows the last 20 enrollments with their wallet integration status.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<button @click="loadEnrollments()" :disabled="enrollmentsLoading"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 text-sm font-medium">
|
||||
<span x-show="!enrollmentsLoading">Load Enrollments</span>
|
||||
<span x-show="enrollmentsLoading">Loading...</span>
|
||||
</button>
|
||||
<template x-if="enrollmentsData">
|
||||
<button @click="copyEnrollmentsResults()"
|
||||
class="px-3 py-2 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 font-medium">
|
||||
Copy Results
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template x-if="enrollmentsData">
|
||||
{% call table_wrapper() %}
|
||||
{{ table_header(['Card #', 'Customer', 'Program', 'Enrolled At', 'Google Object', 'Google JWT', 'Status', 'Actions']) }}
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-for="enr in enrollmentsData.enrollments" :key="enr.card_id">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-4 py-3 font-mono text-xs" x-text="enr.card_number"></td>
|
||||
<td class="px-4 py-3 text-xs" x-text="enr.customer_email || '—'"></td>
|
||||
<td class="px-4 py-3 text-xs" x-text="enr.program_name || '—'"></td>
|
||||
<td class="px-4 py-3 text-xs" x-text="enr.enrolled_at ? enr.enrolled_at.substring(0, 19) : '—'"></td>
|
||||
<td class="px-4 py-3">
|
||||
<span x-text="enr.has_google_wallet ? '✅' : '❌'" class="text-xs"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span x-text="enr.has_google_jwt ? '✅' : '❌'" class="text-xs"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
:class="{
|
||||
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300': enr.status === 'wallet_ready',
|
||||
'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-300': enr.status === 'jwt_only',
|
||||
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300': enr.status === 'no_wallet'
|
||||
}"
|
||||
x-text="enr.status === 'wallet_ready' ? '✅ Ready' : enr.status === 'jwt_only' ? '⚠️ JWT only' : '❌ None'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<button @click="cardSearch = enr.card_number; selectTool('card-inspector'); inspectCard()"
|
||||
class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
|
||||
Inspect
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
<template x-if="enrollmentsData.enrollments.length === 0">
|
||||
<div class="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
No enrollments found.
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template x-if="enrollmentsError">
|
||||
<div class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg text-sm text-red-600 dark:text-red-400" x-text="enrollmentsError"></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ─────────────────────────────────────────────────────────── -->
|
||||
<!-- Tool: Apple Status -->
|
||||
<!-- ─────────────────────────────────────────────────────────── -->
|
||||
<div x-show="activeTool === 'apple-status'" x-cloak>
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Apple Wallet Status</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Apple Wallet configuration status and pass statistics.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<button @click="loadConfig()" :disabled="configLoading"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 text-sm font-medium">
|
||||
<span x-show="!configLoading">Check Config</span>
|
||||
<span x-show="configLoading">Loading...</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template x-if="configData && configData.apple">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-6">
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span x-text="configData.apple.configured ? '✅' : '❌'"></span>
|
||||
<span class="text-gray-700 dark:text-gray-300 font-medium">Configured:</span>
|
||||
<span class="text-gray-500 dark:text-gray-400" x-text="configData.apple.configured ? 'Yes' : 'No'"></span>
|
||||
</div>
|
||||
<template x-if="configData.apple.configured">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>🎫</span>
|
||||
<span class="text-gray-700 dark:text-gray-300">Pass Type ID:</span>
|
||||
<span class="font-mono text-xs text-gray-500 dark:text-gray-400" x-text="configData.apple.pass_type_id"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>🏢</span>
|
||||
<span class="text-gray-700 dark:text-gray-300">Team ID:</span>
|
||||
<span class="font-mono text-xs text-gray-500 dark:text-gray-400" x-text="configData.apple.team_id"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span x-text="configData.apple.credentials_valid ? '✅' : '❌'"></span>
|
||||
<span class="text-gray-700 dark:text-gray-300">Credentials Valid:</span>
|
||||
<span class="text-gray-500 dark:text-gray-400" x-text="configData.apple.credentials_valid ? 'Yes' : 'No'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!configData.apple.configured">
|
||||
<div class="mt-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">Apple Wallet is not configured. Set these environment variables:</p>
|
||||
<ul class="font-mono text-xs text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<li>LOYALTY_APPLE_PASS_TYPE_ID</li>
|
||||
<li>LOYALTY_APPLE_TEAM_ID</li>
|
||||
<li>LOYALTY_APPLE_WWDR_CERT_PATH</li>
|
||||
<li>LOYALTY_APPLE_SIGNER_CERT_PATH</li>
|
||||
<li>LOYALTY_APPLE_SIGNER_KEY_PATH</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Apple errors -->
|
||||
<template x-if="configData.apple.errors && configData.apple.errors.length > 0">
|
||||
<div class="mt-4 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<h4 class="text-sm font-semibold text-red-700 dark:text-red-400 mb-2">Errors</h4>
|
||||
<ul class="text-sm text-red-600 dark:text-red-400 space-y-1">
|
||||
<template x-for="err in configData.apple.errors" :key="err">
|
||||
<li x-text="err"></li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!configData">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-8 text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Click "Check Config" to load Apple Wallet status.</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function walletDebug() {
|
||||
return {
|
||||
// Inherit base layout functionality
|
||||
...data(),
|
||||
|
||||
// Page identifier
|
||||
currentPage: 'wallet-debug',
|
||||
|
||||
// ── Sidebar / tool navigation ──
|
||||
activeTool: 'config-check',
|
||||
expandedCategories: ['Configuration', 'Google Wallet', 'Enrollment', 'Apple Wallet'],
|
||||
toolGroups: [
|
||||
{
|
||||
category: 'Configuration',
|
||||
items: [
|
||||
{ id: 'config-check', label: 'Config Validation', icon: 'cog' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Google Wallet',
|
||||
items: [
|
||||
{ id: 'class-status', label: 'Class Status', icon: 'identification' },
|
||||
{ id: 'card-inspector', label: 'Card Inspector', icon: 'search' },
|
||||
{ id: 'url-tester', label: 'Save URL Tester', icon: 'link' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Enrollment',
|
||||
items: [
|
||||
{ id: 'recent-enrollments', label: 'Recent Enrollments', icon: 'users' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Apple Wallet',
|
||||
items: [
|
||||
{ id: 'apple-status', label: 'Status', icon: 'phone' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
toggleCategory(cat) {
|
||||
const idx = this.expandedCategories.indexOf(cat);
|
||||
if (idx >= 0) this.expandedCategories.splice(idx, 1);
|
||||
else this.expandedCategories.push(cat);
|
||||
},
|
||||
isCategoryExpanded(cat) {
|
||||
return this.expandedCategories.includes(cat);
|
||||
},
|
||||
selectTool(toolId) {
|
||||
this.activeTool = toolId;
|
||||
},
|
||||
|
||||
// ── Config Validation ──
|
||||
configData: null,
|
||||
configLoading: false,
|
||||
configError: '',
|
||||
|
||||
async loadConfig() {
|
||||
this.configLoading = true;
|
||||
this.configError = '';
|
||||
try {
|
||||
this.configData = await apiClient.get('/admin/loyalty/debug/config');
|
||||
} catch (e) {
|
||||
this.configError = e.message || 'Failed to load config';
|
||||
}
|
||||
this.configLoading = false;
|
||||
},
|
||||
|
||||
// ── Class Status ──
|
||||
classesData: null,
|
||||
classesLoading: false,
|
||||
classesError: '',
|
||||
classCreating: null,
|
||||
|
||||
async loadClasses() {
|
||||
this.classesLoading = true;
|
||||
this.classesError = '';
|
||||
try {
|
||||
this.classesData = await apiClient.get('/admin/loyalty/debug/classes');
|
||||
} catch (e) {
|
||||
this.classesError = e.message || 'Failed to load classes';
|
||||
}
|
||||
this.classesLoading = false;
|
||||
},
|
||||
|
||||
async createClass(programId) {
|
||||
this.classCreating = programId;
|
||||
try {
|
||||
const result = await apiClient.post(`/admin/loyalty/debug/classes/${programId}/create`);
|
||||
if (result.success) {
|
||||
await this.loadClasses();
|
||||
} else {
|
||||
this.classesError = result.error || 'Failed to create class';
|
||||
}
|
||||
} catch (e) {
|
||||
this.classesError = e.message || 'Failed to create class';
|
||||
}
|
||||
this.classCreating = null;
|
||||
},
|
||||
|
||||
// ── Card Inspector ──
|
||||
cardSearch: '',
|
||||
cardData: null,
|
||||
cardLoading: false,
|
||||
cardError: '',
|
||||
|
||||
async inspectCard() {
|
||||
if (!this.cardSearch.trim()) return;
|
||||
this.cardLoading = true;
|
||||
this.cardError = '';
|
||||
this.cardData = null;
|
||||
try {
|
||||
const val = this.cardSearch.trim();
|
||||
// If it looks like a number (no dashes), use card_id; otherwise card_number
|
||||
const isNumericId = /^\d+$/.test(val);
|
||||
let url;
|
||||
if (isNumericId) {
|
||||
url = `/admin/loyalty/debug/cards/${val}`;
|
||||
} else {
|
||||
url = `/admin/loyalty/debug/cards/0?card_number=${encodeURIComponent(val)}`;
|
||||
}
|
||||
this.cardData = await apiClient.get(url);
|
||||
} catch (e) {
|
||||
this.cardError = e.message || 'Failed to inspect card';
|
||||
}
|
||||
this.cardLoading = false;
|
||||
},
|
||||
|
||||
generateUrlFromInspector(cardId) {
|
||||
this.urlCardId = cardId;
|
||||
this.selectTool('url-tester');
|
||||
this.generateUrl();
|
||||
},
|
||||
|
||||
// ── Save URL Tester ──
|
||||
urlCardId: '',
|
||||
urlData: null,
|
||||
urlLoading: false,
|
||||
urlError: '',
|
||||
|
||||
async generateUrl() {
|
||||
if (!this.urlCardId) return;
|
||||
this.urlLoading = true;
|
||||
this.urlError = '';
|
||||
this.urlData = null;
|
||||
try {
|
||||
this.urlData = await apiClient.post(`/admin/loyalty/debug/cards/${this.urlCardId}/generate-url`);
|
||||
} catch (e) {
|
||||
this.urlError = e.message || 'Failed to generate URL';
|
||||
}
|
||||
this.urlLoading = false;
|
||||
},
|
||||
|
||||
// ── Recent Enrollments ──
|
||||
enrollmentsData: null,
|
||||
enrollmentsLoading: false,
|
||||
enrollmentsError: '',
|
||||
|
||||
async loadEnrollments() {
|
||||
this.enrollmentsLoading = true;
|
||||
this.enrollmentsError = '';
|
||||
try {
|
||||
this.enrollmentsData = await apiClient.get('/admin/loyalty/debug/recent-enrollments');
|
||||
} catch (e) {
|
||||
this.enrollmentsError = e.message || 'Failed to load enrollments';
|
||||
}
|
||||
this.enrollmentsLoading = false;
|
||||
},
|
||||
|
||||
// ── Copy helpers ──
|
||||
|
||||
copyConfigResults() {
|
||||
if (!this.configData) return;
|
||||
const d = this.configData;
|
||||
let text = '=== Wallet Config Validation ===\n\n';
|
||||
text += '--- Google Wallet ---\n';
|
||||
text += `Configured: ${d.configured ? 'Yes' : 'No'}\n`;
|
||||
text += `Service Account File: ${d.service_account_path || 'Not set'}\n`;
|
||||
text += `Credentials Valid: ${d.credentials_valid ? 'Yes' : 'No'}\n`;
|
||||
text += `Issuer ID: ${d.issuer_id || 'Not set'}\n`;
|
||||
text += `Wallet Origins: ${d.origins && d.origins.length > 0 ? d.origins.join(', ') : 'Empty'}\n`;
|
||||
text += `Default Logo URL: ${d.default_logo_url || 'Not set'}\n`;
|
||||
if (d.service_account_email) text += `Service Account Email: ${d.service_account_email}\n`;
|
||||
if (d.project_id) text += `Project ID: ${d.project_id}\n`;
|
||||
if (d.errors && d.errors.length > 0) text += `\nErrors:\n${d.errors.map(e => ' - ' + e).join('\n')}\n`;
|
||||
if (d.warnings && d.warnings.length > 0) text += `\nWarnings:\n${d.warnings.map(w => ' - ' + w).join('\n')}\n`;
|
||||
if (d.apple) {
|
||||
text += '\n--- Apple Wallet ---\n';
|
||||
text += `Configured: ${d.apple.configured ? 'Yes' : 'No'}\n`;
|
||||
if (d.apple.pass_type_id) text += `Pass Type ID: ${d.apple.pass_type_id}\n`;
|
||||
if (d.apple.team_id) text += `Team ID: ${d.apple.team_id}\n`;
|
||||
if (d.apple.credentials_valid !== undefined) text += `Credentials Valid: ${d.apple.credentials_valid ? 'Yes' : 'No'}\n`;
|
||||
if (d.apple.errors && d.apple.errors.length > 0) text += `Errors:\n${d.apple.errors.map(e => ' - ' + e).join('\n')}\n`;
|
||||
}
|
||||
navigator.clipboard.writeText(text);
|
||||
},
|
||||
|
||||
copyClassesResults() {
|
||||
if (!this.classesData) return;
|
||||
let text = '=== Wallet Class Status ===\n\n';
|
||||
text += 'Program | Merchant | Class ID | Review Status\n';
|
||||
text += '--------|----------|----------|-------------\n';
|
||||
for (const cls of this.classesData.programs) {
|
||||
text += `${cls.program_name} | ${cls.merchant_name || '—'} | ${cls.google_class_id || '—'} | ${cls.review_status}\n`;
|
||||
}
|
||||
navigator.clipboard.writeText(text);
|
||||
},
|
||||
|
||||
copyCardResults() {
|
||||
if (!this.cardData || this.cardData.error) return;
|
||||
const d = this.cardData;
|
||||
let text = '=== Card Inspector ===\n\n';
|
||||
text += `Card Number: ${d.card_number}\n`;
|
||||
text += `Card ID: ${d.card_id}\n`;
|
||||
text += `Customer: ${d.customer_email || '—'}\n`;
|
||||
text += `Program: ${d.program_name || '—'}\n`;
|
||||
text += `Active: ${d.is_active ? 'Yes' : 'No'}\n`;
|
||||
text += `Created: ${d.created_at || '—'}\n`;
|
||||
text += '\n--- Google Wallet ---\n';
|
||||
text += `Object ID: ${d.google_object_id || 'Not created'}\n`;
|
||||
if (d.google_object_jwt && d.google_object_jwt.present) {
|
||||
text += `JWT: Present (${d.google_object_jwt.type})\n`;
|
||||
text += ` Issuer: ${d.google_object_jwt.iss || '?'}\n`;
|
||||
if (d.google_object_jwt.exp) text += ` Expires: ${new Date(d.google_object_jwt.exp * 1000).toISOString()}\n`;
|
||||
} else {
|
||||
text += `JWT: Not present\n`;
|
||||
}
|
||||
text += `Live API Check: ${d.google_object_exists_in_api === true ? 'Object exists' : d.google_object_exists_in_api === false ? 'NOT found' : 'Not checked'}\n`;
|
||||
text += '\n--- Apple Wallet ---\n';
|
||||
text += `Serial Number: ${d.apple_serial_number || 'Not created'}\n`;
|
||||
navigator.clipboard.writeText(text);
|
||||
},
|
||||
|
||||
copyUrlResults() {
|
||||
if (!this.urlData || !this.urlData.success) return;
|
||||
let text = '=== Save URL ===\n\n';
|
||||
text += `URL: ${this.urlData.url}\n`;
|
||||
text += `Card ID: ${this.urlData.card_id}\n`;
|
||||
if (this.urlData.jwt_preview) {
|
||||
const j = this.urlData.jwt_preview;
|
||||
text += `\nJWT Type: ${j.type}\n`;
|
||||
text += `Issuer: ${j.iss || '—'}\n`;
|
||||
text += `Audience: ${j.aud || '—'}\n`;
|
||||
if (j.exp) text += `Expires: ${new Date(j.exp * 1000).toISOString()}\n`;
|
||||
}
|
||||
navigator.clipboard.writeText(text);
|
||||
},
|
||||
|
||||
copyEnrollmentsResults() {
|
||||
if (!this.enrollmentsData) return;
|
||||
let text = '=== Recent Enrollments ===\n\n';
|
||||
text += 'Card # | Customer | Program | Enrolled At | Google Obj | JWT | Status\n';
|
||||
text += '-------|----------|---------|-------------|------------|-----|-------\n';
|
||||
for (const e of this.enrollmentsData.enrollments) {
|
||||
text += `${e.card_number} | ${e.customer_email || '—'} | ${e.program_name || '—'} | ${e.enrolled_at ? e.enrolled_at.substring(0, 19) : '—'} | ${e.has_google_wallet ? 'Yes' : 'No'} | ${e.has_google_jwt ? 'Yes' : 'No'} | ${e.status}\n`;
|
||||
}
|
||||
navigator.clipboard.writeText(text);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user