diff --git a/app/modules/loyalty/routes/pages/admin.py b/app/modules/loyalty/routes/pages/admin.py
index aac21656..1d556457 100644
--- a/app/modules/loyalty/routes/pages/admin.py
+++ b/app/modules/loyalty/routes/pages/admin.py
@@ -99,6 +99,31 @@ async def admin_loyalty_merchant_detail(
)
+@router.get(
+ "/merchants/{merchant_id}/program",
+ response_class=HTMLResponse,
+ include_in_schema=False,
+)
+async def admin_loyalty_program_edit(
+ request: Request,
+ merchant_id: int = Path(..., description="Merchant ID"),
+ current_user: User = Depends(require_menu_access("loyalty-programs", FrontendType.ADMIN)),
+ db: Session = Depends(get_db),
+):
+ """
+ Render program configuration edit page for a merchant.
+ Allows admin to create or edit the merchant's loyalty program settings.
+ """
+ return templates.TemplateResponse(
+ "loyalty/admin/program-edit.html",
+ {
+ "request": request,
+ "user": current_user,
+ "merchant_id": merchant_id,
+ },
+ )
+
+
@router.get(
"/merchants/{merchant_id}/settings",
response_class=HTMLResponse,
diff --git a/app/modules/loyalty/static/admin/js/loyalty-program-edit.js b/app/modules/loyalty/static/admin/js/loyalty-program-edit.js
new file mode 100644
index 00000000..f417c140
--- /dev/null
+++ b/app/modules/loyalty/static/admin/js/loyalty-program-edit.js
@@ -0,0 +1,211 @@
+// app/modules/loyalty/static/admin/js/loyalty-program-edit.js
+// noqa: js-006 - async init pattern is safe, loadData has try/catch
+
+const loyaltyProgramEditLog = window.LogConfig.loggers.loyaltyProgramEdit || window.LogConfig.createLogger('loyaltyProgramEdit');
+
+function adminLoyaltyProgramEdit() {
+ return {
+ ...data(),
+ currentPage: 'loyalty-programs',
+
+ merchantId: null,
+ merchant: null,
+ programId: null,
+
+ settings: {
+ loyalty_type: 'points',
+ points_per_euro: 1,
+ welcome_bonus_points: 0,
+ minimum_redemption_points: 100,
+ points_expiration_days: null,
+ points_rewards: [],
+ stamps_target: 10,
+ stamps_reward_description: '',
+ card_name: '',
+ card_color: '#4F46E5',
+ is_active: true
+ },
+
+ loading: false,
+ saving: false,
+ deleting: false,
+ error: null,
+ isNewProgram: false,
+ showDeleteModal: false,
+
+ get backUrl() {
+ return `/admin/loyalty/merchants/${this.merchantId}`;
+ },
+
+ async init() {
+ loyaltyProgramEditLog.info('=== ADMIN PROGRAM EDIT PAGE INITIALIZING ===');
+ if (window._adminProgramEditInitialized) return;
+ window._adminProgramEditInitialized = true;
+
+ // Extract merchant ID from URL: /admin/loyalty/merchants/{id}/program
+ const pathParts = window.location.pathname.split('/');
+ const merchantsIndex = pathParts.indexOf('merchants');
+ if (merchantsIndex !== -1 && pathParts[merchantsIndex + 1]) {
+ this.merchantId = parseInt(pathParts[merchantsIndex + 1]);
+ }
+
+ if (!this.merchantId) {
+ this.error = 'Invalid merchant ID';
+ loyaltyProgramEditLog.error('Could not extract merchant ID from URL');
+ return;
+ }
+
+ loyaltyProgramEditLog.info('Merchant ID:', this.merchantId);
+ await this.loadData();
+ loyaltyProgramEditLog.info('=== ADMIN PROGRAM EDIT PAGE INITIALIZATION COMPLETE ===');
+ },
+
+ async loadData() {
+ this.loading = true;
+ this.error = null;
+
+ try {
+ // Load merchant info and program data in parallel
+ await Promise.all([
+ this.loadMerchant(),
+ this.loadProgram()
+ ]);
+ } catch (error) {
+ loyaltyProgramEditLog.error('Failed to load data:', error);
+ this.error = error.message || 'Failed to load data';
+ } finally {
+ this.loading = false;
+ }
+ },
+
+ async loadMerchant() {
+ const response = await apiClient.get(`/admin/merchants/${this.merchantId}`);
+ if (response) {
+ this.merchant = response;
+ loyaltyProgramEditLog.info('Merchant loaded:', this.merchant.name);
+ }
+ },
+
+ async loadProgram() {
+ try {
+ // Get program via merchant stats endpoint (includes program data)
+ const response = await apiClient.get(`/admin/loyalty/merchants/${this.merchantId}/stats`);
+
+ if (response && response.program) {
+ const program = response.program;
+ this.programId = program.id;
+ this.isNewProgram = false;
+
+ this.settings = {
+ loyalty_type: program.loyalty_type || 'points',
+ points_per_euro: program.points_per_euro || 1,
+ welcome_bonus_points: program.welcome_bonus_points || 0,
+ minimum_redemption_points: program.minimum_redemption_points || 100,
+ points_expiration_days: program.points_expiration_days || null,
+ points_rewards: program.points_rewards || [],
+ stamps_target: program.stamps_target || 10,
+ stamps_reward_description: program.stamps_reward_description || '',
+ card_name: program.card_name || '',
+ card_color: program.card_color || '#4F46E5',
+ is_active: program.is_active !== false
+ };
+
+ loyaltyProgramEditLog.info('Program loaded, ID:', this.programId);
+ } else {
+ this.isNewProgram = true;
+ // Set default card name from merchant
+ if (this.merchant) {
+ this.settings.card_name = this.merchant.name + ' Loyalty';
+ }
+ loyaltyProgramEditLog.info('No program found, create mode');
+ }
+ } catch (error) {
+ // If stats fail (e.g., no program), we're in create mode
+ this.isNewProgram = true;
+ if (this.merchant) {
+ this.settings.card_name = this.merchant.name + ' Loyalty';
+ }
+ loyaltyProgramEditLog.info('No program, switching to create mode');
+ }
+ },
+
+ async saveSettings() {
+ this.saving = true;
+
+ try {
+ // Ensure rewards have IDs
+ this.settings.points_rewards = this.settings.points_rewards.map((r, i) => ({
+ ...r,
+ id: r.id || `reward_${i + 1}`,
+ is_active: r.is_active !== false
+ }));
+
+ if (this.isNewProgram) {
+ const response = await apiClient.post(
+ `/admin/loyalty/merchants/${this.merchantId}/program`,
+ this.settings
+ );
+ this.programId = response.id;
+ this.isNewProgram = false;
+ Utils.showToast('Program created successfully', 'success');
+ } else {
+ await apiClient.patch(
+ `/admin/loyalty/programs/${this.programId}`,
+ this.settings
+ );
+ Utils.showToast('Program updated successfully', 'success');
+ }
+
+ loyaltyProgramEditLog.info('Program saved');
+ // Navigate back to merchant detail
+ window.location.href = this.backUrl;
+ } catch (error) {
+ Utils.showToast(`Failed to save: ${error.message}`, 'error');
+ loyaltyProgramEditLog.error('Save failed:', error);
+ } finally {
+ this.saving = false;
+ }
+ },
+
+ confirmDelete() {
+ this.showDeleteModal = true;
+ },
+
+ async deleteProgram() {
+ if (!this.programId) return;
+ this.deleting = true;
+
+ try {
+ await apiClient.delete(`/admin/loyalty/programs/${this.programId}`);
+ Utils.showToast('Program deleted', 'success');
+ loyaltyProgramEditLog.info('Program deleted');
+ window.location.href = this.backUrl;
+ } catch (error) {
+ Utils.showToast(`Failed to delete: ${error.message}`, 'error');
+ loyaltyProgramEditLog.error('Delete failed:', error);
+ } finally {
+ this.deleting = false;
+ this.showDeleteModal = false;
+ }
+ },
+
+ addReward() {
+ this.settings.points_rewards.push({
+ id: `reward_${Date.now()}`,
+ name: '',
+ points_required: 100,
+ description: '',
+ is_active: true
+ });
+ },
+
+ removeReward(index) {
+ this.settings.points_rewards.splice(index, 1);
+ }
+ };
+}
+
+if (!window.LogConfig.loggers.loyaltyProgramEdit) {
+ window.LogConfig.loggers.loyaltyProgramEdit = window.LogConfig.createLogger('loyaltyProgramEdit');
+}
+loyaltyProgramEditLog.info('Admin loyalty program edit module loaded');
diff --git a/app/modules/loyalty/static/admin/js/loyalty-programs.js b/app/modules/loyalty/static/admin/js/loyalty-programs.js
index d13e0f3c..4b182a90 100644
--- a/app/modules/loyalty/static/admin/js/loyalty-programs.js
+++ b/app/modules/loyalty/static/admin/js/loyalty-programs.js
@@ -29,6 +29,17 @@ function adminLoyaltyPrograms() {
loading: false,
error: null,
+ // Delete modal state
+ showDeleteModal: false,
+ deletingProgram: null,
+
+ // Create program modal state
+ showCreateModal: false,
+ merchantSearch: '',
+ merchantResults: [],
+ selectedMerchant: null,
+ searchingMerchants: false,
+
// Search and filters
filters: {
search: '',
@@ -246,6 +257,69 @@ function adminLoyaltyPrograms() {
}
},
+ // Delete program
+ confirmDeleteProgram(program) {
+ this.deletingProgram = program;
+ this.showDeleteModal = true;
+ },
+
+ async deleteProgram() {
+ if (!this.deletingProgram) return;
+ try {
+ await apiClient.delete(`/admin/loyalty/programs/${this.deletingProgram.id}`);
+ Utils.showToast('Program deleted successfully', 'success');
+ loyaltyProgramsLog.info('Program deleted:', this.deletingProgram.id);
+ this.showDeleteModal = false;
+ this.deletingProgram = null;
+ await Promise.all([this.loadPrograms(), this.loadStats()]);
+ } catch (error) {
+ Utils.showToast(`Failed to delete program: ${error.message}`, 'error');
+ loyaltyProgramsLog.error('Failed to delete program:', error);
+ this.showDeleteModal = false;
+ this.deletingProgram = null;
+ }
+ },
+
+ // Search merchants for create modal
+ searchMerchants() {
+ if (this._merchantSearchTimeout) {
+ clearTimeout(this._merchantSearchTimeout);
+ }
+ this.selectedMerchant = null;
+ this._merchantSearchTimeout = setTimeout(async () => {
+ if (!this.merchantSearch || this.merchantSearch.length < 2) {
+ this.merchantResults = [];
+ return;
+ }
+ this.searchingMerchants = true;
+ try {
+ const params = new URLSearchParams();
+ params.append('search', this.merchantSearch);
+ params.append('limit', 10);
+ const response = await apiClient.get(`/admin/merchants?${params}`);
+ this.merchantResults = response.merchants || response || [];
+ loyaltyProgramsLog.info(`Found ${this.merchantResults.length} merchants`);
+ } catch (error) {
+ loyaltyProgramsLog.error('Merchant search failed:', error);
+ this.merchantResults = [];
+ } finally {
+ this.searchingMerchants = false;
+ }
+ }, 300);
+ },
+
+ // Check if a merchant already has a program in the loaded list
+ existingProgramForMerchant(merchantId) {
+ if (!merchantId) return false;
+ return this.programs.some(p => p.merchant_id === merchantId);
+ },
+
+ // Navigate to create program page for selected merchant
+ goToCreateProgram() {
+ if (!this.selectedMerchant) return;
+ window.location.href = `/admin/loyalty/merchants/${this.selectedMerchant.id}/program`;
+ },
+
// Format date for display
formatDate(dateString) {
if (!dateString) return 'N/A';
diff --git a/app/modules/loyalty/templates/loyalty/admin/merchant-detail.html b/app/modules/loyalty/templates/loyalty/admin/merchant-detail.html
index 026b5e2d..a39a71ed 100644
--- a/app/modules/loyalty/templates/loyalty/admin/merchant-detail.html
+++ b/app/modules/loyalty/templates/loyalty/admin/merchant-detail.html
@@ -25,11 +25,17 @@
Quick Actions
-
+
@@ -237,7 +243,7 @@
- Admin Settings
+ Admin Policy Settings
@@ -271,7 +277,7 @@
:href="`/admin/loyalty/merchants/${merchantId}/settings`"
class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400">
- Modify admin settings
+ Modify admin policy
diff --git a/app/modules/loyalty/templates/loyalty/admin/merchant-settings.html b/app/modules/loyalty/templates/loyalty/admin/merchant-settings.html
index a2ba3024..52ae6dc8 100644
--- a/app/modules/loyalty/templates/loyalty/admin/merchant-settings.html
+++ b/app/modules/loyalty/templates/loyalty/admin/merchant-settings.html
@@ -9,7 +9,7 @@
{% block alpine_data %}adminLoyaltyMerchantSettings(){% endblock %}
{% block content %}
-{% call detail_page_header("'Loyalty Settings: ' + (merchant?.name || '')", backUrl, subtitle_show='merchant') %}
+{% call detail_page_header("'Admin Policy: ' + (merchant?.name || '')", '/admin/loyalty/merchants/' ~ merchant_id, subtitle_show='merchant') %}
Admin-controlled settings for this merchant's loyalty program
{% endcall %}
diff --git a/app/modules/loyalty/templates/loyalty/admin/program-edit.html b/app/modules/loyalty/templates/loyalty/admin/program-edit.html
new file mode 100644
index 00000000..606639e1
--- /dev/null
+++ b/app/modules/loyalty/templates/loyalty/admin/program-edit.html
@@ -0,0 +1,252 @@
+{# app/modules/loyalty/templates/loyalty/admin/program-edit.html #}
+{% extends "admin/base.html" %}
+{% from 'shared/macros/headers.html' import detail_page_header %}
+{% from 'shared/macros/alerts.html' import loading_state, error_state %}
+
+{% block title %}Program Configuration{% endblock %}
+
+{% block alpine_data %}adminLoyaltyProgramEdit(){% endblock %}
+
+{% block content %}
+{% call detail_page_header("isNewProgram ? 'Create Program: ' + (merchant?.name || '') : 'Edit Program: ' + (merchant?.name || '')", '/admin/loyalty/merchants/' ~ merchant_id, subtitle_show='merchant') %}
+
+{% endcall %}
+
+{{ loading_state('Loading program configuration...') }}
+{{ error_state('Error loading program configuration') }}
+
+
+
+
+
+
+
Delete Loyalty Program
+
+ This will permanently delete the loyalty program and all associated data (cards, transactions, rewards).
+ This action cannot be undone.
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block extra_scripts %}
+
+{% endblock %}
diff --git a/app/modules/loyalty/templates/loyalty/admin/programs.html b/app/modules/loyalty/templates/loyalty/admin/programs.html
index e0ebbbc4..1c0756c3 100644
--- a/app/modules/loyalty/templates/loyalty/admin/programs.html
+++ b/app/modules/loyalty/templates/loyalty/admin/programs.html
@@ -10,7 +10,7 @@
{% block alpine_data %}adminLoyaltyPrograms(){% endblock %}
{% block content %}
-{{ page_header('Loyalty Programs') }}
+{{ page_header('Loyalty Programs', action_label='Create Program', action_onclick="showCreateModal = true") }}
{{ loading_state('Loading loyalty programs...') }}
@@ -219,13 +219,22 @@
-
+
+
+
+