Files
orion/app/modules/loyalty/static/shared/js/loyalty-card-detail-view.js
Samir Boulahtit 6161d69ba2 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>
2026-03-22 19:28:07 +01:00

157 lines
5.4 KiB
JavaScript

// 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');