feat(loyalty): cross-persona page alignment with shared components

Align loyalty pages across admin, merchant, and store personas so each
sees the same page set scoped to their access level. Admin acts as a
superset of merchant with "on behalf" capabilities.

New pages:
- Store: Staff PINs management (CRUD)
- Merchant: Cards, Card Detail, Transactions, Staff PINs (CRUD), Settings (read-only)
- Admin: Merchant Cards, Card Detail, Transactions, PINs (read-only)

Architecture:
- 4 shared Jinja2 partials (cards-list, card-detail, transactions, pins)
- 4 shared JS factory modules parameterized by apiPrefix/scope
- Persona templates are thin wrappers including shared partials
- PinDetailResponse schema for cross-store PIN listings

API: 17 new endpoints (11 merchant, 6 admin on-behalf)
Tests: 38 new integration tests, arch-check green
i18n: ~130 new keys across en/fr/de/lb
Docs: pages-and-navigation.md with full page matrix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-22 19:28:07 +01:00
parent f41f72b86f
commit 6161d69ba2
49 changed files with 4385 additions and 14 deletions

View File

@@ -0,0 +1,22 @@
// app/modules/loyalty/static/admin/js/loyalty-merchant-card-detail.js
// Admin wrapper for shared loyalty card detail view (on-behalf view).
const adminMerchantCardDetailLog = window.LogConfig.loggers.adminMerchantCardDetail || window.LogConfig.createLogger('adminMerchantCardDetail');
function adminMerchantCardDetail() {
// Extract merchant_id and card_id from URL: /admin/loyalty/merchants/{merchant_id}/cards/{card_id}
const pathParts = window.location.pathname.split('/');
const merchantsIndex = pathParts.indexOf('merchants');
const merchantId = merchantsIndex !== -1 ? pathParts[merchantsIndex + 1] : null;
return loyaltyCardDetailView({
apiPrefix: '/admin/loyalty/merchants/' + merchantId,
backUrl: '/admin/loyalty/merchants/' + merchantId + '/cards',
currentPage: 'loyalty-programs',
});
}
if (!window.LogConfig.loggers.adminMerchantCardDetail) {
window.LogConfig.loggers.adminMerchantCardDetail = window.LogConfig.createLogger('adminMerchantCardDetail');
}
adminMerchantCardDetailLog.info('Admin merchant card detail module loaded');

View File

@@ -0,0 +1,23 @@
// app/modules/loyalty/static/admin/js/loyalty-merchant-cards.js
// Admin wrapper for shared loyalty cards list (on-behalf view).
const adminMerchantCardsLog = window.LogConfig.loggers.adminMerchantCards || window.LogConfig.createLogger('adminMerchantCards');
function adminMerchantCards() {
// Extract merchant_id from URL: /admin/loyalty/merchants/{merchant_id}/cards
const pathParts = window.location.pathname.split('/');
const merchantsIndex = pathParts.indexOf('merchants');
const merchantId = merchantsIndex !== -1 ? pathParts[merchantsIndex + 1] : null;
return loyaltyCardsList({
apiPrefix: '/admin/loyalty/merchants/' + merchantId,
baseUrl: '/admin/loyalty/merchants/' + merchantId + '/cards',
showStoreFilter: true,
currentPage: 'loyalty-programs',
});
}
if (!window.LogConfig.loggers.adminMerchantCards) {
window.LogConfig.loggers.adminMerchantCards = window.LogConfig.createLogger('adminMerchantCards');
}
adminMerchantCardsLog.info('Admin merchant cards module loaded');

View File

@@ -0,0 +1,23 @@
// app/modules/loyalty/static/admin/js/loyalty-merchant-pins.js
// Admin wrapper for shared loyalty PINs list (on-behalf read-only view).
const adminMerchantPinsLog = window.LogConfig.loggers.adminMerchantPins || window.LogConfig.createLogger('adminMerchantPins');
function adminMerchantPins() {
// Extract merchant_id from URL: /admin/loyalty/merchants/{merchant_id}/pins
const pathParts = window.location.pathname.split('/');
const merchantsIndex = pathParts.indexOf('merchants');
const merchantId = merchantsIndex !== -1 ? pathParts[merchantsIndex + 1] : null;
return loyaltyPinsList({
apiPrefix: '/admin/loyalty/merchants/' + merchantId,
showStoreFilter: true,
showCrud: false,
currentPage: 'loyalty-programs',
});
}
if (!window.LogConfig.loggers.adminMerchantPins) {
window.LogConfig.loggers.adminMerchantPins = window.LogConfig.createLogger('adminMerchantPins');
}
adminMerchantPinsLog.info('Admin merchant pins module loaded');

