@voxepay/checkout 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1244 @@
1
+ /**
2
+ * VoxePay Checkout Modal Component
3
+ * A modern, glassmorphism payment modal
4
+ */
5
+
6
+ import {
7
+ detectCardBrand,
8
+ validateCardNumber,
9
+ validateExpiry,
10
+ validateCVV
11
+ } from '../utils/card-validator';
12
+ import {
13
+ formatCardNumber,
14
+ formatExpiry,
15
+ formatCVV,
16
+ formatAmount
17
+ } from '../utils/formatter';
18
+ import { generateAuthData, formatExpiryForApi, cleanPan } from '../utils/encryption';
19
+ import { VoxePayApiClient } from '../api/client';
20
+ import type { CheckoutOptions, PaymentResult, PaymentError, BankTransferDetails, PaymentMethod } from '../types';
21
+
22
+ // SVG Icons
23
+ const ICONS = {
24
+ close: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
25
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
26
+ </svg>`,
27
+ lock: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
28
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
29
+ </svg>`,
30
+ check: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
31
+ <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
32
+ </svg>`,
33
+ error: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" stroke="currentColor" stroke-width="2">
34
+ <path stroke-linecap="round" stroke-linejoin="round" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" />
35
+ </svg>`,
36
+ copy: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
37
+ <path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
38
+ </svg>`,
39
+ bank: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
40
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3 21h18M3 10h18M5 6l7-3 7 3M4 10v11M20 10v11M8 14v3M12 14v3M16 14v3" />
41
+ </svg>`,
42
+ card: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
43
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
44
+ </svg>`,
45
+ clock: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
46
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
47
+ </svg>`,
48
+ };
49
+
50
+ // Card brand logos
51
+ const CARD_BRAND_DISPLAY: Record<string, string> = {
52
+ visa: 'VISA',
53
+ mastercard: 'MC',
54
+ amex: 'AMEX',
55
+ verve: 'VERVE',
56
+ discover: 'DISC',
57
+ };
58
+
59
+ export interface ModalState {
60
+ cardNumber: string;
61
+ expiry: string;
62
+ cvv: string;
63
+ otp: string;
64
+ errors: {
65
+ cardNumber?: string;
66
+ expiry?: string;
67
+ cvv?: string;
68
+ otp?: string;
69
+ };
70
+ isProcessing: boolean;
71
+ isSuccess: boolean;
72
+ isOtpStep: boolean;
73
+ otpTimer: number;
74
+ canResendOtp: boolean;
75
+ otpTimerInterval: number | null;
76
+ paymentMethod: PaymentMethod;
77
+ transferTimer: number;
78
+ transferTimerInterval: number | null;
79
+ bankTransferDetails: BankTransferDetails | null;
80
+ // API response fields
81
+ paymentId: string | null;
82
+ transactionId: string | null;
83
+ transactionRef: string | null;
84
+ eciFlag: string | null;
85
+ }
86
+
87
+ export class VoxePayModal {
88
+ private options: CheckoutOptions;
89
+ private state: ModalState;
90
+ private container: HTMLDivElement | null = null;
91
+ private overlay: HTMLDivElement | null = null;
92
+ private apiClient: VoxePayApiClient | null = null;
93
+
94
+ constructor(options: CheckoutOptions) {
95
+ this.options = options;
96
+ const methods = options.paymentMethods || ['card', 'bank_transfer'];
97
+
98
+ // Initialize API client from SDK config
99
+ if (options._sdkConfig) {
100
+ this.apiClient = new VoxePayApiClient(
101
+ options._sdkConfig.apiKey,
102
+ options._sdkConfig.baseUrl
103
+ );
104
+ }
105
+
106
+ this.state = {
107
+ cardNumber: '',
108
+ expiry: '',
109
+ cvv: '',
110
+ otp: '',
111
+ errors: {},
112
+ isProcessing: false,
113
+ isSuccess: false,
114
+ isOtpStep: false,
115
+ otpTimer: 60,
116
+ canResendOtp: false,
117
+ otpTimerInterval: null,
118
+ paymentMethod: methods[0],
119
+ transferTimer: 0,
120
+ transferTimerInterval: null,
121
+ bankTransferDetails: options.bankTransferDetails || null,
122
+ paymentId: null,
123
+ transactionId: null,
124
+ transactionRef: null,
125
+ eciFlag: null,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Open the checkout modal
131
+ */
132
+ open(): void {
133
+ this.injectStyles();
134
+ this.render();
135
+ this.attachEventListeners();
136
+
137
+ // Trigger open animation
138
+ requestAnimationFrame(() => {
139
+ this.overlay?.classList.add('voxepay-visible');
140
+ });
141
+ }
142
+
143
+ /**
144
+ * Close the checkout modal
145
+ */
146
+ close(): void {
147
+ this.stopTransferTimer();
148
+ this.overlay?.classList.remove('voxepay-visible');
149
+
150
+ setTimeout(() => {
151
+ this.container?.remove();
152
+ this.container = null;
153
+ this.overlay = null;
154
+ this.options.onClose?.();
155
+ }, 300);
156
+ }
157
+
158
+ /**
159
+ * Inject styles if not already present
160
+ */
161
+ private injectStyles(): void {
162
+ if (document.getElementById('voxepay-checkout-styles')) return;
163
+
164
+ const style = document.createElement('style');
165
+ style.id = 'voxepay-checkout-styles';
166
+ style.textContent = this.getStyles();
167
+ document.head.appendChild(style);
168
+ }
169
+
170
+ /**
171
+ * Render the modal HTML
172
+ */
173
+ private render(): void {
174
+ this.container = document.createElement('div');
175
+ this.container.className = 'voxepay-checkout';
176
+ this.container.innerHTML = this.getModalHTML();
177
+ document.body.appendChild(this.container);
178
+
179
+ this.overlay = this.container.querySelector('.voxepay-overlay');
180
+ }
181
+
182
+ /**
183
+ * Get available payment methods
184
+ */
185
+ private getPaymentMethods(): PaymentMethod[] {
186
+ return this.options.paymentMethods || ['card', 'bank_transfer'];
187
+ }
188
+
189
+ /**
190
+ * Get modal HTML
191
+ */
192
+ private getModalHTML(): string {
193
+ const formattedAmount = formatAmount(this.options.amount, this.options.currency);
194
+ const methods = this.getPaymentMethods();
195
+ const showTabs = methods.length > 1;
196
+ const isCard = this.state.paymentMethod === 'card';
197
+
198
+ return `
199
+ <div class="voxepay-overlay">
200
+ <div class="voxepay-modal" role="dialog" aria-modal="true" aria-labelledby="voxepay-title">
201
+ <div class="voxepay-header">
202
+ <div class="voxepay-header-left">
203
+ <div class="voxepay-logo">V</div>
204
+ <div>
205
+ <div class="voxepay-amount" id="voxepay-title">Pay ${formattedAmount}</div>
206
+ ${this.options.description ? `<div style="font-size: 0.875rem; color: var(--voxepay-text-muted);">${this.options.description}</div>` : ''}
207
+ </div>
208
+ </div>
209
+ <button class="voxepay-close" aria-label="Close" data-action="close">
210
+ ${ICONS.close}
211
+ </button>
212
+ </div>
213
+
214
+ ${showTabs ? `
215
+ <div class="voxepay-method-tabs">
216
+ ${methods.includes('card') ? `
217
+ <button class="voxepay-method-tab ${isCard ? 'active' : ''}" data-method="card">
218
+ ${ICONS.card} <span>Card</span>
219
+ </button>` : ''}
220
+ ${methods.includes('bank_transfer') ? `
221
+ <button class="voxepay-method-tab ${!isCard ? 'active' : ''}" data-method="bank_transfer">
222
+ ${ICONS.bank} <span>Bank Transfer</span>
223
+ </button>` : ''}
224
+ </div>` : ''}
225
+
226
+ <div class="voxepay-body" id="voxepay-form-container">
227
+ ${isCard ? this.getCardFormHTML(formattedAmount) : this.getBankTransferHTML(formattedAmount)}
228
+ </div>
229
+
230
+ <div class="voxepay-footer">
231
+ <div class="voxepay-powered-by">
232
+ ${ICONS.lock}
233
+ <span>Secured by <strong>VoxePay</strong></span>
234
+ </div>
235
+ </div>
236
+ </div>
237
+ </div>
238
+ `;
239
+ }
240
+
241
+ /**
242
+ * Get card form HTML
243
+ */
244
+ private getCardFormHTML(formattedAmount: string): string {
245
+ return `
246
+ <form id="voxepay-payment-form" novalidate>
247
+ <div class="voxepay-form-group">
248
+ <label class="voxepay-label">
249
+ <span class="voxepay-label-icon">💳</span>
250
+ Card Number
251
+ </label>
252
+ <div class="voxepay-card-input-wrapper">
253
+ <input type="text" class="voxepay-input" id="voxepay-card-number" name="cardNumber"
254
+ placeholder="1234 5678 9012 3456" autocomplete="cc-number" inputmode="numeric" />
255
+ <div class="voxepay-card-brand" id="voxepay-card-brand"></div>
256
+ </div>
257
+ <div class="voxepay-error-message" id="voxepay-card-error" style="display: none;"></div>
258
+ </div>
259
+
260
+ <div class="voxepay-row">
261
+ <div class="voxepay-form-group">
262
+ <label class="voxepay-label"><span class="voxepay-label-icon">📅</span> Expiry</label>
263
+ <input type="text" class="voxepay-input" id="voxepay-expiry" name="expiry"
264
+ placeholder="MM/YY" autocomplete="cc-exp" inputmode="numeric" maxlength="5" />
265
+ <div class="voxepay-error-message" id="voxepay-expiry-error" style="display: none;"></div>
266
+ </div>
267
+ <div class="voxepay-form-group">
268
+ <label class="voxepay-label"><span class="voxepay-label-icon">🔒</span> CVV</label>
269
+ <input type="text" class="voxepay-input" id="voxepay-cvv" name="cvv"
270
+ placeholder="•••" autocomplete="cc-csc" inputmode="numeric" maxlength="4" />
271
+ <div class="voxepay-error-message" id="voxepay-cvv-error" style="display: none;"></div>
272
+ </div>
273
+ </div>
274
+
275
+ <button type="submit" class="voxepay-submit-btn" id="voxepay-submit">
276
+ <span>Pay Now ${formattedAmount}</span>
277
+ </button>
278
+ </form>
279
+ `;
280
+ }
281
+
282
+ /**
283
+ * Get bank transfer HTML
284
+ */
285
+ private getBankTransferHTML(formattedAmount: string): string {
286
+ const details = this.state.bankTransferDetails;
287
+
288
+ if (!details) {
289
+ return `
290
+ <div class="voxepay-transfer-view">
291
+ <div class="voxepay-transfer-loading">
292
+ <div class="voxepay-spinner"></div>
293
+ <p style="margin-top: 16px; color: var(--voxepay-text-muted);">Generating account details...</p>
294
+ </div>
295
+ </div>
296
+ `;
297
+ }
298
+
299
+ return `
300
+ <div class="voxepay-transfer-view">
301
+ <div class="voxepay-transfer-instruction">
302
+ <p>Transfer <strong>${formattedAmount}</strong> to the account below</p>
303
+ </div>
304
+
305
+ <div class="voxepay-transfer-details">
306
+ <div class="voxepay-transfer-detail">
307
+ <span class="voxepay-transfer-label">Account Number</span>
308
+ <div class="voxepay-transfer-value-row">
309
+ <span class="voxepay-transfer-value voxepay-transfer-account" id="voxepay-account-number">${details.accountNumber}</span>
310
+ <button class="voxepay-copy-btn" id="voxepay-copy-btn" data-copy="${details.accountNumber}" title="Copy">
311
+ ${ICONS.copy}
312
+ </button>
313
+ </div>
314
+ </div>
315
+
316
+ <div class="voxepay-transfer-detail">
317
+ <span class="voxepay-transfer-label">Bank Name</span>
318
+ <span class="voxepay-transfer-value">${details.bankName}</span>
319
+ </div>
320
+
321
+ <div class="voxepay-transfer-detail">
322
+ <span class="voxepay-transfer-label">Account Name</span>
323
+ <span class="voxepay-transfer-value">${details.accountName}</span>
324
+ </div>
325
+
326
+ <div class="voxepay-transfer-detail">
327
+ <span class="voxepay-transfer-label">Amount</span>
328
+ <span class="voxepay-transfer-value voxepay-transfer-amount">${formattedAmount}</span>
329
+ </div>
330
+
331
+ <div class="voxepay-transfer-detail">
332
+ <span class="voxepay-transfer-label">Reference</span>
333
+ <span class="voxepay-transfer-value" style="font-family: monospace; letter-spacing: 1px;">${details.reference}</span>
334
+ </div>
335
+ </div>
336
+
337
+ <div class="voxepay-transfer-timer" id="voxepay-transfer-timer">
338
+ ${ICONS.clock}
339
+ <span>Account expires in <strong id="voxepay-transfer-countdown">${this.formatCountdown(details.expiresIn)}</strong></span>
340
+ </div>
341
+
342
+ <button type="button" class="voxepay-submit-btn" id="voxepay-transfer-confirm">
343
+ <span>I've sent the money</span>
344
+ </button>
345
+ </div>
346
+ `;
347
+ }
348
+
349
+ /**
350
+ * Format countdown seconds to MM:SS
351
+ */
352
+ private formatCountdown(seconds: number): string {
353
+ const m = Math.floor(seconds / 60);
354
+ const s = seconds % 60;
355
+ return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
356
+ }
357
+
358
+ /**
359
+ * Render success view
360
+ */
361
+ private renderSuccessView(): void {
362
+ const formContainer = this.container?.querySelector('#voxepay-form-container');
363
+ if (!formContainer) return;
364
+
365
+ const formattedAmount = formatAmount(this.options.amount, this.options.currency);
366
+
367
+ formContainer.innerHTML = `
368
+ <div class="voxepay-success-view">
369
+ <div class="voxepay-success-icon">
370
+ ${ICONS.check}
371
+ </div>
372
+ <h2 class="voxepay-success-title">Payment Successful!</h2>
373
+ <p class="voxepay-success-message">Your payment of ${formattedAmount} has been processed.</p>
374
+ <button class="voxepay-success-btn" data-action="close">Done</button>
375
+ </div>
376
+ `;
377
+
378
+ // Re-attach close listener
379
+ const closeBtn = formContainer.querySelector('[data-action="close"]');
380
+ closeBtn?.addEventListener('click', () => this.close());
381
+ }
382
+
383
+ /**
384
+ * Render OTP verification view
385
+ */
386
+ private renderOTPView(): void {
387
+ const formContainer = this.container?.querySelector('#voxepay-form-container');
388
+ if (!formContainer) return;
389
+
390
+ this.state.isOtpStep = true;
391
+ this.state.otpTimer = 60;
392
+ this.state.canResendOtp = false;
393
+
394
+ const maskedPhone = this.options.customerEmail
395
+ ? `****${this.options.customerEmail.slice(-4)}`
396
+ : '****1234';
397
+
398
+ formContainer.innerHTML = `
399
+ <div class="voxepay-otp-view">
400
+ <div class="voxepay-otp-header">
401
+ <div class="voxepay-otp-icon">📱</div>
402
+ <h3 class="voxepay-otp-title">Verify Your Payment</h3>
403
+ <p class="voxepay-otp-subtitle">We've sent a 6-digit code to ${maskedPhone}</p>
404
+ </div>
405
+
406
+ <div class="voxepay-otp-inputs-container">
407
+ <div class="voxepay-otp-inputs" id="voxepay-otp-inputs">
408
+ <input type="text" maxlength="1" class="voxepay-otp-digit" data-index="0" inputmode="numeric" autocomplete="one-time-code" />
409
+ <input type="text" maxlength="1" class="voxepay-otp-digit" data-index="1" inputmode="numeric" />
410
+ <input type="text" maxlength="1" class="voxepay-otp-digit" data-index="2" inputmode="numeric" />
411
+ <input type="text" maxlength="1" class="voxepay-otp-digit" data-index="3" inputmode="numeric" />
412
+ <input type="text" maxlength="1" class="voxepay-otp-digit" data-index="4" inputmode="numeric" />
413
+ <input type="text" maxlength="1" class="voxepay-otp-digit" data-index="5" inputmode="numeric" />
414
+ </div>
415
+ <div class="voxepay-error-message" id="voxepay-otp-error" style="display: none;"></div>
416
+ </div>
417
+
418
+ <div class="voxepay-otp-timer" id="voxepay-otp-timer">
419
+ Resend code in <span id="voxepay-timer-count">60</span>s
420
+ </div>
421
+
422
+ <button class="voxepay-resend-btn" id="voxepay-resend-otp" disabled>
423
+ Resend OTP
424
+ </button>
425
+
426
+ <button type="button" class="voxepay-submit-btn" id="voxepay-verify-otp">
427
+ <span>🔐 Verify & Pay</span>
428
+ </button>
429
+
430
+ <button class="voxepay-back-btn" id="voxepay-back-to-card">
431
+ ← Back to card details
432
+ </button>
433
+ </div>
434
+ `;
435
+
436
+ this.attachOTPEventListeners();
437
+ this.startOTPTimer();
438
+ }
439
+
440
+ /**
441
+ * Attach OTP-specific event listeners
442
+ */
443
+ private attachOTPEventListeners(): void {
444
+ const otpInputs = this.container?.querySelectorAll('.voxepay-otp-digit') as NodeListOf<HTMLInputElement>;
445
+
446
+ otpInputs?.forEach((input, index) => {
447
+ input.addEventListener('input', (e) => this.handleOTPInput(e, index));
448
+ input.addEventListener('keydown', (e) => this.handleOTPKeydown(e, index));
449
+ input.addEventListener('paste', (e) => this.handleOTPPaste(e));
450
+ });
451
+
452
+ const verifyBtn = this.container?.querySelector('#voxepay-verify-otp');
453
+ verifyBtn?.addEventListener('click', () => this.handleOTPSubmit());
454
+
455
+ const resendBtn = this.container?.querySelector('#voxepay-resend-otp');
456
+ resendBtn?.addEventListener('click', () => this.handleResendOTP());
457
+
458
+ const backBtn = this.container?.querySelector('#voxepay-back-to-card');
459
+ backBtn?.addEventListener('click', () => this.handleBackToCard());
460
+
461
+ (otpInputs?.[0] as HTMLInputElement)?.focus();
462
+ }
463
+
464
+ private handleOTPInput(e: Event, index: number): void {
465
+ const input = e.target as HTMLInputElement;
466
+ const value = input.value.replace(/\D/g, '');
467
+ input.value = value;
468
+
469
+ if (value && index < 5) {
470
+ const nextInput = this.container?.querySelector(`[data-index="${index + 1}"]`) as HTMLInputElement;
471
+ nextInput?.focus();
472
+ }
473
+
474
+ this.updateOTPState();
475
+ this.clearError('otp');
476
+ }
477
+
478
+ private handleOTPKeydown(e: KeyboardEvent, index: number): void {
479
+ const input = e.target as HTMLInputElement;
480
+
481
+ if (e.key === 'Backspace' && !input.value && index > 0) {
482
+ const prevInput = this.container?.querySelector(`[data-index="${index - 1}"]`) as HTMLInputElement;
483
+ prevInput?.focus();
484
+ }
485
+ }
486
+
487
+ private handleOTPPaste(e: ClipboardEvent): void {
488
+ e.preventDefault();
489
+ const pastedData = e.clipboardData?.getData('text').replace(/\D/g, '').slice(0, 6);
490
+
491
+ if (pastedData) {
492
+ const inputs = this.container?.querySelectorAll('.voxepay-otp-digit') as NodeListOf<HTMLInputElement>;
493
+ pastedData.split('').forEach((digit, i) => {
494
+ if (inputs[i]) {
495
+ inputs[i].value = digit;
496
+ }
497
+ });
498
+ this.updateOTPState();
499
+ }
500
+ }
501
+
502
+ private updateOTPState(): void {
503
+ const inputs = this.container?.querySelectorAll('.voxepay-otp-digit') as NodeListOf<HTMLInputElement>;
504
+ let otp = '';
505
+ inputs?.forEach(input => otp += input.value);
506
+ this.state.otp = otp;
507
+ }
508
+
509
+ private startOTPTimer(): void {
510
+ const timerEl = this.container?.querySelector('#voxepay-timer-count');
511
+ const timerContainer = this.container?.querySelector('#voxepay-otp-timer');
512
+ const resendBtn = this.container?.querySelector('#voxepay-resend-otp') as HTMLButtonElement;
513
+
514
+ this.state.otpTimerInterval = window.setInterval(() => {
515
+ this.state.otpTimer--;
516
+
517
+ if (timerEl) {
518
+ timerEl.textContent = String(this.state.otpTimer);
519
+ }
520
+
521
+ if (this.state.otpTimer <= 0) {
522
+ if (this.state.otpTimerInterval) {
523
+ clearInterval(this.state.otpTimerInterval);
524
+ }
525
+ this.state.canResendOtp = true;
526
+ if (timerContainer) (timerContainer as HTMLElement).style.display = 'none';
527
+ if (resendBtn) resendBtn.disabled = false;
528
+ }
529
+ }, 1000);
530
+ }
531
+
532
+ private async handleOTPSubmit(): Promise<void> {
533
+ if (this.state.otp.length !== 6) {
534
+ this.showError('otp', 'Please enter the complete 6-digit code');
535
+ return;
536
+ }
537
+
538
+ this.setOTPProcessing(true);
539
+
540
+ try {
541
+ await this.verifyOTP();
542
+
543
+ if (this.state.otpTimerInterval) {
544
+ clearInterval(this.state.otpTimerInterval);
545
+ }
546
+
547
+ const result = await this.processPayment();
548
+
549
+ this.state.isSuccess = true;
550
+ this.renderSuccessView();
551
+ this.options.onSuccess(result);
552
+ } catch (error) {
553
+ this.setOTPProcessing(false);
554
+ const paymentError = error as PaymentError;
555
+ this.showError('otp', paymentError.message || 'Invalid OTP. Please try again.');
556
+ }
557
+ }
558
+
559
+ private setOTPProcessing(isProcessing: boolean): void {
560
+ const verifyBtn = this.container?.querySelector('#voxepay-verify-otp') as HTMLButtonElement;
561
+
562
+ if (verifyBtn) {
563
+ verifyBtn.disabled = isProcessing;
564
+ verifyBtn.innerHTML = isProcessing
565
+ ? '<div class="voxepay-spinner"></div><span>Verifying...</span>'
566
+ : '<span>🔐 Verify & Pay</span>';
567
+ }
568
+ }
569
+
570
+ private async verifyOTP(): Promise<void> {
571
+ if (!this.apiClient || !this.options._sdkConfig) {
572
+ throw { code: 'SDK_ERROR', message: 'SDK not properly initialized', recoverable: false };
573
+ }
574
+
575
+ if (this.state.otp.length !== 6) {
576
+ throw { code: 'INVALID_OTP', message: 'Please enter the complete 6-digit code', recoverable: true };
577
+ }
578
+
579
+ if (!this.state.paymentId || !this.state.transactionId) {
580
+ throw { code: 'MISSING_PAYMENT_DATA', message: 'Payment session expired. Please try again.', recoverable: false };
581
+ }
582
+
583
+ await this.apiClient.validateOTP({
584
+ paymentId: this.state.paymentId,
585
+ otp: this.state.otp,
586
+ transactionId: this.state.transactionId,
587
+ eciFlag: this.state.eciFlag || undefined,
588
+ organizationId: this.options._sdkConfig.organizationId,
589
+ });
590
+ }
591
+
592
+ private async handleResendOTP(): Promise<void> {
593
+ const timerContainer = this.container?.querySelector('#voxepay-otp-timer') as HTMLElement;
594
+ const resendBtn = this.container?.querySelector('#voxepay-resend-otp') as HTMLButtonElement;
595
+
596
+ // Call resend OTP API
597
+ if (this.apiClient && this.options._sdkConfig && this.state.transactionRef) {
598
+ try {
599
+ if (resendBtn) resendBtn.disabled = true;
600
+ await this.apiClient.resendOTP({
601
+ transactionRef: this.state.transactionRef,
602
+ organizationId: this.options._sdkConfig.organizationId,
603
+ });
604
+ } catch (error: any) {
605
+ console.error('[VoxePay] Failed to resend OTP:', error);
606
+ // Continue with timer reset even if API fails
607
+ }
608
+ }
609
+
610
+ this.state.otpTimer = 60;
611
+ this.state.canResendOtp = false;
612
+
613
+ if (timerContainer) timerContainer.style.display = 'block';
614
+ if (resendBtn) resendBtn.disabled = true;
615
+
616
+ const inputs = this.container?.querySelectorAll('.voxepay-otp-digit') as NodeListOf<HTMLInputElement>;
617
+ inputs?.forEach(input => input.value = '');
618
+ this.state.otp = '';
619
+
620
+ this.startOTPTimer();
621
+ (inputs?.[0] as HTMLInputElement)?.focus();
622
+ }
623
+
624
+ private handleBackToCard(): void {
625
+ if (this.state.otpTimerInterval) {
626
+ clearInterval(this.state.otpTimerInterval);
627
+ }
628
+
629
+ this.state.isOtpStep = false;
630
+ this.state.otp = '';
631
+
632
+ if (this.container) {
633
+ this.container.innerHTML = this.getModalHTML();
634
+ this.overlay = this.container.querySelector('.voxepay-overlay');
635
+ this.overlay?.classList.add('voxepay-visible');
636
+ this.attachEventListeners();
637
+
638
+ const cardInput = this.container.querySelector('#voxepay-card-number') as HTMLInputElement;
639
+ const expiryInput = this.container.querySelector('#voxepay-expiry') as HTMLInputElement;
640
+ const cvvInput = this.container.querySelector('#voxepay-cvv') as HTMLInputElement;
641
+
642
+ if (cardInput) cardInput.value = formatCardNumber(this.state.cardNumber);
643
+ if (expiryInput) expiryInput.value = this.state.expiry;
644
+ if (cvvInput) cvvInput.value = this.state.cvv;
645
+ }
646
+ }
647
+
648
+ /**
649
+ * Attach event listeners
650
+ */
651
+ private attachEventListeners(): void {
652
+ const closeBtn = this.container?.querySelector('[data-action=\"close\"]');
653
+ closeBtn?.addEventListener('click', () => this.close());
654
+
655
+ this.overlay?.addEventListener('click', (e) => {
656
+ if (e.target === this.overlay) {
657
+ this.close();
658
+ }
659
+ });
660
+
661
+ document.addEventListener('keydown', this.handleEscape);
662
+
663
+ // Payment method tabs
664
+ const tabs = this.container?.querySelectorAll('.voxepay-method-tab');
665
+ tabs?.forEach(tab => {
666
+ tab.addEventListener('click', () => {
667
+ const method = (tab as HTMLElement).dataset.method as PaymentMethod;
668
+ if (method && method !== this.state.paymentMethod) {
669
+ this.switchPaymentMethod(method);
670
+ }
671
+ });
672
+ });
673
+
674
+ // Card-specific listeners
675
+ if (this.state.paymentMethod === 'card') {
676
+ const cardInput = this.container?.querySelector('#voxepay-card-number') as HTMLInputElement;
677
+ cardInput?.addEventListener('input', (e) => this.handleCardInput(e));
678
+ cardInput?.addEventListener('blur', () => this.validateField('cardNumber'));
679
+
680
+ const expiryInput = this.container?.querySelector('#voxepay-expiry') as HTMLInputElement;
681
+ expiryInput?.addEventListener('input', (e) => this.handleExpiryInput(e));
682
+ expiryInput?.addEventListener('blur', () => this.validateField('expiry'));
683
+
684
+ const cvvInput = this.container?.querySelector('#voxepay-cvv') as HTMLInputElement;
685
+ cvvInput?.addEventListener('input', (e) => this.handleCVVInput(e));
686
+ cvvInput?.addEventListener('blur', () => this.validateField('cvv'));
687
+
688
+ const form = this.container?.querySelector('#voxepay-payment-form') as HTMLFormElement;
689
+ form?.addEventListener('submit', (e) => this.handleSubmit(e));
690
+ }
691
+
692
+ // Bank transfer-specific listeners
693
+ if (this.state.paymentMethod === 'bank_transfer') {
694
+ this.attachBankTransferListeners();
695
+
696
+ // If no details yet, load them
697
+ if (!this.state.bankTransferDetails) {
698
+ this.loadBankTransferDetails();
699
+ } else {
700
+ this.startTransferTimer();
701
+ }
702
+ }
703
+ }
704
+
705
+ /**
706
+ * Attach bank transfer specific event listeners
707
+ */
708
+ private attachBankTransferListeners(): void {
709
+ const copyBtn = this.container?.querySelector('#voxepay-copy-btn');
710
+ copyBtn?.addEventListener('click', () => {
711
+ const accountNum = (copyBtn as HTMLElement).dataset.copy || '';
712
+ this.copyToClipboard(accountNum);
713
+ });
714
+
715
+ const confirmBtn = this.container?.querySelector('#voxepay-transfer-confirm');
716
+ confirmBtn?.addEventListener('click', () => this.handleTransferConfirm());
717
+ }
718
+
719
+ /**
720
+ * Switch between payment methods
721
+ */
722
+ private switchPaymentMethod(method: PaymentMethod): void {
723
+ this.stopTransferTimer();
724
+ this.state.paymentMethod = method;
725
+
726
+ // Re-render the whole modal
727
+ if (this.container) {
728
+ this.container.innerHTML = this.getModalHTML();
729
+ this.overlay = this.container.querySelector('.voxepay-overlay');
730
+ this.overlay?.classList.add('voxepay-visible');
731
+ this.attachEventListeners();
732
+
733
+ // Restore card values if switching back to card
734
+ if (method === 'card' && this.state.cardNumber) {
735
+ const cardInput = this.container.querySelector('#voxepay-card-number') as HTMLInputElement;
736
+ const expiryInput = this.container.querySelector('#voxepay-expiry') as HTMLInputElement;
737
+ const cvvInput = this.container.querySelector('#voxepay-cvv') as HTMLInputElement;
738
+ if (cardInput) cardInput.value = formatCardNumber(this.state.cardNumber);
739
+ if (expiryInput) expiryInput.value = this.state.expiry;
740
+ if (cvvInput) cvvInput.value = this.state.cvv;
741
+ }
742
+ }
743
+ }
744
+
745
+ /**
746
+ * Load bank transfer details (from callback or generate mock)
747
+ */
748
+ private async loadBankTransferDetails(): Promise<void> {
749
+ try {
750
+ let details: BankTransferDetails;
751
+
752
+ if (this.options.onBankTransferRequested) {
753
+ details = await this.options.onBankTransferRequested();
754
+ } else {
755
+ // Mock/default bank details for testing
756
+ await new Promise(resolve => setTimeout(resolve, 1500));
757
+ details = {
758
+ accountNumber: '0123456789',
759
+ bankName: 'VoxePay Bank',
760
+ accountName: 'VoxePay Collections',
761
+ reference: `VP-${Date.now().toString(36).toUpperCase()}`,
762
+ expiresIn: 1800, // 30 minutes
763
+ };
764
+ }
765
+
766
+ this.state.bankTransferDetails = details;
767
+
768
+ // Re-render the bank transfer view
769
+ const formContainer = this.container?.querySelector('#voxepay-form-container');
770
+ if (formContainer) {
771
+ const formattedAmount = formatAmount(this.options.amount, this.options.currency);
772
+ formContainer.innerHTML = this.getBankTransferHTML(formattedAmount);
773
+ this.attachBankTransferListeners();
774
+ this.startTransferTimer();
775
+ }
776
+ } catch (error) {
777
+ this.options.onError({
778
+ code: 'BANK_TRANSFER_INIT_FAILED',
779
+ message: 'Could not generate bank transfer details. Please try again.',
780
+ recoverable: true,
781
+ });
782
+ }
783
+ }
784
+
785
+ /**
786
+ * Copy text to clipboard with visual feedback
787
+ */
788
+ private async copyToClipboard(text: string): Promise<void> {
789
+ try {
790
+ await navigator.clipboard.writeText(text);
791
+ const copyBtn = this.container?.querySelector('#voxepay-copy-btn') as HTMLElement;
792
+ if (copyBtn) {
793
+ copyBtn.innerHTML = `${ICONS.check}`;
794
+ copyBtn.classList.add('voxepay-copied');
795
+ setTimeout(() => {
796
+ copyBtn.innerHTML = `${ICONS.copy}`;
797
+ copyBtn.classList.remove('voxepay-copied');
798
+ }, 2000);
799
+ }
800
+ } catch {
801
+ // Fallback for older browsers
802
+ const textarea = document.createElement('textarea');
803
+ textarea.value = text;
804
+ textarea.style.position = 'fixed';
805
+ textarea.style.opacity = '0';
806
+ document.body.appendChild(textarea);
807
+ textarea.select();
808
+ document.execCommand('copy');
809
+ document.body.removeChild(textarea);
810
+ }
811
+ }
812
+
813
+ /**
814
+ * Handle "I've sent the money" confirmation
815
+ */
816
+ private async handleTransferConfirm(): Promise<void> {
817
+ const confirmBtn = this.container?.querySelector('#voxepay-transfer-confirm') as HTMLButtonElement;
818
+ if (confirmBtn) {
819
+ confirmBtn.disabled = true;
820
+ confirmBtn.innerHTML = '<div class="voxepay-spinner"></div><span>Confirming transfer...</span>';
821
+ }
822
+
823
+ this.stopTransferTimer();
824
+
825
+ try {
826
+ // Simulate transfer verification
827
+ await new Promise(resolve => setTimeout(resolve, 3000));
828
+
829
+ const result: PaymentResult = {
830
+ id: `pay_transfer_${Date.now()}`,
831
+ status: 'pending',
832
+ amount: this.options.amount,
833
+ currency: this.options.currency,
834
+ timestamp: new Date().toISOString(),
835
+ reference: this.state.bankTransferDetails?.reference,
836
+ paymentMethod: 'bank_transfer',
837
+ };
838
+
839
+ this.state.isSuccess = true;
840
+ this.renderSuccessView();
841
+ this.options.onSuccess(result);
842
+ } catch (error) {
843
+ if (confirmBtn) {
844
+ confirmBtn.disabled = false;
845
+ confirmBtn.innerHTML = "<span>I've sent the money</span>";
846
+ }
847
+ const paymentError = error as PaymentError;
848
+ this.options.onError(paymentError);
849
+ }
850
+ }
851
+
852
+ /**
853
+ * Start the transfer countdown timer
854
+ */
855
+ private startTransferTimer(): void {
856
+ if (!this.state.bankTransferDetails) return;
857
+
858
+ this.state.transferTimer = this.state.bankTransferDetails.expiresIn;
859
+
860
+ this.state.transferTimerInterval = window.setInterval(() => {
861
+ this.state.transferTimer--;
862
+
863
+ const countdownEl = this.container?.querySelector('#voxepay-transfer-countdown');
864
+ if (countdownEl) {
865
+ countdownEl.textContent = this.formatCountdown(this.state.transferTimer);
866
+ }
867
+
868
+ if (this.state.transferTimer <= 0) {
869
+ this.stopTransferTimer();
870
+ // Show expired state
871
+ const timerEl = this.container?.querySelector('#voxepay-transfer-timer') as HTMLElement;
872
+ if (timerEl) {
873
+ timerEl.innerHTML = `${ICONS.clock} <span style="color: var(--voxepay-error);">Account expired. Please try again.</span>`;
874
+ }
875
+ const confirmBtn = this.container?.querySelector('#voxepay-transfer-confirm') as HTMLButtonElement;
876
+ if (confirmBtn) confirmBtn.disabled = true;
877
+ }
878
+ }, 1000);
879
+ }
880
+
881
+ /**
882
+ * Stop the transfer countdown timer
883
+ */
884
+ private stopTransferTimer(): void {
885
+ if (this.state.transferTimerInterval) {
886
+ clearInterval(this.state.transferTimerInterval);
887
+ this.state.transferTimerInterval = null;
888
+ }
889
+ }
890
+
891
+ private handleEscape = (e: KeyboardEvent): void => {
892
+ if (e.key === 'Escape') {
893
+ this.close();
894
+ document.removeEventListener('keydown', this.handleEscape);
895
+ }
896
+ };
897
+
898
+ private handleCardInput(e: Event): void {
899
+ const input = e.target as HTMLInputElement;
900
+ const formatted = formatCardNumber(input.value);
901
+ input.value = formatted;
902
+ this.state.cardNumber = formatted.replace(/\s/g, '');
903
+
904
+ const brandEl = this.container?.querySelector('#voxepay-card-brand') as HTMLElement;
905
+ const brand = detectCardBrand(this.state.cardNumber);
906
+ if (brandEl) {
907
+ brandEl.textContent = brand ? CARD_BRAND_DISPLAY[brand.code] || '' : '';
908
+ brandEl.style.opacity = brand ? '1' : '0';
909
+ }
910
+
911
+ this.clearError('cardNumber');
912
+ }
913
+
914
+ private handleExpiryInput(e: Event): void {
915
+ const input = e.target as HTMLInputElement;
916
+ const formatted = formatExpiry(input.value);
917
+ input.value = formatted;
918
+ this.state.expiry = formatted;
919
+ this.clearError('expiry');
920
+ }
921
+
922
+ private handleCVVInput(e: Event): void {
923
+ const input = e.target as HTMLInputElement;
924
+ const formatted = formatCVV(input.value);
925
+ input.value = formatted;
926
+ this.state.cvv = formatted;
927
+ this.clearError('cvv');
928
+ }
929
+
930
+ private validateField(field: keyof ModalState['errors']): boolean {
931
+ let result: { valid: boolean; error?: string };
932
+
933
+ switch (field) {
934
+ case 'cardNumber':
935
+ result = validateCardNumber(this.state.cardNumber);
936
+ break;
937
+ case 'expiry':
938
+ result = validateExpiry(this.state.expiry);
939
+ break;
940
+ case 'cvv':
941
+ result = validateCVV(this.state.cvv, this.state.cardNumber);
942
+ break;
943
+ default:
944
+ return true;
945
+ }
946
+
947
+ if (!result.valid) {
948
+ this.showError(field, result.error || 'Invalid');
949
+ return false;
950
+ }
951
+
952
+ this.clearError(field);
953
+ return true;
954
+ }
955
+
956
+ private showError(field: string, message: string): void {
957
+ const errorEl = this.container?.querySelector(`#voxepay-${field === 'cardNumber' ? 'card' : field}-error`) as HTMLElement;
958
+ const inputEl = this.container?.querySelector(`#voxepay-${field === 'cardNumber' ? 'card-number' : field}`) as HTMLElement;
959
+
960
+ if (errorEl) {
961
+ errorEl.innerHTML = `${ICONS.error} ${message}`;
962
+ errorEl.style.display = 'flex';
963
+ }
964
+ inputEl?.classList.add('voxepay-error');
965
+ }
966
+
967
+ private clearError(field: string): void {
968
+ const errorEl = this.container?.querySelector(`#voxepay-${field === 'cardNumber' ? 'card' : field}-error`) as HTMLElement;
969
+ const inputEl = this.container?.querySelector(`#voxepay-${field === 'cardNumber' ? 'card-number' : field}`) as HTMLElement;
970
+
971
+ if (errorEl) {
972
+ errorEl.style.display = 'none';
973
+ }
974
+ inputEl?.classList.remove('voxepay-error');
975
+ }
976
+
977
+ private async handleSubmit(e: Event): Promise<void> {
978
+ e.preventDefault();
979
+
980
+ const isCardValid = this.validateField('cardNumber');
981
+ const isExpiryValid = this.validateField('expiry');
982
+ const isCVVValid = this.validateField('cvv');
983
+
984
+ if (!isCardValid || !isExpiryValid || !isCVVValid) {
985
+ return;
986
+ }
987
+
988
+ if (!this.apiClient || !this.options._sdkConfig) {
989
+ this.showError('cardNumber', 'SDK not properly initialized. Missing API key or organization ID.');
990
+ return;
991
+ }
992
+
993
+ this.setProcessing(true);
994
+
995
+ try {
996
+ // Encrypt card data
997
+ const pan = cleanPan(this.state.cardNumber);
998
+ const expiryDate = formatExpiryForApi(this.state.expiry);
999
+ const authData = await generateAuthData({
1000
+ version: '1',
1001
+ pan,
1002
+ pin: '',
1003
+ expiryDate,
1004
+ cvv: this.state.cvv,
1005
+ });
1006
+
1007
+ // Generate a unique transaction reference
1008
+ const transactionRef = `VP-${Date.now().toString(36).toUpperCase()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
1009
+
1010
+ // Call initiate payment API
1011
+ const response = await this.apiClient.initiatePayment({
1012
+ organizationId: this.options._sdkConfig.organizationId,
1013
+ transactionRef,
1014
+ customerEmail: this.options.customerEmail,
1015
+ customerPhone: this.options.customerPhone,
1016
+ amount: this.options.amount / 100, // Convert from kobo/cents to main unit
1017
+ currency: this.options.currency,
1018
+ paymentMethod: 'CARD',
1019
+ authData,
1020
+ narration: this.options.description,
1021
+ });
1022
+
1023
+ // Store response data for OTP verification
1024
+ this.state.paymentId = response.paymentId;
1025
+ this.state.transactionId = response.transactionId;
1026
+ this.state.transactionRef = response.transactionRef || transactionRef;
1027
+ this.state.eciFlag = response.eciFlag || null;
1028
+
1029
+ this.setProcessing(false);
1030
+ this.renderOTPView();
1031
+ } catch (error: any) {
1032
+ this.setProcessing(false);
1033
+ const message = error?.message || 'Payment failed. Please try again.';
1034
+ this.options.onError({
1035
+ code: error?.code || 'PAYMENT_INITIATE_FAILED',
1036
+ message,
1037
+ recoverable: true,
1038
+ details: error?.data,
1039
+ });
1040
+ this.showError('cardNumber', message);
1041
+ }
1042
+ }
1043
+
1044
+ private setProcessing(isProcessing: boolean): void {
1045
+ this.state.isProcessing = isProcessing;
1046
+ const submitBtn = this.container?.querySelector('#voxepay-submit') as HTMLButtonElement;
1047
+
1048
+ if (submitBtn) {
1049
+ submitBtn.disabled = isProcessing;
1050
+ submitBtn.innerHTML = isProcessing
1051
+ ? '<div class="voxepay-spinner"></div><span>Processing...</span>'
1052
+ : `<span>Pay Now ${formatAmount(this.options.amount, this.options.currency)}</span>`;
1053
+ }
1054
+ }
1055
+
1056
+ private async processPayment(): Promise<PaymentResult> {
1057
+ return {
1058
+ id: this.state.paymentId || `pay_${Date.now()}`,
1059
+ status: 'success',
1060
+ amount: this.options.amount,
1061
+ currency: this.options.currency,
1062
+ timestamp: new Date().toISOString(),
1063
+ reference: this.state.transactionRef || undefined,
1064
+ paymentMethod: 'card',
1065
+ };
1066
+ }
1067
+
1068
+ /**
1069
+ * Get VoxePay branded styles
1070
+ */
1071
+ private getStyles(): string {
1072
+ return `
1073
+ :root {
1074
+ /* VoxePay Blue Color Palette */
1075
+ --voxepay-primary: #0061FF;
1076
+ --voxepay-primary-hover: #0056E0;
1077
+ --voxepay-secondary: #0047CC;
1078
+ --voxepay-accent: #60A5FA;
1079
+ --voxepay-glow: rgba(0, 97, 255, 0.35);
1080
+ --voxepay-success: #10B981;
1081
+ --voxepay-success-bg: rgba(16, 185, 129, 0.1);
1082
+ --voxepay-error: #EF4444;
1083
+
1084
+ /* Dark Mode (Default) */
1085
+ --voxepay-bg: #0C0C1D;
1086
+ --voxepay-surface: rgba(255, 255, 255, 0.04);
1087
+ --voxepay-surface-hover: rgba(255, 255, 255, 0.08);
1088
+ --voxepay-border: rgba(255, 255, 255, 0.08);
1089
+ --voxepay-text: #FFFFFF;
1090
+ --voxepay-text-muted: #B4B4C7;
1091
+ --voxepay-text-subtle: #6B7280;
1092
+ --voxepay-input-bg: rgba(255, 255, 255, 0.06);
1093
+
1094
+ /* Effects */
1095
+ --voxepay-backdrop-blur: blur(24px);
1096
+ --voxepay-border-radius: 12px;
1097
+ --voxepay-border-radius-lg: 16px;
1098
+ --voxepay-border-radius-xl: 24px;
1099
+ --voxepay-glow-shadow: 0 0 60px var(--voxepay-glow);
1100
+ --voxepay-shadow: 0 25px 60px -15px rgba(0, 0, 0, 0.6);
1101
+ --voxepay-transition-fast: 150ms ease;
1102
+ --voxepay-transition: 300ms cubic-bezier(0.4, 0, 0.2, 1);
1103
+ }
1104
+
1105
+ /* Light Mode */
1106
+ html.voxepay-light {
1107
+ --voxepay-primary: #0061FF;
1108
+ --voxepay-primary-hover: #0056E0;
1109
+ --voxepay-secondary: #0047CC;
1110
+ --voxepay-glow: rgba(0, 97, 255, 0.2);
1111
+
1112
+ --voxepay-bg: #FFFFFF;
1113
+ --voxepay-surface: #F8FAFC;
1114
+ --voxepay-surface-hover: #F1F5F9;
1115
+ --voxepay-border: #E2E8F0;
1116
+ --voxepay-text: #0F172A;
1117
+ --voxepay-text-muted: #475569;
1118
+ --voxepay-text-subtle: #94A3B8;
1119
+ --voxepay-input-bg: #F8FAFC;
1120
+
1121
+ --voxepay-glow-shadow: 0 0 40px rgba(0, 97, 255, 0.12);
1122
+ --voxepay-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15);
1123
+ }
1124
+
1125
+ .voxepay-checkout * { box-sizing: border-box; margin: 0; padding: 0; }
1126
+ .voxepay-checkout { font-family: 'DM Sans', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 1rem; color: var(--voxepay-text); line-height: 1.5; -webkit-font-smoothing: antialiased; }
1127
+
1128
+ .voxepay-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); backdrop-filter: var(--voxepay-backdrop-blur); -webkit-backdrop-filter: var(--voxepay-backdrop-blur); display: flex; align-items: center; justify-content: center; z-index: 999999; opacity: 0; visibility: hidden; transition: opacity var(--voxepay-transition), visibility var(--voxepay-transition); }
1129
+ html.voxepay-light .voxepay-overlay { background: rgba(15, 23, 42, 0.4); }
1130
+ .voxepay-overlay.voxepay-visible { opacity: 1; visibility: visible; }
1131
+
1132
+ .voxepay-modal { background: var(--voxepay-bg); border: 1px solid var(--voxepay-border); border-radius: var(--voxepay-border-radius-xl); width: 100%; max-width: 420px; max-height: 90vh; overflow: hidden; box-shadow: var(--voxepay-shadow), var(--voxepay-glow-shadow); transform: scale(0.95) translateY(20px); opacity: 0; transition: transform var(--voxepay-transition), opacity var(--voxepay-transition); }
1133
+ .voxepay-overlay.voxepay-visible .voxepay-modal { transform: scale(1) translateY(0); opacity: 1; }
1134
+
1135
+ .voxepay-header { display: flex; align-items: center; justify-content: space-between; padding: 20px 24px; border-bottom: 1px solid var(--voxepay-border); background: var(--voxepay-surface); }
1136
+ .voxepay-header-left { display: flex; align-items: center; gap: 12px; }
1137
+ .voxepay-logo { width: 36px; height: 36px; border-radius: var(--voxepay-border-radius); background: linear-gradient(135deg, var(--voxepay-primary), var(--voxepay-secondary)); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 1.25rem; color: white; box-shadow: 0 4px 12px rgba(0, 97, 255, 0.3); }
1138
+ .voxepay-amount { font-size: 1.25rem; font-weight: 600; }
1139
+ .voxepay-close { width: 36px; height: 36px; border: none; background: var(--voxepay-surface-hover); border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--voxepay-text-muted); transition: all var(--voxepay-transition-fast); }
1140
+ .voxepay-close:hover { background: var(--voxepay-border); color: var(--voxepay-text); transform: rotate(90deg); }
1141
+ .voxepay-close svg { width: 18px; height: 18px; }
1142
+
1143
+ .voxepay-body { padding: 24px; background: var(--voxepay-bg); }
1144
+ .voxepay-form-group { margin-bottom: 20px; }
1145
+ .voxepay-label { display: flex; align-items: center; gap: 8px; font-size: 0.875rem; font-weight: 500; color: var(--voxepay-text-muted); margin-bottom: 8px; }
1146
+ .voxepay-label-icon { font-size: 1rem; }
1147
+
1148
+ .voxepay-input { width: 100%; padding: 14px 16px; background: var(--voxepay-input-bg); border: 1.5px solid var(--voxepay-border); border-radius: var(--voxepay-border-radius); color: var(--voxepay-text); font-size: 1rem; font-family: inherit; outline: none; transition: border-color var(--voxepay-transition-fast), box-shadow var(--voxepay-transition-fast), background var(--voxepay-transition-fast); }
1149
+ .voxepay-input::placeholder { color: var(--voxepay-text-subtle); }
1150
+ .voxepay-input:hover { border-color: var(--voxepay-text-subtle); }
1151
+ .voxepay-input:focus { border-color: var(--voxepay-primary); box-shadow: 0 0 0 4px var(--voxepay-glow); background: var(--voxepay-bg); }
1152
+ .voxepay-input.voxepay-error { border-color: var(--voxepay-error); box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.15); }
1153
+
1154
+ .voxepay-card-input-wrapper { position: relative; }
1155
+ .voxepay-card-brand { position: absolute; right: 14px; top: 50%; transform: translateY(-50%); padding: 4px 8px; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, var(--voxepay-primary), var(--voxepay-secondary)); border-radius: 6px; font-size: 0.7rem; font-weight: 700; color: white; opacity: 0; transition: opacity var(--voxepay-transition-fast); letter-spacing: 0.5px; }
1156
+ .voxepay-card-input-wrapper .voxepay-input { padding-right: 70px; }
1157
+
1158
+ .voxepay-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
1159
+
1160
+ .voxepay-error-message { display: flex; align-items: center; gap: 6px; font-size: 0.813rem; color: var(--voxepay-error); margin-top: 8px; animation: voxepay-shake 0.4s ease; }
1161
+ .voxepay-error-message svg { width: 16px; height: 16px; flex-shrink: 0; }
1162
+ @keyframes voxepay-shake { 0%, 100% { transform: translateX(0); } 20%, 60% { transform: translateX(-4px); } 40%, 80% { transform: translateX(4px); } }
1163
+
1164
+ .voxepay-submit-btn { width: 100%; padding: 16px 24px; background: linear-gradient(135deg, var(--voxepay-primary), var(--voxepay-secondary)); border: none; border-radius: var(--voxepay-border-radius); color: white; font-size: 1.063rem; font-weight: 600; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 10px; transition: all var(--voxepay-transition-fast); position: relative; overflow: hidden; box-shadow: 0 4px 15px rgba(0, 97, 255, 0.35); }
1165
+ .voxepay-submit-btn:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 8px 25px rgba(0, 97, 255, 0.45); }
1166
+ .voxepay-submit-btn:active:not(:disabled) { transform: translateY(0); }
1167
+ .voxepay-submit-btn:disabled { opacity: 0.6; cursor: not-allowed; box-shadow: none; }
1168
+ .voxepay-submit-btn span { position: relative; z-index: 1; }
1169
+
1170
+ .voxepay-spinner { width: 20px; height: 20px; border: 2.5px solid rgba(255, 255, 255, 0.3); border-top-color: white; border-radius: 50%; animation: voxepay-spin 0.8s linear infinite; }
1171
+ @keyframes voxepay-spin { to { transform: rotate(360deg); } }
1172
+
1173
+ .voxepay-footer { text-align: center; padding: 16px 24px 20px; border-top: 1px solid var(--voxepay-border); background: var(--voxepay-surface); }
1174
+ .voxepay-powered-by { font-size: 0.75rem; color: var(--voxepay-text-subtle); display: flex; align-items: center; justify-content: center; gap: 6px; }
1175
+ .voxepay-powered-by svg { width: 14px; height: 14px; color: var(--voxepay-primary); }
1176
+ .voxepay-powered-by strong { color: var(--voxepay-primary); font-weight: 600; }
1177
+
1178
+ .voxepay-success-view { text-align: center; padding: 40px 24px; }
1179
+ .voxepay-success-icon { width: 80px; height: 80px; margin: 0 auto 24px; background: linear-gradient(135deg, var(--voxepay-success), #059669); border-radius: 50%; display: flex; align-items: center; justify-content: center; animation: voxepay-success-pop 0.5s ease; box-shadow: 0 8px 25px rgba(16, 185, 129, 0.35); }
1180
+ @keyframes voxepay-success-pop { 0% { transform: scale(0); opacity: 0; } 50% { transform: scale(1.1); } 100% { transform: scale(1); opacity: 1; } }
1181
+ .voxepay-success-icon svg { width: 40px; height: 40px; color: white; }
1182
+ .voxepay-success-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 8px; color: var(--voxepay-text); }
1183
+ .voxepay-success-message { font-size: 1rem; color: var(--voxepay-text-muted); margin-bottom: 24px; }
1184
+ .voxepay-success-btn { padding: 12px 32px; background: var(--voxepay-surface); border: 1.5px solid var(--voxepay-border); border-radius: var(--voxepay-border-radius); color: var(--voxepay-text); font-size: 1rem; font-weight: 500; cursor: pointer; transition: all var(--voxepay-transition-fast); }
1185
+ .voxepay-success-btn:hover { background: var(--voxepay-surface-hover); border-color: var(--voxepay-primary); color: var(--voxepay-primary); }
1186
+
1187
+ /* OTP View */
1188
+ .voxepay-otp-view { text-align: center; padding: 24px 16px; }
1189
+ .voxepay-otp-header { margin-bottom: 24px; }
1190
+ .voxepay-otp-icon { font-size: 3rem; margin-bottom: 12px; }
1191
+ .voxepay-otp-title { font-size: 1.25rem; font-weight: 700; color: var(--voxepay-text); margin-bottom: 8px; }
1192
+ .voxepay-otp-subtitle { font-size: 0.875rem; color: var(--voxepay-text-muted); }
1193
+
1194
+ .voxepay-otp-inputs-container { margin-bottom: 16px; }
1195
+ .voxepay-otp-inputs { display: flex; justify-content: center; gap: 8px; margin-bottom: 8px; }
1196
+ .voxepay-otp-digit { width: 48px; height: 56px; text-align: center; font-size: 1.5rem; font-weight: 700; border: 2px solid var(--voxepay-border); border-radius: var(--voxepay-border-radius); background: var(--voxepay-input-bg); color: var(--voxepay-text); outline: none; transition: all var(--voxepay-transition-fast); }
1197
+ .voxepay-otp-digit:focus { border-color: var(--voxepay-primary); box-shadow: 0 0 0 4px var(--voxepay-glow); }
1198
+
1199
+ .voxepay-otp-timer { font-size: 0.875rem; color: var(--voxepay-text-muted); margin-bottom: 12px; }
1200
+ .voxepay-otp-timer span { font-weight: 700; color: var(--voxepay-primary); }
1201
+
1202
+ .voxepay-resend-btn { padding: 8px 16px; background: transparent; border: 1px solid var(--voxepay-border); border-radius: var(--voxepay-border-radius); color: var(--voxepay-text-muted); font-size: 0.875rem; cursor: pointer; margin-bottom: 16px; transition: all var(--voxepay-transition-fast); }
1203
+ .voxepay-resend-btn:hover:not(:disabled) { border-color: var(--voxepay-primary); color: var(--voxepay-primary); }
1204
+ .voxepay-resend-btn:disabled { opacity: 0.5; cursor: not-allowed; }
1205
+
1206
+ .voxepay-back-btn { display: block; width: 100%; padding: 12px; background: transparent; border: none; color: var(--voxepay-text-muted); font-size: 0.875rem; cursor: pointer; margin-top: 12px; transition: color var(--voxepay-transition-fast); }
1207
+ .voxepay-back-btn:hover { color: var(--voxepay-primary); }
1208
+
1209
+ /* Payment Method Tabs */
1210
+ .voxepay-method-tabs { display: flex; border-bottom: 1px solid var(--voxepay-border); background: var(--voxepay-surface); }
1211
+ .voxepay-method-tab { flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 12px 12px; border: none; background: transparent; color: var(--voxepay-text-muted); font-size: 0.813rem; font-weight: 500; font-family: inherit; cursor: pointer; transition: all var(--voxepay-transition-fast); border-bottom: 2px solid transparent; white-space: nowrap; }
1212
+ .voxepay-method-tab svg { width: 16px; height: 16px; flex-shrink: 0; }
1213
+ .voxepay-method-tab:hover { color: var(--voxepay-text); background: var(--voxepay-surface-hover); }
1214
+ .voxepay-method-tab.active { color: var(--voxepay-primary); border-bottom-color: var(--voxepay-primary); background: var(--voxepay-bg); }
1215
+
1216
+ /* Bank Transfer View */
1217
+ .voxepay-transfer-view { padding: 4px 0; }
1218
+ .voxepay-transfer-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 0; }
1219
+ .voxepay-transfer-instruction { text-align: center; margin-bottom: 20px; font-size: 0.938rem; color: var(--voxepay-text-muted); }
1220
+ .voxepay-transfer-instruction strong { color: var(--voxepay-text); font-size: 1.063rem; }
1221
+ .voxepay-transfer-details { background: var(--voxepay-surface); border: 1px solid var(--voxepay-border); border-radius: var(--voxepay-border-radius); overflow: hidden; margin-bottom: 16px; }
1222
+ .voxepay-transfer-detail { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid var(--voxepay-border); }
1223
+ .voxepay-transfer-detail:last-child { border-bottom: none; }
1224
+ .voxepay-transfer-label { font-size: 0.813rem; color: var(--voxepay-text-muted); font-weight: 500; }
1225
+ .voxepay-transfer-value { font-size: 0.938rem; font-weight: 600; color: var(--voxepay-text); }
1226
+ .voxepay-transfer-value-row { display: flex; align-items: center; gap: 8px; }
1227
+ .voxepay-transfer-account { font-size: 1.125rem; font-weight: 700; color: var(--voxepay-primary); letter-spacing: 1.5px; font-family: 'DM Sans', monospace; }
1228
+ .voxepay-transfer-amount { color: var(--voxepay-primary); }
1229
+
1230
+ /* Copy Button */
1231
+ .voxepay-copy-btn { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border: 1px solid var(--voxepay-border); border-radius: 8px; background: var(--voxepay-surface-hover); color: var(--voxepay-text-muted); cursor: pointer; transition: all var(--voxepay-transition-fast); flex-shrink: 0; }
1232
+ .voxepay-copy-btn svg { width: 16px; height: 16px; }
1233
+ .voxepay-copy-btn:hover { border-color: var(--voxepay-primary); color: var(--voxepay-primary); background: rgba(0, 97, 255, 0.1); }
1234
+ .voxepay-copy-btn.voxepay-copied { border-color: var(--voxepay-success); color: var(--voxepay-success); background: var(--voxepay-success-bg); }
1235
+
1236
+ /* Transfer Timer */
1237
+ .voxepay-transfer-timer { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 10px 16px; background: var(--voxepay-surface); border: 1px solid var(--voxepay-border); border-radius: var(--voxepay-border-radius); margin-bottom: 16px; font-size: 0.875rem; color: var(--voxepay-text-muted); }
1238
+ .voxepay-transfer-timer svg { width: 16px; height: 16px; color: var(--voxepay-primary); flex-shrink: 0; }
1239
+ .voxepay-transfer-timer strong { color: var(--voxepay-primary); font-weight: 700; font-family: monospace; font-size: 0.938rem; }
1240
+
1241
+ @media (max-width: 480px) { .voxepay-modal { max-width: 100%; max-height: 100%; border-radius: 0; height: 100%; } .voxepay-body { padding: 20px; } .voxepay-otp-digit { width: 42px; height: 50px; font-size: 1.25rem; } .voxepay-transfer-detail { flex-direction: column; align-items: flex-start; gap: 4px; } }
1242
+ `;
1243
+ }
1244
+ }