| @@ -163,12 +163,28 @@ | ||
| 163 | 163 | const btn = document.getElementById('stripe-submit-btn'); |
| 164 | 164 | btn.disabled = true; |
| 165 | 165 | btn.textContent = __('Processing...', 'yatra'); |
| 166 | 166 | |
| 167 | + // Append `stripe=success` to the gateway's return URL using proper URL | |
| 168 | + // composition. The naive `+ '?stripe=success'` form breaks when | |
| 169 | + // confirmation_url already carries query params (e.g. `?balance=paid` | |
| 170 | + // from a remaining-balance flow) — two question marks produces an | |
| 171 | + // invalid URL and browsers drop the second segment, losing the flag. | |
| 172 | + let confirmReturnUrl = data.confirmation_url; | |
| 173 | + try { | |
| 174 | + const u = new URL(confirmReturnUrl, window.location.origin); | |
| 175 | + u.searchParams.set('stripe', 'success'); | |
| 176 | + confirmReturnUrl = u.toString(); | |
| 177 | + } catch (_e) { | |
| 178 | + // Fallback for malformed bases (shouldn't happen in practice) | |
| 179 | + const join = confirmReturnUrl.includes('?') ? '&' : '?'; | |
| 180 | + confirmReturnUrl = `${confirmReturnUrl}${join}stripe=success`; | |
| 181 | + } | |
| 182 | + | |
| 167 | 183 | const { error } = await this.stripe.confirmPayment({ |
| 168 | 184 | elements, |
| 169 | 185 | confirmParams: { |
| 170 | - return_url: data.confirmation_url + '?stripe=success', | |
| 186 | + return_url: confirmReturnUrl, | |
| 171 | 187 | }, |
| 172 | 188 | }); |
| 173 | 189 | |
| 174 | 190 | if (error) { |
| @@ -219,19 +235,27 @@ | ||
| 219 | 235 | const stripePreselected = !!stripeOption.checked; |
| 220 | 236 | container.style.display = stripePreselected ? 'block' : 'none'; |
| 221 | 237 | |
| 222 | 238 | this.methodSwitcher = this.createMethodSwitcher(); |
| 223 | - 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 | + } | |
| 224 | 247 | |
| 225 | 248 | if (this.supportsPaymentRequest) { |
| 226 | 249 | const requestWrapper = document.createElement('div'); |
| 227 | 250 | requestWrapper.className = 'yatra-stripe-payment-request-wrapper'; |
| 228 | - 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'); | |
| 229 | 253 | requestWrapper.innerHTML = ` |
| 230 | 254 | <div class="yatra-wallet-header"> |
| 231 | 255 | <div> |
| 232 | 256 | <p class="yatra-wallet-eyebrow">${__('One-tap checkout', 'yatra')}</p> |
| 233 | - <h4 class="yatra-wallet-title">Apple Pay & Google Pay</h4> | |
| 257 | + <h4 class="yatra-wallet-title">${walletTitle}</h4> | |
| 234 | 258 | </div> |
| 235 | 259 | <div class="yatra-wallet-badges"> |
| 236 | 260 | <span class="wallet-badge wallet-badge--apple"> Pay</span> |
| 237 | 261 | <span class="wallet-badge wallet-badge--google">G Pay</span> |
| @@ -305,9 +329,9 @@ | ||
| 305 | 329 | initializeStripe() { |
| 306 | 330 | const publishableKey = window.yatraBookingData?.stripe?.publishableKey |
| 307 | 331 | || window.yatraBookingData?.stripe?.publishable_key; |
| 308 | 332 | if (!publishableKey) { |
| 309 | - this.displayError('Stripe publishable key is missing. Please contact support.'); | |
| 333 | + this.displayError(__('Stripe publishable key is missing. Please contact support.', 'yatra')); | |
| 310 | 334 | return; |
| 311 | 335 | } |
| 312 | 336 | |
| 313 | 337 | if (typeof Stripe === 'undefined') { |
| @@ -314,9 +338,9 @@ | ||
| 314 | 338 | const script = document.createElement('script'); |
| 315 | 339 | script.src = 'https://js.stripe.com/v3/'; |
| 316 | 340 | script.onload = () => this.setupStripeElements(publishableKey); |
| 317 | 341 | script.onerror = () => { |
| 318 | - 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')); | |
| 319 | 343 | }; |
| 320 | 344 | document.head.appendChild(script); |
| 321 | 345 | } else { |
| 322 | 346 | this.setupStripeElements(publishableKey); |
| @@ -365,9 +389,9 @@ | ||
| 365 | 389 | stripeContainer.style.display = 'block'; |
| 366 | 390 | } |
| 367 | 391 | } catch (error) { |
| 368 | 392 | console.error('Error initializing Stripe:', error); |
| 369 | - 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')); | |
| 370 | 394 | } |
| 371 | 395 | } |
| 372 | 396 | |
| 373 | 397 | async handleGatewayIntercept(event) { |
| @@ -383,21 +407,21 @@ | ||
| 383 | 407 | return false; |
| 384 | 408 | } |
| 385 | 409 | |
| 386 | 410 | if (!this.supportsCard) { |
| 387 | - 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')); | |
| 388 | 412 | return false; |
| 389 | 413 | } |
| 390 | 414 | |
| 391 | 415 | const bookingData = event?.detail?.bookingData || this.collectBookingData(); |
| 392 | 416 | if (!bookingData) { |
| 393 | - this.displayError('Please complete the booking form before continuing.'); | |
| 417 | + this.displayError(__('Please complete the booking form before continuing.', 'yatra')); | |
| 394 | 418 | return false; |
| 395 | 419 | } |
| 396 | 420 | |
| 397 | 421 | if (!this.stripe || !this.cardElement) { |
| 398 | 422 | this.initializeStripe(); |
| 399 | - 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')); | |
| 400 | 424 | return false; |
| 401 | 425 | } |
| 402 | 426 | |
| 403 | 427 | if (!this.originalButtonHtml && event?.detail?.originalBtnHtml) { |
| @@ -434,13 +458,13 @@ | ||
| 434 | 458 | currency: bookingInfo.currency || bookingData.currency || 'USD' |
| 435 | 459 | }); |
| 436 | 460 | window.location.href = this.buildConfirmationUrlFromBookingInfo(bookingInfo); |
| 437 | 461 | } else { |
| 438 | - 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')); | |
| 439 | 463 | } |
| 440 | 464 | } catch (error) { |
| 441 | 465 | console.error('Stripe payment error:', error); |
| 442 | - 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')); | |
| 443 | 467 | this.setLoadingState(false); |
| 444 | 468 | } |
| 445 | 469 | |
| 446 | 470 | return false; |
| @@ -620,8 +644,13 @@ | ||
| 620 | 644 | icon: this.getMethodIconMarkup('apple_pay') |
| 621 | 645 | }); |
| 622 | 646 | } |
| 623 | 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 | + | |
| 624 | 653 | const buttons = document.createElement('div'); |
| 625 | 654 | buttons.className = 'yatra-method-switcher__buttons'; |
| 626 | 655 | |
| 627 | 656 | availableButtons.forEach((method) => { |
| @@ -831,9 +860,9 @@ | ||
| 831 | 860 | async handlePaymentRequestPayment(event) { |
| 832 | 861 | try { |
| 833 | 862 | const bookingData = this.collectBookingData(); |
| 834 | 863 | if (!bookingData) { |
| 835 | - 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')); | |
| 836 | 865 | } |
| 837 | 866 | |
| 838 | 867 | this.setLoadingState(true); |
| 839 | 868 | const paymentContext = await this.prepareStripePayment(bookingData, this.paymentForm); |
| @@ -870,20 +899,58 @@ | ||
| 870 | 899 | console.error('Stripe payment request error:', error); |
| 871 | 900 | if (event && typeof event.complete === 'function') { |
| 872 | 901 | event.complete('fail'); |
| 873 | 902 | } |
| 874 | - 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')); | |
| 875 | 904 | this.setLoadingState(false); |
| 876 | 905 | } |
| 877 | 906 | } |
| 878 | 907 | |
| 879 | 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 | + | |
| 880 | 946 | // Always call the same endpoint - server decides based on session type |
| 881 | 947 | const bookingResponse = await fetch(`${this.apiUrl}/booking/create`, { |
| 882 | 948 | method: 'POST', |
| 883 | 949 | headers: { |
| 884 | 950 | 'Content-Type': 'application/json', |
| 885 | - 'X-WP-Nonce': this.nonce | |
| 951 | + 'X-WP-Nonce': this.nonce, | |
| 952 | + 'X-Yatra-Booking-Nonce': bookingNonce | |
| 886 | 953 | }, |
| 887 | 954 | body: JSON.stringify(bookingData) |
| 888 | 955 | }); |
| 889 | 956 | |
| @@ -889,9 +956,9 @@ | ||
| 889 | 956 | |
| 890 | 957 | const bookingResult = await bookingResponse.json(); |
| 891 | 958 | |
| 892 | 959 | if (!bookingResult.success) { |
| 893 | - 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')); | |
| 894 | 961 | } |
| 895 | 962 | |
| 896 | 963 | const bookingInfo = bookingResult.data; |
| 897 | 964 | const effectiveAmount = this.getEffectiveAmount(bookingData, bookingInfo); |
| @@ -903,9 +970,9 @@ | ||
| 903 | 970 | .filter(Boolean) |
| 904 | 971 | .join(' ') || bookingData.full_name; |
| 905 | 972 | |
| 906 | 973 | if (!customerEmail) { |
| 907 | - throw new Error('Email address is required.'); | |
| 974 | + throw new Error(__('Email address is required.', 'yatra')); | |
| 908 | 975 | } |
| 909 | 976 | |
| 910 | 977 | const billingDetails = this.getBillingDetails(bookingData, formElement); |
| 911 | 978 | |
| @@ -923,9 +990,9 @@ | ||
| 923 | 990 | .filter(Boolean) |
| 924 | 991 | .join(' ') || bookingData.full_name; |
| 925 | 992 | |
| 926 | 993 | if (!customerEmail) { |
| 927 | - throw new Error('Email address is required.'); | |
| 994 | + throw new Error(__('Email address is required.', 'yatra')); | |
| 928 | 995 | } |
| 929 | 996 | |
| 930 | 997 | const paymentIntentResponse = await fetch(`${this.apiUrl}/payment/create-intent`, { |
| 931 | 998 | method: 'POST', |
| @@ -947,14 +1014,14 @@ | ||
| 947 | 1014 | }); |
| 948 | 1015 | |
| 949 | 1016 | const intentPayload = await paymentIntentResponse.json(); |
| 950 | 1017 | if (!paymentIntentResponse.ok) { |
| 951 | - 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')); | |
| 952 | 1019 | } |
| 953 | 1020 | const clientSecret = intentPayload.client_secret; |
| 954 | 1021 | |
| 955 | 1022 | if (!clientSecret) { |
| 956 | - 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')); | |
| 957 | 1024 | } |
| 958 | 1025 | |
| 959 | 1026 | const billingDetails = this.getBillingDetails(bookingData, formElement); |
| 960 | 1027 | |
| @@ -1091,9 +1158,12 @@ | ||
| 1091 | 1158 | } |
| 1092 | 1159 | |
| 1093 | 1160 | if (isLoading) { |
| 1094 | 1161 | this.submitButton.disabled = true; |
| 1095 | - 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'); | |
| 1096 | 1166 | } else { |
| 1097 | 1167 | this.submitButton.disabled = false; |
| 1098 | 1168 | this.submitButton.innerHTML = this.originalButtonHtml; |
| 1099 | 1169 | } |
| @@ -1212,9 +1282,9 @@ | ||
| 1212 | 1282 | }); |
| 1213 | 1283 | |
| 1214 | 1284 | const payload = await response.json(); |
| 1215 | 1285 | if (!response.ok || payload?.success === false) { |
| 1216 | - throw new Error(payload?.message || 'Failed to record payment.'); | |
| 1286 | + throw new Error(payload?.message || __('Failed to record payment.', 'yatra')); | |
| 1217 | 1287 | } |
| 1218 | 1288 | |
| 1219 | 1289 | return payload; |
| 1220 | 1290 | } catch (error) { |