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:
@@ -300,7 +300,8 @@
|
||||
"col_points": "Punkte",
|
||||
"col_location": "Standort",
|
||||
"col_notes": "Notizen",
|
||||
"no_transactions": "Keine Transaktionen gefunden"
|
||||
"no_transactions": "Keine Transaktionen gefunden",
|
||||
"col_category": "Kategorie"
|
||||
},
|
||||
"transactions": {
|
||||
"title": "Transaktionen",
|
||||
@@ -684,6 +685,7 @@
|
||||
"add_stamp": "Stempel hinzufügen",
|
||||
"current": "Aktuell:",
|
||||
"cooldown_active": "Wartezeit aktiv",
|
||||
"cooldown_wait_minutes": "Bitte warten Sie {minutes} Minuten zwischen Transaktionen auf derselben Karte.",
|
||||
"redeem_stamps": "Stempel einlösen",
|
||||
"not_enough_stamps": "Noch nicht genug Stempel",
|
||||
"earn_points": "Punkte sammeln",
|
||||
|
||||
@@ -304,7 +304,8 @@
|
||||
"col_points": "Points",
|
||||
"col_location": "Location",
|
||||
"col_notes": "Notes",
|
||||
"no_transactions": "No transactions found"
|
||||
"no_transactions": "No transactions found",
|
||||
"col_category": "Category"
|
||||
},
|
||||
"transactions": {
|
||||
"title": "Transactions",
|
||||
@@ -692,6 +693,7 @@
|
||||
"add_stamp": "Add Stamp",
|
||||
"current": "Current:",
|
||||
"cooldown_active": "Cooldown active",
|
||||
"cooldown_wait_minutes": "Please wait {minutes} minutes between transactions on the same card.",
|
||||
"redeem_stamps": "Redeem Stamps",
|
||||
"not_enough_stamps": "Not enough stamps yet",
|
||||
"earn_points": "Earn Points",
|
||||
|
||||
@@ -300,7 +300,8 @@
|
||||
"col_points": "Points",
|
||||
"col_location": "Emplacement",
|
||||
"col_notes": "Notes",
|
||||
"no_transactions": "Aucune transaction trouvée"
|
||||
"no_transactions": "Aucune transaction trouvée",
|
||||
"col_category": "Catégorie"
|
||||
},
|
||||
"transactions": {
|
||||
"title": "Transactions",
|
||||
@@ -684,6 +685,7 @@
|
||||
"add_stamp": "Ajouter un tampon",
|
||||
"current": "Actuel :",
|
||||
"cooldown_active": "Temps d'attente actif",
|
||||
"cooldown_wait_minutes": "Veuillez patienter {minutes} minutes entre les transactions pour la même carte.",
|
||||
"redeem_stamps": "Échanger les tampons",
|
||||
"not_enough_stamps": "Pas assez de tampons encore",
|
||||
"earn_points": "Gagner des points",
|
||||
|
||||
@@ -300,7 +300,8 @@
|
||||
"col_points": "Punkten",
|
||||
"col_location": "Standuert",
|
||||
"col_notes": "Notizen",
|
||||
"no_transactions": "Keng Transaktioune fonnt"
|
||||
"no_transactions": "Keng Transaktioune fonnt",
|
||||
"col_category": "Kategorie"
|
||||
},
|
||||
"transactions": {
|
||||
"title": "Transaktionen",
|
||||
@@ -684,6 +685,7 @@
|
||||
"add_stamp": "Stempel derbäisetzen",
|
||||
"current": "Aktuell:",
|
||||
"cooldown_active": "Waardezäit aktiv",
|
||||
"cooldown_wait_minutes": "W.e.g. waart {minutes} Minutten tëschent Transaktiounen op der selwechter Kaart.",
|
||||
"redeem_stamps": "Stempelen aléisen",
|
||||
"not_enough_stamps": "Nach net genuch Stempelen",
|
||||
"earn_points": "Punkten sammelen",
|
||||
|
||||
@@ -11,6 +11,9 @@ const loyaltyCardDetailViewLog = window.LogConfig.loggers.loyaltyCardDetailView
|
||||
* @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',
|
||||
|
||||
@@ -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'
|
||||
paginate: true,
|
||||
txLabels: (window._cardDetailLabels && window._cardDetailLabels.txLabels) || {},
|
||||
txNotes: (window._cardDetailLabels && window._cardDetailLabels.txNotes) || {},
|
||||
});
|
||||
} 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; }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!window.LogConfig.loggers.loyaltyCardDetail) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user