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:
@@ -480,6 +480,9 @@
|
||||
"table_points_earned": "Points Earned",
|
||||
"table_points_redeemed": "Points Redeemed",
|
||||
"table_transactions_30d": "Transactions (30d)",
|
||||
"transaction_categories": "Transaction Categories",
|
||||
"select_store": "Select a store...",
|
||||
"no_categories": "No categories configured. Click Add to create one.",
|
||||
"admin_policy_settings": "Admin Policy Settings",
|
||||
"staff_pin_policy": "Staff PIN Policy",
|
||||
"self_enrollment": "Self Enrollment",
|
||||
@@ -709,6 +712,7 @@
|
||||
"reward_redeemed": "Reward redeemed: {name}",
|
||||
"card_label": "Card",
|
||||
"confirm": "Confirm",
|
||||
"select_category": "Category (what was sold)",
|
||||
"pin_authorize_text": "Enter your staff PIN to authorize this transaction",
|
||||
"free_item": "Free item",
|
||||
"reward_label": "Reward",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -201,6 +201,83 @@
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
<!-- Transaction Categories (per store) -->
|
||||
<div x-show="locations.length > 0" 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('tag', 'inline w-5 h-5 mr-2')"></span>
|
||||
{{ _('loyalty.admin.merchant_detail.transaction_categories') }}
|
||||
</h3>
|
||||
|
||||
<!-- Store selector -->
|
||||
<div class="mb-4">
|
||||
<select x-model="selectedCategoryStoreId" @change="loadCategoriesForStore()"
|
||||
class="w-full md:w-auto px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">{{ _('loyalty.admin.merchant_detail.select_store') }}</option>
|
||||
<template x-for="loc in locations" :key="loc.store_id">
|
||||
<option :value="loc.store_id" x-text="loc.store_name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Categories list -->
|
||||
<div x-show="selectedCategoryStoreId">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="storeCategories.length + ' categories'"></p>
|
||||
<button @click="showAddCategory = true" type="button"
|
||||
class="px-3 py-1.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
||||
<span x-html="$icon('plus', 'inline w-4 h-4 mr-1')"></span>
|
||||
{{ _('loyalty.common.add') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add category inline form -->
|
||||
<div x-show="showAddCategory" class="mb-4 p-3 border border-purple-200 dark:border-purple-800 rounded-lg bg-purple-50 dark:bg-purple-900/20">
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">{{ _('loyalty.common.name') }}</label>
|
||||
<input type="text" x-model="newCategoryName" maxlength="100"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<button @click="createCategory()" :disabled="!newCategoryName"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
{{ _('loyalty.common.save') }}
|
||||
</button>
|
||||
<button @click="showAddCategory = false; newCategoryName = ''" type="button"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600">
|
||||
{{ _('loyalty.common.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Categories table -->
|
||||
<div class="space-y-2">
|
||||
<template x-for="cat in storeCategories" :key="cat.id">
|
||||
<div class="flex items-center justify-between p-3 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="cat.name"></span>
|
||||
<span x-show="!cat.is_active" class="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-500 dark:bg-gray-700">{{ _('loyalty.common.inactive') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="toggleCategoryActive(cat)" type="button"
|
||||
:aria-label="cat.is_active ? '{{ _('loyalty.common.deactivate') }}' : '{{ _('loyalty.common.activate') }}'"
|
||||
class="text-sm" :class="cat.is_active ? 'text-orange-500 hover:text-orange-700' : 'text-green-500 hover:text-green-700'">
|
||||
<span x-html="$icon(cat.is_active ? 'pause' : 'play', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button @click="deleteCategory(cat.id)" type="button"
|
||||
aria-label="{{ _('loyalty.common.delete') }}"
|
||||
class="text-red-500 hover:text-red-700">
|
||||
<span x-html="$icon('trash', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="storeCategories.length === 0" class="text-sm text-gray-500 dark:text-gray-400 py-4 text-center">
|
||||
{{ _('loyalty.admin.merchant_detail.no_categories') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Merchant Settings (Admin-controlled) -->
|
||||
<div 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">
|
||||
|
||||
@@ -324,6 +324,24 @@
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
{{ _('loyalty.store.terminal.pin_authorize_text') }}
|
||||
</p>
|
||||
|
||||
<!-- Category selector (only shown when categories exist and action is stamp/earn) -->
|
||||
<div x-show="categories.length > 0 && (pendingAction === 'stamp' || pendingAction === 'earn')" class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-2">{{ _('loyalty.store.terminal.select_category') }}</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template x-for="cat in categories" :key="cat.id">
|
||||
<button type="button"
|
||||
@click="selectedCategory = (selectedCategory === cat.id) ? null : cat.id"
|
||||
class="px-3 py-1.5 text-sm font-medium rounded-full border transition-colors"
|
||||
:class="selectedCategory === cat.id
|
||||
? 'bg-purple-600 text-white border-purple-600'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600'"
|
||||
x-text="cat.name">
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mb-4" aria-live="polite" aria-atomic="true">
|
||||
<div class="flex gap-2" role="status" :aria-label="pinDigits.length + ' of 4 digits entered'">
|
||||
<template x-for="i in 4">
|
||||
|
||||
Reference in New Issue
Block a user