feat(loyalty): restructure program CRUD by interface
Some checks failed
CI / ruff (push) Successful in 10s
CI / pytest (push) Failing after 45m49s
CI / validate (push) Successful in 23s
CI / dependency-scanning (push) Successful in 29s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

Move program CRUD from store to merchant/admin interfaces.
Store becomes view-only for program config while merchant gets
full CRUD and admin gets override capabilities.

Merchant portal:
- New API endpoints (GET/POST/PATCH/DELETE /program)
- New settings page with create/edit/delete form
- Overview page now has Create/Edit Program buttons
- Settings menu item added to sidebar

Admin portal:
- New CRUD endpoints (create for merchant, update, delete)
- New activate/deactivate program endpoints
- Programs list has edit and toggle buttons per row
- Merchant detail has create/delete/toggle program actions

Store portal:
- Removed POST/PATCH /program endpoints (now read-only)
- Removed settings page route and template
- Terminal, cards, stats, enroll unchanged

Tests: 112 passed (58 new) covering merchant API, admin CRUD,
store endpoint removal, and program service unit tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 13:32:20 +01:00
parent d648c921b7
commit 6b46a78e72
22 changed files with 1616 additions and 113 deletions

View File

@@ -105,10 +105,27 @@
<!-- Program Configuration -->
<div x-show="program" class="px-4 py-3 mb-8 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('cog', 'inline w-5 h-5 mr-2')"></span>
Program Configuration
</h3>
<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">
<button @click="toggleActive()"
class="flex items-center px-3 py-1.5 text-sm font-medium rounded-lg border transition-colors"
:class="program?.is_active
? 'text-orange-600 border-orange-300 hover:bg-orange-50 dark:text-orange-400 dark:border-orange-700 dark:hover:bg-orange-900/20'
: 'text-green-600 border-green-300 hover:bg-green-50 dark:text-green-400 dark:border-green-700 dark:hover:bg-green-900/20'">
<span x-html="$icon(program?.is_active ? 'pause' : 'play', 'w-4 h-4 mr-1')"></span>
<span x-text="program?.is_active ? 'Deactivate' : 'Activate'"></span>
</button>
<button @click="confirmDeleteProgram()"
class="flex items-center px-3 py-1.5 text-sm font-medium text-red-600 border border-red-300 rounded-lg hover:bg-red-50 dark:text-red-400 dark:border-red-700 dark:hover:bg-red-900/20">
<span x-html="$icon('trash', 'w-4 h-4 mr-1')"></span>
Delete
</button>
</div>
</div>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Program Name</p>
@@ -142,11 +159,39 @@
<!-- No Program Notice -->
<div x-show="!program" class="px-4 py-3 mb-8 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800">
<div class="flex items-center">
<span x-html="$icon('exclamation-triangle', 'w-5 h-5 text-yellow-500 mr-3')"></span>
<div>
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">No Loyalty Program</h3>
<p class="text-sm text-yellow-700 dark:text-yellow-300">This merchant has not set up a loyalty program yet. Stores can set up the program from their dashboard.</p>
<div class="flex items-center justify-between">
<div class="flex items-center">
<span x-html="$icon('exclamation-triangle', 'w-5 h-5 text-yellow-500 mr-3')"></span>
<div>
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">No Loyalty Program</h3>
<p class="text-sm text-yellow-700 dark:text-yellow-300">This merchant has not set up a loyalty program yet.</p>
</div>
</div>
<button @click="createProgram()"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
Create Program
</button>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div x-show="showDeleteModal" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Delete Loyalty Program</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
This will permanently delete the loyalty program and all associated data. This action cannot be undone.
</p>
<div class="flex justify-end gap-3">
<button @click="showDeleteModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button @click="deleteProgram()"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700">
Delete Program
</button>
</div>
</div>
</div>

View File

@@ -217,14 +217,24 @@
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</a>
<!-- Settings Button -->
<!-- Edit Button -->
<a
:href="'/admin/loyalty/merchants/' + program.merchant_id + '/settings'"
:href="'/admin/loyalty/merchants/' + program.merchant_id"
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Merchant loyalty settings"
title="Edit program"
>
<span x-html="$icon('cog', 'w-5 h-5')"></span>
<span x-html="$icon('pencil', 'w-5 h-5')"></span>
</a>
<!-- Activate/Deactivate Toggle -->
<button
@click="toggleProgramActive(program)"
class="flex items-center justify-center p-2 rounded-lg focus:outline-none transition-colors"
:class="program.is_active ? 'text-orange-600 hover:bg-orange-50 dark:text-orange-400 dark:hover:bg-gray-700' : 'text-green-600 hover:bg-green-50 dark:text-green-400 dark:hover:bg-gray-700'"
:title="program.is_active ? 'Deactivate program' : 'Activate program'"
>
<span x-html="$icon(program.is_active ? 'pause' : 'play', 'w-5 h-5')"></span>
</button>
</div>
</td>
</tr>

