PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.7
Yatra – Travel Booking & Tour Operator Software v3.0.2.7
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / assets / js / stripe.js

stripe.js in Yatra – Travel Booking & Tour Operator Software 3.0.2.7, at assets/js/stripe.js

1,234 lines 48.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Stripe Payment Integration for Yatra
3 * Supports Stripe Elements (card) and Payment Request (Google Pay / Apple Pay)
4 */
5
6 const __ = (window.wp && window.wp.i18n && window.wp.i18n.__) ? window.wp.i18n.__ : (text => text);
7
8 class YatraStripe {
9 constructor(apiUrl, nonce) {
10 this.apiUrl = apiUrl;
11 this.nonce = nonce;
12 this.stripe = null;
13 this.elements = null;
14 this.cardElement = null;
15 this.paymentRequest = null;
16 this.paymentRequestButton = null;
17 this.paymentRequestMounted = false;
18 this.paymentForm = document.getElementById('yatra-booking-form');
19 this.submitButton = document.getElementById('yatra-submit-booking');
20 this.originalButtonHtml = this.submitButton ? this.submitButton.innerHTML : '';
21 this.methodButtons = {};
22 this.activeMethod = null;
23 this.cardWrapper = null;
24 this.walletWrapper = null;
25 this.walletCapabilities = {
26 google_pay: false,
27 apple_pay: false,
28 };
29 this.walletMethodHints = {
30 google_pay: '',
31 apple_pay: '',
32 };
33 this.walletAvailabilityKnown = false;
34 /** Short labels for each method switcher option (tooltip base copy). */
35 this.methodHelpText = {};
36
37 this.stripeSettings = window.yatraBookingData?.stripe || {};
38 let enabledMethods = this.stripeSettings.enabledMethods || ['card'];
39 if (typeof enabledMethods === 'string') {
40 enabledMethods = enabledMethods.split(',');
41 }
42 if (!Array.isArray(enabledMethods) || enabledMethods.length === 0) {
43 enabledMethods = ['card'];
44 }
45 this.enabledMethods = enabledMethods.map((method) => method.trim().toLowerCase()).filter(Boolean);
46 if (this.enabledMethods.length === 0) {
47 this.enabledMethods = ['card'];
48 }
49
50 this.supportsCard = this.enabledMethods.includes('card');
51 this.supportsPaymentRequest = this.enabledMethods.some((method) => method === 'google_pay' || method === 'apple_pay');
52
53 this.companyCountry = window.yatraBookingData?.companyCountry || 'US';
54 this.tripTitle = window.yatraBookingData?.tripTitle || 'Trip Booking';
55
56 window.yatraStripeInstance = this;
57
58 if (document.readyState === 'loading') {
59 document.addEventListener('DOMContentLoaded', () => this.init());
60 } else {
61 this.init();
62 }
63 }
64
65 init() {
66 if (!document.querySelector('input[name="payment_gateway"][value="stripe"]')) {
67 return;
68 }
69
70 this.addStripeElementsContainer();
71
72 document.querySelectorAll('input[name="payment_gateway"]').forEach((radio) => {
73 radio.addEventListener('change', (e) => this.handlePaymentMethodChange(e));
74 });
75
76 if (typeof Stripe !== 'undefined') {
77 this.initializeStripe();
78 }
79
80 // Listen for unified booking submit event (new system)
81 document.addEventListener('yatra_booking_submit', (event) => this.handleBookingSubmit(event));
82
83 // Listen for payment response for client-side Stripe payments
84 document.addEventListener('yatra_payment_response', (event) => this.handlePaymentResponse(event));
85
86 // Legacy event support
87 document.addEventListener('yatraBeforeBookingSubmit', (event) => this.handleGatewayIntercept(event));
88 }
89
90 /**
91 * Handle unified booking submit event
92 */
93 handleBookingSubmit(event) {
94 if (event.detail?.gateway !== 'stripe') {
95 return; // Let other gateways handle
96 }
97
98 // Stripe intercepts the form submission to handle card payment
99 event.preventDefault();
100
101 // Use the existing handleGatewayIntercept logic
102 this.handleGatewayIntercept({
103 preventDefault: () => {},
104 detail: event.detail
105 });
106 }
107
108 /**
109 * Handle payment response for Stripe (when server returns requires_action)
110 */
111 handlePaymentResponse(event) {
112 if (event.detail?.requires_action !== 'stripe_payment') {
113 return;
114 }
115
116
117 this.handleStripePaymentAction(event.detail);
118 }
119
120 /**
121 * Handle Stripe payment action from server response
122 */
123 async handleStripePaymentAction(data) {
124 try {
125 // Load Stripe if not already loaded
126 if (!this.stripe && data.publishable_key) {
127 await this.loadStripeScript();
128 this.stripe = Stripe(data.publishable_key);
129 }
130
131 if (!this.stripe) {
132 throw new Error('Stripe not initialized');
133 }
134
135 const elements = this.stripe.elements({
136 clientSecret: data.client_secret
137 });
138
139 // Create payment element in a modal
140 const modal = document.createElement('div');
141 modal.id = 'yatra-stripe-modal';
142 modal.innerHTML = `
143 <div style="position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 9999; display: flex; align-items: center; justify-content: center;">
144 <div style="background: white; padding: 32px; border-radius: 12px; max-width: 500px; width: 90%; box-shadow: 0 4px 20px rgba(0,0,0,0.2);">
145 <h2 style="margin: 0 0 24px; text-align: center;">${__('Complete Your Payment', 'yatra')}</h2>
146 <div id="stripe-payment-element"></div>
147 <button id="stripe-submit-btn" style="width: 100%; padding: 16px; background: #635bff; color: white; border: none; border-radius: 8px; font-size: 16px; font-weight: 600; margin-top: 24px; cursor: pointer;">
148 ${__('Pay', 'yatra')} ${data.currency} ${parseFloat(data.amount).toFixed(2)}
149 </button>
150 <button id="stripe-cancel-btn" style="width: 100%; padding: 12px; background: transparent; color: #666; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; margin-top: 12px; cursor: pointer;">
151 ${__('Cancel', 'yatra')}
152 </button>
153 <div id="stripe-error-message" style="margin-top: 16px; color: #ef4444; display: none;"></div>
154 </div>
155 </div>
156 `;
157 document.body.appendChild(modal);
158
159 const paymentElement = elements.create('payment');
160 paymentElement.mount('#stripe-payment-element');
161
162 document.getElementById('stripe-submit-btn').addEventListener('click', async () => {
163 const btn = document.getElementById('stripe-submit-btn');
164 btn.disabled = true;
165 btn.textContent = __('Processing...', 'yatra');
166
167 const { error } = await this.stripe.confirmPayment({
168 elements,
169 confirmParams: {
170 return_url: data.confirmation_url + '?stripe=success',
171 },
172 });
173
174 if (error) {
175 document.getElementById('stripe-error-message').textContent = error.message;
176 document.getElementById('stripe-error-message').style.display = 'block';
177 btn.disabled = false;
178 btn.textContent = `${__('Pay', 'yatra')} ${data.currency} ${parseFloat(data.amount).toFixed(2)}`;
179 }
180 });
181
182 document.getElementById('stripe-cancel-btn').addEventListener('click', () => {
183 modal.remove();
184 document.dispatchEvent(new CustomEvent('yatra_payment_cancelled', {
185 detail: { gateway: 'stripe' }
186 }));
187 });
188
189 } catch (error) {
190 console.error('Stripe payment error:', error);
191 document.dispatchEvent(new CustomEvent('yatra_payment_failed', {
192 detail: { gateway: 'stripe', error: error.message }
193 }));
194 }
195 }
196
197 loadStripeScript() {
198 return new Promise((resolve, reject) => {
199 if (typeof Stripe !== 'undefined') {
200 resolve();
201 return;
202 }
203 const script = document.createElement('script');
204 script.src = 'https://js.stripe.com/v3/';
205 script.onload = resolve;
206 script.onerror = reject;
207 document.head.appendChild(script);
208 });
209 }
210
211 addStripeElementsContainer() {
212 const stripeOption = document.querySelector('input[name="payment_gateway"][value="stripe"]');
213 if (!stripeOption || document.querySelector('.yatra-stripe-container')) {
214 return;
215 }
216
217 const container = document.createElement('div');
218 container.className = 'yatra-stripe-container';
219 const stripePreselected = !!stripeOption.checked;
220 container.style.display = stripePreselected ? 'block' : 'none';
221
222 this.methodSwitcher = this.createMethodSwitcher();
223 container.appendChild(this.methodSwitcher);
224
225 if (this.supportsPaymentRequest) {
226 const requestWrapper = document.createElement('div');
227 requestWrapper.className = 'yatra-stripe-payment-request-wrapper';
228 const walletNote = window.yatraTranslations?.stripeWalletNote || 'Pay instantly with Apple Pay or Google Pay.';
229 requestWrapper.innerHTML = `
230 <div class="yatra-wallet-header">
231 <div>
232 <p class="yatra-wallet-eyebrow">${__('One-tap checkout', 'yatra')}</p>
233 <h4 class="yatra-wallet-title">Apple Pay & Google Pay</h4>
234 </div>
235 <div class="yatra-wallet-badges">
236 <span class="wallet-badge wallet-badge--apple"> Pay</span>
237 <span class="wallet-badge wallet-badge--google">G Pay</span>
238 </div>
239 </div>
240 <p class="yatra-wallet-desc">${walletNote}</p>
241 <div id="yatra-stripe-payment-request" class="yatra-stripe-payment-request placeholder"></div>
242 <div id="yatra-stripe-wallet-status" class="yatra-wallet-status">
243 ${__('Checking your device for wallet support…', 'yatra')}
244 </div>
245 `;
246 container.appendChild(requestWrapper);
247 this.walletWrapper = requestWrapper;
248 this.walletWrapper.style.display = 'none';
249 }
250
251 if (this.supportsCard) {
252 const cardWrapper = document.createElement('div');
253 cardWrapper.className = 'yatra-stripe-card-wrapper';
254 cardWrapper.innerHTML = `
255 <div id="yatra-stripe-card-element" class="yatra-stripe-card-element"></div>
256 <div id="yatra-stripe-card-errors" class="yatra-stripe-errors" role="alert"></div>
257 `;
258 container.appendChild(cardWrapper);
259 this.cardWrapper = cardWrapper;
260 this.cardWrapper.style.display = 'none';
261 }
262
263 const gatewayExtra = document.getElementById('yatra-gateway-extra-stripe');
264 if (gatewayExtra) {
265 gatewayExtra.appendChild(container);
266 } else {
267 let parentElement = stripeOption.closest('.yatra-payment-option')
268 || stripeOption.closest('.payment_method_stripe')
269 || stripeOption.closest('.payment-method')
270 || stripeOption.parentElement;
271
272 if (parentElement) {
273 parentElement.appendChild(container);
274 } else {
275 const paymentOptions = document.querySelector('.yatra-payment-options, .payment-methods');
276 if (paymentOptions && paymentOptions.parentNode) {
277 paymentOptions.parentNode.insertBefore(container, paymentOptions.nextSibling);
278 }
279 }
280 }
281
282 this.setActiveMethod(this.getDefaultMethod());
283 this.syncMethodButtonsState();
284
285 if (stripePreselected && !this.stripe) {
286 this.initializeStripe();
287 }
288 }
289
290 handlePaymentMethodChange(event) {
291 const selectedMethod = event.target.value;
292 const stripeContainer = document.querySelector('.yatra-stripe-container');
293
294 if (selectedMethod === 'stripe') {
295 if (!this.stripe) {
296 this.initializeStripe();
297 } else if (stripeContainer) {
298 stripeContainer.style.display = 'block';
299 }
300 } else if (stripeContainer) {
301 stripeContainer.style.display = 'none';
302 }
303 }
304
305 initializeStripe() {
306 const publishableKey = window.yatraBookingData?.stripe?.publishableKey
307 || window.yatraBookingData?.stripe?.publishable_key;
308 if (!publishableKey) {
309 this.displayError('Stripe publishable key is missing. Please contact support.');
310 return;
311 }
312
313 if (typeof Stripe === 'undefined') {
314 const script = document.createElement('script');
315 script.src = 'https://js.stripe.com/v3/';
316 script.onload = () => this.setupStripeElements(publishableKey);
317 script.onerror = () => {
318 this.displayError('Failed to load Stripe.js. Please try again or choose another payment method.');
319 };
320 document.head.appendChild(script);
321 } else {
322 this.setupStripeElements(publishableKey);
323 }
324 }
325
326 setupStripeElements(publishableKey) {
327 try {
328 this.stripe = Stripe(publishableKey, { apiVersion: '2023-10-16' });
329 this.elements = this.stripe.elements({
330 fonts: [{ cssSrc: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap' }],
331 locale: 'auto'
332 });
333
334 if (this.supportsCard) {
335 const style = {
336 base: {
337 color: '#32325d',
338 fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
339 fontSmoothing: 'antialiased',
340 fontSize: '16px',
341 '::placeholder': { color: '#aab7c4' }
342 },
343 invalid: {
344 color: '#e53935',
345 iconColor: '#e53935'
346 }
347 };
348
349 this.cardElement = this.elements.create('card', {
350 style,
351 hidePostalCode: true
352 });
353
354 const cardElement = document.getElementById('yatra-stripe-card-element');
355 if (cardElement) {
356 this.cardElement.mount(cardElement);
357 this.cardElement.on('change', (event) => this.displayError(event.error?.message || ''));
358 }
359 }
360
361 this.initializePaymentRequestButton();
362
363 const stripeContainer = document.querySelector('.yatra-stripe-container');
364 if (stripeContainer && document.querySelector('input[name="payment_gateway"]:checked')?.value === 'stripe') {
365 stripeContainer.style.display = 'block';
366 }
367 } catch (error) {
368 console.error('Error initializing Stripe:', error);
369 this.displayError('Failed to initialize payment processor. Please try again or use another payment method.');
370 }
371 }
372
373 async handleGatewayIntercept(event) {
374 const selectedMethod = document.querySelector('input[name="payment_gateway"]:checked')?.value;
375 if (selectedMethod !== 'stripe') {
376 return true;
377 }
378
379 event.preventDefault();
380
381 if (this.activeMethod && this.activeMethod !== 'card') {
382 this.displayError(__('Please pay using the Apple Pay or Google Pay button above.', 'yatra'));
383 return false;
384 }
385
386 if (!this.supportsCard) {
387 this.displayError('Please use Apple Pay or Google Pay above to complete your payment.');
388 return false;
389 }
390
391 const bookingData = event?.detail?.bookingData || this.collectBookingData();
392 if (!bookingData) {
393 this.displayError('Please complete the booking form before continuing.');
394 return false;
395 }
396
397 if (!this.stripe || !this.cardElement) {
398 this.initializeStripe();
399 this.displayError('Stripe is still loading. Please wait a moment and try again.');
400 return false;
401 }
402
403 if (!this.originalButtonHtml && event?.detail?.originalBtnHtml) {
404 this.originalButtonHtml = event.detail.originalBtnHtml;
405 } else if (!this.originalButtonHtml && this.submitButton) {
406 this.originalButtonHtml = this.submitButton.innerHTML;
407 }
408
409 this.displayError('');
410 this.setLoadingState(true);
411
412 try {
413 const paymentContext = await this.prepareStripePayment(bookingData, event?.detail?.form || this.paymentForm);
414 const { bookingInfo, clientSecret, billingDetails, effectiveAmount } = paymentContext;
415
416 const { error, paymentIntent } = await this.stripe.confirmCardPayment(clientSecret, {
417 payment_method: {
418 card: this.cardElement,
419 billing_details: billingDetails
420 },
421 return_url: this.buildReturnUrl(bookingInfo)
422 });
423
424 if (error) {
425 throw error;
426 }
427
428 if (paymentIntent.status === 'succeeded') {
429 await this.notifyPaymentSuccess({
430 bookingId: bookingInfo.booking_id,
431 transactionId: paymentIntent.id,
432 saveCard: !!bookingData.save_card,
433 amount: effectiveAmount,
434 currency: bookingInfo.currency || bookingData.currency || 'USD'
435 });
436 window.location.href = this.buildConfirmationUrlFromBookingInfo(bookingInfo);
437 } else {
438 throw new Error('Payment processing was not completed. Please check your payment details and try again.');
439 }
440 } catch (error) {
441 console.error('Stripe payment error:', error);
442 this.displayError(error.message || 'Payment failed. Please try again or use another payment method.');
443 this.setLoadingState(false);
444 }
445
446 return false;
447 }
448
449 async initializePaymentRequestButton() {
450 if (!this.supportsPaymentRequest || !this.stripe || this.paymentRequestMounted) {
451 return;
452 }
453
454 try {
455 this.paymentRequest = this.stripe.paymentRequest({
456 country: (this.companyCountry || 'US').toUpperCase(),
457 currency: (window.yatraBookingData?.currency || 'USD').toLowerCase(),
458 total: this.getPaymentRequestTotal(),
459 requestPayerName: true,
460 requestPayerEmail: true,
461 });
462
463 const result = await this.paymentRequest.canMakePayment();
464 if (!result) {
465 this.setWalletStatus(
466 __('Apple Pay and Google Pay are not available on this browser/device.', 'yatra'),
467 'warning'
468 );
469 this.updateWalletCapabilities(null);
470 this.showWalletPlaceholder(__('Use card payment instead or switch to a supported device.', 'yatra'));
471 this.syncMethodButtonsState();
472 return;
473 }
474
475 this.updateWalletCapabilities(result === true ? { applePay: true, googlePay: true } : result);
476 this.setWalletStatus(
477 __('Great! Apple Pay or Google Pay is available on this device.', 'yatra'),
478 'success'
479 );
480 this.syncMethodButtonsState();
481
482 this.paymentRequest.on('paymentmethod', (event) => this.handlePaymentRequestPayment(event));
483
484 this.paymentRequestButton = this.elements.create('paymentRequestButton', {
485 paymentRequest: this.paymentRequest,
486 style: {
487 paymentRequestButton: {
488 type: 'default',
489 theme: 'dark',
490 height: '48px'
491 }
492 }
493 });
494
495 const prContainer = document.getElementById('yatra-stripe-payment-request');
496 if (prContainer) {
497 this.paymentRequestButton.mount(prContainer);
498 this.paymentRequestMounted = true;
499 prContainer.classList.remove('placeholder');
500 const stripeContainer = document.querySelector('.yatra-stripe-container');
501 if (stripeContainer && document.querySelector('input[name="payment_gateway"]:checked')?.value === 'stripe') {
502 stripeContainer.style.display = 'block';
503 }
504 }
505 } catch (error) {
506 console.error('Failed to initialize Stripe Payment Request button:', error);
507 this.setWalletStatus(
508 __('Unable to load Apple Pay / Google Pay. Please try again later or use card payment.', 'yatra'),
509 'error'
510 );
511 this.showWalletPlaceholder(__('Wallet payment failed to load.', 'yatra'));
512 this.updateWalletCapabilities(null);
513 const errHint = __('Wallet payments could not be initialized. Use your card below, or try again after refreshing the page.', 'yatra');
514 this.walletMethodHints.google_pay = errHint;
515 this.walletMethodHints.apple_pay = errHint;
516 this.syncMethodButtonsState();
517 }
518 }
519
520 setWalletStatus(message, variant = 'info') {
521 const statusEl = document.getElementById('yatra-stripe-wallet-status');
522 if (!statusEl) {
523 return;
524 }
525 statusEl.textContent = message;
526 statusEl.classList.remove('is-success', 'is-warning', 'is-error');
527 if (variant === 'success') {
528 statusEl.classList.add('is-success');
529 } else if (variant === 'warning') {
530 statusEl.classList.add('is-warning');
531 } else if (variant === 'error') {
532 statusEl.classList.add('is-error');
533 }
534 }
535
536 showWalletPlaceholder(message) {
537 const container = document.getElementById('yatra-stripe-payment-request');
538 if (!container) {
539 return;
540 }
541 container.classList.add('wallet-placeholder');
542 container.innerHTML = `<span>${message}</span>`;
543 }
544
545 updateWalletCapabilities(result) {
546 const availability = { apple_pay: false, google_pay: false };
547
548 if (result && typeof result === 'object') {
549 availability.apple_pay = !!(result.applePay || result.apple_pay);
550 availability.google_pay = !!(result.googlePay || result.google_pay);
551 } else if (result === true) {
552 availability.apple_pay = true;
553 availability.google_pay = true;
554 }
555
556 this.walletCapabilities = availability;
557
558 const hintGoogle = !availability.google_pay && this.enabledMethods.includes('google_pay')
559 ? __('Google Pay is not available in this browser. Try Chrome or Edge on desktop or Android, use HTTPS, and ensure you have a card in your Google account.', 'yatra')
560 : '';
561 const hintApple = !availability.apple_pay && this.enabledMethods.includes('apple_pay')
562 ? __('Apple Pay is not available here. Use Safari on iPhone, iPad, or Mac, serve this page over HTTPS, and add your domain in Stripe for Apple Pay.', 'yatra')
563 : '';
564
565 this.walletMethodHints = {
566 google_pay: hintGoogle,
567 apple_pay: hintApple,
568 };
569 this.walletAvailabilityKnown = true;
570 }
571
572 createMethodSwitcher() {
573 const wrapper = document.createElement('div');
574 wrapper.className = 'yatra-method-switcher';
575
576 const availableButtons = [];
577
578 const header = document.createElement('div');
579 header.className = 'yatra-method-switcher__header';
580
581 const eyebrow = document.createElement('p');
582 eyebrow.className = 'yatra-label-eyebrow';
583 eyebrow.textContent = __('Choose a payment option', 'yatra');
584
585 const title = document.createElement('div');
586 title.className = 'yatra-method-switcher__title';
587 title.textContent = __('Payment methods', 'yatra');
588
589 const helper = document.createElement('p');
590 helper.className = 'yatra-method-switcher__helper';
591 helper.textContent = __('Pick the option that works best for you.', 'yatra');
592
593 header.appendChild(eyebrow);
594 header.appendChild(title);
595 header.appendChild(helper);
596
597 if (this.supportsCard) {
598 availableButtons.push({
599 id: 'card',
600 label: __('Card', 'yatra'),
601 description: __('Use any major debit or credit card via Stripe Elements.', 'yatra'),
602 icon: this.getMethodIconMarkup('card')
603 });
604 }
605
606 if (this.enabledMethods.includes('google_pay')) {
607 availableButtons.push({
608 id: 'google_pay',
609 label: __('Google Pay', 'yatra'),
610 description: __('Pay instantly with the cards saved to your Google account.', 'yatra'),
611 icon: this.getMethodIconMarkup('google_pay')
612 });
613 }
614
615 if (this.enabledMethods.includes('apple_pay')) {
616 availableButtons.push({
617 id: 'apple_pay',
618 label: __('Apple Pay', 'yatra'),
619 description: __('Use Apple Wallet on iPhone, iPad, or Mac to pay securely.', 'yatra'),
620 icon: this.getMethodIconMarkup('apple_pay')
621 });
622 }
623
624 const buttons = document.createElement('div');
625 buttons.className = 'yatra-method-switcher__buttons';
626
627 availableButtons.forEach((method) => {
628 this.methodHelpText[method.id] = method.description;
629 const btn = document.createElement('button');
630 btn.type = 'button';
631 btn.className = 'yatra-method-button';
632 btn.dataset.method = method.id;
633 btn.innerHTML = `
634 ${method.icon}
635 <div class="method-content">
636 <span class="method-label">${method.label}</span>
637 </div>
638 <span class="method-tooltip">
639 <svg viewBox="0 0 20 20" aria-hidden="true" focusable="false">
640 <circle cx="10" cy="10" r="9" stroke="currentColor" stroke-width="1.3" fill="none"></circle>
641 <circle cx="10" cy="6" r="1.2" fill="currentColor"></circle>
642 <path d="M10 9v6" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"></path>
643 </svg>
644 </span>
645 `;
646 this.applyMethodTooltipText(btn.querySelector('.method-tooltip'), method.description);
647 btn.addEventListener('click', () => {
648 if (btn.getAttribute('aria-disabled') === 'true') {
649 return;
650 }
651 this.setActiveMethod(method.id);
652 });
653 this.methodButtons[method.id] = btn;
654 buttons.appendChild(btn);
655 });
656
657 wrapper.appendChild(header);
658 wrapper.appendChild(buttons);
659 return wrapper;
660 }
661
662 getMethodIconMarkup(type) {
663 const base = '<span class="method-icon method-icon--' + type + '" aria-hidden="true">';
664 const close = '</span>';
665
666 const icons = {
667 card: `${base}
668 <svg viewBox="0 0 40 24" role="presentation" focusable="false">
669 <rect x="1" y="1" width="38" height="22" rx="6" fill="#111827" stroke="rgba(255,255,255,0.2)" stroke-width="1" />
670 <rect x="4" y="8" width="32" height="3" fill="#4b5563"></rect>
671 <rect x="4" y="14" width="15" height="4" fill="#f3f4f6"></rect>
672 <circle cx="28" cy="16" r="3.5" fill="#f97316"></circle>
673 <circle cx="33" cy="16" r="3.5" fill="#facc15"></circle>
674 </svg>
675 ${close}`,
676 google_pay: `${base}
677 <svg viewBox="0 0 40 24" role="presentation" focusable="false">
678 <g transform="translate(8 4)">
679 <path fill="#ea4335" d="M6.4 7.6a3.6 3.6 0 0 1 3.4-3.6c1 0 1.7.4 2.2.8L14 3.8A6 6 0 0 0 9.8 2C6.5 2 3.7 4.7 3.7 8s2.8 6 6.1 6c1.8 0 3.3-.6 4.3-1.8 1.1-1.3 1.4-3.1 1.2-4.6H9.8v2.5h3.6c-.2.9-.7 1.7-1.5 2.3-.7.6-1.7.9-2.8.9-2.3 0-4.2-1.9-4.2-4.2z"></path>
680 <path fill="#4285f4" d="M20.5 4.8a2.8 2.8 0 0 1 2.6 1.6V5H25v9.2h-1.9V9.6a1.6 1.6 0 0 0-1.7-1.7c-1.1 0-1.7.7-1.7 1.9v4.4h-1.9V5h1.9v1.3c.4-.8 1.3-1.5 2.8-1.5z"></path>
681 <path fill="#34a853" d="M16.7 9.2c0-1.1-.9-2-2-2s-2 .9-2 2 .9 2 2 2 2-.9 2-2zm3.9 0c0 3-2.3 5.2-5.9 5.2a5.9 5.9 0 1 1 0-11.8 5.8 5.8 0 0 1 4.1 1.7l-1.6 1.6A3.5 3.5 0 0 0 14.7 4C12.3 4 10.4 6 10.4 8.4s1.9 4.4 4.3 4.4c2 0 3.2-1.1 3.6-2.6h-3.6V8.7h5.8c.1.2.1.4.1.5z"></path>
682 </g>
683 </svg>
684 ${close}`,
685 apple_pay: `${base}
686 <svg viewBox="0 0 40 24" role="presentation" focusable="false">
687 <g transform="translate(7 3)" fill="currentColor">
688 <path d="M15.1 4.8c-.7.9-1.8 1.4-2.8 1.3-.1-1 .3-2 1-2.7.7-.8 2.1-1.5 3.2-1.5.1 1.1-.3 2.2-1.4 2.9z"></path>
689 <path d="M18.3 12.7c-.5 1.1-1.2 2.2-2.1 3.2-.9 1-1.8 1.9-3 1.9s-1.5-.6-2.9-.6-1.9.6-3 .6c-1.3 0-2.3-1.1-3.2-2.1C2.3 13.7 1 10 2.7 7.5c.8-1.2 2.2-2 3.6-2 1.4 0 2.4.9 3 .9.7 0 2-.9 3.6-.9.6 0 2.5.2 3.6 1.9.1.1 2 .7 1.8 5.3z"></path>
690 <rect x="20.5" y="4.3" width="1.9" height="9.4" rx="0.5"></rect>
691 <path d="M26.8 4.1c2.2 0 3.8 1.4 3.8 3.4 0 2.1-1.6 3.4-3.9 3.4h-2V14h-2V4.1zm-2 5.4h1.7c1.2 0 2-.7 2-1.9s-.8-1.9-2-1.9h-1.7z"></path>
692 </g>
693 </svg>
694 ${close}`
695 };
696
697 return icons[type] || `${base}</span>`;
698 }
699
700 getDefaultMethod() {
701 if (this.supportsCard) {
702 return 'card';
703 }
704
705 if (this.enabledMethods.includes('google_pay')) {
706 return 'google_pay';
707 }
708
709 if (this.enabledMethods.includes('apple_pay')) {
710 return 'apple_pay';
711 }
712
713 return 'card';
714 }
715
716 setActiveMethod(method) {
717 if (!method) {
718 return;
719 }
720
721 // Guard against selecting unsupported methods
722 if (!this.isMethodSelectable(method)) {
723 method = this.getDefaultMethod();
724 }
725
726 if (this.activeMethod === method) {
727 return;
728 }
729
730 this.activeMethod = method;
731
732 if (this.cardWrapper) {
733 this.cardWrapper.style.display = method === 'card' ? 'block' : 'none';
734 }
735
736 if (this.walletWrapper) {
737 const shouldShowWallet = method === 'google_pay' || method === 'apple_pay';
738 this.walletWrapper.style.display = shouldShowWallet ? 'block' : 'none';
739 }
740
741 this.syncMethodButtonsState();
742 }
743
744 syncMethodButtonsState() {
745 Object.entries(this.methodButtons).forEach(([method, button]) => {
746 if (!button) {
747 return;
748 }
749
750 const isActive = method === this.activeMethod;
751 button.classList.toggle('is-active', isActive);
752 button.classList.remove('is-disabled', 'is-pending');
753 button.removeAttribute('aria-disabled');
754
755 const tooltipEl = button.querySelector('.method-tooltip');
756 const baseHelp = this.methodHelpText[method] || '';
757
758 if (this.isWalletMethod(method)) {
759 if (!this.walletAvailabilityKnown) {
760 button.classList.add('is-pending');
761 button.setAttribute('aria-disabled', 'true');
762 this.applyMethodTooltipText(
763 tooltipEl,
764 __('Checking if Google Pay or Apple Pay is available on this device…', 'yatra')
765 );
766 } else {
767 const isSupported = !!this.walletCapabilities[method];
768 if (!isSupported) {
769 button.classList.add('is-disabled');
770 button.setAttribute('aria-disabled', 'true');
771 const why = this.walletMethodHints[method] || __('This wallet is not available on this device or browser. Use card payment instead.', 'yatra');
772 this.applyMethodTooltipText(tooltipEl, why);
773 if (isActive) {
774 this.setActiveMethod('card');
775 }
776 } else {
777 this.applyMethodTooltipText(tooltipEl, baseHelp);
778 }
779 }
780 } else {
781 this.applyMethodTooltipText(tooltipEl, baseHelp);
782 }
783 });
784
785 // Ensure at least one method is visible if everything else disabled
786 const activeBtn = this.activeMethod ? this.methodButtons[this.activeMethod] : null;
787 const activeBlocked = activeBtn && activeBtn.getAttribute('aria-disabled') === 'true';
788 if (!this.activeMethod || activeBlocked) {
789 const fallback = this.supportsCard ? 'card'
790 : (this.walletCapabilities.google_pay ? 'google_pay'
791 : (this.walletCapabilities.apple_pay ? 'apple_pay' : null));
792 if (fallback) {
793 this.setActiveMethod(fallback);
794 }
795 }
796 }
797
798 /**
799 * @param {HTMLElement | null} el
800 * @param {string} text
801 */
802 applyMethodTooltipText(el, text) {
803 if (!el) {
804 return;
805 }
806 const t = String(text);
807 el.setAttribute('data-tooltip', t);
808 el.setAttribute('aria-label', t);
809 el.setAttribute('title', t);
810 }
811
812 isWalletMethod(method) {
813 return method === 'google_pay' || method === 'apple_pay';
814 }
815
816 isMethodSelectable(method) {
817 if (method === 'card') {
818 return !!this.supportsCard;
819 }
820
821 if (this.isWalletMethod(method)) {
822 if (!this.walletAvailabilityKnown) {
823 return false;
824 }
825 return !!this.walletCapabilities[method];
826 }
827
828 return false;
829 }
830
831 async handlePaymentRequestPayment(event) {
832 try {
833 const bookingData = this.collectBookingData();
834 if (!bookingData) {
835 throw new Error('Please complete the booking form before using Apple Pay or Google Pay.');
836 }
837
838 this.setLoadingState(true);
839 const paymentContext = await this.prepareStripePayment(bookingData, this.paymentForm);
840 const { bookingInfo, clientSecret, effectiveAmount } = paymentContext;
841
842 const confirmation = await this.stripe.confirmCardPayment(clientSecret, {
843 payment_method: event.paymentMethod.id,
844 return_url: this.buildReturnUrl(bookingInfo)
845 });
846
847 if (confirmation.error) {
848 event.complete('fail');
849 throw confirmation.error;
850 }
851
852 event.complete('success');
853
854 if (confirmation.paymentIntent?.status === 'requires_action') {
855 return;
856 }
857
858 if (confirmation.paymentIntent?.status === 'succeeded') {
859 await this.notifyPaymentSuccess({
860 bookingId: bookingInfo.booking_id,
861 transactionId: confirmation.paymentIntent.id,
862 saveCard: !!bookingData.save_card,
863 amount: effectiveAmount,
864 currency: bookingInfo.currency || bookingData.currency || 'USD'
865 });
866 }
867
868 window.location.href = this.buildConfirmationUrlFromBookingInfo(bookingInfo);
869 } catch (error) {
870 console.error('Stripe payment request error:', error);
871 if (event && typeof event.complete === 'function') {
872 event.complete('fail');
873 }
874 this.displayError(error.message || 'Payment failed. Please try again or use another payment method.');
875 this.setLoadingState(false);
876 }
877 }
878
879 async prepareStripePayment(bookingData, formElement) {
880 // Always call the same endpoint - server decides based on session type
881 const bookingResponse = await fetch(`${this.apiUrl}/booking/create`, {
882 method: 'POST',
883 headers: {
884 'Content-Type': 'application/json',
885 'X-WP-Nonce': this.nonce
886 },
887 body: JSON.stringify(bookingData)
888 });
889
890 const bookingResult = await bookingResponse.json();
891
892 if (!bookingResult.success) {
893 throw new Error(bookingResult.message || 'Failed to process request. Please try again.');
894 }
895
896 const bookingInfo = bookingResult.data;
897 const effectiveAmount = this.getEffectiveAmount(bookingData, bookingInfo);
898
899 if (bookingInfo.client_secret) {
900 const customerEmail = bookingInfo.customer_email || bookingData.contact_email || bookingData.email;
901 const customerName = bookingInfo.customer_name ||
902 [bookingData.contact_first_name, bookingData.contact_last_name]
903 .filter(Boolean)
904 .join(' ') || bookingData.full_name;
905
906 if (!customerEmail) {
907 throw new Error('Email address is required.');
908 }
909
910 const billingDetails = this.getBillingDetails(bookingData, formElement);
911
912 return {
913 bookingInfo,
914 clientSecret: bookingInfo.client_secret,
915 billingDetails,
916 effectiveAmount,
917 };
918 }
919
920 const customerEmail = bookingInfo.customer_email || bookingData.contact_email || bookingData.email;
921 const customerName = bookingInfo.customer_name ||
922 [bookingData.contact_first_name, bookingData.contact_last_name]
923 .filter(Boolean)
924 .join(' ') || bookingData.full_name;
925
926 if (!customerEmail) {
927 throw new Error('Email address is required.');
928 }
929
930 const paymentIntentResponse = await fetch(`${this.apiUrl}/payment/create-intent`, {
931 method: 'POST',
932 headers: {
933 'Content-Type': 'application/json',
934 'X-WP-Nonce': this.nonce
935 },
936 body: JSON.stringify({
937 gateway: 'stripe',
938 booking_id: bookingInfo.booking_id,
939 amount: effectiveAmount,
940 currency: bookingInfo.currency,
941 trip_id: bookingInfo.trip_id || bookingData.trip_id || null,
942 trip_date: bookingInfo.trip_date || bookingData.travel_date || '',
943 customer_email: customerEmail,
944 customer_name: customerName,
945 return_url: this.buildReturnUrl(bookingInfo)
946 })
947 });
948
949 const intentPayload = await paymentIntentResponse.json();
950 if (!paymentIntentResponse.ok) {
951 throw new Error(intentPayload?.message || 'Unable to process payment. Please try again.');
952 }
953 const clientSecret = intentPayload.client_secret;
954
955 if (!clientSecret) {
956 throw new Error(intentPayload?.error?.message || 'Unable to process payment. Please try again.');
957 }
958
959 const billingDetails = this.getBillingDetails(bookingData, formElement);
960
961 return {
962 bookingInfo,
963 clientSecret,
964 billingDetails,
965 effectiveAmount,
966 };
967 }
968
969 getBillingDetails(bookingData, formElement) {
970 const getField = (keys) => {
971 const candidates = Array.isArray(keys) ? keys : [keys];
972 for (const key of candidates) {
973 if (bookingData && bookingData[key]) {
974 return bookingData[key];
975 }
976 if (formElement) {
977 const input = formElement.querySelector(`[name="${key}"]`);
978 if (input && input.value) {
979 return input.value;
980 }
981 }
982 }
983 return '';
984 };
985
986 const firstName = getField(['billing_first_name', 'contact_first_name', 'first_name']);
987 const lastName = getField(['billing_last_name', 'contact_last_name', 'last_name']);
988 const email = getField(['billing_email', 'contact_email', 'email']);
989 const phone = getField(['billing_phone', 'contact_phone', 'phone']);
990 const line1 = getField(['billing_address_1', 'contact_address']);
991 const city = getField(['billing_city', 'contact_city']);
992 const country = getField(['billing_country', 'contact_country']);
993 const postalCode = getField(['billing_postcode', 'contact_postcode', 'contact_zip']);
994
995 const billingDetails = {};
996 const name = `${firstName} ${lastName}`.trim();
997 if (name) {
998 billingDetails.name = name;
999 }
1000 if (email) {
1001 billingDetails.email = email;
1002 }
1003 if (phone) {
1004 billingDetails.phone = phone;
1005 }
1006
1007 const address = {};
1008 if (line1) {
1009 address.line1 = line1;
1010 }
1011 if (city) {
1012 address.city = city;
1013 }
1014 if (country) {
1015 const normalizedCountry = country.trim().toUpperCase();
1016 if (normalizedCountry.length === 2) {
1017 address.country = normalizedCountry;
1018 }
1019 }
1020 if (postalCode) {
1021 address.postal_code = postalCode;
1022 }
1023
1024 if (Object.keys(address).length > 0) {
1025 billingDetails.address = address;
1026 }
1027
1028 return billingDetails;
1029 }
1030
1031 collectBookingData() {
1032 if (!this.paymentForm) {
1033 return null;
1034 }
1035
1036 const formData = new FormData(this.paymentForm);
1037 const bookingData = {};
1038
1039 for (const [key, value] of formData.entries()) {
1040 const matches = key.match(/^([^\[]+)(?:\[([^\]]*)\])?(?:\[([^\]]*)\])?$/);
1041
1042 if (matches) {
1043 const [, base, index, field] = matches;
1044
1045 if (index !== undefined && field !== undefined) {
1046 bookingData[base] = bookingData[base] || {};
1047 bookingData[base][index] = bookingData[base][index] || {};
1048 bookingData[base][index][field] = value;
1049 } else if (index !== undefined) {
1050 bookingData[base] = bookingData[base] || [];
1051 bookingData[base][index] = value;
1052 } else {
1053 bookingData[key] = value;
1054 }
1055 } else {
1056 bookingData[key] = value;
1057 }
1058 }
1059
1060 bookingData.accept_terms = this.paymentForm.querySelector('input[name="accept_terms"]')?.checked || false;
1061 bookingData.accept_privacy = this.paymentForm.querySelector('input[name="accept_privacy"]')?.checked || false;
1062 bookingData.subscribe_newsletter = this.paymentForm.querySelector('input[name="subscribe_newsletter"]')?.checked || false;
1063 bookingData.payment_due = parseFloat(this.paymentForm.getAttribute('data-payment-due'))
1064 || window.yatraBookingData?.paymentDue
1065 || null;
1066 bookingData.currency = window.yatraBookingData?.currency
1067 || bookingData.currency
1068 || this.paymentForm.querySelector('input[name="currency"]')?.value
1069 || 'USD';
1070 bookingData.payment_gateway = 'stripe';
1071
1072 return bookingData;
1073 }
1074
1075 displayError(message) {
1076 const errorElement = document.getElementById('yatra-stripe-card-errors');
1077 if (errorElement) {
1078 errorElement.textContent = message || '';
1079 errorElement.style.display = message ? 'block' : 'none';
1080 if (message) {
1081 errorElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
1082 }
1083 } else if (message) {
1084 console.error(message);
1085 }
1086 }
1087
1088 setLoadingState(isLoading) {
1089 if (!this.submitButton) {
1090 return;
1091 }
1092
1093 if (isLoading) {
1094 this.submitButton.disabled = true;
1095 this.submitButton.innerHTML = '<span class="yatra-spinner"></span> Processing Payment...';
1096 } else {
1097 this.submitButton.disabled = false;
1098 this.submitButton.innerHTML = this.originalButtonHtml;
1099 }
1100 }
1101
1102 getEffectiveAmount(bookingData, bookingInfo) {
1103 // After /booking/create, server-calculated charge (deposit / partial / full) is authoritative.
1104 // `amount` = charge for this checkout (matches gateway). Never fall back to `total_amount` (full order).
1105 const serverFirst = [
1106 parseFloat(bookingInfo?.amount),
1107 parseFloat(bookingInfo?.amount_due)
1108 ];
1109 for (let i = 0; i < serverFirst.length; i++) {
1110 const v = serverFirst[i];
1111 if (!isNaN(v) && v > 0) {
1112 return v;
1113 }
1114 }
1115
1116 const summaryDue = parseFloat(window.yatraBookingSummary?.amountDue);
1117 if (!isNaN(summaryDue) && summaryDue > 0) {
1118 return summaryDue;
1119 }
1120
1121 const candidates = [
1122 parseFloat(bookingData?.amount_due),
1123 parseFloat(bookingData?.payment_due),
1124 parseFloat(this.paymentForm?.dataset.paymentDue),
1125 parseFloat(window.yatraBookingData?.paymentDue)
1126 ].map((val) => (isNaN(val) ? 0 : val));
1127
1128 const picked = candidates.find((val) => val > 0) ?? 0;
1129 return picked > 0 ? picked : 0;
1130 }
1131
1132 getCurrentPaymentDue() {
1133 const summaryDue = parseFloat(window.yatraBookingSummary?.amountDue);
1134 if (!isNaN(summaryDue) && summaryDue > 0) {
1135 return summaryDue;
1136 }
1137
1138 const candidates = [
1139 parseFloat(this.paymentForm?.dataset.paymentDue),
1140 parseFloat(window.yatraBookingData?.paymentDue)
1141 ].map((val) => (isNaN(val) ? 0 : val));
1142
1143 return candidates.find((val) => val > 0) ?? 0;
1144 }
1145
1146 getPaymentRequestTotal() {
1147 return {
1148 label: this.tripTitle,
1149 amount: this.getAmountInCents(this.getCurrentPaymentDue())
1150 };
1151 }
1152
1153 getAmountInCents(amount) {
1154 const value = isNaN(amount) ? 0 : amount;
1155 return Math.max(50, Math.round(value * 100));
1156 }
1157
1158 /**
1159 * Pageless confirmation URLs use the same base as Settings → Default Booking URL Base
1160 * (e.g. book → /book/confirmation/{reference}/). Never hard-code /booking-confirmation/.
1161 */
1162 buildConfirmationUrlFromBookingInfo(bookingInfo) {
1163 if (bookingInfo.redirect_url) {
1164 return bookingInfo.redirect_url;
1165 }
1166 const d = window.yatraBookingData || {};
1167 const site = (d.siteUrl || window.location.origin || '').replace(/\/$/, '');
1168 const ref = String(bookingInfo.reference || '').trim();
1169 const id = bookingInfo.booking_id;
1170 const segment = ref || (id != null && id !== '' ? String(id) : '');
1171
1172 if (!segment) {
1173 return `${site}/`;
1174 }
1175
1176 if (d.permalinkStructure === 'plain') {
1177 return `${site}/?yatra_booking_confirmation=${encodeURIComponent(segment)}`;
1178 }
1179
1180 const bb = String(d.bookingBase || 'book').replace(/^\/|\/$/g, '');
1181 return `${site}/${bb}/confirmation/${encodeURIComponent(segment)}/`;
1182 }
1183
1184 buildReturnUrl(bookingInfo) {
1185 const base = this.buildConfirmationUrlFromBookingInfo(bookingInfo);
1186 try {
1187 const url = new URL(base, window.location.origin);
1188 url.searchParams.set('stripe_return', '1');
1189 return url.toString();
1190 } catch (e) {
1191 const join = base.includes('?') ? '&' : '?';
1192 return `${base}${join}stripe_return=1`;
1193 }
1194 }
1195
1196 async notifyPaymentSuccess({ bookingId, transactionId, saveCard, amount, currency }) {
1197 try {
1198 const response = await fetch(`${this.apiUrl}/payment/confirm`, {
1199 method: 'POST',
1200 headers: {
1201 'Content-Type': 'application/json',
1202 'X-WP-Nonce': this.nonce,
1203 },
1204 body: JSON.stringify({
1205 gateway: 'stripe',
1206 booking_id: bookingId,
1207 transaction_id: transactionId,
1208 save_card: !!saveCard,
1209 amount,
1210 currency,
1211 }),
1212 });
1213
1214 const payload = await response.json();
1215 if (!response.ok || payload?.success === false) {
1216 throw new Error(payload?.message || 'Failed to record payment.');
1217 }
1218
1219 return payload;
1220 } catch (error) {
1221 console.error('Failed to notify backend about payment success:', error);
1222 throw error;
1223 }
1224 }
1225 }
1226
1227 document.addEventListener('DOMContentLoaded', () => {
1228 if (document.getElementById('yatra-booking-form')) {
1229 const apiUrl = window.yatraBookingData?.apiUrl || '/wp-json/yatra/v1';
1230 const nonce = window.yatraBookingData?.nonce || '';
1231 new YatraStripe(apiUrl, nonce);
1232 }
1233 });
1234