feat(loyalty): add dedicated program edit page with full CRUD and tests
Some checks failed
Some checks failed
Add /admin/loyalty/merchants/{id}/program route for program configuration
with a dedicated Alpine.js edit page supporting create/edit/delete flows.
Restructure programs dashboard with create modal (merchant search +
duplicate detection) and delete confirmation. Rename "Loyalty Settings"
to "Admin Policy" for clearer separation of concerns.
Add integration tests for all admin page routes (12 tests) and program
list search/filter/pagination endpoints (9 tests).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
211
app/modules/loyalty/static/admin/js/loyalty-program-edit.js
Normal file
211
app/modules/loyalty/static/admin/js/loyalty-program-edit.js
Normal file
@@ -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');
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user