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:
156
app/modules/loyalty/static/shared/js/loyalty-card-detail-view.js
Normal file
156
app/modules/loyalty/static/shared/js/loyalty-card-detail-view.js
Normal 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');
|
||||
247
app/modules/loyalty/static/shared/js/loyalty-cards-list.js
Normal file
247
app/modules/loyalty/static/shared/js/loyalty-cards-list.js
Normal 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');
|
||||
283
app/modules/loyalty/static/shared/js/loyalty-pins-list.js
Normal file
283
app/modules/loyalty/static/shared/js/loyalty-pins-list.js
Normal 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');
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user