feat: add SQL query presets, shared program form, and loyalty API/admin improvements
Some checks failed
CI / ruff (push) Successful in 9s
CI / pytest (push) Failing after 48m35s
CI / validate (push) Successful in 25s
CI / dependency-scanning (push) Successful in 29s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

- Add Loyalty and Billing SQL query presets to dev tools
- Extract shared program-form.html partial and loyalty-program-form.js mixin
- Refactor admin program-edit to use shared form partial
- Add store loyalty API endpoints for program management

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 22:53:19 +01:00
parent eee33d6a1b
commit 8cf5da6914
6 changed files with 535 additions and 267 deletions

View File

@@ -6,32 +6,15 @@ const loyaltyProgramEditLog = window.LogConfig.loggers.loyaltyProgramEdit || win
function adminLoyaltyProgramEdit() {
return {
...data(),
...createProgramFormMixin(),
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}`;
@@ -88,39 +71,22 @@ function adminLoyaltyProgramEdit() {
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
};
this.populateSettings(program);
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';
@@ -133,17 +99,12 @@ function adminLoyaltyProgramEdit() {
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
}));
const payload = this.buildPayload();
if (this.isNewProgram) {
const response = await apiClient.post(
`/admin/loyalty/merchants/${this.merchantId}/program`,
this.settings
payload
);
this.programId = response.id;
this.isNewProgram = false;
@@ -151,13 +112,12 @@ function adminLoyaltyProgramEdit() {
} else {
await apiClient.patch(
`/admin/loyalty/programs/${this.programId}`,
this.settings
payload
);
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');
@@ -167,10 +127,6 @@ function adminLoyaltyProgramEdit() {
}
},
confirmDelete() {
this.showDeleteModal = true;
},
async deleteProgram() {
if (!this.programId) return;
this.deleting = true;
@@ -188,20 +144,6 @@ function adminLoyaltyProgramEdit() {
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);
}
};
}

View File

@@ -0,0 +1,127 @@
// app/modules/loyalty/static/shared/js/loyalty-program-form.js
// Shared mixin for loyalty program settings forms (admin, merchant, store).
/**
* Factory that returns the shared Alpine.js properties and methods
* for the loyalty program settings form.
*
* Each page spreads this into its own component and provides:
* - init(), loadData(), saveSettings(), deleteProgram()
* with the correct API paths and navigation.
*/
function createProgramFormMixin() {
return {
// ---- state ----
settings: {
loyalty_type: 'points',
stamps_target: 10,
stamps_reward_description: '',
stamps_reward_value_cents: null,
points_per_euro: 1,
welcome_bonus_points: 0,
minimum_redemption_points: 100,
minimum_purchase_cents: 0,
points_expiration_days: null,
points_rewards: [],
cooldown_minutes: 15,
max_daily_stamps: 5,
require_staff_pin: true,
card_name: '',
card_color: '#4F46E5',
card_secondary_color: '',
logo_url: '',
hero_image_url: '',
terms_text: '',
privacy_url: '',
is_active: true,
},
isNewProgram: false,
saving: false,
deleting: false,
showDeleteModal: false,
// ---- helpers ----
/**
* Populate settings from an API program response object.
*/
populateSettings(program) {
this.settings = {
loyalty_type: program.loyalty_type || 'points',
stamps_target: program.stamps_target || 10,
stamps_reward_description: program.stamps_reward_description || '',
stamps_reward_value_cents: program.stamps_reward_value_cents || null,
points_per_euro: program.points_per_euro || 1,
welcome_bonus_points: program.welcome_bonus_points || 0,
minimum_redemption_points: program.minimum_redemption_points || 100,
minimum_purchase_cents: program.minimum_purchase_cents || 0,
points_expiration_days: program.points_expiration_days || null,
points_rewards: (program.points_rewards || []).map(r => ({
id: r.id,
name: r.name,
points_required: r.points_required,
description: r.description || '',
is_active: r.is_active !== false,
})),
cooldown_minutes: program.cooldown_minutes ?? 15,
max_daily_stamps: program.max_daily_stamps || 5,
require_staff_pin: program.require_staff_pin !== false,
card_name: program.card_name || '',
card_color: program.card_color || '#4F46E5',
card_secondary_color: program.card_secondary_color || '',
logo_url: program.logo_url || '',
hero_image_url: program.hero_image_url || '',
terms_text: program.terms_text || '',
privacy_url: program.privacy_url || '',
is_active: program.is_active !== false,
};
},
/**
* Build a clean payload from settings for POST/PATCH/PUT.
* Ensures rewards have IDs and cleans empty optional fields.
*/
buildPayload() {
const payload = { ...this.settings };
// Ensure rewards have IDs
payload.points_rewards = (payload.points_rewards || []).map((r, i) => ({
...r,
id: r.id || `reward_${i + 1}`,
is_active: r.is_active !== false,
}));
// Clean empty optional fields
if (!payload.stamps_reward_value_cents) payload.stamps_reward_value_cents = null;
if (!payload.points_expiration_days) payload.points_expiration_days = null;
if (!payload.minimum_purchase_cents) payload.minimum_purchase_cents = null;
if (!payload.card_name) payload.card_name = null;
if (!payload.card_secondary_color) payload.card_secondary_color = null;
if (!payload.logo_url) payload.logo_url = null;
if (!payload.hero_image_url) payload.hero_image_url = null;
if (!payload.terms_text) payload.terms_text = null;
if (!payload.privacy_url) payload.privacy_url = null;
return payload;
},
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);
},
confirmDelete() {
this.showDeleteModal = true;
},
};
}