feat(loyalty): transaction categories — admin UI + web terminal
Some checks failed
Some checks failed
Admin merchant detail page:
- New "Transaction Categories" section with store selector
- Inline add form, activate/deactivate toggle, delete button
- Categories CRUD via /admin/loyalty/stores/{id}/categories API
Web terminal:
- Loads categories on init via /store/loyalty/categories
- Category pill selector shown in PIN modal before stamp/earn actions
- Selected category_id passed to stamp and points API calls
- Categories are optional (selector hidden when none configured)
4 new i18n keys (EN).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,12 @@ function adminLoyaltyMerchantDetail() {
|
||||
settings: null,
|
||||
locations: [],
|
||||
|
||||
// Transaction categories
|
||||
selectedCategoryStoreId: '',
|
||||
storeCategories: [],
|
||||
showAddCategory: false,
|
||||
newCategoryName: '',
|
||||
|
||||
// State
|
||||
loading: false,
|
||||
error: null,
|
||||
@@ -258,6 +264,59 @@ function adminLoyaltyMerchantDetail() {
|
||||
formatNumber(num) {
|
||||
if (num === null || num === undefined) return '0';
|
||||
return new Intl.NumberFormat('en-US').format(num);
|
||||
},
|
||||
|
||||
// Transaction categories
|
||||
async loadCategoriesForStore() {
|
||||
if (!this.selectedCategoryStoreId) {
|
||||
this.storeCategories = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/loyalty/stores/${this.selectedCategoryStoreId}/categories`);
|
||||
this.storeCategories = response?.categories || [];
|
||||
} catch (error) {
|
||||
loyaltyMerchantDetailLog.warn('Failed to load categories:', error.message);
|
||||
this.storeCategories = [];
|
||||
}
|
||||
},
|
||||
|
||||
async createCategory() {
|
||||
if (!this.newCategoryName || !this.selectedCategoryStoreId) return;
|
||||
try {
|
||||
await apiClient.post(`/admin/loyalty/stores/${this.selectedCategoryStoreId}/categories`, {
|
||||
name: this.newCategoryName,
|
||||
display_order: this.storeCategories.length,
|
||||
});
|
||||
this.newCategoryName = '';
|
||||
this.showAddCategory = false;
|
||||
await this.loadCategoriesForStore();
|
||||
Utils.showToast('Category created', 'success');
|
||||
} catch (error) {
|
||||
Utils.showToast(error.message || 'Failed to create category', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async toggleCategoryActive(cat) {
|
||||
try {
|
||||
await apiClient.patch(`/admin/loyalty/stores/${this.selectedCategoryStoreId}/categories/${cat.id}`, {
|
||||
is_active: !cat.is_active,
|
||||
});
|
||||
await this.loadCategoriesForStore();
|
||||
} catch (error) {
|
||||
Utils.showToast(error.message || 'Failed to update category', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCategory(catId) {
|
||||
if (!confirm('Delete this category?')) return;
|
||||
try {
|
||||
await apiClient.delete(`/admin/loyalty/stores/${this.selectedCategoryStoreId}/categories/${catId}`);
|
||||
await this.loadCategoriesForStore();
|
||||
Utils.showToast('Category deleted', 'success');
|
||||
} catch (error) {
|
||||
Utils.showToast(error.message || 'Failed to delete category', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ function storeLoyaltyTerminal() {
|
||||
// Transaction inputs
|
||||
earnAmount: null,
|
||||
selectedReward: '',
|
||||
selectedCategory: null,
|
||||
categories: [],
|
||||
|
||||
// PIN entry
|
||||
showPinEntry: false,
|
||||
@@ -63,6 +65,7 @@ function storeLoyaltyTerminal() {
|
||||
}
|
||||
|
||||
await this.loadData();
|
||||
await this.loadCategories();
|
||||
|
||||
loyaltyTerminalLog.info('=== LOYALTY TERMINAL INITIALIZATION COMPLETE ===');
|
||||
},
|
||||
@@ -279,13 +282,25 @@ function storeLoyaltyTerminal() {
|
||||
}
|
||||
},
|
||||
|
||||
// Load categories for this store
|
||||
async loadCategories() {
|
||||
try {
|
||||
const response = await apiClient.get('/store/loyalty/categories');
|
||||
this.categories = (response?.categories || []).filter(c => c.is_active);
|
||||
loyaltyTerminalLog.info(`Loaded ${this.categories.length} categories`);
|
||||
} catch (error) {
|
||||
loyaltyTerminalLog.warn('Failed to load categories:', error.message);
|
||||
}
|
||||
},
|
||||
|
||||
// Add stamp
|
||||
async addStamp() {
|
||||
loyaltyTerminalLog.info('Adding stamp...');
|
||||
|
||||
await apiClient.post('/store/loyalty/stamp', {
|
||||
card_id: this.selectedCard.id,
|
||||
staff_pin: this.pinDigits
|
||||
staff_pin: this.pinDigits,
|
||||
category_id: this.selectedCategory || undefined,
|
||||
});
|
||||
|
||||
Utils.showToast(I18n.t('loyalty.store.terminal.stamp_added'), 'success');
|
||||
@@ -297,7 +312,7 @@ function storeLoyaltyTerminal() {
|
||||
|
||||
await apiClient.post('/store/loyalty/stamp/redeem', {
|
||||
card_id: this.selectedCard.id,
|
||||
staff_pin: this.pinDigits
|
||||
staff_pin: this.pinDigits,
|
||||
});
|
||||
|
||||
Utils.showToast(I18n.t('loyalty.store.terminal.stamps_redeemed'), 'success');
|
||||
@@ -310,7 +325,8 @@ function storeLoyaltyTerminal() {
|
||||
const response = await apiClient.post('/store/loyalty/points/earn', {
|
||||
card_id: this.selectedCard.id,
|
||||
purchase_amount_cents: Math.round(this.earnAmount * 100),
|
||||
staff_pin: this.pinDigits
|
||||
staff_pin: this.pinDigits,
|
||||
category_id: this.selectedCategory || undefined,
|
||||
});
|
||||
|
||||
const pointsEarned = response.points_earned || Math.floor(this.earnAmount * (this.program?.points_per_euro || 1));
|
||||
@@ -329,7 +345,7 @@ function storeLoyaltyTerminal() {
|
||||
await apiClient.post('/store/loyalty/points/redeem', {
|
||||
card_id: this.selectedCard.id,
|
||||
reward_id: this.selectedReward,
|
||||
staff_pin: this.pinDigits
|
||||
staff_pin: this.pinDigits,
|
||||
});
|
||||
|
||||
Utils.showToast(I18n.t('loyalty.store.terminal.reward_redeemed', {name: reward.name}), 'success');
|
||||
|
||||
Reference in New Issue
Block a user