refactor(loyalty): migrate store cards + card-detail to shared partials

The store frontend was inlining two CRUD bodies that already had shared
equivalents under loyalty/templates/loyalty/shared/. Migrate them to the
established pattern (thin per-persona wrapper + shared body partial).

- store/cards.html: 171 -> 56 LOC. Now sets cards_api_prefix /
  cards_base_url / show_store_filter=false and includes
  shared/cards-list.html (same partial merchant already uses).
- store/card-detail.html: 205 -> 55 LOC. Includes
  shared/card-detail-view.html with new flags show_copy_buttons,
  show_category_column, show_pagination so its extras survive.
- shared/card-detail-view.html: gain those three boolean flags plus
  reads txLabels/txNotes from the Alpine factory (empty defaults so
  admin/merchant callers still get raw values).
- shared/loyalty-card-detail-view.js: factory accepts txLabels, txNotes,
  paginate config; exposes pagination state unconditionally so the
  partial's pagination macro resolves; fix latent bug where
  formatDateTime called toLocaleDateString with ignored hour/minute
  opts.
- store/loyalty-cards.js + loyalty-card-detail.js: now thin wrappers
  calling the shared factories.
- locales/{en,fr,de,lb}.json: add loyalty.shared.card_detail.col_category
  for the new optional column.
- Add `noqa: TPL-016` on the 5 legit-exception loyalty templates
  (admin/programs aggregator, admin/merchant-settings, admin/wallet-debug,
  store/enroll, store/terminal) ahead of the rule landing in a follow-up
  commit. Note the per-file reason inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 23:09:29 +02:00
