Some checks failed
Wire the Phase 7 analytics API endpoints into the store analytics page with interactive visualizations: - Revenue chart (Chart.js bar+line combo): monthly points earned as bars + active customers as line overlay with dual Y axes. - At-risk members panel: ranked list of churning cards showing customer name and days inactive, with count badge. - Cohort retention table: enrollment month rows × M0-M5 retention columns with color-coded percentage cells (green >60%, yellow >30%, red <30%). Chart.js loaded on-demand via existing CDN loader with local fallback. Data fetched in parallel via Promise.all for the 3 analytics endpoints. All sections gracefully degrade to "not enough data" message when empty. 7 new i18n keys (EN only — FR/DE/LB translations to be added). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
186 lines
7.1 KiB
JavaScript
186 lines
7.1 KiB
JavaScript
// app/modules/loyalty/static/store/js/loyalty-analytics.js
|
|
// noqa: js-006 - async init pattern is safe, loadData has try/catch
|
|
|
|
const loyaltyAnalyticsLog = window.LogConfig.loggers.loyaltyAnalytics || window.LogConfig.createLogger('loyaltyAnalytics');
|
|
|
|
function storeLoyaltyAnalytics() {
|
|
return {
|
|
...data(),
|
|
currentPage: 'analytics',
|
|
|
|
program: null,
|
|
|
|
stats: {
|
|
total_cards: 0,
|
|
active_cards: 0,
|
|
new_this_month: 0,
|
|
total_points_issued: 0,
|
|
total_points_redeemed: 0,
|
|
total_points_balance: 0,
|
|
points_issued_30d: 0,
|
|
points_redeemed_30d: 0,
|
|
transactions_30d: 0,
|
|
avg_points_per_member: 0,
|
|
estimated_liability_cents: 0,
|
|
},
|
|
|
|
// Advanced analytics
|
|
cohortData: { cohorts: [] },
|
|
churnData: { at_risk_count: 0, cards: [] },
|
|
revenueData: { monthly: [], by_store: [] },
|
|
revenueChart: null,
|
|
|
|
loading: false,
|
|
error: null,
|
|
|
|
get redemptionRate() {
|
|
if (this.stats.points_issued_30d === 0) return 0;
|
|
return Math.round((this.stats.points_redeemed_30d / this.stats.points_issued_30d) * 100);
|
|
},
|
|
|
|
get issuedPercentage() {
|
|
const total = this.stats.points_issued_30d + this.stats.points_redeemed_30d;
|
|
if (total === 0) return 50;
|
|
return Math.round((this.stats.points_issued_30d / total) * 100);
|
|
},
|
|
|
|
get redeemedPercentage() {
|
|
return 100 - this.issuedPercentage;
|
|
},
|
|
|
|
async init() {
|
|
loyaltyAnalyticsLog.info('=== LOYALTY ANALYTICS PAGE INITIALIZING ===');
|
|
if (window._loyaltyAnalyticsInitialized) return;
|
|
window._loyaltyAnalyticsInitialized = true;
|
|
|
|
// Call parent init to set storeCode from URL
|
|
const parentInit = data().init;
|
|
if (parentInit) {
|
|
await parentInit.call(this);
|
|
}
|
|
|
|
await this.loadProgram();
|
|
if (this.program) {
|
|
await this.loadStats();
|
|
this.loadAdvancedAnalytics();
|
|
}
|
|
loyaltyAnalyticsLog.info('=== LOYALTY ANALYTICS PAGE INITIALIZATION COMPLETE ===');
|
|
},
|
|
|
|
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 loadStats() {
|
|
this.loading = true;
|
|
this.error = null;
|
|
|
|
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_issued: response.total_points_issued || 0,
|
|
total_points_redeemed: response.total_points_redeemed || 0,
|
|
total_points_balance: response.total_points_balance || 0,
|
|
points_issued_30d: response.points_issued_30d || 0,
|
|
points_redeemed_30d: response.points_redeemed_30d || 0,
|
|
transactions_30d: response.transactions_30d || 0,
|
|
avg_points_per_member: response.avg_points_per_member || 0,
|
|
estimated_liability_cents: response.estimated_liability_cents || 0,
|
|
};
|
|
loyaltyAnalyticsLog.info('Stats loaded');
|
|
}
|
|
} catch (error) {
|
|
loyaltyAnalyticsLog.error('Failed to load stats:', error);
|
|
this.error = error.message;
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
async loadAdvancedAnalytics() {
|
|
try {
|
|
const [cohort, churn, revenue] = await Promise.all([
|
|
apiClient.get('/store/loyalty/analytics/cohorts'),
|
|
apiClient.get('/store/loyalty/analytics/churn'),
|
|
apiClient.get('/store/loyalty/analytics/revenue'),
|
|
]);
|
|
if (cohort) this.cohortData = cohort;
|
|
if (churn) this.churnData = churn;
|
|
if (revenue) {
|
|
this.revenueData = revenue;
|
|
this.$nextTick(() => this.renderRevenueChart());
|
|
}
|
|
loyaltyAnalyticsLog.info('Advanced analytics loaded');
|
|
} catch (error) {
|
|
loyaltyAnalyticsLog.warn('Advanced analytics failed:', error.message);
|
|
}
|
|
},
|
|
|
|
renderRevenueChart() {
|
|
const canvas = document.getElementById('revenueChart');
|
|
if (!canvas || !window.Chart || !this.revenueData.monthly.length) return;
|
|
|
|
if (this.revenueChart) this.revenueChart.destroy();
|
|
|
|
const labels = this.revenueData.monthly.map(m => m.month);
|
|
const pointsData = this.revenueData.monthly.map(m => m.total_points_earned);
|
|
const customersData = this.revenueData.monthly.map(m => m.unique_customers);
|
|
|
|
this.revenueChart = new Chart(canvas, {
|
|
type: 'bar',
|
|
data: {
|
|
labels,
|
|
datasets: [
|
|
{
|
|
label: 'Points Earned',
|
|
data: pointsData,
|
|
backgroundColor: 'rgba(99, 102, 241, 0.7)',
|
|
borderRadius: 4,
|
|
yAxisID: 'y',
|
|
},
|
|
{
|
|
label: 'Active Customers',
|
|
data: customersData,
|
|
type: 'line',
|
|
borderColor: '#10b981',
|
|
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
|
fill: true,
|
|
tension: 0.3,
|
|
yAxisID: 'y1',
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
interaction: { mode: 'index', intersect: false },
|
|
plugins: { legend: { position: 'bottom' } },
|
|
scales: {
|
|
y: { position: 'left', title: { display: true, text: 'Points' } },
|
|
y1: { position: 'right', title: { display: true, text: 'Customers' }, grid: { drawOnChartArea: false } },
|
|
},
|
|
},
|
|
});
|
|
},
|
|
|
|
formatNumber(num) {
|
|
if (num === null || num === undefined) return '0';
|
|
return new Intl.NumberFormat('en-US').format(num);
|
|
}
|
|
};
|
|
}
|
|
|
|
if (!window.LogConfig.loggers.loyaltyAnalytics) {
|
|
window.LogConfig.loggers.loyaltyAnalytics = window.LogConfig.createLogger('loyaltyAnalytics');
|
|
}
|
|
loyaltyAnalyticsLog.info('Loyalty analytics module loaded');
|