| @@ -235,19 +235,27 @@ | ||
| 235 | 235 | const stripePreselected = !!stripeOption.checked; |
| 236 | 236 | container.style.display = stripePreselected ? 'block' : 'none'; |
| 237 | 237 | |
| 238 | 238 | this.methodSwitcher = this.createMethodSwitcher(); |
| 239 | - container.appendChild(this.methodSwitcher); | |
| 239 | + // Show the "Payment methods" chooser only when there's an actual choice | |
| 240 | + // (Card + Apple/Google Pay). With Card as the only enabled method the | |
| 241 | + // chooser is redundant chrome, so present the card field directly. | |
| 242 | + if (this.hasMultipleMethods) { | |
| 243 | + container.appendChild(this.methodSwitcher); | |
| 244 | + } else { | |
| 245 | + container.classList.add('yatra-stripe-container--single-method'); | |
| 246 | + } | |
| 240 | 247 | |
| 241 | 248 | if (this.supportsPaymentRequest) { |
| 242 | 249 | const requestWrapper = document.createElement('div'); |
| 243 | 250 | requestWrapper.className = 'yatra-stripe-payment-request-wrapper'; |
| 244 | - const walletNote = window.yatraTranslations?.stripeWalletNote || 'Pay instantly with Apple Pay or Google Pay.'; | |
| 251 | + const walletNote = window.yatraTranslations?.stripeWalletNote || __('Pay instantly with Apple Pay or Google Pay.', 'yatra'); | |
| 252 | + const walletTitle = __('Apple Pay & Google Pay', 'yatra'); | |
| 245 | 253 | requestWrapper.innerHTML = ` |
| 246 | 254 | <div class="yatra-wallet-header"> |
| 247 | 255 | <div> |
| 248 | 256 | <p class="yatra-wallet-eyebrow">${__('One-tap checkout', 'yatra')}</p> |
| 249 | - <h4 class="yatra-wallet-title">Apple Pay & Google Pay</h4> | |
| 257 | + <h4 class="yatra-wallet-title">${walletTitle}</h4> | |
| 250 | 258 | </div> |
| 251 | 259 | <div class="yatra-wallet-badges"> |
| 252 | 260 | <span class="wallet-badge wallet-badge--apple"> Pay</span> |
| 253 | 261 | <span class="wallet-badge wallet-badge--google">G Pay</span> |
| @@ -321,9 +329,9 @@ | ||
| 321 | 329 | initializeStripe() { |
| 322 | 330 | const publishableKey = window.yatraBookingData?.stripe?.publishableKey |
| 323 | 331 | || window.yatraBookingData?.stripe?.publishable_key; |
| 324 | 332 | if (!publishableKey) { |
| 325 | - this.displayError('Stripe publishable key is missing. Please contact support.'); | |
| 333 | + this.displayError(__('Stripe publishable key is missing. Please contact support.', 'yatra')); | |
| 326 | 334 | return; |
| 327 | 335 | } |
| 328 | 336 | |
| 329 | 337 | if (typeof Stripe === 'undefined') { |
| @@ -330,9 +338,9 @@ | ||
| 330 | 338 | const script = document.createElement('script'); |
| 331 | 339 | script.src = 'https://js.stripe.com/v3/'; |
| 332 | 340 | script.onload = () => this.setupStripeElements(publishableKey); |
| 333 | 341 | script.onerror = () => { |
| 334 | - this.displayError('Failed to load Stripe.js. Please try again or choose another payment method.'); | |
| 342 | + this.displayError(__('Failed to load Stripe.js. Please try again or choose another payment method.', 'yatra')); | |
| 335 | 343 | }; |
| 336 | 344 | document.head.appendChild(script); |
| 337 | 345 | } else { |
| 338 | 346 | this.setupStripeElements(publishableKey); |
| @@ -381,9 +389,9 @@ | ||
| 381 | 389 | stripeContainer.style.display = 'block'; |
| 382 | 390 | } |
| 383 | 391 | } catch (error) { |
| 384 | 392 | console.error('Error initializing Stripe:', error); |
| 385 | - this.displayError('Failed to initialize payment processor. Please try again or use another payment method.'); | |
| 393 | + this.displayError(__('Failed to initialize payment processor. Please try again or use another payment method.', 'yatra')); | |
| 386 | 394 | } |
| 387 | 395 | } |
| 388 | 396 | |
| 389 | 397 | async handleGatewayIntercept(event) { |
| @@ -399,21 +407,21 @@ | ||
| 399 | 407 | return false; |
| 400 | 408 | } |
| 401 | 409 | |
| 402 | 410 | if (!this.supportsCard) { |
| 403 | - this.displayError('Please use Apple Pay or Google Pay above to complete your payment.'); | |
| 411 | + this.displayError(__('Please use Apple Pay or Google Pay above to complete your payment.', 'yatra')); | |
| 404 | 412 | return false; |
| 405 | 413 | } |
| 406 | 414 | |
| 407 | 415 | const bookingData = event?.detail?.bookingData || this.collectBookingData(); |
| 408 | 416 | if (!bookingData) { |
| 409 | - this.displayError('Please complete the booking form before continuing.'); | |
| 417 | + this.displayError(__('Please complete the booking form before continuing.', 'yatra')); | |
| 410 | 418 | return false; |
| 411 | 419 | } |
| 412 | 420 | |
| 413 | 421 | if (!this.stripe || !this.cardElement) { |
| 414 | 422 | this.initializeStripe(); |
| 415 | - this.displayError('Stripe is still loading. Please wait a moment and try again.'); | |
| 423 | + this.displayError(__('Stripe is still loading. Please wait a moment and try again.', 'yatra')); | |
| 416 | 424 | return false; |
| 417 | 425 | } |
| 418 | 426 | |
| 419 | 427 | if (!this.originalButtonHtml && event?.detail?.originalBtnHtml) { |
| @@ -450,13 +458,13 @@ | ||
| 450 | 458 | currency: bookingInfo.currency || bookingData.currency || 'USD' |
| 451 | 459 | }); |
| 452 | 460 | window.location.href = this.buildConfirmationUrlFromBookingInfo(bookingInfo); |
| 453 | 461 | } else { |
| 454 | - throw new Error('Payment processing was not completed. Please check your payment details and try again.'); | |
| 462 | + throw new Error(__('Payment processing was not completed. Please check your payment details and try again.', 'yatra')); | |
| 455 | 463 | } |
| 456 | 464 | } catch (error) { |
| 457 | 465 | console.error('Stripe payment error:', error); |
| 458 | - this.displayError(error.message || 'Payment failed. Please try again or use another payment method.'); | |
| 466 | + this.displayError(error.message || __('Payment failed. Please try again or use another payment method.', 'yatra')); | |
| 459 | 467 | this.setLoadingState(false); |
| 460 | 468 | } |
| 461 | 469 | |
| 462 | 470 | return false; |
| @@ -636,8 +644,13 @@ | ||
| 636 | 644 | icon: this.getMethodIconMarkup('apple_pay') |
| 637 | 645 | }); |
| 638 | 646 | } |
| 639 | 647 | |
| 648 | + // Only a real choice (Card + a wallet) warrants the chooser. With Card | |
| 649 | + // as the only enabled method, the render step drops this switcher and | |
| 650 | + // shows the card field on its own (no redundant "Payment methods" row). | |
| 651 | + this.hasMultipleMethods = availableButtons.length > 1; | |
| 652 | + | |
| 640 | 653 | const buttons = document.createElement('div'); |
| 641 | 654 | buttons.className = 'yatra-method-switcher__buttons'; |
| 642 | 655 | |
| 643 | 656 | availableButtons.forEach((method) => { |
| @@ -847,9 +860,9 @@ | ||
| 847 | 860 | async handlePaymentRequestPayment(event) { |
| 848 | 861 | try { |
| 849 | 862 | const bookingData = this.collectBookingData(); |
| 850 | 863 | if (!bookingData) { |
| 851 | - throw new Error('Please complete the booking form before using Apple Pay or Google Pay.'); | |
| 864 | + throw new Error(__('Please complete the booking form before using Apple Pay or Google Pay.', 'yatra')); | |
| 852 | 865 | } |
| 853 | 866 | |
| 854 | 867 | this.setLoadingState(true); |
| 855 | 868 | const paymentContext = await this.prepareStripePayment(bookingData, this.paymentForm); |
| @@ -886,20 +899,58 @@ | ||
| 886 | 899 | console.error('Stripe payment request error:', error); |
| 887 | 900 | if (event && typeof event.complete === 'function') { |
| 888 | 901 | event.complete('fail'); |
| 889 | 902 | } |
| 890 | - this.displayError(error.message || 'Payment failed. Please try again or use another payment method.'); | |
| 903 | + this.displayError(error.message || __('Payment failed. Please try again or use another payment method.', 'yatra')); | |
| 891 | 904 | this.setLoadingState(false); |
| 892 | 905 | } |
| 893 | 906 | } |
| 894 | 907 | |
| 895 | 908 | async prepareStripePayment(bookingData, formElement) { |
| 909 | + // /booking/create requires BOTH the standard WP REST nonce | |
| 910 | + // (X-WP-Nonce) AND a booking-scoped action nonce | |
| 911 | + // (X-Yatra-Booking-Nonce). The booking-scoped one is what | |
| 912 | + // gates the actual write; the endpoint's public | |
| 913 | + // permission_callback intentionally bypasses WP's default | |
| 914 | + // cookie/nonce check so guest checkouts work, and falls back | |
| 915 | + // to the booking-scoped nonce as the real CSRF defence. | |
| 916 | + // Missing it returns { code: 'invalid_nonce' } 403. | |
| 917 | + const bookingNonce = | |
| 918 | + (typeof window !== 'undefined' && window.yatraBookingData && window.yatraBookingData.bookingNonce) | |
| 919 | + || (typeof document !== 'undefined' | |
| 920 | + ? (document.querySelector('input[name="yatra_booking_nonce"]') || {}).value | |
| 921 | + : '') | |
| 922 | + || ''; | |
| 923 | + | |
| 924 | + | |
| 925 | + // reCAPTCHA v3: this gateway posts to /booking/create itself, so it must | |
| 926 | + // attach its own token — the shared submit path in booking.js never runs | |
| 927 | + // for an intercepted gateway submit. Without this the server receives an | |
| 928 | + // empty token and rejects the booking with "reCAPTCHA verification | |
| 929 | + // failed", which no score threshold can get past. | |
| 930 | + // | |
| 931 | + // A fresh token every attempt: v3 tokens are single-use and expire after | |
| 932 | + // ~2 minutes, so a retry after a declined card must not reuse the old one. | |
| 933 | + try { | |
| 934 | + const yatraRc = (typeof window !== 'undefined') ? window.yatraRecaptcha : null; | |
| 935 | + if (yatraRc && typeof yatraRc.protects === 'function' && yatraRc.protects('booking') | |
| 936 | + && typeof yatraRc.execute === 'function') { | |
| 937 | + const recaptchaToken = await yatraRc.execute('booking'); | |
| 938 | + if (recaptchaToken) { | |
| 939 | + bookingData.recaptcha_token = recaptchaToken; | |
| 940 | + } | |
| 941 | + } | |
| 942 | + } catch (e) { | |
| 943 | + // Never block checkout on the helper itself; the server still decides. | |
| 944 | + } | |
| 945 | + | |
| 896 | 946 | // Always call the same endpoint - server decides based on session type |
| 897 | 947 | const bookingResponse = await fetch(`${this.apiUrl}/booking/create`, { |
| 898 | 948 | method: 'POST', |
| 899 | 949 | headers: { |
| 900 | 950 | 'Content-Type': 'application/json', |
| 901 | - 'X-WP-Nonce': this.nonce | |
| 951 | + 'X-WP-Nonce': this.nonce, | |
| 952 | + 'X-Yatra-Booking-Nonce': bookingNonce | |
| 902 | 953 | }, |
| 903 | 954 | body: JSON.stringify(bookingData) |
| 904 | 955 | }); |
| 905 | 956 | |
| @@ -905,9 +956,9 @@ | ||
| 905 | 956 | |
| 906 | 957 | const bookingResult = await bookingResponse.json(); |
| 907 | 958 | |
| 908 | 959 | if (!bookingResult.success) { |
| 909 | - throw new Error(bookingResult.message || 'Failed to process request. Please try again.'); | |
| 960 | + throw new Error(bookingResult.message || __('Failed to process request. Please try again.', 'yatra')); | |
| 910 | 961 | } |
| 911 | 962 | |
| 912 | 963 | const bookingInfo = bookingResult.data; |
| 913 | 964 | const effectiveAmount = this.getEffectiveAmount(bookingData, bookingInfo); |
| @@ -919,9 +970,9 @@ | ||
| 919 | 970 | .filter(Boolean) |
| 920 | 971 | .join(' ') || bookingData.full_name; |
| 921 | 972 | |
| 922 | 973 | if (!customerEmail) { |
| 923 | - throw new Error('Email address is required.'); | |
| 974 | + throw new Error(__('Email address is required.', 'yatra')); | |
| 924 | 975 | } |
| 925 | 976 | |
| 926 | 977 | const billingDetails = this.getBillingDetails(bookingData, formElement); |
| 927 | 978 | |
| @@ -939,9 +990,9 @@ | ||
| 939 | 990 | .filter(Boolean) |
| 940 | 991 | .join(' ') || bookingData.full_name; |
| 941 | 992 | |
| 942 | 993 | if (!customerEmail) { |
| 943 | - throw new Error('Email address is required.'); | |
| 994 | + throw new Error(__('Email address is required.', 'yatra')); | |
| 944 | 995 | } |
| 945 | 996 | |
| 946 | 997 | const paymentIntentResponse = await fetch(`${this.apiUrl}/payment/create-intent`, { |
| 947 | 998 | method: 'POST', |
| @@ -963,14 +1014,14 @@ | ||
| 963 | 1014 | }); |
| 964 | 1015 | |
| 965 | 1016 | const intentPayload = await paymentIntentResponse.json(); |
| 966 | 1017 | if (!paymentIntentResponse.ok) { |
| 967 | - throw new Error(intentPayload?.message || 'Unable to process payment. Please try again.'); | |
| 1018 | + throw new Error(intentPayload?.message || __('Unable to process payment. Please try again.', 'yatra')); | |
| 968 | 1019 | } |
| 969 | 1020 | const clientSecret = intentPayload.client_secret; |
| 970 | 1021 | |
| 971 | 1022 | if (!clientSecret) { |
| 972 | - throw new Error(intentPayload?.error?.message || 'Unable to process payment. Please try again.'); | |
| 1023 | + throw new Error(intentPayload?.error?.message || __('Unable to process payment. Please try again.', 'yatra')); | |
| 973 | 1024 | } |
| 974 | 1025 | |
| 975 | 1026 | const billingDetails = this.getBillingDetails(bookingData, formElement); |
| 976 | 1027 | |
| @@ -1107,9 +1158,12 @@ | ||
| 1107 | 1158 | } |
| 1108 | 1159 | |
| 1109 | 1160 | if (isLoading) { |
| 1110 | 1161 | this.submitButton.disabled = true; |
| 1111 | - this.submitButton.innerHTML = '<span class="yatra-spinner"></span> Processing Payment...'; | |
| 1162 | + // Spinner markup is fixed; only the user-visible text is | |
| 1163 | + // wrapped so it ends up in the .pot with a clean msgid. | |
| 1164 | + this.submitButton.innerHTML = | |
| 1165 | + '<span class="yatra-spinner"></span> ' + __('Processing Payment...', 'yatra'); | |
| 1112 | 1166 | } else { |
| 1113 | 1167 | this.submitButton.disabled = false; |
| 1114 | 1168 | this.submitButton.innerHTML = this.originalButtonHtml; |
| 1115 | 1169 | } |
| @@ -1228,9 +1282,9 @@ | ||
| 1228 | 1282 | }); |
| 1229 | 1283 | |
| 1230 | 1284 | const payload = await response.json(); |
| 1231 | 1285 | if (!response.ok || payload?.success === false) { |
| 1232 | - throw new Error(payload?.message || 'Failed to record payment.'); | |
| 1286 | + throw new Error(payload?.message || __('Failed to record payment.', 'yatra')); | |
| 1233 | 1287 | } |
| 1234 | 1288 | |
| 1235 | 1289 | return payload; |
| 1236 | 1290 | } catch (error) { |