parent 93ab072f55
commit c661c1e394
15 changed files with 4001 additions and 4415 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -8,9 +8,12 @@ const loyaltyCardDetailViewLog = window.LogConfig.loggers.loyaltyCardDetailView
* Factory that returns an Alpine.js data object for a single loyalty card detail view.
*
* @param {Object} config
* @param {string} config.apiPrefix - API path prefix, e.g. '/store/loyalty'
* @param {string} config.backUrl - URL for the "back to list" navigation
* @param {string} config.currentPage - Alpine currentPage identifier
* @param {string} config.apiPrefix - API path prefix, e.g. '/store/loyalty'
* @param {string} config.backUrl - URL for the "back to list" navigation
* @param {string} config.currentPage - Alpine currentPage identifier
* @param {boolean} [config.paginate] - Enable paginated transaction history (default false, loads up to 50)
* @param {Object} [config.txLabels] - Map of transaction_type -> translated label (default {})
* @param {Object} [config.txNotes] - Map of raw_note -> translated note (default {})
*/
function loyaltyCardDetailView(config) {
const guardKey = '_loyaltyCardDetailView_' + config.currentPage + '_initialized';
@@ -24,6 +27,14 @@ function loyaltyCardDetailView(config) {
card: null,
transactions: [],
// Translation maps (defaults to empty so admin/merchant get raw values)
txLabels: config.txLabels || {},
txNotes: config.txNotes || {},
// Pagination (always present so the shared template's pagination macro
// resolves; only the wrapper that sets show_pagination renders it)
pagination: { page: 1, per_page: 20, total: 0 },
// State
loading: false,
error: null,
@@ -41,7 +52,6 @@ function loyaltyCardDetailView(config) {
await parentInit.call(this);
}
// Extract cardId from URL: find 'cards' segment and take the next segment
this.cardId = this._extractCardIdFromUrl();
if (!this.cardId) {
this.error = I18n.t('loyalty.errors.card_not_found');
@@ -49,6 +59,12 @@ function loyaltyCardDetailView(config) {
return;
}
if (config.paginate && window.PlatformSettings) {
try {
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
} catch (e) { /* keep default */ }
}
loyaltyCardDetailViewLog.info('Card ID extracted:', this.cardId);
await this.loadData();
loyaltyCardDetailViewLog.info('=== LOYALTY CARD DETAIL VIEW INITIALIZATION COMPLETE ===');
@@ -100,21 +116,65 @@ function loyaltyCardDetailView(config) {
}
},
async loadTransactions() {
async loadTransactions(page = 1) {
try {
const response = await apiClient.get(
config.apiPrefix + '/cards/' + this.cardId + '/transactions?limit=50'
);
let url;
if (config.paginate) {
const skip = (page - 1) * this.pagination.per_page;
url = config.apiPrefix + '/cards/' + this.cardId
+ '/transactions?skip=' + skip + '&limit=' + this.pagination.per_page;
} else {
url = config.apiPrefix + '/cards/' + this.cardId + '/transactions?limit=50';
}
const response = await apiClient.get(url);
if (response) {
this.transactions = Array.isArray(response)
? response
: (response.transactions || []);
if (config.paginate) {
this.pagination.total = response.total || 0;
this.pagination.page = page;
}
}
} catch (error) {
loyaltyCardDetailViewLog.warn('Failed to load transactions:', error.message);
}
},
// Standard pagination interface (matches shared pagination macro).
// Always present; only rendered when the template sets show_pagination.
get totalPages() {
return Math.max(1, Math.ceil(this.pagination.total / this.pagination.per_page));
},
get startIndex() {
return this.pagination.total === 0
? 0
: (this.pagination.page - 1) * this.pagination.per_page + 1;
},
get endIndex() {
return Math.min(this.pagination.page * this.pagination.per_page, this.pagination.total);
},
get pageNumbers() {
const pages = [];
const total = this.totalPages;
const current = this.pagination.page;
if (total <= 7) {
for (let i = 1; i <= total; i++) pages.push(i);
} else {
pages.push(1);
if (current > 3) pages.push('...');
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
for (let i = start; i <= end; i++) pages.push(i);
if (current < total - 2) pages.push('...');
pages.push(total);
}
return pages;
},
previousPage() { if (this.pagination.page > 1) this.loadTransactions(this.pagination.page - 1); },
nextPage() { if (this.pagination.page < this.totalPages) this.loadTransactions(this.pagination.page + 1); },
goToPage(p) { if (p !== '...' && p >= 1 && p <= this.totalPages) this.loadTransactions(p); },
// Formatting helpers
formatNumber(num) {
return num == null ? '0' : new Intl.NumberFormat('en-US').format(num);
@@ -136,7 +196,7 @@ function loyaltyCardDetailView(config) {
formatDateTime(dateString) {
if (!dateString) return 'Never';
try {
return new Date(dateString).toLocaleDateString('en-US', {
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',

View File

@@ -1,148 +1,17 @@
// app/modules/loyalty/static/store/js/loyalty-card-detail.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
// Store wrapper around the shared loyaltyCardDetailView factory.
const loyaltyCardDetailLog = window.LogConfig.loggers.loyaltyCardDetail || window.LogConfig.createLogger('loyaltyCardDetail');
function storeLoyaltyCardDetail() {
return {
...data(),
return loyaltyCardDetailView({
apiPrefix: '/store/loyalty',
backUrl: '/store/' + (window.STORE_CODE || '') + '/loyalty/cards',
currentPage: 'cards',
cardId: null,
card: null,
transactions: [],
pagination: { page: 1, per_page: 20, total: 0 },
txLabels: window._cardDetailLabels?.txLabels || {},
txNotes: window._cardDetailLabels?.txNotes || {},
loading: false,
error: null,
async init() {
loyaltyCardDetailLog.info('=== LOYALTY CARD DETAIL PAGE INITIALIZING ===');
if (window._loyaltyCardDetailInitialized) return;
window._loyaltyCardDetailInitialized = true;
// IMPORTANT: Call parent init first to set storeCode from URL
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
// Extract card ID from URL
const pathParts = window.location.pathname.split('/');
const cardsIndex = pathParts.indexOf('cards');
if (cardsIndex !== -1 && pathParts[cardsIndex + 1]) {
this.cardId = parseInt(pathParts[cardsIndex + 1]);
}
if (!this.cardId) {
this.error = 'Invalid card ID';
return;
}
// Use platform pagination setting if available
if (window.PlatformSettings) {
try {
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
} catch (e) { /* use default */ }
}
await this.loadData();
loyaltyCardDetailLog.info('=== LOYALTY CARD DETAIL PAGE INITIALIZATION COMPLETE ===');
},
async loadData() {
this.loading = true;
this.error = null;
try {
await Promise.all([
this.loadCard(),
this.loadTransactions()
]);
} catch (error) {
loyaltyCardDetailLog.error('Failed to load data:', error);
this.error = error.message;
} finally {
this.loading = false;
}
},
async loadCard() {
const response = await apiClient.get(`/store/loyalty/cards/${this.cardId}`);
if (response) {
this.card = response;
loyaltyCardDetailLog.info('Card loaded:', this.card.card_number);
}
},
async loadTransactions(page = 1) {
try {
const skip = (page - 1) * this.pagination.per_page;
const response = await apiClient.get(
`/store/loyalty/cards/${this.cardId}/transactions?skip=${skip}&limit=${this.pagination.per_page}`
);
if (response && response.transactions) {
this.transactions = response.transactions;
this.pagination.total = response.total || 0;
this.pagination.page = page;
loyaltyCardDetailLog.info(`Loaded ${this.transactions.length} of ${this.pagination.total} transactions (page ${page})`);
}
} catch (error) {
loyaltyCardDetailLog.warn('Failed to load transactions:', error.message);
}
},
// Standard pagination interface (matches shared pagination macro)
get totalPages() {
return Math.max(1, Math.ceil(this.pagination.total / this.pagination.per_page));
},
get startIndex() {
if (this.pagination.total === 0) return 0;
return (this.pagination.page - 1) * this.pagination.per_page + 1;
},
get endIndex() {
return Math.min(this.pagination.page * this.pagination.per_page, this.pagination.total);
},
get pageNumbers() {
const pages = [];
for (let i = 1; i <= this.totalPages; i++) {
if (i === 1 || i === this.totalPages || Math.abs(i - this.pagination.page) <= 1) {
pages.push(i);
} else if (pages[pages.length - 1] !== '...') {
pages.push('...');
}
}
return pages;
},
previousPage() { if (this.pagination.page > 1) this.loadTransactions(this.pagination.page - 1); },
nextPage() { if (this.pagination.page < this.totalPages) this.loadTransactions(this.pagination.page + 1); },
goToPage(p) { if (p >= 1 && p <= this.totalPages) this.loadTransactions(p); },
formatNumber(num) {
return num == null ? '0' : new Intl.NumberFormat('en-US').format(num);
},
formatDate(dateString) {
if (!dateString) return '-';
try {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric'
});
} catch (e) { return dateString; }
},
formatDateTime(dateString) {
if (!dateString) return '-';
try {
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
} catch (e) { return dateString; }
}
};
paginate: true,
txLabels: (window._cardDetailLabels && window._cardDetailLabels.txLabels) || {},
txNotes: (window._cardDetailLabels && window._cardDetailLabels.txNotes) || {},
});
}
if (!window.LogConfig.loggers.loyaltyCardDetail) {

View File

@@ -1,162 +1,15 @@
// app/modules/loyalty/static/store/js/loyalty-cards.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
// Store wrapper around the shared loyaltyCardsList factory.
const loyaltyCardsLog = window.LogConfig.loggers.loyaltyCards || window.LogConfig.createLogger('loyaltyCards');
function storeLoyaltyCards() {
return {
...data(),
return loyaltyCardsList({
apiPrefix: '/store/loyalty',
baseUrl: '/store/' + (window.STORE_CODE || '') + '/loyalty/cards',
showStoreFilter: false,
currentPage: 'cards',
// Data
cards: [],
program: null,
stats: {
total_cards: 0,
active_cards: 0,
new_this_month: 0,
total_points_balance: 0
},
// Filters
filters: {
search: '',
status: ''
},
// Pagination
pagination: {
page: 1,
per_page: 20,
total: 0,
pages: 0
},
// State
loading: false,
error: null,
async init() {
loyaltyCardsLog.info('=== LOYALTY CARDS PAGE INITIALIZING ===');
if (window._loyaltyCardsInitialized) return;
window._loyaltyCardsInitialized = true;
// IMPORTANT: Call parent init first to set storeCode from URL
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
if (window.PlatformSettings) {
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
}
await this.loadData();
loyaltyCardsLog.info('=== LOYALTY CARDS PAGE INITIALIZATION COMPLETE ===');
},
async loadData() {
this.loading = true;
this.error = null;
try {
await this.loadProgram();
if (this.program) {
await Promise.all([this.loadCards(), this.loadStats()]);
}
} catch (error) {
loyaltyCardsLog.error('Failed to load data:', error);
this.error = error.message;
} finally {
this.loading = false;
}
},
async loadProgram() {
try {
const response = await apiClient.get('/store/loyalty/program');
if (response) this.program = response;
} catch (error) {
if (error.status !== 404) throw error;
}
},
async loadCards() {
const params = new URLSearchParams();
params.append('skip', (this.pagination.page - 1) * this.pagination.per_page);
params.append('limit', this.pagination.per_page);
if (this.filters.search) params.append('search', this.filters.search);
if (this.filters.status) params.append('is_active', this.filters.status === 'active');
const response = await apiClient.get(`/store/loyalty/cards?${params}`);
if (response) {
this.cards = response.cards || [];
this.pagination.total = response.total || 0;
this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page);
}
},
async loadStats() {
try {
const response = await apiClient.get('/store/loyalty/stats');
if (response) {
this.stats = {
total_cards: response.total_cards || 0,
active_cards: response.active_cards || 0,
new_this_month: response.new_this_month || 0,
total_points_balance: response.total_points_balance || 0
};
}
} catch (error) {
loyaltyCardsLog.warn('Failed to load stats:', error.message);
}
},
debouncedSearch() {
if (this._searchTimeout) clearTimeout(this._searchTimeout);
this._searchTimeout = setTimeout(() => {
this.pagination.page = 1;
this.loadCards();
}, 300);
},
applyFilter() {
this.pagination.page = 1;
this.loadCards();
},
get totalPages() { return this.pagination.pages; },
get startIndex() { return this.pagination.total === 0 ? 0 : (this.pagination.page - 1) * this.pagination.per_page + 1; },
get endIndex() { const end = this.pagination.page * this.pagination.per_page; return end > this.pagination.total ? this.pagination.total : end; },
get pageNumbers() {
const pages = [];
const total = this.totalPages;
const current = this.pagination.page;
if (total <= 7) { for (let i = 1; i <= total; i++) pages.push(i); }
else {
pages.push(1);
if (current > 3) pages.push('...');
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
for (let i = start; i <= end; i++) pages.push(i);
if (current < total - 2) pages.push('...');
pages.push(total);
}
return pages;
},
previousPage() { if (this.pagination.page > 1) { this.pagination.page--; this.loadCards(); } },
nextPage() { if (this.pagination.page < this.totalPages) { this.pagination.page++; this.loadCards(); } },
goToPage(num) { if (num !== '...' && num >= 1 && num <= this.totalPages) { this.pagination.page = num; this.loadCards(); } },
formatNumber(num) { return num == null ? '0' : new Intl.NumberFormat('en-US').format(num); },
formatDate(dateString) {
if (!dateString) return 'Never';
try {
return new Date(dateString).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
} catch (e) { return dateString; }
}
};
});
}
if (!window.LogConfig.loggers.loyaltyCards) {

View File

@@ -1,4 +1,5 @@
{# app/modules/loyalty/templates/loyalty/admin/merchant-settings.html #}
{# noqa: TPL-016 — admin-only merchant config aggregator, no per-persona equivalent #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/headers.html' import detail_page_header %}

View File

@@ -1,4 +1,5 @@
{# app/modules/loyalty/templates/loyalty/admin/programs.html #}
{# noqa: TPL-016 — admin multi-merchant programs aggregator: different shape from the single-program merchant/store views #}
{% extends "admin/base.html" %}
{% from 'shared/macros/pagination.html' import pagination %}
{% from 'shared/macros/headers.html' import page_header %}

View File

@@ -1,4 +1,5 @@
{# app/modules/loyalty/templates/loyalty/admin/wallet-debug.html #}
{# noqa: TPL-016 — admin-only Apple/Google wallet diagnostic tool, no persona equivalent #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}

View File

@@ -3,9 +3,17 @@
Shared loyalty card detail view partial. Set these variables before including:
- card_detail_api_prefix (str): API base URL for card data
- card_detail_back_url (str): URL for the back button
- show_copy_buttons (bool, default false): Show clipboard-copy buttons next to customer name/email/phone/card_number
- show_category_column (bool, default false): Add a "Category" column to the transaction history table
- show_pagination (bool, default false): Render pagination after the transaction history table
The factory exposes `txLabels` (object: transaction_type -> translated label) and
`txNotes` (object: raw_note -> translated note) — defaults to {} so admin/merchant
callers that don't provide translations get the raw values shown.
#}
{% 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 %}
<!-- Quick Stats -->
<div class="grid gap-6 mb-8 md:grid-cols-4">
@@ -58,15 +66,42 @@
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.name') }}</p>
{% if show_copy_buttons %}
<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>
{% else %}
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_name || '-'">-</p>
{% endif %}
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.email') }}</p>
{% if show_copy_buttons %}
<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>
{% else %}
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_email || '-'">-</p>
{% endif %}
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.phone') }}</p>
{% if show_copy_buttons %}
<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>
{% else %}
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_phone || '-'">-</p>
{% endif %}
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.birthday') }}</p>
@@ -84,7 +119,16 @@
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.card_number') }}</p>
{% if show_copy_buttons %}
<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>
{% else %}
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="card?.card_number">-</p>
{% endif %}
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.status') }}</p>
@@ -110,12 +154,17 @@
<span x-html="$icon('clock', 'inline w-5 h-5 mr-2')"></span>
{{ _('loyalty.shared.card_detail.transaction_history') }}
</h3>
{% set _tx_colspan = 6 if show_category_column else 5 %}
{% call table_wrapper() %}
{% if show_category_column %}
{{ table_header([_('loyalty.shared.card_detail.col_date'), _('loyalty.shared.card_detail.col_type'), _('loyalty.shared.card_detail.col_points'), _('loyalty.shared.card_detail.col_category'), _('loyalty.shared.card_detail.col_location'), _('loyalty.shared.card_detail.col_notes')]) }}
{% else %}
{{ table_header([_('loyalty.shared.card_detail.col_date'), _('loyalty.shared.card_detail.col_type'), _('loyalty.shared.card_detail.col_points'), _('loyalty.shared.card_detail.col_location'), _('loyalty.shared.card_detail.col_notes')]) }}
{% endif %}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="transactions.length === 0">
<tr>
<td colspan="5" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
<td colspan="{{ _tx_colspan }}" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('clock', 'w-12 h-12 mb-2 text-gray-300')"></span>
<p class="font-medium">{{ _('loyalty.shared.card_detail.no_transactions') }}</p>
@@ -132,15 +181,22 @@
'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="tx.transaction_type.replace(/_/g, ' ')"></span>
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>
{% if show_category_column %}
<td class="px-4 py-3 text-sm text-gray-500" x-text="tx.category_names?.join(', ') || '-'"></td>
{% endif %}
<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="tx.notes || '-'"></td>
<td class="px-4 py-3 text-sm text-gray-500" x-text="txNotes[tx.notes] || tx.notes || '-'"></td>
</tr>
</template>
</tbody>
{% endcall %}
{% if show_pagination %}
{{ pagination() }}
{% endif %}
</div>

View File

@@ -2,8 +2,6 @@
{% 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 %}
@@ -20,168 +18,19 @@
{{ 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>
{% set card_detail_api_prefix = '/store/loyalty' %}
{% set card_detail_back_url = '/store/' ~ store_code ~ '/loyalty/cards' %}
{% set show_copy_buttons = true %}
{% set show_category_column = true %}
{% set show_pagination = true %}
{% include 'loyalty/shared/card-detail-view.html' %}
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Server-rendered transaction type labels + system note translations
// Server-rendered transaction type labels + system note translations.
// The shared factory reads these via window._cardDetailLabels.
window._cardDetailLabels = {
txLabels: {
card_created: {{ _('loyalty.transactions.card_created')|tojson }},
@@ -201,5 +50,6 @@ window._cardDetailLabels = {
}
};
</script>
<script defer src="{{ static_v(request, 'loyalty_static', path='shared/js/loyalty-card-detail-view.js') }}"></script>
<script defer src="{{ static_v(request, 'loyalty_static', path='store/js/loyalty-card-detail.js') }}"></script>
{% endblock %}

View File

@@ -1,9 +1,7 @@
{# app/modules/loyalty/templates/loyalty/store/cards.html #}
{% extends "store/base.html" %}
{% from 'shared/macros/pagination.html' import pagination %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% block title %}{{ _('loyalty.store.cards.title') }}{% endblock %}
@@ -12,7 +10,6 @@
{% block alpine_data %}storeLoyaltyCards(){% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title=_('loyalty.store.cards.title'), subtitle=_('loyalty.store.cards.subtitle')) %}
<div class="flex items-center gap-3">
{{ refresh_button(loading_var='loading', onclick='loadCards()', variant='secondary') }}
@@ -25,7 +22,6 @@
{% endcall %}
{{ loading_state(_('loyalty.store.cards.loading')) }}
{{ error_state(_('loyalty.store.cards.error_loading')) }}
<!-- No Program Setup Notice -->
@@ -48,124 +44,13 @@
</div>
</div>
<!-- Stats Cards -->
<div x-show="!loading && program" 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('users', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.store.cards.total_members') }}</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.total_cards)">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('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.store.cards.active_30d') }}</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.active_cards)">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('user-plus', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.store.cards.new_this_month') }}</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.new_this_month)">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('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.cards.total_points_balance') }}</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.total_points_balance)">0</p>
</div>
</div>
</div>
<!-- Search and Filters -->
<div x-show="!loading && program" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex flex-wrap items-center gap-4">
<div class="flex-1 min-w-[200px]">
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
</span>
<input type="text"
x-model="filters.search"
@input="debouncedSearch()"
placeholder="{{ _('loyalty.store.cards.search_placeholder') }}"
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<select x-model="filters.status" @change="applyFilter()"
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
<option value="">{{ _('loyalty.store.cards.all_status') }}</option>
<option value="active">{{ _('loyalty.common.active') }}</option>
<option value="inactive">{{ _('loyalty.common.inactive') }}</option>
</select>
</div>
</div>
<!-- Cards Table -->
<div x-show="!loading && program">
{% call table_wrapper() %}
{{ table_header([_('loyalty.store.cards.col_member'), _('loyalty.store.cards.col_card_number'), _('loyalty.store.cards.col_points_balance'), _('loyalty.store.cards.col_last_activity'), _('loyalty.store.cards.col_status'), _('loyalty.store.cards.col_actions')]) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="cards.length === 0">
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('users', 'w-12 h-12 mb-2 text-gray-300')"></span>
<p class="font-medium">{{ _('loyalty.store.cards.no_members') }}</p>
<p class="text-xs mt-1" x-text="filters.search ? '{{ _('loyalty.store.cards.adjust_search')|replace("'", "\\'") }}' : '{{ _('loyalty.store.cards.enroll_first')|replace("'", "\\'") }}'"></p>
</div>
</td>
</tr>
</template>
<template x-for="card in cards" :key="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">
<div class="flex items-center text-sm">
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block"
:style="'background-color: ' + (program?.card_color || '#4F46E5') + '20'">
<span class="absolute inset-0 flex items-center justify-center text-xs font-semibold"
:style="'color: ' + (program?.card_color || '#4F46E5')"
x-text="card.customer_name?.charAt(0).toUpperCase() || '?'"></span>
</div>
<div>
<p class="font-semibold" x-text="card.customer_name || 'Unknown'"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="card.customer_email || card.customer_phone || '-'"></p>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm font-mono" x-text="card.card_number"></td>
<td class="px-4 py-3 text-sm font-semibold" x-text="formatNumber(card.points_balance)"></td>
<td class="px-4 py-3 text-sm" x-text="formatDate(card.last_activity_at)"></td>
<td class="px-4 py-3 text-xs">
<span class="px-2 py-1 font-semibold leading-tight 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 ? '{{ _('loyalty.common.active')|replace("'", "\\'") }}' : '{{ _('loyalty.common.inactive')|replace("'", "\\'") }}'"></span>
</td>
<td class="px-4 py-3">
<a :href="'/store/{{ store_code }}/loyalty/cards/' + card.id"
class="text-purple-600 hover:text-purple-700 dark:text-purple-400"
>{{ _('loyalty.common.view') }}</a>
</td>
</tr>
</template>
</tbody>
{% endcall %}
{{ pagination() }}
</div>
{% set cards_api_prefix = '/store/loyalty' %}
{% set cards_base_url = '/store/' ~ store_code ~ '/loyalty/cards' %}
{% set show_store_filter = false %}
{% include 'loyalty/shared/cards-list.html' %}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ static_v(request, 'loyalty_static', path='shared/js/loyalty-cards-list.js') }}"></script>
<script defer src="{{ static_v(request, 'loyalty_static', path='store/js/loyalty-cards.js') }}"></script>
{% endblock %}

View File

@@ -1,4 +1,5 @@
{# app/modules/loyalty/templates/loyalty/store/enroll.html #}
{# noqa: TPL-016 — store-only counter-staff enrollment flow, no merchant/admin equivalent #}
{% extends "store/base.html" %}
{% from 'shared/macros/headers.html' import detail_page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}

View File

@@ -1,4 +1,5 @@
{# app/modules/loyalty/templates/loyalty/store/terminal.html #}
{# noqa: TPL-016 — store-only POS hardware terminal UI, no persona equivalent #}
{% extends "store/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}