View File

@@ -0,0 +1,22 @@
// app/modules/loyalty/static/admin/js/loyalty-merchant-transactions.js
// Admin wrapper for shared loyalty transactions list (on-behalf view).
const adminMerchantTransactionsLog = window.LogConfig.loggers.adminMerchantTransactions || window.LogConfig.createLogger('adminMerchantTransactions');
function adminMerchantTransactions() {
// Extract merchant_id from URL: /admin/loyalty/merchants/{merchant_id}/transactions
const pathParts = window.location.pathname.split('/');
const merchantsIndex = pathParts.indexOf('merchants');
const merchantId = merchantsIndex !== -1 ? pathParts[merchantsIndex + 1] : null;
return loyaltyTransactionsList({
apiPrefix: '/admin/loyalty/merchants/' + merchantId,
showStoreFilter: true,
currentPage: 'loyalty-programs',
});
}
if (!window.LogConfig.loggers.adminMerchantTransactions) {
window.LogConfig.loggers.adminMerchantTransactions = window.LogConfig.createLogger('adminMerchantTransactions');
}
adminMerchantTransactionsLog.info('Admin merchant transactions module loaded');

View File

@@ -0,0 +1,17 @@
// app/modules/loyalty/static/merchant/js/loyalty-card-detail.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
const merchantCardDetailLog = window.LogConfig.loggers.merchantCardDetail || window.LogConfig.createLogger('merchantCardDetail');
function merchantLoyaltyCardDetail() {
return loyaltyCardDetailView({
apiPrefix: '/merchants/loyalty',
backUrl: '/merchants/loyalty/cards',
currentPage: 'loyalty-cards',
});
}
if (!window.LogConfig.loggers.merchantCardDetail) {
window.LogConfig.loggers.merchantCardDetail = window.LogConfig.createLogger('merchantCardDetail');
}
merchantCardDetailLog.info('Merchant loyalty card detail module loaded');

View File

@@ -0,0 +1,18 @@
// app/modules/loyalty/static/merchant/js/loyalty-cards.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
const merchantCardsLog = window.LogConfig.loggers.merchantCards || window.LogConfig.createLogger('merchantCards');
function merchantLoyaltyCards() {
return loyaltyCardsList({
apiPrefix: '/merchants/loyalty',
baseUrl: '/merchants/loyalty/cards',
showStoreFilter: true,
currentPage: 'loyalty-cards',
});
}
if (!window.LogConfig.loggers.merchantCards) {
window.LogConfig.loggers.merchantCards = window.LogConfig.createLogger('merchantCards');
}
merchantCardsLog.info('Merchant loyalty cards module loaded');

View File

@@ -0,0 +1,53 @@
// app/modules/loyalty/static/merchant/js/loyalty-merchant-settings.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
const merchantSettingsViewLog = window.LogConfig.loggers.merchantSettingsView || window.LogConfig.createLogger('merchantSettingsView');
function merchantLoyaltyMerchantSettings() {
return {
...data(),
currentPage: 'loyalty-settings',
settings: null,
loading: false,
error: null,
async init() {
merchantSettingsViewLog.info('=== MERCHANT LOYALTY SETTINGS VIEW PAGE INITIALIZING ===');
if (window._merchantLoyaltyMerchantSettingsInitialized) return;
window._merchantLoyaltyMerchantSettingsInitialized = true;
this.loadMenuConfig();
await this.loadSettings();
merchantSettingsViewLog.info('=== MERCHANT LOYALTY SETTINGS VIEW PAGE INITIALIZATION COMPLETE ===');
},
async loadSettings() {
this.loading = true;
this.error = null;
try {
const response = await apiClient.get('/merchants/loyalty/settings');
if (response) {
this.settings = response;
merchantSettingsViewLog.info('Settings loaded');
}
} catch (error) {
if (error.status === 404) {
merchantSettingsViewLog.info('No settings found');
this.settings = null;
} else {
merchantSettingsViewLog.error('Failed to load settings:', error);
this.error = error.message || 'Failed to load settings';
}
} finally {
this.loading = false;
}
},
};
}
if (!window.LogConfig.loggers.merchantSettingsView) {
window.LogConfig.loggers.merchantSettingsView = window.LogConfig.createLogger('merchantSettingsView');
}
merchantSettingsViewLog.info('Merchant loyalty settings view module loaded');

