Files
orion/app/modules/loyalty/static/store/js/loyalty-terminal.js
Samir Boulahtit 316ec42566 fix(loyalty): use card_id instead of id in terminal JS
The terminal's selectedCard comes from CardLookupResponse which uses
card_id field, but the JS was referencing selectedCard.id (undefined).
This caused all terminal transactions to fail with "LoyaltyCard with
identifier 'unknown' not found" instead of processing the transaction
or showing proper PIN validation errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:50:20 +01:00

343 lines
12 KiB
JavaScript

// app/modules/loyalty/static/store/js/loyalty-terminal.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
// Use centralized logger
const loyaltyTerminalLog = window.LogConfig.loggers.loyaltyTerminal || window.LogConfig.createLogger('loyaltyTerminal');
// ============================================
// STORE LOYALTY TERMINAL FUNCTION
// ============================================
function storeLoyaltyTerminal() {
return {
// Inherit base layout functionality
...data(),
// Page identifier
currentPage: 'terminal',
// Program state
program: null,
availableRewards: [],
// Customer lookup
searchQuery: '',
lookingUp: false,
selectedCard: null,
// Transaction inputs
earnAmount: null,
selectedReward: '',
// PIN entry
showPinEntry: false,
pinDigits: '',
pendingAction: null, // 'earn' or 'redeem'
processing: false,
// Recent transactions
recentTransactions: [],
// State
loading: false,
error: null,
// Initialize
async init() {
loyaltyTerminalLog.info('=== LOYALTY TERMINAL INITIALIZING ===');
// Prevent multiple initializations
if (window._loyaltyTerminalInitialized) {
loyaltyTerminalLog.warn('Loyalty terminal already initialized, skipping...');
return;
}
window._loyaltyTerminalInitialized = true;
// IMPORTANT: Call parent init first to set storeCode from URL
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
await this.loadData();
loyaltyTerminalLog.info('=== LOYALTY TERMINAL INITIALIZATION COMPLETE ===');
},
// Load initial data
async loadData() {
this.loading = true;
this.error = null;
try {
await Promise.all([
this.loadProgram(),
this.loadRecentTransactions()
]);
} catch (error) {
loyaltyTerminalLog.error('Failed to load data:', error);
this.error = error.message || 'Failed to load terminal';
} finally {
this.loading = false;
}
},
// Load program info
async loadProgram() {
try {
loyaltyTerminalLog.info('Loading program info...');
const response = await apiClient.get('/store/loyalty/program');
if (response) {
this.program = response;
this.availableRewards = response.points_rewards || [];
loyaltyTerminalLog.info('Program loaded:', this.program.display_name);
}
} catch (error) {
if (error.status === 404) {
loyaltyTerminalLog.info('No program configured');
this.program = null;
} else {
throw error;
}
}
},
// Load recent transactions
async loadRecentTransactions() {
try {
loyaltyTerminalLog.info('Loading recent transactions...');
const response = await apiClient.get('/store/loyalty/transactions?limit=10');
if (response && response.transactions) {
this.recentTransactions = response.transactions;
loyaltyTerminalLog.info(`Loaded ${this.recentTransactions.length} transactions`);
}
} catch (error) {
loyaltyTerminalLog.warn('Failed to load transactions:', error.message);
// Don't throw - transactions are optional
}
},
// Look up customer
async lookupCustomer() {
if (!this.searchQuery) return;
this.lookingUp = true;
this.selectedCard = null;
try {
loyaltyTerminalLog.info('Looking up customer:', this.searchQuery);
const response = await apiClient.get(`/store/loyalty/cards/lookup?q=${encodeURIComponent(this.searchQuery)}`);
if (response) {
this.selectedCard = response;
loyaltyTerminalLog.info('Customer found:', this.selectedCard.customer_name);
this.searchQuery = '';
}
} catch (error) {
if (error.status === 404) {
Utils.showToast(I18n.t('loyalty.store.terminal.customer_not_found'), 'warning');
} else {
Utils.showToast(I18n.t('loyalty.store.terminal.error_lookup', {message: error.message}), 'error');
}
loyaltyTerminalLog.error('Lookup failed:', error);
} finally {
this.lookingUp = false;
}
},
// Clear selected customer
clearCustomer() {
this.selectedCard = null;
this.earnAmount = null;
this.selectedReward = '';
},
// Get selected reward points
getSelectedRewardPoints() {
if (!this.selectedReward) return 0;
const reward = this.availableRewards.find(r => r.id === this.selectedReward);
return reward ? reward.points_required : 0;
},
// Show PIN modal
showPinModal(action) {
this.pendingAction = action;
this.pinDigits = '';
this.showPinEntry = true;
},
// PIN entry methods
addPinDigit(digit) {
if (this.pinDigits.length < 4) {
this.pinDigits += digit.toString();
}
},
removePinDigit() {
this.pinDigits = this.pinDigits.slice(0, -1);
},
cancelPinEntry() {
this.showPinEntry = false;
this.pinDigits = '';
this.pendingAction = null;
},
// Submit transaction
async submitTransaction() {
if (this.pinDigits.length !== 4) return;
this.processing = true;
try {
if (this.pendingAction === 'stamp') {
await this.addStamp();
} else if (this.pendingAction === 'redeemStamps') {
await this.redeemStamps();
} else if (this.pendingAction === 'earn') {
await this.earnPoints();
} else if (this.pendingAction === 'redeem') {
await this.redeemReward();
}
// Close modal and refresh
this.showPinEntry = false;
this.pinDigits = '';
this.pendingAction = null;
// Refresh customer card and transactions
if (this.selectedCard) {
await this.refreshCard();
}
await this.loadRecentTransactions();
} catch (error) {
Utils.showToast(I18n.t('loyalty.store.terminal.transaction_failed', {message: error.message}), 'error');
loyaltyTerminalLog.error('Transaction failed:', error);
} finally {
this.processing = false;
}
},
// Add stamp
async addStamp() {
loyaltyTerminalLog.info('Adding stamp...');
await apiClient.post('/store/loyalty/stamp', {
card_id: this.selectedCard.card_id,
staff_pin: this.pinDigits
});
Utils.showToast(I18n.t('loyalty.store.terminal.stamp_added'), 'success');
},
// Redeem stamps
async redeemStamps() {
loyaltyTerminalLog.info('Redeeming stamps...');
await apiClient.post('/store/loyalty/stamp/redeem', {
card_id: this.selectedCard.card_id,
staff_pin: this.pinDigits
});
Utils.showToast(I18n.t('loyalty.store.terminal.stamps_redeemed'), 'success');
},
// Earn points
async earnPoints() {
loyaltyTerminalLog.info('Earning points...', { amount: this.earnAmount });
const response = await apiClient.post('/store/loyalty/points/earn', {
card_id: this.selectedCard.card_id,
purchase_amount_cents: Math.round(this.earnAmount * 100),
staff_pin: this.pinDigits
});
const pointsEarned = response.points_earned || Math.floor(this.earnAmount * (this.program?.points_per_euro || 1));
Utils.showToast(I18n.t('loyalty.store.terminal.x_points_awarded', {points: pointsEarned}), 'success');
this.earnAmount = null;
},
// Redeem reward
async redeemReward() {
const reward = this.availableRewards.find(r => r.id === this.selectedReward);
if (!reward) return;
loyaltyTerminalLog.info('Redeeming reward...', { reward: reward.name });
await apiClient.post('/store/loyalty/points/redeem', {
card_id: this.selectedCard.card_id,
reward_id: this.selectedReward,
staff_pin: this.pinDigits
});
Utils.showToast(I18n.t('loyalty.store.terminal.reward_redeemed', {name: reward.name}), 'success');
this.selectedReward = '';
},
// Refresh card data
async refreshCard() {
try {
const response = await apiClient.get(`/store/loyalty/cards/${this.selectedCard.card_id}`);
if (response) {
this.selectedCard = response;
}
} catch (error) {
loyaltyTerminalLog.warn('Failed to refresh card:', error.message);
}
},
// Format number
getTransactionLabel(tx) {
const type = tx.transaction_type;
if (type) {
return I18n.t('loyalty.transactions.' + type, {defaultValue: type.replace(/_/g, ' ')});
}
return I18n.t('loyalty.common.unknown');
},
getTransactionColor(tx) {
const type = tx.transaction_type || '';
if (['card_created', 'welcome_bonus', 'stamp_earned', 'points_earned'].includes(type)) {
return 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100';
}
if (['stamp_redeemed', 'points_redeemed'].includes(type)) {
return 'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100';
}
if (['stamp_voided', 'points_voided', 'points_expired', 'card_deactivated'].includes(type)) {
return 'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100';
}
return 'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100';
},
formatNumber(num) {
if (num === null || num === undefined) return '0';
return new Intl.NumberFormat('en-US').format(num);
},
// Format time
formatTime(dateString) {
if (!dateString) return '-';
try {
const date = new Date(dateString);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
} catch (e) {
return dateString;
}
}
};
}
// Register logger
if (!window.LogConfig.loggers.loyaltyTerminal) {
window.LogConfig.loggers.loyaltyTerminal = window.LogConfig.createLogger('loyaltyTerminal');
}
loyaltyTerminalLog.info('Loyalty terminal module loaded');