All checks were successful
Adds a `static_v(request, name, path=...)` Jinja helper that appends
?v=<commit-sha> from app.core.build_info, plus a CachedStaticFiles
subclass that serves Cache-Control: public, max-age=31536000, immutable
in production and no-cache in development. Browsers refetch JS/CSS
automatically on every deploy without the user having to hard-reload.
- New: app/core/static_files.py (CachedStaticFiles)
- Updated: app/templates_config.py (static_v helper)
- Updated: main.py (use CachedStaticFiles for *_static mounts)
- Codemod: 143 url_for('*_static', path='*.js'|'*.css') → static_v(...)
across 123 templates. Images/fonts/JSON locales intentionally
unchanged (out of scope).
- Arch rule: FE-024 (warning) flags raw url_for on JS/CSS to prevent
drift. Note: FE-008 was already taken by the number_stepper rule.
- docs/proposals/static-asset-cache-busting.md marked Done.
Closes plan from docs/proposals/static-asset-cache-busting.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
206 lines
13 KiB
HTML
206 lines
13 KiB
HTML
{# app/modules/loyalty/templates/loyalty/store/card-detail.html #}
|
|
{% extends "store/base.html" %}
|
|
{% from 'shared/macros/headers.html' import detail_page_header %}
|
|
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
|
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
|
|
{% from 'shared/macros/pagination.html' import pagination %}
|
|
|
|
{% block title %}{{ _('loyalty.store.card_detail.title') }}{% endblock %}
|
|
|
|
{% block i18n_modules %}['loyalty']{% endblock %}
|
|
|
|
{% block alpine_data %}storeLoyaltyCardDetail(){% endblock %}
|
|
|
|
{% block content %}
|
|
{% call detail_page_header("card?.customer_name || '" + _('loyalty.store.card_detail.title') + "'", '/store/' + store_code + '/loyalty/cards', subtitle_show='card') %}
|
|
{{ _('loyalty.store.card_detail.card_label') }}: <span x-text="card?.card_number"></span>
|
|
{% endcall %}
|
|
|
|
{{ loading_state(_('loyalty.store.card_detail.loading')) }}
|
|
{{ error_state(_('loyalty.store.card_detail.error_loading')) }}
|
|
|
|
<div x-show="!loading && card">
|
|
<!-- Quick Stats -->
|
|
<div class="grid gap-6 mb-8 md:grid-cols-4">
|
|
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
|
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
|
|
<span x-html="$icon('currency-dollar', 'w-5 h-5')"></span>
|
|
</div>
|
|
<div>
|
|
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.store.card_detail.points_balance') }}</p>
|
|
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(card?.points_balance)">0</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
|
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
|
<span x-html="$icon('trending-up', 'w-5 h-5')"></span>
|
|
</div>
|
|
<div>
|
|
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.store.card_detail.total_earned') }}</p>
|
|
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(card?.total_points_earned)">0</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
|
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
|
<span x-html="$icon('gift', 'w-5 h-5')"></span>
|
|
</div>
|
|
<div>
|
|
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.store.card_detail.total_redeemed') }}</p>
|
|
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(card?.total_points_redeemed)">0</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
|
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
|
|
<span x-html="$icon('calendar', 'w-5 h-5')"></span>
|
|
</div>
|
|
<div>
|
|
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.store.card_detail.member_since') }}</p>
|
|
<p class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="formatDate(card?.created_at)">-</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid gap-6 mb-8 md:grid-cols-2">
|
|
<!-- Customer Info -->
|
|
<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">
|
|
<span x-html="$icon('user', 'inline w-5 h-5 mr-2')"></span>
|
|
{{ _('loyalty.store.card_detail.customer_information') }}
|
|
</h3>
|
|
<div class="space-y-3">
|
|
<div>
|
|
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.name') }}</p>
|
|
<div class="flex items-center gap-2">
|
|
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_name || '-'">-</p>
|
|
<button x-show="card?.customer_name" @click="Utils.copyToClipboard(card.customer_name)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
|
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.email') }}</p>
|
|
<div class="flex items-center gap-2">
|
|
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_email || '-'">-</p>
|
|
<button x-show="card?.customer_email" @click="Utils.copyToClipboard(card.customer_email)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
|
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.phone') }}</p>
|
|
<div class="flex items-center gap-2">
|
|
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_phone || '-'">-</p>
|
|
<button x-show="card?.customer_phone" @click="Utils.copyToClipboard(card.customer_phone)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
|
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.birthday') }}</p>
|
|
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_birthday || '-'">-</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Card Info -->
|
|
<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">
|
|
<span x-html="$icon('credit-card', 'inline w-5 h-5 mr-2')"></span>
|
|
{{ _('loyalty.store.card_detail.card_details') }}
|
|
</h3>
|
|
<div class="space-y-3">
|
|
<div>
|
|
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.card_number') }}</p>
|
|
<div class="flex items-center gap-2">
|
|
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="card?.card_number">-</p>
|
|
<button x-show="card?.card_number" @click="Utils.copyToClipboard(card.card_number)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
|
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.status') }}</p>
|
|
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
|
:class="card?.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100'"
|
|
x-text="card?.is_active ? $t('loyalty.common.active') : $t('loyalty.common.inactive')"></span>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.last_activity') }}</p>
|
|
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(card?.last_activity_at) || 'Never'">-</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.enrolled_at') }}</p>
|
|
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.enrolled_at_store_name || 'Unknown'">-</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Transaction History -->
|
|
<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">
|
|
<span x-html="$icon('clock', 'inline w-5 h-5 mr-2')"></span>
|
|
{{ _('loyalty.store.card_detail.transaction_history') }}
|
|
</h3>
|
|
{% call table_wrapper() %}
|
|
{{ table_header([_('loyalty.store.card_detail.col_date'), _('loyalty.store.card_detail.col_type'), _('loyalty.store.card_detail.col_points'), _('loyalty.store.terminal.select_category'), _('loyalty.store.card_detail.col_location'), _('loyalty.store.card_detail.col_notes')]) }}
|
|
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
|
<template x-if="transactions.length === 0">
|
|
<tr>
|
|
<td colspan="6" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
|
|
{{ _('loyalty.store.card_detail.no_transactions') }}
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
<template x-for="tx in transactions" :key="tx.id">
|
|
<tr class="text-gray-700 dark:text-gray-400">
|
|
<td class="px-4 py-3 text-sm" x-text="formatDateTime(tx.transaction_at)"></td>
|
|
<td class="px-4 py-3">
|
|
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
|
:class="{
|
|
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': tx.points_delta > 0,
|
|
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': tx.points_delta < 0
|
|
}"
|
|
x-text="txLabels[tx.transaction_type] || tx.transaction_type.replace(/_/g, ' ')"></span>
|
|
</td>
|
|
<td class="px-4 py-3 text-sm font-medium"
|
|
:class="tx.points_delta > 0 ? 'text-green-600' : 'text-orange-600'"
|
|
x-text="(tx.points_delta > 0 ? '+' : '') + formatNumber(tx.points_delta)"></td>
|
|
<td class="px-4 py-3 text-sm text-gray-500" x-text="tx.category_names?.join(', ') || '-'"></td>
|
|
<td class="px-4 py-3 text-sm" x-text="tx.store_name || '-'"></td>
|
|
<td class="px-4 py-3 text-sm text-gray-500" x-text="txNotes[tx.notes] || tx.notes || '-'"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
{% endcall %}
|
|
|
|
{{ pagination() }}
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_scripts %}
|
|
<script>
|
|
// Server-rendered transaction type labels + system note translations
|
|
window._cardDetailLabels = {
|
|
txLabels: {
|
|
card_created: {{ _('loyalty.transactions.card_created')|tojson }},
|
|
welcome_bonus: {{ _('loyalty.transactions.welcome_bonus')|tojson }},
|
|
stamp_earned: {{ _('loyalty.transactions.stamp_earned')|tojson }},
|
|
stamp_redeemed: {{ _('loyalty.transactions.stamp_redeemed')|tojson }},
|
|
stamp_voided: {{ _('loyalty.transactions.stamp_voided')|tojson }},
|
|
points_earned: {{ _('loyalty.transactions.points_earned')|tojson }},
|
|
points_redeemed: {{ _('loyalty.transactions.points_redeemed')|tojson }},
|
|
points_voided: {{ _('loyalty.transactions.points_voided')|tojson }},
|
|
points_adjustment: {{ _('loyalty.transactions.points_adjustment')|tojson }},
|
|
points_expired: {{ _('loyalty.transactions.points_expired')|tojson }},
|
|
reward_redeemed: {{ _('loyalty.transactions.reward_redeemed')|tojson }},
|
|
},
|
|
txNotes: {
|
|
"Welcome bonus on enrollment": {{ _('loyalty.transactions.welcome_bonus_note')|tojson }},
|
|
}
|
|
};
|
|
</script>
|
|
<script defer src="{{ static_v(request, 'loyalty_static', path='store/js/loyalty-card-detail.js') }}"></script>
|
|
{% endblock %}
|