View File

@@ -0,0 +1,18 @@
// app/modules/loyalty/static/merchant/js/loyalty-pins.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
const merchantPinsLog = window.LogConfig.loggers.merchantPins || window.LogConfig.createLogger('merchantPins');
function merchantLoyaltyPins() {
return loyaltyPinsList({
apiPrefix: '/merchants/loyalty',
showStoreFilter: true,
showCrud: true,
currentPage: 'loyalty-pins',
});
}
if (!window.LogConfig.loggers.merchantPins) {
window.LogConfig.loggers.merchantPins = window.LogConfig.createLogger('merchantPins');
}
merchantPinsLog.info('Merchant loyalty pins module loaded');

View File

@@ -0,0 +1,17 @@
// app/modules/loyalty/static/merchant/js/loyalty-transactions.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
const merchantTransactionsLog = window.LogConfig.loggers.merchantTransactions || window.LogConfig.createLogger('merchantTransactions');
function merchantLoyaltyTransactions() {
return loyaltyTransactionsList({
apiPrefix: '/merchants/loyalty',
showStoreFilter: true,
currentPage: 'loyalty-transactions',
});
}
if (!window.LogConfig.loggers.merchantTransactions) {
window.LogConfig.loggers.merchantTransactions = window.LogConfig.createLogger('merchantTransactions');
}
merchantTransactionsLog.info('Merchant loyalty transactions module loaded');

View File

@@ -0,0 +1,156 @@
// app/modules/loyalty/static/shared/js/loyalty-card-detail-view.js
// Shared Alpine.js data factory for loyalty card detail pages.
// Used by store, merchant, and admin frontends.
const loyaltyCardDetailViewLog = window.LogConfig.loggers.loyaltyCardDetailView || window.LogConfig.createLogger('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
*/
function loyaltyCardDetailView(config) {
const guardKey = '_loyaltyCardDetailView_' + config.currentPage + '_initialized';
return {
...data(),
currentPage: config.currentPage,
// Data
cardId: null,
card: null,
transactions: [],
// State
loading: false,
error: null,
// Config (exposed for templates)
_config: config,
async init() {
loyaltyCardDetailViewLog.info('=== LOYALTY CARD DETAIL VIEW INITIALIZING ===', config.currentPage);
if (window[guardKey]) return;
window[guardKey] = true;
const parentInit = data().init;
if (parentInit) {
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');
loyaltyCardDetailViewLog.error('Could not extract card ID from URL');
return;
}
loyaltyCardDetailViewLog.info('Card ID extracted:', this.cardId);
await this.loadData();
loyaltyCardDetailViewLog.info('=== LOYALTY CARD DETAIL VIEW INITIALIZATION COMPLETE ===');
},
/**
* Extract the card ID from the current URL path.
* Looks for the 'cards' segment and returns the segment immediately after it.
* e.g. /store/ORION/loyalty/cards/abc123 -> 'abc123'
*/
_extractCardIdFromUrl() {
try {
const segments = window.location.pathname.split('/').filter(Boolean);
const cardsIndex = segments.indexOf('cards');
if (cardsIndex !== -1 && cardsIndex + 1 < segments.length) {
return segments[cardsIndex + 1];
}
} catch (error) {
loyaltyCardDetailViewLog.error('Error parsing URL for card ID:', error);
}
return null;
},
async loadData() {
this.loading = true;
this.error = null;
try {
await Promise.all([
this.loadCard(),
this.loadTransactions()
]);
} catch (error) {
loyaltyCardDetailViewLog.error('Failed to load card data:', error);
this.error = error.message;
} finally {
this.loading = false;
}
},
async loadCard() {
try {
const response = await apiClient.get(config.apiPrefix + '/cards/' + this.cardId);
if (response) {
this.card = response;
}
} catch (error) {
loyaltyCardDetailViewLog.error('Failed to load card:', error);
throw error;
}
},
async loadTransactions() {
try {
const response = await apiClient.get(
config.apiPrefix + '/cards/' + this.cardId + '/transactions?limit=50'
);
if (response) {
this.transactions = Array.isArray(response)
? response
: (response.transactions || []);
}
} catch (error) {
loyaltyCardDetailViewLog.warn('Failed to load transactions:', error.message);
}
},
// Formatting helpers
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;
}
},
formatDateTime(dateString) {
if (!dateString) return 'Never';
try {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch (e) {
return dateString;
}
}
};
}
if (!window.LogConfig.loggers.loyaltyCardDetailView) {
window.LogConfig.loggers.loyaltyCardDetailView = window.LogConfig.createLogger('loyaltyCardDetailView');
}
loyaltyCardDetailViewLog.info('Loyalty card detail view shared module loaded');

