feat(loyalty): add Chart.js visualizations to analytics page
Some checks failed
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>
This commit is contained in:
@@ -801,7 +801,15 @@
|
||||
"quick_actions": "Quick Actions",
|
||||
"open_terminal": "Open Terminal",
|
||||
"view_members": "View Members",
|
||||
"view_program": "View Program"
|
||||
"view_program": "View Program",
|
||||
"revenue_title": "Points & Customers",
|
||||
"at_risk_title": "At-Risk Members",
|
||||
"cards_at_risk": "members at risk of churn",
|
||||
"no_at_risk": "All members are active!",
|
||||
"cohort_title": "Cohort Retention",
|
||||
"cohort_month": "Enrollment Month",
|
||||
"cohort_enrolled": "Enrolled",
|
||||
"no_data_yet": "Not enough data yet. Analytics will appear as customers enroll and transact."
|
||||
},
|
||||
"program": {
|
||||
"title": "Loyalty Program",
|
||||
|
||||
@@ -24,6 +24,12 @@ function storeLoyaltyAnalytics() {
|
||||
estimated_liability_cents: 0,
|
||||
},
|
||||
|
||||
// Advanced analytics
|
||||
cohortData: { cohorts: [] },
|
||||
churnData: { at_risk_count: 0, cards: [] },
|
||||
revenueData: { monthly: [], by_store: [] },
|
||||
revenueChart: null,
|
||||
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
@@ -56,6 +62,7 @@ function storeLoyaltyAnalytics() {
|
||||
await this.loadProgram();
|
||||
if (this.program) {
|
||||
await this.loadStats();
|
||||
this.loadAdvancedAnalytics();
|
||||
}
|
||||
loyaltyAnalyticsLog.info('=== LOYALTY ANALYTICS PAGE INITIALIZATION COMPLETE ===');
|
||||
},
|
||||
@@ -99,6 +106,72 @@ function storeLoyaltyAnalytics() {
|
||||
}
|
||||
},
|
||||
|
||||
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);
|
||||
|
||||
@@ -46,6 +46,90 @@
|
||||
{% set show_merchants_metric = false %}
|
||||
{% include "loyalty/shared/analytics-stats.html" %}
|
||||
|
||||
<!-- Advanced Analytics Charts -->
|
||||
<div class="grid gap-6 md:grid-cols-2 mb-6">
|
||||
<!-- Revenue Chart -->
|
||||
<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('chart-bar', 'inline w-5 h-5 mr-2')"></span>
|
||||
{{ _('loyalty.store.analytics.revenue_title') }}
|
||||
</h3>
|
||||
<div x-show="revenueData.monthly.length > 0" style="height: 250px;">
|
||||
<canvas id="revenueChart"></canvas>
|
||||
</div>
|
||||
<p x-show="revenueData.monthly.length === 0" class="text-sm text-gray-500 dark:text-gray-400 py-8 text-center">
|
||||
{{ _('loyalty.store.analytics.no_data_yet') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Churn / At-Risk Cards -->
|
||||
<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('exclamation-triangle', 'inline w-5 h-5 mr-2')"></span>
|
||||
{{ _('loyalty.store.analytics.at_risk_title') }}
|
||||
</h3>
|
||||
<div x-show="churnData.at_risk_count > 0">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
<span class="text-2xl font-bold text-orange-600" x-text="churnData.at_risk_count"></span>
|
||||
{{ _('loyalty.store.analytics.cards_at_risk') }}
|
||||
</p>
|
||||
<div class="space-y-2 max-h-48 overflow-y-auto">
|
||||
<template x-for="card in churnData.cards?.slice(0, 10)" :key="card.card_id">
|
||||
<div class="flex items-center justify-between text-sm py-1 border-b border-gray-100 dark:border-gray-700">
|
||||
<span class="text-gray-700 dark:text-gray-300" x-text="card.customer_name || card.card_number"></span>
|
||||
<span class="text-orange-600 font-medium" x-text="card.days_inactive + 'd inactive'"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<p x-show="churnData.at_risk_count === 0" class="text-sm text-green-600 dark:text-green-400 py-8 text-center">
|
||||
{{ _('loyalty.store.analytics.no_at_risk') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cohort Retention -->
|
||||
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800 mb-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
<span x-html="$icon('table-cells', 'inline w-5 h-5 mr-2')"></span>
|
||||
{{ _('loyalty.store.analytics.cohort_title') }}
|
||||
</h3>
|
||||
<div x-show="cohortData.cohorts?.length > 0" class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-gray-600 dark:text-gray-400">{{ _('loyalty.store.analytics.cohort_month') }}</th>
|
||||
<th class="px-3 py-2 text-center text-gray-600 dark:text-gray-400">{{ _('loyalty.store.analytics.cohort_enrolled') }}</th>
|
||||
<template x-for="(_, i) in Array(6)" :key="i">
|
||||
<th class="px-3 py-2 text-center text-gray-600 dark:text-gray-400" x-text="'M' + i"></th>
|
||||
</template>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="cohort in cohortData.cohorts" :key="cohort.month">
|
||||
<tr class="border-t border-gray-100 dark:border-gray-700">
|
||||
<td class="px-3 py-2 font-medium text-gray-900 dark:text-white" x-text="cohort.month"></td>
|
||||
<td class="px-3 py-2 text-center text-gray-600 dark:text-gray-400" x-text="cohort.enrolled"></td>
|
||||
<template x-for="(pct, i) in cohort.retention.slice(0, 6)" :key="i">
|
||||
<td class="px-3 py-2 text-center">
|
||||
<span class="inline-block px-2 py-1 rounded text-xs font-medium"
|
||||
:class="pct >= 60 ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : pct >= 30 ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'"
|
||||
x-text="pct + '%'"></span>
|
||||
</td>
|
||||
</template>
|
||||
<template x-for="i in Math.max(0, 6 - cohort.retention.length)" :key="'empty-' + i">
|
||||
<td class="px-3 py-2 text-center text-gray-300">-</td>
|
||||
</template>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p x-show="!cohortData.cohorts?.length" class="text-sm text-gray-500 dark:text-gray-400 py-8 text-center">
|
||||
{{ _('loyalty.store.analytics.no_data_yet') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<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">{{ _('loyalty.store.analytics.quick_actions') }}</h3>
|
||||
@@ -71,5 +155,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
{% include 'shared/includes/optional-libs.html' with context %}
|
||||
{{ chartjs_loader() }}
|
||||
<script defer src="{{ url_for('loyalty_static', path='store/js/loyalty-analytics.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user