feat(loyalty): align program view, edit, and analytics pages across all frontends
Some checks failed
CI / ruff (push) Successful in 11s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled

Standardize naming (Program for view/edit, Analytics for stats), create shared
read-only program-view partial, fix admin edit field population bug (14 missing
fields), add store Program menu item, and rename merchant Overview→Program,
Settings→Analytics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 22:51:26 +01:00
parent aefca3115e
commit eee33d6a1b
20 changed files with 674 additions and 665 deletions

View File

@@ -0,0 +1,228 @@
{# app/modules/loyalty/templates/loyalty/shared/program-view.html #}
{#
Read-only program configuration view partial.
Include with:
{% include "loyalty/shared/program-view.html" %}
Expected Jinja2 variables (set before include):
- edit_url (str) — href for "Edit Program" button
- show_edit_button (bool, default true) — whether to show the edit button
Expected Alpine.js state on the parent component:
- program.* — full program object (from API or stats.program)
#}
<div x-show="program" class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('cog', 'inline w-5 h-5 mr-2')"></span>
Program Configuration
</h3>
<div class="flex items-center gap-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize"
:class="{
'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300': program?.loyalty_type === 'points',
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300': program?.loyalty_type === 'stamps',
'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300': program?.loyalty_type === 'hybrid'
}"
x-text="program?.loyalty_type || 'unknown'"></span>
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold leading-tight rounded-full"
:class="program?.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700'">
<span x-text="program?.is_active ? 'Active' : 'Inactive'"></span>
</span>
{% if show_edit_button is not defined or show_edit_button %}
<a href="{{ edit_url }}"
class="flex items-center px-3 py-1.5 text-sm font-medium text-purple-600 border border-purple-300 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:border-purple-700 dark:hover:bg-purple-900/20">
<span x-html="$icon('pencil', 'w-4 h-4 mr-1')"></span>
Edit
</a>
{% endif %}
</div>
</div>
<!-- Program Info -->
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3 mb-6">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Program Name</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="program?.display_name || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Card Name</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="program?.card_name || '-'">-</p>
</div>
</div>
<!-- Stamps Config -->
<template x-if="program?.loyalty_type === 'stamps' || program?.loyalty_type === 'hybrid'">
<div class="mb-6">
<h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase mb-3 flex items-center">
<span x-html="$icon('star', 'inline w-4 h-4 mr-1')"></span>
Stamps Configuration
</h4>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Stamps Target</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="program?.stamps_target || '-'">-</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Reward Description</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="program?.stamps_reward_description || '-'">-</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Reward Value</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
x-text="program?.stamps_reward_value_cents ? '€' + (program.stamps_reward_value_cents / 100).toFixed(2) : '-'">-</p>
</div>
</div>
</div>
</template>
<!-- Points Config -->
<template x-if="program?.loyalty_type === 'points' || program?.loyalty_type === 'hybrid'">
<div class="mb-6">
<h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase mb-3 flex items-center">
<span x-html="$icon('currency-dollar', 'inline w-4 h-4 mr-1')"></span>
Points Configuration
</h4>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Points per EUR</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="program?.points_per_euro || 1">-</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Welcome Bonus</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
x-text="program?.welcome_bonus_points ? program.welcome_bonus_points + ' points' : 'None'">-</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Minimum Redemption</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
x-text="program?.minimum_redemption_points ? program.minimum_redemption_points + ' points' : 'None'">-</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Minimum Purchase</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
x-text="program?.minimum_purchase_cents ? '€' + (program.minimum_purchase_cents / 100).toFixed(2) : 'None'">-</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Points Expiration</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
x-text="program?.points_expiration_days ? program.points_expiration_days + ' days of inactivity' : 'Never'">-</p>
</div>
</div>
</div>
</template>
<!-- Redemption Rewards -->
<template x-if="(program?.loyalty_type === 'points' || program?.loyalty_type === 'hybrid') && program?.points_rewards?.length > 0">
<div class="mb-6">
<h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase mb-3 flex items-center">
<span x-html="$icon('gift', 'inline w-4 h-4 mr-1')"></span>
Redemption Rewards
</h4>
<div class="overflow-hidden border border-gray-200 dark:border-gray-700 rounded-lg">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase">Reward</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase">Points Required</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase">Description</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="reward in program.points_rewards" :key="reward.name">
<tr>
<td class="px-4 py-2 font-medium text-gray-700 dark:text-gray-300" x-text="reward.name">-</td>
<td class="px-4 py-2 text-gray-600 dark:text-gray-400" x-text="reward.points_required">-</td>
<td class="px-4 py-2 text-gray-600 dark:text-gray-400" x-text="reward.description || '-'">-</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<!-- Anti-Fraud -->
<div class="mb-6">
<h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase mb-3 flex items-center">
<span x-html="$icon('shield-check', 'inline w-4 h-4 mr-1')"></span>
Anti-Fraud
</h4>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Cooldown</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300"
x-text="program?.cooldown_minutes ? program.cooldown_minutes + ' minutes' : 'None'">-</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Max Daily Stamps</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="program?.max_daily_stamps || '-'">-</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Staff PIN Required</p>
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold leading-tight rounded-full"
:class="program?.require_staff_pin ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100'">
<span x-text="program?.require_staff_pin ? 'Yes' : 'No'"></span>
</span>
</div>
</div>
</div>
<!-- Branding -->
<div class="mb-6">
<h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase mb-3 flex items-center">
<span x-html="$icon('paint-brush', 'inline w-4 h-4 mr-1')"></span>
Branding
</h4>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Primary Color</p>
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded border border-gray-300 dark:border-gray-600"
:style="'background-color: ' + (program?.card_color || '#6B21A8')"></div>
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="program?.card_color || '#6B21A8'"></span>
</div>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Secondary Color</p>
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded border border-gray-300 dark:border-gray-600"
:style="'background-color: ' + (program?.card_secondary_color || '#FFFFFF')"></div>
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="program?.card_secondary_color || '#FFFFFF'"></span>
</div>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Logo URL</p>
<p class="text-sm text-gray-700 dark:text-gray-300 truncate" x-text="program?.logo_url || '-'">-</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Hero Image URL</p>
<p class="text-sm text-gray-700 dark:text-gray-300 truncate" x-text="program?.hero_image_url || '-'">-</p>
</div>
</div>
</div>
<!-- Terms -->
<div>
<h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase mb-3 flex items-center">
<span x-html="$icon('document-text', 'inline w-4 h-4 mr-1')"></span>
Terms & Privacy
</h4>
<div class="grid gap-6 md:grid-cols-2">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Terms & Conditions</p>
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-line" x-text="program?.terms_text || '-'">-</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Privacy Policy URL</p>
<template x-if="program?.privacy_url">
<a :href="program.privacy_url" target="_blank" class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400" x-text="program.privacy_url"></a>
</template>
<template x-if="!program?.privacy_url">
<p class="text-sm text-gray-700 dark:text-gray-300">-</p>
</template>
</div>
</div>
</div>
</div>