View File

@@ -0,0 +1,247 @@
// app/modules/loyalty/static/shared/js/loyalty-cards-list.js
// Shared Alpine.js data factory for loyalty cards listing pages.
// Used by store, merchant, and admin frontends.
const loyaltyCardsListLog = window.LogConfig.loggers.loyaltyCardsList || window.LogConfig.createLogger('loyaltyCardsList');
/**
* Factory that returns an Alpine.js data object for loyalty card listing.
*
* @param {Object} config
* @param {string} config.apiPrefix - API path prefix, e.g. '/store/loyalty'
* @param {string} config.baseUrl - Base URL for card detail links, e.g. '/store/ORION/loyalty/cards'
* @param {boolean} config.showStoreFilter - Whether to show the store/location dropdown filter
* @param {string} config.currentPage - Alpine currentPage identifier
*/
function loyaltyCardsList(config) {
const guardKey = '_loyaltyCardsList_' + config.currentPage + '_initialized';
return {
...data(),
currentPage: config.currentPage,
// Data
cards: [],
program: null,
stats: {
total_cards: 0,
active_cards: 0,
new_this_month: 0,
total_points_balance: 0
},
locations: [],
// Filters
filters: {
search: '',
status: '',
store_id: ''
},
// Pagination
pagination: {
page: 1,
per_page: 20,
total: 0,
pages: 0
},
// State
loading: false,
error: null,
// Config (exposed for templates)
_config: config,
async init() {
loyaltyCardsListLog.info('=== LOYALTY CARDS LIST INITIALIZING ===', config.currentPage);
if (window[guardKey]) return;
window[guardKey] = true;
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
if (window.PlatformSettings) {
try {
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
} catch (error) {
loyaltyCardsListLog.warn('Failed to get rows per page setting:', error.message);
}
}
await this.loadData();
loyaltyCardsListLog.info('=== LOYALTY CARDS LIST INITIALIZATION COMPLETE ===');
},
async loadData() {
this.loading = true;
this.error = null;
try {
await this.loadProgram();
if (this.program) {
const parallel = [this.loadCards(), this.loadStats()];
if (config.showStoreFilter) {
parallel.push(this.loadLocations());
}
await Promise.all(parallel);
}
} catch (error) {
loyaltyCardsListLog.error('Failed to load data:', error);
this.error = error.message;
} finally {
this.loading = false;
}
},
async loadProgram() {
try {
const response = await apiClient.get(config.apiPrefix + '/program');
if (response) this.program = response;
} catch (error) {
if (error.status !== 404) throw error;
}
},
async loadCards() {
try {
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');
if (this.filters.store_id) params.append('store_id', this.filters.store_id);
const response = await apiClient.get(config.apiPrefix + '/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);
}
} catch (error) {
loyaltyCardsListLog.error('Failed to load cards:', error);
throw error;
}
},
async loadStats() {
try {
const response = await apiClient.get(config.apiPrefix + '/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) {
loyaltyCardsListLog.warn('Failed to load stats:', error.message);
}
},
async loadLocations() {
try {
const response = await apiClient.get(config.apiPrefix + '/locations');
if (response) {
this.locations = Array.isArray(response) ? response : (response.locations || []);
}
} catch (error) {
loyaltyCardsListLog.warn('Failed to load locations:', 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();
},
// Pagination helpers
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();
}
},
// Formatting helpers
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.loyaltyCardsList) {
window.LogConfig.loggers.loyaltyCardsList = window.LogConfig.createLogger('loyaltyCardsList');
}
loyaltyCardsListLog.info('Loyalty cards list shared module loaded');

View File

@@ -0,0 +1,283 @@
// app/modules/loyalty/static/shared/js/loyalty-pins-list.js
// Shared Alpine.js data factory for loyalty staff PIN listing pages.
// Used by store, merchant, and admin frontends.
const loyaltyPinsListLog = window.LogConfig.loggers.loyaltyPinsList || window.LogConfig.createLogger('loyaltyPinsList');
/**
* Factory that returns an Alpine.js data object for loyalty PIN management.
*
* @param {Object} config
* @param {string} config.apiPrefix - API path prefix, e.g. '/store/loyalty'
* @param {boolean} config.showStoreFilter - Whether to show the store/location dropdown filter
* @param {boolean} config.showCrud - Whether to show create/edit/delete UI
* @param {string} config.currentPage - Alpine currentPage identifier
*/
function loyaltyPinsList(config) {
const guardKey = '_loyaltyPinsList_' + config.currentPage + '_initialized';
return {
...data(),
currentPage: config.currentPage,
// Data
pins: [],
program: null,
locations: [],
// Filters
filters: {
store_id: '',
status: ''
},
// Stats (computed from pins array)
stats: {
total: 0,
active: 0,
locked: 0
},
// State
loading: false,
error: null,
// Modal state
showCreateModal: false,
showEditModal: false,
showDeleteModal: false,
editingPin: null,
deletingPin: null,
// Form state
pinForm: {
name: '',
staff_id: '',
pin: '',
store_id: ''
},
// Action state
saving: false,
deleting: false,
// Config (exposed for templates)
_config: config,
async init() {
loyaltyPinsListLog.info('=== LOYALTY PINS LIST INITIALIZING ===', config.currentPage);
if (window[guardKey]) return;
window[guardKey] = true;
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
await this.loadData();
loyaltyPinsListLog.info('=== LOYALTY PINS LIST INITIALIZATION COMPLETE ===');
},
async loadData() {
this.loading = true;
this.error = null;
try {
await this.loadProgram();
if (this.program) {
const parallel = [this.loadPins()];
if (config.showStoreFilter) {
parallel.push(this.loadLocations());
}
await Promise.all(parallel);
}
} catch (error) {
loyaltyPinsListLog.error('Failed to load data:', error);
this.error = error.message;
} finally {
this.loading = false;
}
},
async loadProgram() {
try {
const response = await apiClient.get(config.apiPrefix + '/program');
if (response) this.program = response;
} catch (error) {
if (error.status !== 404) throw error;
}
},
async loadPins() {
try {
const params = new URLSearchParams();
if (this.filters.store_id) params.append('store_id', this.filters.store_id);
if (this.filters.status) params.append('is_active', this.filters.status === 'active');
const queryString = params.toString();
const url = config.apiPrefix + '/pins' + (queryString ? '?' + queryString : '');
const response = await apiClient.get(url);
if (response) {
this.pins = Array.isArray(response) ? response : (response.pins || []);
this.computeStats();
}
} catch (error) {
loyaltyPinsListLog.error('Failed to load pins:', error);
throw error;
}
},
async loadLocations() {
try {
const response = await apiClient.get(config.apiPrefix + '/locations');
if (response) {
this.locations = Array.isArray(response) ? response : (response.locations || []);
}
} catch (error) {
loyaltyPinsListLog.warn('Failed to load locations:', error.message);
}
},
computeStats() {
this.stats.total = this.pins.length;
this.stats.active = this.pins.filter(p => p.is_active && !p.is_locked).length;
this.stats.locked = this.pins.filter(p => p.is_locked).length;
},
applyFilter() {
this.loadPins();
},
// ---- CRUD operations (only used when config.showCrud is true) ----
openCreateModal() {
this.pinForm = {
name: '',
staff_id: '',
pin: '',
store_id: ''
};
this.showCreateModal = true;
},
openEditModal(pin) {
this.editingPin = pin;
this.pinForm = {
name: pin.name || '',
staff_id: pin.staff_id || '',
pin: '',
store_id: pin.store_id || ''
};
this.showEditModal = true;
},
openDeleteModal(pin) {
this.deletingPin = pin;
this.showDeleteModal = true;
},
async createPin() {
this.saving = true;
try {
const payload = { ...this.pinForm };
if (!payload.store_id) delete payload.store_id;
await apiClient.post(config.apiPrefix + '/pins', payload);
Utils.showToast(I18n.t('loyalty.toasts.pin_created'), 'success');
this.showCreateModal = false;
await this.loadPins();
} catch (error) {
loyaltyPinsListLog.error('Failed to create pin:', error);
Utils.showToast(error.message || I18n.t('loyalty.toasts.pin_create_error'), 'error');
} finally {
this.saving = false;
}
},
async updatePin() {
if (!this.editingPin) return;
this.saving = true;
try {
const payload = { ...this.pinForm };
// Only include pin field if a new pin was entered
if (!payload.pin) delete payload.pin;
if (!payload.store_id) delete payload.store_id;
await apiClient.patch(
config.apiPrefix + '/pins/' + this.editingPin.id,
payload
);
Utils.showToast(I18n.t('loyalty.toasts.pin_updated'), 'success');
this.showEditModal = false;
this.editingPin = null;
await this.loadPins();
} catch (error) {
loyaltyPinsListLog.error('Failed to update pin:', error);
Utils.showToast(error.message || I18n.t('loyalty.toasts.pin_update_error'), 'error');
} finally {
this.saving = false;
}
},
async deletePin() {
if (!this.deletingPin) return;
this.deleting = true;
try {
await apiClient.delete(config.apiPrefix + '/pins/' + this.deletingPin.id);
Utils.showToast(I18n.t('loyalty.toasts.pin_deleted'), 'success');
this.showDeleteModal = false;
this.deletingPin = null;
await this.loadPins();
} catch (error) {
loyaltyPinsListLog.error('Failed to delete pin:', error);
Utils.showToast(error.message || I18n.t('loyalty.toasts.pin_delete_error'), 'error');
} finally {
this.deleting = false;
}
},
async unlockPin(pin) {
try {
await apiClient.post(config.apiPrefix + '/pins/' + pin.id + '/unlock');
Utils.showToast(I18n.t('loyalty.toasts.pin_unlocked'), 'success');
await this.loadPins();
} catch (error) {
loyaltyPinsListLog.error('Failed to unlock pin:', error);
Utils.showToast(error.message || I18n.t('loyalty.toasts.pin_unlock_error'), 'error');
}
},
// Formatting helpers
formatDate(dateString) {
if (!dateString) return 'Never';
try {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch (e) {
return dateString;
}
},
formatDateTime(dateString) {
if (!dateString) return 'Never';
try {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch (e) {
return dateString;
}
}
};
}
if (!window.LogConfig.loggers.loyaltyPinsList) {
window.LogConfig.loggers.loyaltyPinsList = window.LogConfig.createLogger('loyaltyPinsList');
}
loyaltyPinsListLog.info('Loyalty pins list shared module loaded');

View File

@@ -0,0 +1,248 @@
// app/modules/loyalty/static/shared/js/loyalty-transactions-list.js
// Shared Alpine.js data factory for loyalty transactions listing pages.
// Used by store, merchant, and admin frontends.
const loyaltyTransactionsListLog = window.LogConfig.loggers.loyaltyTransactionsList || window.LogConfig.createLogger('loyaltyTransactionsList');
/**
* Factory that returns an Alpine.js data object for loyalty transaction listing.
*
* @param {Object} config
* @param {string} config.apiPrefix - API path prefix, e.g. '/store/loyalty'
* @param {boolean} config.showStoreFilter - Whether to show the store/location dropdown filter
* @param {string} config.currentPage - Alpine currentPage identifier
*/
function loyaltyTransactionsList(config) {
const guardKey = '_loyaltyTransactionsList_' + config.currentPage + '_initialized';
return {
...data(),
currentPage: config.currentPage,
// Data
transactions: [],
locations: [],
// Filters
filters: {
search: '',
type: '',
store_id: ''
},
// Pagination
pagination: {
page: 1,
per_page: 20,
total: 0,
pages: 0
},
// State
loading: false,
error: null,
// Config (exposed for templates)
_config: config,
async init() {
loyaltyTransactionsListLog.info('=== LOYALTY TRANSACTIONS LIST INITIALIZING ===', config.currentPage);
if (window[guardKey]) return;
window[guardKey] = true;
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
if (window.PlatformSettings) {
try {
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
} catch (error) {
loyaltyTransactionsListLog.warn('Failed to get rows per page setting:', error.message);
}
}
await this.loadData();
loyaltyTransactionsListLog.info('=== LOYALTY TRANSACTIONS LIST INITIALIZATION COMPLETE ===');
},
async loadData() {
this.loading = true;
this.error = null;
try {
const parallel = [this.loadTransactions()];
if (config.showStoreFilter) {
parallel.push(this.loadLocations());
}
await Promise.all(parallel);
} catch (error) {
loyaltyTransactionsListLog.error('Failed to load data:', error);
this.error = error.message;
} finally {
this.loading = false;
}
},
async loadTransactions() {
try {
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.type) params.append('type', this.filters.type);
if (this.filters.store_id) params.append('store_id', this.filters.store_id);
const response = await apiClient.get(config.apiPrefix + '/transactions?' + params);
if (response) {
this.transactions = response.transactions || [];
this.pagination.total = response.total || 0;
this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page);
}
} catch (error) {
loyaltyTransactionsListLog.error('Failed to load transactions:', error);
throw error;
}
},
async loadLocations() {
try {
const response = await apiClient.get(config.apiPrefix + '/locations');
if (response) {
this.locations = Array.isArray(response) ? response : (response.locations || []);
}
} catch (error) {
loyaltyTransactionsListLog.warn('Failed to load locations:', error.message);
}
},
debouncedSearch() {
if (this._searchTimeout) clearTimeout(this._searchTimeout);
this._searchTimeout = setTimeout(() => {
this.pagination.page = 1;
this.loadTransactions();
}, 300);
},
applyFilter() {
this.pagination.page = 1;
this.loadTransactions();
},
// Pagination helpers
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.loadTransactions();
}
},
nextPage() {
if (this.pagination.page < this.totalPages) {
this.pagination.page++;
this.loadTransactions();
}
},
goToPage(num) {
if (num !== '...' && num >= 1 && num <= this.totalPages) {
this.pagination.page = num;
this.loadTransactions();
}
},
/**
* Returns Tailwind CSS classes for a transaction type badge.
* @param {string} type - Transaction type (e.g. 'earn', 'redeem', 'bonus', 'adjustment', 'expire')
* @returns {string} CSS class string
*/
getTransactionBadgeClass(type) {
switch (type) {
case 'earn':
return 'bg-green-100 text-green-800';
case 'redeem':
return 'bg-blue-100 text-blue-800';
case 'bonus':
return 'bg-purple-100 text-purple-800';
case 'adjustment':
return 'bg-yellow-100 text-yellow-800';
case 'expire':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
},
// Formatting helpers
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;
}
},
formatDateTime(dateString) {
if (!dateString) return 'Never';
try {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch (e) {
return dateString;
}
}
};
}
if (!window.LogConfig.loggers.loyaltyTransactionsList) {
window.LogConfig.loggers.loyaltyTransactionsList = window.LogConfig.createLogger('loyaltyTransactionsList');
}
loyaltyTransactionsListLog.info('Loyalty transactions list shared module loaded');

View File

@@ -0,0 +1,18 @@
// app/modules/loyalty/static/store/js/loyalty-pins.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
const storePinsLog = window.LogConfig.loggers.storePins || window.LogConfig.createLogger('storePins');
function storeLoyaltyPins() {
return loyaltyPinsList({
apiPrefix: '/store/loyalty',
showStoreFilter: false,
showCrud: true,
currentPage: 'pins',
});
}
if (!window.LogConfig.loggers.storePins) {
window.LogConfig.loggers.storePins = window.LogConfig.createLogger('storePins');
}
storePinsLog.info('Store loyalty pins module loaded');