feat(loyalty): add Chart.js visualizations to analytics page
Some checks failed
CI / pytest (push) Failing after 2h21m5s
CI / ruff (push) Successful in 13s
CI / validate (push) Successful in 27s
CI / dependency-scanning (push) Successful in 31s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

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:
2026-04-11 23:30:36 +02:00
parent 4a60d75a13
commit dd9dc04328
3 changed files with 168 additions and 1 deletions

View File

@@ -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 %}