View File

@@ -7,9 +7,18 @@
<div x-data="merchantLoyaltyOverview()">
<!-- Page Header -->
<div class="mb-8 mt-6">
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Loyalty Overview</h2>
<p class="mt-1 text-gray-500 dark:text-gray-400">Loyalty program statistics across all your stores.</p>
<div class="mb-8 mt-6 flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Loyalty Overview</h2>
<p class="mt-1 text-gray-500 dark:text-gray-400">Loyalty program statistics across all your stores.</p>
</div>
<template x-if="stats.program_id">
<a href="/merchants/loyalty/settings"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
Edit Program
</a>
</template>
</div>
<!-- No Program State -->
@@ -18,8 +27,13 @@
<span x-html="$icon('gift', 'w-12 h-12 mx-auto text-gray-400')"></span>
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No Loyalty Program</h3>
<p class="mt-2 text-gray-500 dark:text-gray-400">
Your loyalty program hasn't been set up yet. Contact the platform administrator or set it up from your store dashboard.
Your loyalty program hasn't been set up yet. Create one to start rewarding your customers.
</p>
<a href="/merchants/loyalty/settings"
class="inline-flex items-center mt-4 px-6 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
Create Program
</a>
</div>
</template>

View File

@@ -1,11 +1,11 @@
{# app/modules/loyalty/templates/loyalty/store/settings.html #}
{% extends "store/base.html" %}
{# app/modules/loyalty/templates/loyalty/merchant/settings.html #}
{% extends "merchant/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Loyalty Settings{% endblock %}
{% block alpine_data %}storeLoyaltySettings(){% endblock %}
{% block alpine_data %}merchantLoyaltySettings(){% endblock %}
{% block content %}
{% call page_header_flex(title='Loyalty Program Settings', subtitle='Configure your loyalty program') %}{% endcall %}
@@ -24,7 +24,7 @@
<div class="grid gap-6 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Points per EUR spent</label>
<input type="number" min="1" max="100" x-model.number="settings.points_per_euro" {# noqa: FE-008 #}
<input type="number" min="1" max="100" x-model.number="settings.points_per_euro"
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
<p class="mt-1 text-xs text-gray-500">1 EUR = <span x-text="settings.points_per_euro || 1"></span> point(s)</p>
</div>
@@ -142,17 +142,48 @@
</div>
<!-- Actions -->
<div class="flex items-center justify-end gap-4">
<div class="flex items-center justify-between">
<div>
<template x-if="!isNewProgram">
<button type="button" @click="confirmDelete()"
class="flex items-center px-4 py-2 text-sm font-medium text-red-600 border border-red-300 rounded-lg hover:bg-red-50 dark:text-red-400 dark:border-red-700 dark:hover:bg-red-900/20">
<span x-html="$icon('trash', 'w-4 h-4 mr-2')"></span>
Delete Program
</button>
</template>
</div>
<button type="submit" :disabled="saving"
class="flex items-center px-6 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2 animate-spin')"></span>
<span x-text="saving ? 'Saving...' : 'Save Settings'"></span>
<span x-text="saving ? 'Saving...' : (isNewProgram ? 'Create Program' : 'Save Settings')"></span>
</button>
</div>
</form>
</div>
<!-- Delete Confirmation Modal -->
<div x-show="showDeleteModal" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Delete Loyalty Program</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
This will permanently delete your loyalty program and all associated data (cards, transactions, rewards).
This action cannot be undone.
</p>
<div class="flex justify-end gap-3">
<button @click="showDeleteModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button @click="deleteProgram()" :disabled="deleting"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50">
<span x-text="deleting ? 'Deleting...' : 'Delete Program'"></span>
</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('loyalty_static', path='store/js/loyalty-settings.js') }}"></script>
<script defer src="{{ url_for('loyalty_static', path='merchant/js/loyalty-settings.js') }}"></script>
{% endblock %}