minified
5 days ago
payment-history.js
3 weeks ago
payment-manager.js
3 months ago
stripe-payment.js
2 months ago
stripe-payment.js
1657 lines
| 1 | /** |
| 2 | * SureForms Stripe Payment Integration |
| 3 | * |
| 4 | * @since 2.0.0 |
| 5 | */ |
| 6 | /* global Stripe, srfm_ajax */ |
| 7 | |
| 8 | /** |
| 9 | * Get composite key for a payment block within a specific form instance. |
| 10 | * Supports multiple embeds of the same form on one page. |
| 11 | * |
| 12 | * @param {HTMLElement} form - The form element. |
| 13 | * @param {string} blockId - The block ID from data-block-id attribute. |
| 14 | * @return {string} Composite key in format "instanceId-blockId", or plain blockId as fallback. |
| 15 | */ |
| 16 | function getPaymentKey( form, blockId ) { |
| 17 | const instanceId = form.getAttribute( 'data-srfm-instance' ); |
| 18 | return instanceId ? `${ instanceId }-${ blockId }` : blockId; |
| 19 | } |
| 20 | window.srfmGetPaymentKey = getPaymentKey; |
| 21 | |
| 22 | class StripePayment { |
| 23 | // Store Stripe instances |
| 24 | static stripeInstances = {}; |
| 25 | static paymentElements = {}; |
| 26 | static paymentIntents = {}; |
| 27 | static subscriptionIntents = {}; |
| 28 | // BOTH MODE: per-block guard so rapid type-flips don't race two |
| 29 | // Stripe.elements() instances on the same DOM container. Keyed by |
| 30 | // compositeKey, cleared in the 'ready' handler of the new element. |
| 31 | static reinitInProgress = new Set(); |
| 32 | |
| 33 | // Initialize on page load |
| 34 | static { |
| 35 | window.srfmPaymentElements = this.paymentElements; |
| 36 | } |
| 37 | |
| 38 | /** |
| 39 | * Constructor for the Calculations class. |
| 40 | * @param {HTMLElement} form - The form element containing calculation fields. |
| 41 | */ |
| 42 | constructor( form ) { |
| 43 | this.form = form; |
| 44 | // Find all payment blocks within the form. |
| 45 | const getPaymentFields = this.form.querySelectorAll( |
| 46 | '.srfm-block.srfm-payment-block' |
| 47 | ); |
| 48 | |
| 49 | // Initialize Stripe payment for each payment field. |
| 50 | getPaymentFields.forEach( ( field ) => { |
| 51 | this.processPayment( field ); |
| 52 | } ); |
| 53 | } |
| 54 | |
| 55 | /** |
| 56 | * Create payment or subscription intent during form submission. |
| 57 | * Unified function that handles both payment types based on the paymentType parameter. |
| 58 | * |
| 59 | * @param {string} blockId - Block ID (original, for server API). |
| 60 | * @param {string} compositeKey - Composite key (instanceId-blockId, for client-side maps). |
| 61 | * @param {number} amount - The amount for the payment/subscription. |
| 62 | * @param {HTMLElement} paymentInput - The payment input element. |
| 63 | * @param {string} paymentType - Payment type: 'one-time' or 'subscription'. |
| 64 | * @return {Promise<Object>} Resolves with payment/subscription data. |
| 65 | */ |
| 66 | async createPaymentIntentOnSubmission( |
| 67 | blockId, |
| 68 | compositeKey, |
| 69 | amount, |
| 70 | paymentInput, |
| 71 | paymentType = 'one-time' |
| 72 | ) { |
| 73 | // Setup |
| 74 | const isSubscription = paymentType === 'subscription'; |
| 75 | const customerData = this.extractCustomerData( paymentInput ); |
| 76 | |
| 77 | // Extract common data |
| 78 | const currency = paymentInput.dataset.currency || 'usd'; |
| 79 | const description = |
| 80 | paymentInput.dataset.description || |
| 81 | ( isSubscription |
| 82 | ? PAYMENT_UTILITY.getStripeStrings( |
| 83 | 'sureforms_subscription', |
| 84 | 'SureForms Subscription' |
| 85 | ) |
| 86 | : PAYMENT_UTILITY.getStripeStrings( |
| 87 | 'sureforms_payment', |
| 88 | 'SureForms Payment' |
| 89 | ) ); |
| 90 | |
| 91 | // Build FormData |
| 92 | const data = new FormData(); |
| 93 | data.append( |
| 94 | 'action', |
| 95 | isSubscription |
| 96 | ? 'srfm_create_subscription_intent' |
| 97 | : 'srfm_create_payment_intent' |
| 98 | ); |
| 99 | // Read submit token from form element for server-side verification. |
| 100 | const formElement = paymentInput.closest( 'form' ); |
| 101 | data.append( 'token', formElement?.getAttribute( 'data-submit-token' ) || '' ); |
| 102 | // Handle zero-decimal currencies (JPY, KRW, etc.) - don't multiply by 100 |
| 103 | const formattedAmount = |
| 104 | window?.srfmStripe?.zeroDecimalCurrencies?.includes( |
| 105 | currency.toUpperCase() |
| 106 | ) |
| 107 | ? parseInt( amount ) |
| 108 | : parseInt( amount * 100 ); |
| 109 | data.append( 'amount', formattedAmount ); |
| 110 | data.append( 'currency', currency ); |
| 111 | data.append( 'description', description ); |
| 112 | data.append( 'block_id', blockId ); |
| 113 | data.append( 'customer_email', customerData.email ); |
| 114 | data.append( 'customer_name', customerData.name ); |
| 115 | const formIdInput = formElement?.querySelector( |
| 116 | 'input[name="form-id"]' |
| 117 | ); |
| 118 | if ( formIdInput?.value ) { |
| 119 | data.append( 'form_id', formIdInput.value ); |
| 120 | } |
| 121 | |
| 122 | // Add subscription-specific data |
| 123 | if ( isSubscription ) { |
| 124 | data.append( 'interval', customerData.interval ); |
| 125 | data.append( 'plan_name', customerData.planName ); |
| 126 | } |
| 127 | |
| 128 | // Make API call |
| 129 | try { |
| 130 | const response = await fetch( srfm_ajax.ajax_url, { |
| 131 | method: 'POST', |
| 132 | body: data, |
| 133 | } ); |
| 134 | |
| 135 | const responseData = await response.json(); |
| 136 | |
| 137 | // Handle success |
| 138 | if ( responseData.success ) { |
| 139 | const clientSecret = responseData.data.client_secret; |
| 140 | const paymentIntentId = responseData.data.payment_intent_id; |
| 141 | const customerId = responseData?.data?.customer_id || null; |
| 142 | |
| 143 | // Store payment/subscription data |
| 144 | if ( isSubscription ) { |
| 145 | const subscriptionId = responseData.data.subscription_id; |
| 146 | |
| 147 | StripePayment.subscriptionIntents[ compositeKey ] = { |
| 148 | subscriptionId, |
| 149 | customerId: customerId || null, |
| 150 | paymentIntentId, |
| 151 | amount, |
| 152 | interval: customerData.interval, |
| 153 | }; |
| 154 | } else { |
| 155 | StripePayment.paymentIntents[ compositeKey ] = { |
| 156 | paymentIntentId, |
| 157 | customerId: customerId || null, |
| 158 | }; |
| 159 | } |
| 160 | |
| 161 | // Update elements with client secret |
| 162 | const elementData = |
| 163 | StripePayment.paymentElements[ compositeKey ]; |
| 164 | if ( elementData ) { |
| 165 | // CRITICAL: Store client secret WITHOUT calling elements.update() |
| 166 | // This preserves user-entered card data |
| 167 | elementData.clientSecret = clientSecret; |
| 168 | } |
| 169 | |
| 170 | return { valid: true }; |
| 171 | } |
| 172 | |
| 173 | // Handle failure |
| 174 | return { |
| 175 | valid: false, |
| 176 | message: |
| 177 | responseData.data?.message || |
| 178 | responseData.data || |
| 179 | PAYMENT_UTILITY.getStripeStrings( |
| 180 | 'payment_unavailable', |
| 181 | 'Payment is currently unavailable. Please contact the site administrator.' |
| 182 | ), |
| 183 | }; |
| 184 | } catch ( error ) { |
| 185 | return { |
| 186 | valid: false, |
| 187 | message: |
| 188 | error.message || |
| 189 | PAYMENT_UTILITY.getStripeStrings( |
| 190 | 'payment_unavailable', |
| 191 | 'Payment is currently unavailable. Please contact the site administrator.' |
| 192 | ), |
| 193 | }; |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | processPayment( field ) { |
| 198 | const paymentInput = field.querySelector( 'input.srfm-payment-input' ); |
| 199 | |
| 200 | if ( ! paymentInput ) { |
| 201 | return; |
| 202 | } |
| 203 | |
| 204 | const blockId = field.getAttribute( 'data-block-id' ); |
| 205 | const compositeKey = getPaymentKey( this.form, blockId ); |
| 206 | // Check payment type from data attribute |
| 207 | const paymentType = |
| 208 | paymentInput.getAttribute( 'data-payment-type' ) || 'one-time'; |
| 209 | |
| 210 | // Initialize Stripe elements using unified function |
| 211 | this.initializePaymentElements( |
| 212 | compositeKey, |
| 213 | paymentInput, |
| 214 | paymentType |
| 215 | ); |
| 216 | } |
| 217 | |
| 218 | /** |
| 219 | * Initialize Stripe elements for one-time payments or subscriptions. |
| 220 | * Unified function that handles both payment types based on the paymentType parameter. |
| 221 | * |
| 222 | * @param {string} compositeKey - Composite key (instanceId-blockId) for client-side maps. |
| 223 | * @param {HTMLElement} paymentInput - The payment input element. |
| 224 | * @param {string} paymentType - Payment type: 'one-time' or 'subscription'. |
| 225 | * @return {void} This function does not return a value. |
| 226 | */ |
| 227 | initializePaymentElements( |
| 228 | compositeKey, |
| 229 | paymentInput, |
| 230 | paymentType = 'one-time' |
| 231 | ) { |
| 232 | // CRITICAL: Check if elements already exist to prevent re-initialization |
| 233 | // Re-mounting elements destroys user-entered card data |
| 234 | if ( StripePayment.paymentElements[ compositeKey ] ) { |
| 235 | return; |
| 236 | } |
| 237 | |
| 238 | const stripeKey = paymentInput.dataset.stripeKey; |
| 239 | |
| 240 | if ( ! stripeKey ) { |
| 241 | return; |
| 242 | } |
| 243 | |
| 244 | const elementContainer = paymentInput |
| 245 | .closest( '.srfm-block' ) |
| 246 | .querySelector( '.srfm-stripe-payment-element' ); |
| 247 | |
| 248 | if ( ! elementContainer ) { |
| 249 | return; |
| 250 | } |
| 251 | |
| 252 | // Initialize Stripe |
| 253 | if ( ! StripePayment.stripeInstances[ compositeKey ] ) { |
| 254 | StripePayment.stripeInstances[ compositeKey ] = Stripe( stripeKey ); |
| 255 | } |
| 256 | |
| 257 | const stripe = StripePayment.stripeInstances[ compositeKey ]; |
| 258 | |
| 259 | // Build elements configuration based on payment type |
| 260 | const elementsConfig = { |
| 261 | mode: paymentType === 'subscription' ? 'subscription' : 'payment', |
| 262 | currency: paymentInput.dataset.currency || 'usd', |
| 263 | amount: 12000, |
| 264 | appearance: { |
| 265 | theme: 'stripe', |
| 266 | variables: { |
| 267 | colorPrimary: '#0073aa', |
| 268 | colorBackground: '#ffffff', |
| 269 | colorText: '#424242', |
| 270 | colorDanger: '#df1b41', |
| 271 | spacingUnit: '4px', |
| 272 | borderRadius: '4px', |
| 273 | fontFamily: '"Manrope", sans-serif', |
| 274 | }, |
| 275 | }, |
| 276 | fields: { |
| 277 | billingDetails: { |
| 278 | email: 'auto', // � |
| 279 | Email + Link enabled |
| 280 | }, |
| 281 | }, |
| 282 | }; |
| 283 | |
| 284 | // Add type-specific configuration |
| 285 | if ( paymentType === 'one-time' ) { |
| 286 | elementsConfig.captureMethod = 'manual'; |
| 287 | // Manual capture is incompatible with some account-enabled payment methods |
| 288 | // (e.g. Bacs Direct Debit, Link, Cash App, BNPL). When any of those are enabled, |
| 289 | // Stripe rejects the deferred elements/sessions request with HTTP 400 and the |
| 290 | // Payment Element fails to render — which is why the card field does not load in |
| 291 | // live mode while test mode (card-only) works. Scope the element to card so only |
| 292 | // capture-compatible methods are offered. Apple Pay / Google Pay still appear |
| 293 | // (they are surfaced through `card`); the methods dropped here could never be used |
| 294 | // with manual capture anyway, so no working checkout is lost. |
| 295 | elementsConfig.paymentMethodTypes = [ 'card' ]; |
| 296 | } |
| 297 | |
| 298 | // Create and mount payment element |
| 299 | const elements = stripe.elements( elementsConfig ); |
| 300 | const paymentElement = elements.create( 'payment' ); |
| 301 | paymentElement.mount( elementContainer ); |
| 302 | |
| 303 | // Store references (structure varies by payment type) |
| 304 | const storedData = { |
| 305 | stripe, |
| 306 | elements, |
| 307 | paymentElement, |
| 308 | clientSecret: null, // Will be set when payment/subscription intent is created |
| 309 | paymentType, |
| 310 | }; |
| 311 | |
| 312 | StripePayment.paymentElements[ compositeKey ] = storedData; |
| 313 | |
| 314 | // Update window object |
| 315 | window.srfmPaymentElements = StripePayment.paymentElements; |
| 316 | |
| 317 | // Setup event handlers |
| 318 | this.setupPaymentElementEvents( paymentElement, compositeKey ); |
| 319 | } |
| 320 | |
| 321 | /** |
| 322 | * Setup event handlers for payment element. |
| 323 | * |
| 324 | * @param {Object} paymentElement - The Stripe payment element. |
| 325 | * @param {string} compositeKey - Composite key (instanceId-blockId) used to clear the reinit-in-flight flag. |
| 326 | * @return {void} This function does not return a value. |
| 327 | */ |
| 328 | setupPaymentElementEvents( paymentElement, compositeKey ) { |
| 329 | // Ready event — clears the reinit-in-flight flag so subsequent type |
| 330 | // flips can proceed. Without this, a stale flag would block all future |
| 331 | // reinits for this block. |
| 332 | paymentElement.on( 'ready', () => { |
| 333 | if ( compositeKey ) { |
| 334 | StripePayment.reinitInProgress.delete( compositeKey ); |
| 335 | } |
| 336 | } ); |
| 337 | } |
| 338 | |
| 339 | /** |
| 340 | * Get payment amount based on amount type (fixed or user-defined). |
| 341 | * |
| 342 | * @param {HTMLElement} paymentInput - The payment input element. |
| 343 | * @return {number|false} The payment amount in dollars, or false if invalid. |
| 344 | */ |
| 345 | static getPaymentAmount( paymentInput ) { |
| 346 | const amountType = paymentInput.dataset.amountType || 'fixed'; |
| 347 | let amount = 0; |
| 348 | |
| 349 | if ( amountType === 'fixed' ) { |
| 350 | // Get fixed amount from data attribute |
| 351 | amount = parseFloat( paymentInput.dataset.fixedAmount || 0 ); |
| 352 | } else { |
| 353 | // Get the format type for dynamic amounts |
| 354 | const formatType = |
| 355 | paymentInput.getAttribute( |
| 356 | 'data-dynamic-amount-format-type' |
| 357 | ) || 'us-style'; |
| 358 | const rawAmount = paymentInput.dataset.currentAmount || 0; |
| 359 | |
| 360 | // Normalize the amount based on format type |
| 361 | amount = StripePayment.normalizeAmount( rawAmount, formatType ); |
| 362 | } |
| 363 | |
| 364 | // Validate the amount - must be valid, not negative, and greater than 0 |
| 365 | if ( isNaN( amount ) ) { |
| 366 | return false; |
| 367 | } |
| 368 | |
| 369 | if ( amount < 0 ) { |
| 370 | return false; |
| 371 | } |
| 372 | |
| 373 | if ( amount <= 0 ) { |
| 374 | return false; |
| 375 | } |
| 376 | |
| 377 | // Additional validation using existing method |
| 378 | if ( ! StripePayment.validatePaymentAmount( amount ) ) { |
| 379 | return false; |
| 380 | } |
| 381 | |
| 382 | return amount; |
| 383 | } |
| 384 | |
| 385 | /** |
| 386 | * Extract customer data from form fields or use dummy data. |
| 387 | * |
| 388 | * @param {HTMLElement} paymentInput - The payment input element. |
| 389 | * @return {Object} An object containing name, email, interval, and planName. |
| 390 | */ |
| 391 | extractCustomerData( paymentInput ) { |
| 392 | const form = paymentInput.closest( 'form' ); |
| 393 | const block = paymentInput.closest( '.srfm-block' ); |
| 394 | |
| 395 | // Get subscription plan data from input attributes |
| 396 | const planName = |
| 397 | paymentInput.dataset.subscriptionPlanName || |
| 398 | PAYMENT_UTILITY.getStripeStrings( |
| 399 | 'subscription_plan', |
| 400 | 'Subscription Plan' |
| 401 | ); |
| 402 | const interval = paymentInput.dataset.subscriptionInterval || 'month'; |
| 403 | |
| 404 | // Use static methods to extract customer data from mapped form fields |
| 405 | const customerName = |
| 406 | StripePayment.extractBillingName( form, block ) || |
| 407 | PAYMENT_UTILITY.getStripeStrings( |
| 408 | 'sureforms_customer', |
| 409 | 'SureForms Customer' |
| 410 | ); |
| 411 | const customerEmail = |
| 412 | StripePayment.extractBillingEmail( form, block ) || |
| 413 | PAYMENT_UTILITY.getStripeStrings( |
| 414 | 'customer_example_email', |
| 415 | 'customer@example.com' |
| 416 | ); |
| 417 | |
| 418 | return { |
| 419 | name: customerName, |
| 420 | email: customerEmail, |
| 421 | interval, |
| 422 | planName, |
| 423 | }; |
| 424 | } |
| 425 | |
| 426 | /** |
| 427 | * Static method to create payment intent for a payment block during form submission. |
| 428 | * This should be called from the form submission handler. |
| 429 | * |
| 430 | * @param {HTMLFormElement} form - The form element. |
| 431 | * @param {HTMLElement} paymentBlock - The payment block element. |
| 432 | * @return {Promise<Object>} Resolves with payment intent or subscription intent data. |
| 433 | */ |
| 434 | static async createPaymentIntentsForForm( form, paymentBlock ) { |
| 435 | const paymentInput = paymentBlock.querySelector( |
| 436 | 'input.srfm-payment-input' |
| 437 | ); |
| 438 | const paymentType = |
| 439 | paymentInput.getAttribute( 'data-payment-type' ) || 'one-time'; |
| 440 | |
| 441 | if ( ! paymentInput ) { |
| 442 | return { |
| 443 | valid: false, |
| 444 | message: PAYMENT_UTILITY.getStripeStrings( |
| 445 | 'payment_unavailable', |
| 446 | 'Payment is currently unavailable. Please contact the site administrator.' |
| 447 | ), |
| 448 | }; |
| 449 | } |
| 450 | |
| 451 | // Get payment amount using helper method |
| 452 | const amount = StripePayment.getPaymentAmount( paymentInput ); |
| 453 | |
| 454 | if ( false === amount ) { |
| 455 | return { |
| 456 | valid: false, |
| 457 | message: PAYMENT_UTILITY.getStripeStrings( |
| 458 | 'payment_amount_not_configured', |
| 459 | 'Payment is currently unavailable. Please contact the site administrator to configure the payment amount.' |
| 460 | ), |
| 461 | }; |
| 462 | } else if ( amount <= 0 ) { |
| 463 | return { |
| 464 | valid: false, |
| 465 | message: PAYMENT_UTILITY.getStripeStrings( |
| 466 | 'payment_amount_not_configured', |
| 467 | 'Payment is currently unavailable. Please contact the site administrator to configure the payment amount.' |
| 468 | ), |
| 469 | }; |
| 470 | } |
| 471 | |
| 472 | try { |
| 473 | // Create a temporary instance to call the method |
| 474 | const tempInstance = new StripePayment( form ); |
| 475 | const blockId = paymentBlock.getAttribute( 'data-block-id' ); |
| 476 | const compositeKey = getPaymentKey( form, blockId ); |
| 477 | |
| 478 | // Use unified function for both payment types |
| 479 | const result = await tempInstance.createPaymentIntentOnSubmission( |
| 480 | blockId, |
| 481 | compositeKey, |
| 482 | amount, |
| 483 | paymentInput, |
| 484 | paymentType |
| 485 | ); |
| 486 | |
| 487 | return { |
| 488 | blockId, |
| 489 | compositeKey, |
| 490 | paymentType, |
| 491 | valid: true, |
| 492 | ...result, |
| 493 | }; |
| 494 | } catch ( error ) { |
| 495 | return { |
| 496 | valid: false, |
| 497 | message: |
| 498 | error.message || |
| 499 | PAYMENT_UTILITY.getStripeStrings( |
| 500 | 'payment_unavailable', |
| 501 | 'Payment is currently unavailable. Please contact the site administrator.' |
| 502 | ), |
| 503 | }; |
| 504 | } |
| 505 | } |
| 506 | |
| 507 | /** |
| 508 | * Validate payment amount before processing |
| 509 | * @param {number} amount - The amount to validate. |
| 510 | * @return {boolean} True if the amount is valid, false otherwise. |
| 511 | */ |
| 512 | static validatePaymentAmount( amount ) { |
| 513 | // Stripe minimum is $0.50 for most currencies |
| 514 | const minAmount = 0.5; |
| 515 | const maxAmount = 999999.99; // Reasonable maximum |
| 516 | |
| 517 | if ( isNaN( amount ) || amount < minAmount ) { |
| 518 | return false; |
| 519 | } |
| 520 | if ( amount > maxAmount ) { |
| 521 | return false; |
| 522 | } |
| 523 | return true; |
| 524 | } |
| 525 | |
| 526 | /** |
| 527 | * Normalize amount based on number format type (EU-style or US-style) |
| 528 | * @param {string|number} amount - The amount to normalize. |
| 529 | * @param {string} formatType - The format type: 'eu-style' or 'us-style'. |
| 530 | * @return {number} The normalized amount as a number. |
| 531 | */ |
| 532 | static normalizeAmount( amount, formatType = 'us-style' ) { |
| 533 | // If already a number, return it |
| 534 | if ( typeof amount === 'number' ) { |
| 535 | return amount; |
| 536 | } |
| 537 | |
| 538 | // Convert to string and trim |
| 539 | const amountStr = String( amount ).trim(); |
| 540 | |
| 541 | if ( formatType === 'eu-style' ) { |
| 542 | // EU-style: 1.234,56 (period = thousands, comma = decimal) |
| 543 | // Remove periods (thousands separator) and replace comma with period (decimal) |
| 544 | return parseFloat( |
| 545 | amountStr.replace( /\./g, '' ).replace( ',', '.' ) |
| 546 | ); |
| 547 | } |
| 548 | |
| 549 | // US-style (default): 1,234.56 (comma = thousands, period = decimal) |
| 550 | // Remove commas (thousands separator) |
| 551 | return parseFloat( amountStr.replace( /,/g, '' ) ); |
| 552 | } |
| 553 | |
| 554 | /** |
| 555 | * Extract billing name from form fields |
| 556 | * @param {HTMLElement} form - The form element. |
| 557 | * @param {HTMLElement} paymentBlock - The payment block wrapper element. |
| 558 | * @return {string} The extracted billing name or a default value. |
| 559 | */ |
| 560 | static extractBillingName( form, paymentBlock ) { |
| 561 | // Get the customer name field slug from payment input data attribute |
| 562 | const paymentInput = paymentBlock.querySelector( |
| 563 | 'input.srfm-payment-input' |
| 564 | ); |
| 565 | const customerNameFieldSlug = paymentInput |
| 566 | ? paymentInput.getAttribute( 'data-customer-name-field' ) |
| 567 | : null; |
| 568 | |
| 569 | if ( ! customerNameFieldSlug || customerNameFieldSlug.trim() === '' ) { |
| 570 | return ''; |
| 571 | } |
| 572 | |
| 573 | // Find the actual name input field in the form using the slug |
| 574 | const nameInput = form.querySelector( |
| 575 | `.srfm-input-block.srfm-slug-${ customerNameFieldSlug } .srfm-input-common` |
| 576 | ); |
| 577 | |
| 578 | if ( ! nameInput ) { |
| 579 | return ''; |
| 580 | } |
| 581 | |
| 582 | // Return the trimmed value |
| 583 | return nameInput.value.trim() || ''; |
| 584 | } |
| 585 | |
| 586 | /** |
| 587 | * Extract billing email from form fields |
| 588 | * @param {HTMLElement} form - The form element. |
| 589 | * @param {HTMLElement} paymentBlock - The payment block wrapper element. |
| 590 | * @return {string} The extracted billing email or a default value. |
| 591 | */ |
| 592 | static extractBillingEmail( form, paymentBlock ) { |
| 593 | // Get the customer email field slug from payment input data attribute |
| 594 | const paymentInput = paymentBlock.querySelector( |
| 595 | 'input.srfm-payment-input' |
| 596 | ); |
| 597 | const customerEmailFieldSlug = paymentInput |
| 598 | ? paymentInput.getAttribute( 'data-customer-email-field' ) |
| 599 | : null; |
| 600 | |
| 601 | if ( |
| 602 | ! customerEmailFieldSlug || |
| 603 | customerEmailFieldSlug.trim() === '' |
| 604 | ) { |
| 605 | return ''; |
| 606 | } |
| 607 | |
| 608 | // Find the actual email input field in the form using the slug |
| 609 | const emailInput = form.querySelector( |
| 610 | `.srfm-email-block.srfm-slug-${ customerEmailFieldSlug } .srfm-input-common` |
| 611 | ); |
| 612 | |
| 613 | if ( ! emailInput ) { |
| 614 | return ''; |
| 615 | } |
| 616 | |
| 617 | // Return the trimmed value |
| 618 | return emailInput.value.trim() || ''; |
| 619 | } |
| 620 | |
| 621 | /** |
| 622 | * Confirm payment for a specific block |
| 623 | * @param {string} compositeKey - Composite key (instanceId-blockId) for client-side maps. |
| 624 | * @param {Object} paymentData - The payment data. |
| 625 | * @param {HTMLElement} form - The form element. |
| 626 | * @return {Promise<string>} The payment intent or setup intent ID if successful. |
| 627 | */ |
| 628 | static async srfmConfirmPayment( compositeKey, paymentData, form ) { |
| 629 | const { elements } = paymentData; |
| 630 | |
| 631 | // Validate card details AFTER payment intent is created but BEFORE confirmation |
| 632 | // This is the correct timing to avoid card data loss |
| 633 | const { error: submitError } = await elements.submit(); |
| 634 | |
| 635 | if ( submitError ) { |
| 636 | return { |
| 637 | valid: false, |
| 638 | error: submitError.message, |
| 639 | message: submitError.message, |
| 640 | }; |
| 641 | } |
| 642 | |
| 643 | // Handle payment confirmation via unified handler |
| 644 | try { |
| 645 | return await StripePayment.confirmStripePayment( |
| 646 | compositeKey, |
| 647 | paymentData, |
| 648 | form |
| 649 | ); |
| 650 | } catch ( error ) { |
| 651 | // Catch any errors thrown by confirmStripePayment and return consistent structure |
| 652 | return { |
| 653 | valid: false, |
| 654 | error: error.message || error, |
| 655 | message: |
| 656 | error.message || |
| 657 | PAYMENT_UTILITY.getStripeStrings( |
| 658 | 'payment_failed', |
| 659 | 'Payment failed' |
| 660 | ), |
| 661 | }; |
| 662 | } |
| 663 | } |
| 664 | |
| 665 | static async confirmStripePayment( compositeKey, paymentData, form ) { |
| 666 | const { stripe, elements, clientSecret } = paymentData; |
| 667 | |
| 668 | // Extract original blockId from compositeKey for DOM queries |
| 669 | // compositeKey format is always "numericInstanceId-blockId" |
| 670 | const separatorIndex = compositeKey.indexOf( '-' ); |
| 671 | const blockId = |
| 672 | separatorIndex > -1 |
| 673 | ? compositeKey.substring( separatorIndex + 1 ) |
| 674 | : compositeKey; |
| 675 | |
| 676 | // Get the payment block element |
| 677 | const paymentBlock = form.querySelector( |
| 678 | `[data-block-id="${ blockId }"]` |
| 679 | ); |
| 680 | // Update form input with subscription data for backend processing |
| 681 | const paymentInput = paymentBlock.querySelector( |
| 682 | '.srfm-payment-input' |
| 683 | ); |
| 684 | |
| 685 | // BOTH MODE: read paymentType fresh from the live data attribute rather |
| 686 | // than the cached paymentData. reinitForBlock() rebuilds the cache on |
| 687 | // type flip, and a confirm captured before the flip would otherwise |
| 688 | // branch on a stale type. If paymentType and clientSecret ever truly |
| 689 | // diverge, Stripe itself rejects the wrong confirmSetup/confirmPayment |
| 690 | // call — no need for a redundant client-side guard here. |
| 691 | const paymentType = |
| 692 | paymentInput.getAttribute( 'data-payment-type' ) || |
| 693 | paymentData.paymentType || |
| 694 | 'one-time'; |
| 695 | |
| 696 | const amountType = |
| 697 | paymentInput.getAttribute( 'data-amount-type' ) || 'fixed'; |
| 698 | |
| 699 | // Prepare billing details using StripePayment class methods |
| 700 | const billingDetails = { |
| 701 | name: StripePayment.extractBillingName( form, paymentBlock ), |
| 702 | email: StripePayment.extractBillingEmail( form, paymentBlock ), |
| 703 | }; |
| 704 | |
| 705 | const stripeArgs = { |
| 706 | elements, |
| 707 | clientSecret, |
| 708 | confirmParams: { |
| 709 | return_url: window.location.href, |
| 710 | payment_method_data: { |
| 711 | billing_details: billingDetails, |
| 712 | }, |
| 713 | }, |
| 714 | redirect: 'if_required', |
| 715 | }; |
| 716 | |
| 717 | const paymentResult = await ( paymentType === 'subscription' |
| 718 | ? stripe.confirmSetup( stripeArgs ) |
| 719 | : stripe.confirmPayment( stripeArgs ) ); |
| 720 | |
| 721 | if ( paymentResult?.error ) { |
| 722 | console.warn( { 'Payment Confirmation Error': paymentResult } ); |
| 723 | |
| 724 | const getErrorCode = |
| 725 | paymentResult?.error?.decline_code || |
| 726 | paymentResult?.error?.code; |
| 727 | // Get the user-friendly message for the decline code |
| 728 | const errorMessage = |
| 729 | PAYMENT_UTILITY.getStripeStrings( getErrorCode ); |
| 730 | |
| 731 | return { |
| 732 | valid: false, |
| 733 | error: paymentResult.error, |
| 734 | message: errorMessage, |
| 735 | ...paymentResult, |
| 736 | }; |
| 737 | } |
| 738 | |
| 739 | if ( |
| 740 | 'one-time' === paymentType && |
| 741 | ! [ 'succeeded', 'requires_capture' ].includes( |
| 742 | paymentResult?.paymentIntent?.status |
| 743 | ) |
| 744 | ) { |
| 745 | const errorMessage = PAYMENT_UTILITY.getStripeStrings( |
| 746 | 'payment_could_not_be_completed', |
| 747 | 'Payment could not be completed. Please try again or contact the site administrator.' |
| 748 | ); |
| 749 | |
| 750 | return { |
| 751 | valid: false, |
| 752 | error: errorMessage, |
| 753 | message: errorMessage, |
| 754 | paymentResult, |
| 755 | }; |
| 756 | } |
| 757 | |
| 758 | const amount = StripePayment.getPaymentAmount( paymentInput ); |
| 759 | |
| 760 | const resultArgs = { |
| 761 | paymentResult, |
| 762 | compositeKey, |
| 763 | blockId, |
| 764 | paymentType, |
| 765 | amountType, |
| 766 | amount, |
| 767 | paymentInput, |
| 768 | billingDetails, |
| 769 | }; |
| 770 | |
| 771 | StripePayment.prepareInputValueData( resultArgs ); |
| 772 | |
| 773 | return { valid: true }; |
| 774 | } |
| 775 | |
| 776 | /** |
| 777 | * Prepares and sets the payment input value data as a JSON string. |
| 778 | * |
| 779 | * @param {Object} args - The configuration arguments. |
| 780 | * @param {string} args.blockId - The payment block ID. |
| 781 | * @param {string} args.paymentType - The type of payment ('subscription' or 'one-time'). |
| 782 | * @param {string} args.amountType - The type of amount ('fixed' or 'user-defined'). |
| 783 | * @param {number} args.amount - The payment amount. |
| 784 | * @param {HTMLInputElement} args.paymentInput - The input field to store payment data. |
| 785 | * @param {Object} args.paymentResult - The result object from Stripe payment confirmation. |
| 786 | */ |
| 787 | static prepareInputValueData( args ) { |
| 788 | const { |
| 789 | compositeKey, |
| 790 | blockId, |
| 791 | paymentType, |
| 792 | amountType, |
| 793 | amount, |
| 794 | paymentInput, |
| 795 | paymentResult, |
| 796 | billingDetails, |
| 797 | } = args; |
| 798 | |
| 799 | const value = { |
| 800 | blockId, |
| 801 | amountType, |
| 802 | amount, |
| 803 | ...( billingDetails || {} ), |
| 804 | }; |
| 805 | |
| 806 | if ( 'subscription' === paymentType ) { |
| 807 | const subscriptionData = |
| 808 | StripePayment.subscriptionIntents[ compositeKey ]; |
| 809 | const getSubscriptionName = paymentInput.getAttribute( |
| 810 | 'data-subscription-plan-name' |
| 811 | ); |
| 812 | const getSubscriptionBillingCycles = paymentInput.getAttribute( |
| 813 | 'data-subscription-billing-cycles' |
| 814 | ); |
| 815 | const getSubscriptionInterval = paymentInput.getAttribute( |
| 816 | 'data-subscription-interval' |
| 817 | ); |
| 818 | value.subscriptionPlanName = getSubscriptionName; |
| 819 | value.subscriptionBillingCycles = getSubscriptionBillingCycles; |
| 820 | value.subscriptionInterval = getSubscriptionInterval; |
| 821 | |
| 822 | value.paymentId = paymentResult?.setupIntent?.payment_method; |
| 823 | value.setupIntent = paymentResult?.setupIntent?.id; |
| 824 | value.subscriptionId = subscriptionData?.subscriptionId; |
| 825 | value.customerId = subscriptionData?.customerId; |
| 826 | value.paymentType = 'stripe-subscription'; |
| 827 | value.status = 'succeeded'; |
| 828 | } else { |
| 829 | const paymentData = StripePayment.paymentIntents[ compositeKey ]; |
| 830 | const customerId = paymentData?.customerId || null; |
| 831 | value.paymentId = paymentResult?.paymentIntent?.id; |
| 832 | value.paymentType = 'stripe'; |
| 833 | value.customerId = customerId || null; |
| 834 | } |
| 835 | |
| 836 | paymentInput.value = JSON.stringify( value ); |
| 837 | } |
| 838 | } |
| 839 | |
| 840 | // BOTH MODE: start — helper to re-mount Stripe Elements when the user flips the |
| 841 | // payment-type chooser between one-time and subscription. Stripe's elements.update() |
| 842 | // does not reliably support switching `mode`, so we unmount, drop the cached |
| 843 | // element, and let the existing initializer pathway recreate everything. |
| 844 | StripePayment.reinitForBlock = function ( form, paymentBlock ) { |
| 845 | const paymentInput = paymentBlock.querySelector( 'input.srfm-payment-input' ); |
| 846 | if ( ! paymentInput ) { |
| 847 | return; |
| 848 | } |
| 849 | |
| 850 | const blockId = paymentBlock.getAttribute( 'data-block-id' ); |
| 851 | const compositeKey = getPaymentKey( form, blockId ); |
| 852 | |
| 853 | // Bail if a reinit is already in flight for this block. Stripe's iframe |
| 854 | // mount is async, and a second pass before 'ready' fires would race two |
| 855 | // elements.create('payment') instances on the same DOM container. The |
| 856 | // caller (radio change handler) reverts the radio UI when this guard |
| 857 | // trips so the visible state stays consistent. |
| 858 | if ( StripePayment.reinitInProgress.has( compositeKey ) ) { |
| 859 | return; |
| 860 | } |
| 861 | |
| 862 | const existing = StripePayment.paymentElements[ compositeKey ]; |
| 863 | if ( existing && existing.paymentElement ) { |
| 864 | try { |
| 865 | existing.paymentElement.unmount(); |
| 866 | } catch ( err ) { |
| 867 | // Already unmounted — safe to ignore. |
| 868 | } |
| 869 | } |
| 870 | delete StripePayment.paymentElements[ compositeKey ]; |
| 871 | delete StripePayment.paymentIntents[ compositeKey ]; |
| 872 | delete StripePayment.subscriptionIntents[ compositeKey ]; |
| 873 | window.srfmPaymentElements = StripePayment.paymentElements; |
| 874 | |
| 875 | StripePayment.reinitInProgress.add( compositeKey ); |
| 876 | |
| 877 | // Re-init only the target block. Previously this used `new StripePayment(form)` |
| 878 | // which walked every payment block in the form and re-ran processPayment on |
| 879 | // each — wasted work on multi-block forms. Object.create gives us a stub |
| 880 | // bound to the form (the only `this` field processPayment touches indirectly |
| 881 | // via initializePaymentElements) without triggering the constructor walk. |
| 882 | const stub = Object.create( StripePayment.prototype ); |
| 883 | stub.form = form; |
| 884 | stub.processPayment( paymentBlock ); |
| 885 | }; |
| 886 | // BOTH MODE: end |
| 887 | |
| 888 | // Make StripePayment available globally for form submission |
| 889 | window.StripePayment = StripePayment; |
| 890 | |
| 891 | const PAYMENT_UTILITY = { |
| 892 | currentForm: null, |
| 893 | amountPlaceHolder: '', |
| 894 | init: ( form ) => { |
| 895 | PAYMENT_UTILITY.currentForm = form; |
| 896 | PAYMENT_UTILITY.amountPlaceHolder = PAYMENT_UTILITY.getStripeStrings( |
| 897 | 'amount_placeholder', |
| 898 | 'Please complete the form to view the amount.' |
| 899 | ); |
| 900 | PAYMENT_UTILITY.listenAmountChanges(); |
| 901 | }, |
| 902 | /** |
| 903 | * Format a number according to the format type (EU-style or US-style) |
| 904 | * @param {number|string} amount - The amount to format |
| 905 | * @param {string} formatType - The format type: 'eu-style' or 'us-style' |
| 906 | * @return {string} The formatted number string |
| 907 | */ |
| 908 | formatNumberByType: ( amount, formatType = 'us-style' ) => { |
| 909 | // Normalize to a number first |
| 910 | const normalizedAmount = StripePayment.normalizeAmount( |
| 911 | amount, |
| 912 | formatType |
| 913 | ); |
| 914 | |
| 915 | if ( isNaN( normalizedAmount ) ) { |
| 916 | return '0.00'; |
| 917 | } |
| 918 | |
| 919 | // Format to 2 decimal places |
| 920 | const fixedAmount = normalizedAmount.toFixed( 2 ); |
| 921 | |
| 922 | if ( formatType === 'eu-style' ) { |
| 923 | // EU-style: 1.234,56 (period = thousands, comma = decimal) |
| 924 | const parts = fixedAmount.split( '.' ); |
| 925 | const integerPart = parts[ 0 ].replace( |
| 926 | /\B(?=(\d{3})+(?!\d))/g, |
| 927 | '.' |
| 928 | ); |
| 929 | const decimalPart = parts[ 1 ]; |
| 930 | return integerPart + ',' + decimalPart; |
| 931 | } |
| 932 | |
| 933 | // US-style (default): 1,234.56 (comma = thousands, period = decimal) |
| 934 | const parts = fixedAmount.split( '.' ); |
| 935 | const integerPart = parts[ 0 ].replace( /\B(?=(\d{3})+(?!\d))/g, ',' ); |
| 936 | const decimalPart = parts[ 1 ]; |
| 937 | return integerPart + '.' + decimalPart; |
| 938 | }, |
| 939 | |
| 940 | /** |
| 941 | * Format subscription message by replacing {amount} placeholder with formatted amount |
| 942 | * @param {string} messageFormat - The message format template (e.g., "{amount} per day for 8 payments") |
| 943 | * @param {number} amount - The payment amount |
| 944 | * @param {string} currencySymbol - The currency symbol (e.g., "$") |
| 945 | * @param {string} inputFormatType - The format type: 'eu-style' or 'us-style' |
| 946 | * @return {string} Formatted message |
| 947 | */ |
| 948 | formatSubscriptionMessage: ( |
| 949 | messageFormat, |
| 950 | amount, |
| 951 | currencySymbol, |
| 952 | inputFormatType = 'us-style' |
| 953 | ) => { |
| 954 | if ( ! messageFormat ) { |
| 955 | return PAYMENT_UTILITY.amountPlaceHolder; |
| 956 | } |
| 957 | |
| 958 | // Format amount with currency using the appropriate number format and position |
| 959 | const formattedNumber = |
| 960 | ! amount || amount <= 0 |
| 961 | ? '' |
| 962 | : PAYMENT_UTILITY.formatNumberByType( amount, inputFormatType ); |
| 963 | |
| 964 | const formattedAmount = |
| 965 | '' !== formattedNumber |
| 966 | ? PAYMENT_UTILITY.formatAmountWithCurrencyPosition( |
| 967 | currencySymbol, |
| 968 | formattedNumber |
| 969 | ) |
| 970 | : ''; |
| 971 | |
| 972 | // Replace {amount} placeholder with formatted amount |
| 973 | return '' !== formattedAmount |
| 974 | ? messageFormat.replace( '{amount}', formattedAmount ) |
| 975 | : PAYMENT_UTILITY.amountPlaceHolder; |
| 976 | }, |
| 977 | updatePaymentBlockAmount: ( |
| 978 | paymentInput, |
| 979 | amount, |
| 980 | inputFormatType = 'us-style' |
| 981 | ) => { |
| 982 | // BOTH MODE: in "both" payment-type mode two .srfm-payment-value spans |
| 983 | // exist (one per amount block). Target the VISIBLE one so the correct |
| 984 | // type's amount updates. Falls back to the first match for non-both mode. |
| 985 | const paymentBlock = paymentInput.closest( '.srfm-block' ); |
| 986 | const getPlaceHolderElement = |
| 987 | paymentBlock.querySelector( |
| 988 | '.srfm-payment-amount-block:not([hidden]) .srfm-payment-value' |
| 989 | ) || paymentBlock.querySelector( '.srfm-payment-value' ); |
| 990 | if ( getPlaceHolderElement ) { |
| 991 | const getCurrencySymbol = getPlaceHolderElement.getAttribute( |
| 992 | 'data-currency-symbol' |
| 993 | ); |
| 994 | const messageFormat = getPlaceHolderElement.getAttribute( |
| 995 | 'data-message-format' |
| 996 | ); |
| 997 | |
| 998 | if ( getCurrencySymbol ) { |
| 999 | // Check if message format exists (for subscription messages) |
| 1000 | if ( messageFormat ) { |
| 1001 | const formattedMessage = |
| 1002 | PAYMENT_UTILITY.formatSubscriptionMessage( |
| 1003 | messageFormat, |
| 1004 | amount, |
| 1005 | getCurrencySymbol, |
| 1006 | inputFormatType |
| 1007 | ); |
| 1008 | getPlaceHolderElement.innerHTML = formattedMessage; |
| 1009 | } else { |
| 1010 | // Fallback to simple amount display (backward compatible) |
| 1011 | // Format the amount according to the number format type and currency position |
| 1012 | const formattedNumber = PAYMENT_UTILITY.formatNumberByType( |
| 1013 | amount, |
| 1014 | inputFormatType |
| 1015 | ); |
| 1016 | |
| 1017 | getPlaceHolderElement.innerHTML = |
| 1018 | PAYMENT_UTILITY.formatAmountWithCurrencyPosition( |
| 1019 | getCurrencySymbol, |
| 1020 | formattedNumber |
| 1021 | ); |
| 1022 | } |
| 1023 | } |
| 1024 | } |
| 1025 | |
| 1026 | const normalizedAmount = StripePayment.normalizeAmount( |
| 1027 | amount, |
| 1028 | inputFormatType |
| 1029 | ); |
| 1030 | |
| 1031 | paymentInput.setAttribute( 'data-current-amount', normalizedAmount ); |
| 1032 | paymentInput.setAttribute( |
| 1033 | 'data-dynamic-amount-format-type', |
| 1034 | inputFormatType |
| 1035 | ); |
| 1036 | }, |
| 1037 | listenAmountChanges: () => { |
| 1038 | const paymentInputs = PAYMENT_UTILITY.currentForm.querySelectorAll( |
| 1039 | '.srfm-block.srfm-payment-block input.srfm-payment-input[data-variable-amount-field]' |
| 1040 | ); |
| 1041 | |
| 1042 | if ( paymentInputs.length > 0 ) { |
| 1043 | for ( let i = 0; i < paymentInputs.length; i++ ) { |
| 1044 | const paymentInput = paymentInputs[ i ]; |
| 1045 | const getBlockMappedSlug = paymentInput.getAttribute( |
| 1046 | 'data-variable-amount-field' |
| 1047 | ); |
| 1048 | |
| 1049 | // BOTH MODE: tear down any listeners previously bound for this |
| 1050 | // payment input. switchActivePaymentType() calls this method again |
| 1051 | // each time the user flips one-time/subscription, which previously |
| 1052 | // piled up duplicate listeners on the source field. Round-trip |
| 1053 | // flips compounded the leak (field A → field B → field A → ...). |
| 1054 | // Each prior listener still fires on input events and can overwrite |
| 1055 | // the displayed amount with a stale source field's value. |
| 1056 | if ( paymentInput._srfmAmountListenerController ) { |
| 1057 | paymentInput._srfmAmountListenerController.abort(); |
| 1058 | } |
| 1059 | const controller = new AbortController(); |
| 1060 | paymentInput._srfmAmountListenerController = controller; |
| 1061 | const listenerOpts = { signal: controller.signal }; |
| 1062 | |
| 1063 | if ( getBlockMappedSlug ) { |
| 1064 | const getMappedBlock = |
| 1065 | PAYMENT_UTILITY.currentForm.querySelector( |
| 1066 | `.srfm-block.srfm-slug-${ getBlockMappedSlug }` |
| 1067 | ); |
| 1068 | if ( getMappedBlock ) { |
| 1069 | // Check block type. |
| 1070 | if ( |
| 1071 | getMappedBlock.classList.contains( |
| 1072 | 'srfm-number-block' |
| 1073 | ) |
| 1074 | ) { |
| 1075 | const getMappedBlockInput = |
| 1076 | getMappedBlock.querySelector( |
| 1077 | 'input.srfm-input-common' |
| 1078 | ); |
| 1079 | if ( getMappedBlockInput ) { |
| 1080 | getMappedBlockInput.addEventListener( |
| 1081 | 'input', |
| 1082 | ( event ) => { |
| 1083 | const getMappedBlockInputValue = |
| 1084 | event.target.value; |
| 1085 | // Get format type from the number input's data attribute |
| 1086 | const inputFormatType = |
| 1087 | getMappedBlockInput.getAttribute( |
| 1088 | 'format-type' |
| 1089 | ) || 'us-style'; |
| 1090 | PAYMENT_UTILITY.updatePaymentBlockAmount( |
| 1091 | paymentInput, |
| 1092 | getMappedBlockInputValue, |
| 1093 | inputFormatType |
| 1094 | ); |
| 1095 | }, |
| 1096 | listenerOpts |
| 1097 | ); |
| 1098 | // Get initial format type for the initial value |
| 1099 | const inputFormatType = |
| 1100 | getMappedBlockInput.getAttribute( |
| 1101 | 'format-type' |
| 1102 | ) || 'us-style'; |
| 1103 | PAYMENT_UTILITY.updatePaymentBlockAmount( |
| 1104 | paymentInput, |
| 1105 | getMappedBlockInput.value, |
| 1106 | inputFormatType |
| 1107 | ); |
| 1108 | } |
| 1109 | } else if ( |
| 1110 | getMappedBlock.classList.contains( |
| 1111 | 'srfm-dropdown-block' |
| 1112 | ) |
| 1113 | ) { |
| 1114 | const hiddenInput = getMappedBlock.querySelector( |
| 1115 | '.srfm-input-dropdown-hidden' |
| 1116 | ); |
| 1117 | if ( hiddenInput ) { |
| 1118 | hiddenInput.addEventListener( |
| 1119 | 'change', |
| 1120 | () => { |
| 1121 | const amount = |
| 1122 | PAYMENT_UTILITY.getDropdownAmount( |
| 1123 | getMappedBlock, |
| 1124 | hiddenInput |
| 1125 | ); |
| 1126 | PAYMENT_UTILITY.updatePaymentBlockAmount( |
| 1127 | paymentInput, |
| 1128 | amount |
| 1129 | ); |
| 1130 | }, |
| 1131 | listenerOpts |
| 1132 | ); |
| 1133 | // Set initial value |
| 1134 | const initialAmount = |
| 1135 | PAYMENT_UTILITY.getDropdownAmount( |
| 1136 | getMappedBlock, |
| 1137 | hiddenInput |
| 1138 | ); |
| 1139 | PAYMENT_UTILITY.updatePaymentBlockAmount( |
| 1140 | paymentInput, |
| 1141 | initialAmount |
| 1142 | ); |
| 1143 | } |
| 1144 | } else if ( |
| 1145 | getMappedBlock.classList.contains( |
| 1146 | 'srfm-multi-choice-block' |
| 1147 | ) |
| 1148 | ) { |
| 1149 | const hiddenInput = getMappedBlock.querySelector( |
| 1150 | '.srfm-input-multi-choice-hidden' |
| 1151 | ); |
| 1152 | if ( hiddenInput ) { |
| 1153 | hiddenInput.addEventListener( |
| 1154 | 'change', |
| 1155 | () => { |
| 1156 | const amount = |
| 1157 | PAYMENT_UTILITY.getMultiChoiceAmount( |
| 1158 | getMappedBlock, |
| 1159 | hiddenInput |
| 1160 | ); |
| 1161 | PAYMENT_UTILITY.updatePaymentBlockAmount( |
| 1162 | paymentInput, |
| 1163 | amount |
| 1164 | ); |
| 1165 | }, |
| 1166 | listenerOpts |
| 1167 | ); |
| 1168 | // Set initial value |
| 1169 | const initialAmount = |
| 1170 | PAYMENT_UTILITY.getMultiChoiceAmount( |
| 1171 | getMappedBlock, |
| 1172 | hiddenInput |
| 1173 | ); |
| 1174 | PAYMENT_UTILITY.updatePaymentBlockAmount( |
| 1175 | paymentInput, |
| 1176 | initialAmount |
| 1177 | ); |
| 1178 | } |
| 1179 | } else if ( |
| 1180 | getMappedBlock.classList.contains( |
| 1181 | 'srfm-hidden-block' |
| 1182 | ) |
| 1183 | ) { |
| 1184 | const hiddenFieldInput = |
| 1185 | getMappedBlock.querySelector( |
| 1186 | '.srfm-hidden-input' |
| 1187 | ); |
| 1188 | if ( hiddenFieldInput ) { |
| 1189 | // Hidden inputs don't fire native input/change |
| 1190 | // events when set programmatically, so listen |
| 1191 | // for both — integrators that set the value |
| 1192 | // via JS should dispatch a 'change' event. |
| 1193 | const syncAmount = () => { |
| 1194 | const trimmed = |
| 1195 | hiddenFieldInput.value.trim(); |
| 1196 | const rawValue = |
| 1197 | /^\d+(\.\d+)?$/.test( trimmed ) |
| 1198 | ? parseFloat( trimmed ) |
| 1199 | : NaN; |
| 1200 | const amount = |
| 1201 | isNaN( rawValue ) || rawValue < 0 |
| 1202 | ? 0 |
| 1203 | : rawValue; |
| 1204 | PAYMENT_UTILITY.updatePaymentBlockAmount( |
| 1205 | paymentInput, |
| 1206 | amount |
| 1207 | ); |
| 1208 | }; |
| 1209 | hiddenFieldInput.addEventListener( |
| 1210 | 'change', |
| 1211 | syncAmount |
| 1212 | ); |
| 1213 | hiddenFieldInput.addEventListener( |
| 1214 | 'input', |
| 1215 | syncAmount |
| 1216 | ); |
| 1217 | // Set initial value from defaultValue. |
| 1218 | syncAmount(); |
| 1219 | } |
| 1220 | } |
| 1221 | } |
| 1222 | } |
| 1223 | } |
| 1224 | } |
| 1225 | }, |
| 1226 | getCurrencySymbol: ( currencyCode ) => { |
| 1227 | // Use localized currency data from PHP |
| 1228 | const currenciesData = window.srfmStripe?.currenciesData || {}; |
| 1229 | const upperCurrencyCode = currencyCode?.toUpperCase(); |
| 1230 | const currencyData = currenciesData[ upperCurrencyCode ]; |
| 1231 | |
| 1232 | // Return symbol from localized data, or fallback to currency code |
| 1233 | return currencyData?.symbol || currencyCode; |
| 1234 | }, |
| 1235 | /** |
| 1236 | * Get currency sign position from settings |
| 1237 | * @return {string} Currency sign position ('left', 'right', 'left_space', 'right_space') |
| 1238 | */ |
| 1239 | getCurrencySignPosition: () => { |
| 1240 | return window.srfmStripe?.currencySignPosition || 'left'; |
| 1241 | }, |
| 1242 | /** |
| 1243 | * Format amount with currency symbol based on position setting |
| 1244 | * @param {string} currencySymbol - The currency symbol (e.g., "$") |
| 1245 | * @param {string|number} formattedAmount - The formatted amount string |
| 1246 | * @param {string} position - Currency sign position (optional, defaults to setting) |
| 1247 | * @return {string} Formatted amount with currency symbol in correct position |
| 1248 | */ |
| 1249 | formatAmountWithCurrencyPosition: ( |
| 1250 | currencySymbol, |
| 1251 | formattedAmount, |
| 1252 | position = null |
| 1253 | ) => { |
| 1254 | const signPosition = |
| 1255 | position || PAYMENT_UTILITY.getCurrencySignPosition(); |
| 1256 | |
| 1257 | switch ( signPosition ) { |
| 1258 | case 'right': |
| 1259 | return `${ formattedAmount }${ currencySymbol }`; |
| 1260 | case 'left_space': |
| 1261 | return `${ currencySymbol } ${ formattedAmount }`; |
| 1262 | case 'right_space': |
| 1263 | return `${ formattedAmount } ${ currencySymbol }`; |
| 1264 | case 'left': |
| 1265 | default: |
| 1266 | return `${ currencySymbol }${ formattedAmount }`; |
| 1267 | } |
| 1268 | }, |
| 1269 | /** |
| 1270 | * Get amount from dropdown block based on selected option values |
| 1271 | * @param {HTMLElement} dropdownBlock - The dropdown block element |
| 1272 | * @param {HTMLElement} hiddenInput - The hidden input containing selected values |
| 1273 | * @return {number} The total amount from selected options |
| 1274 | */ |
| 1275 | getDropdownAmount: ( dropdownBlock, hiddenInput ) => { |
| 1276 | const selectedValues = []; |
| 1277 | const hiddenInputValue = hiddenInput.value; |
| 1278 | |
| 1279 | if ( ! hiddenInputValue ) { |
| 1280 | return 0; |
| 1281 | } |
| 1282 | |
| 1283 | const { extractValue, normalizeDashes } = |
| 1284 | window.srfm?.srfmUtility || {}; |
| 1285 | |
| 1286 | // Extract selected values from hidden input (format: "Option 1 | Option 2") |
| 1287 | const selectedOptions = extractValue |
| 1288 | ? extractValue( hiddenInputValue ) |
| 1289 | : hiddenInputValue.split( '|' ).map( ( v ) => v.trim() ); |
| 1290 | |
| 1291 | // Get all dropdown options |
| 1292 | const options = dropdownBlock.querySelectorAll( |
| 1293 | '.srfm-dropdown-input option[option-value]' |
| 1294 | ); |
| 1295 | |
| 1296 | selectedOptions.forEach( ( selectedOption ) => { |
| 1297 | options.forEach( ( option ) => { |
| 1298 | const optionText = normalizeDashes( option.innerText?.trim() ); |
| 1299 | const selectedOptionText = normalizeDashes( |
| 1300 | selectedOption?.trim() |
| 1301 | ); |
| 1302 | |
| 1303 | if ( optionText === selectedOptionText ) { |
| 1304 | const optionValue = option.getAttribute( 'option-value' ); |
| 1305 | // Only add numeric values |
| 1306 | if ( ! isNaN( optionValue ) ) { |
| 1307 | selectedValues.push( parseFloat( optionValue ) ); |
| 1308 | } else if ( '' === optionValue ) { |
| 1309 | selectedValues.push( 0 ); |
| 1310 | } |
| 1311 | } |
| 1312 | } ); |
| 1313 | } ); |
| 1314 | |
| 1315 | // Sum all selected option values |
| 1316 | return selectedValues.length > 0 |
| 1317 | ? selectedValues.reduce( ( sum, value ) => sum + value, 0 ) |
| 1318 | : 0; |
| 1319 | }, |
| 1320 | /** |
| 1321 | * Get amount from multi-choice block based on selected option values |
| 1322 | * @param {HTMLElement} multiChoiceBlock - The multi-choice block element |
| 1323 | * @param {HTMLElement} hiddenInput - The hidden input containing selected values |
| 1324 | * @return {number} The total amount from selected options |
| 1325 | */ |
| 1326 | getMultiChoiceAmount: ( multiChoiceBlock, hiddenInput ) => { |
| 1327 | const selectedValues = []; |
| 1328 | const hiddenInputValue = hiddenInput.value; |
| 1329 | |
| 1330 | if ( ! hiddenInputValue ) { |
| 1331 | return 0; |
| 1332 | } |
| 1333 | |
| 1334 | const { extractValue } = window.srfm?.srfmUtility || {}; |
| 1335 | |
| 1336 | // Extract selected values from hidden input (format: "Option 1 | Option 2") |
| 1337 | const selectedOptions = extractValue( hiddenInputValue ); |
| 1338 | |
| 1339 | // Get all multi-choice options |
| 1340 | const choices = multiChoiceBlock.querySelectorAll( |
| 1341 | '.srfm-multi-choice-single' |
| 1342 | ); |
| 1343 | |
| 1344 | // Normalize whitespace: collapse multiple spaces to one, then trim. |
| 1345 | // This is necessary because browsers collapse consecutive whitespace in |
| 1346 | // innerText, while the stored value (from data-option-text attribute) |
| 1347 | // preserves the original spacing from the block attributes. |
| 1348 | const normalizeStr = ( str ) => |
| 1349 | typeof str === 'string' ? str.trim().replace( /\s+/g, ' ' ) : ''; |
| 1350 | |
| 1351 | selectedOptions.forEach( ( selectedOption ) => { |
| 1352 | choices.forEach( ( choice ) => { |
| 1353 | const label = choice.querySelector( |
| 1354 | '.srfm-option-container label' |
| 1355 | ); |
| 1356 | if ( |
| 1357 | normalizeStr( label?.innerText ) === |
| 1358 | normalizeStr( selectedOption ) |
| 1359 | ) { |
| 1360 | const input = choice.querySelector( |
| 1361 | '.srfm-input-multi-choice-single' |
| 1362 | ); |
| 1363 | const optionValue = input?.getAttribute( 'option-value' ); |
| 1364 | // Only add numeric values |
| 1365 | if ( ! isNaN( optionValue ) ) { |
| 1366 | selectedValues.push( parseFloat( optionValue ) ); |
| 1367 | } else if ( '' === optionValue ) { |
| 1368 | selectedValues.push( 0 ); |
| 1369 | } |
| 1370 | } |
| 1371 | } ); |
| 1372 | } ); |
| 1373 | |
| 1374 | // Sum all selected option values |
| 1375 | return selectedValues.length > 0 |
| 1376 | ? selectedValues.reduce( ( sum, value ) => sum + value, 0 ) |
| 1377 | : 0; |
| 1378 | }, |
| 1379 | getStripeStrings: ( code, defaultMessage = '' ) => { |
| 1380 | // If no code provided |
| 1381 | if ( ! code || code === null || code === undefined ) { |
| 1382 | // Return default message if provided, otherwise unknown error |
| 1383 | return defaultMessage && defaultMessage.trim() !== '' |
| 1384 | ? defaultMessage |
| 1385 | : window.srfmStripe?.strings?.unknown_error || |
| 1386 | 'An unknown error occurred. Please try again or contact the site administrator.'; |
| 1387 | } |
| 1388 | |
| 1389 | // Check if code exists in localized strings |
| 1390 | const localizedMessage = window.srfmStripe?.strings?.[ code ]; |
| 1391 | |
| 1392 | if ( localizedMessage ) { |
| 1393 | // Code found in localized strings, return it |
| 1394 | return localizedMessage; |
| 1395 | } |
| 1396 | |
| 1397 | // Code not found in localized strings |
| 1398 | // Return default message if provided, otherwise unknown error |
| 1399 | return defaultMessage && defaultMessage.trim() !== '' |
| 1400 | ? defaultMessage |
| 1401 | : window.srfmStripe?.strings?.unknown_error || |
| 1402 | 'An unknown error occurred. Please try again or contact the site administrator.'; |
| 1403 | }, |
| 1404 | }; |
| 1405 | |
| 1406 | window.srfmPaymentUtility = PAYMENT_UTILITY; |
| 1407 | |
| 1408 | /** |
| 1409 | * Initializes StripePayment for forms after SureForms initialization event. |
| 1410 | */ |
| 1411 | document.addEventListener( 'srfm_form_after_initialization', ( event ) => { |
| 1412 | const form = event?.detail?.form; |
| 1413 | if ( form ) { |
| 1414 | // Check if form has payment blocks before initializing |
| 1415 | const paymentBlocks = form.querySelectorAll( |
| 1416 | '.srfm-block.srfm-payment-block' |
| 1417 | ); |
| 1418 | if ( paymentBlocks.length > 0 ) { |
| 1419 | new StripePayment( form ); |
| 1420 | PAYMENT_UTILITY.init( form ); |
| 1421 | // BOTH MODE: wire the one-time / subscription radio chooser, if present. |
| 1422 | initPaymentTypeChoosers( form ); |
| 1423 | } |
| 1424 | } |
| 1425 | } ); |
| 1426 | |
| 1427 | // BOTH MODE: start — payment-type chooser (one-time vs subscription) wiring. |
| 1428 | |
| 1429 | /** |
| 1430 | * Wire the payment-type chooser radios for any payment blocks in this form |
| 1431 | * that were saved with paymentType === 'both'. |
| 1432 | * |
| 1433 | * @param {HTMLFormElement} form - The form element. |
| 1434 | */ |
| 1435 | function initPaymentTypeChoosers( form ) { |
| 1436 | const paymentBlocks = form.querySelectorAll( |
| 1437 | '.srfm-block.srfm-payment-block' |
| 1438 | ); |
| 1439 | |
| 1440 | paymentBlocks.forEach( ( paymentBlock ) => { |
| 1441 | const paymentInput = paymentBlock.querySelector( |
| 1442 | 'input.srfm-payment-input' |
| 1443 | ); |
| 1444 | if ( ! paymentInput ) { |
| 1445 | return; |
| 1446 | } |
| 1447 | |
| 1448 | // Only wire when the admin chose "both" mode. |
| 1449 | if ( |
| 1450 | paymentInput.getAttribute( 'data-original-payment-type' ) !== |
| 1451 | 'both' |
| 1452 | ) { |
| 1453 | return; |
| 1454 | } |
| 1455 | |
| 1456 | const radios = paymentBlock.querySelectorAll( |
| 1457 | '.srfm-payment-type-choice-radio' |
| 1458 | ); |
| 1459 | if ( radios.length === 0 ) { |
| 1460 | return; |
| 1461 | } |
| 1462 | |
| 1463 | radios.forEach( ( radio ) => { |
| 1464 | radio.addEventListener( 'change', ( event ) => { |
| 1465 | if ( ! event.target.checked ) { |
| 1466 | return; |
| 1467 | } |
| 1468 | |
| 1469 | // BOTH MODE: block type-flip while a payment is in flight. |
| 1470 | // reinitForBlock() unmounts the Stripe Element and deletes the |
| 1471 | // cached intent the pending confirmPayment is still using — |
| 1472 | // flipping mid-submit produces double charges or wrong-type |
| 1473 | // completions. Revert the radio to whatever data-payment-type |
| 1474 | // currently is so the UI does not lie about the user's choice. |
| 1475 | const blockId = |
| 1476 | paymentBlock.getAttribute( 'data-block-id' ); |
| 1477 | const compositeKey = getPaymentKey( form, blockId ); |
| 1478 | const reinitInFlight = |
| 1479 | StripePayment.reinitInProgress.has( compositeKey ); |
| 1480 | |
| 1481 | if ( |
| 1482 | form.dataset.srfmPaymentInFlight === 'true' || |
| 1483 | reinitInFlight |
| 1484 | ) { |
| 1485 | const activeType = |
| 1486 | paymentInput.getAttribute( 'data-payment-type' ) || |
| 1487 | 'one-time'; |
| 1488 | radios.forEach( ( r ) => { |
| 1489 | r.checked = r.value === activeType; |
| 1490 | } ); |
| 1491 | return; |
| 1492 | } |
| 1493 | |
| 1494 | switchActivePaymentType( |
| 1495 | form, |
| 1496 | paymentBlock, |
| 1497 | paymentInput, |
| 1498 | event.target.value |
| 1499 | ); |
| 1500 | } ); |
| 1501 | } ); |
| 1502 | } ); |
| 1503 | } |
| 1504 | |
| 1505 | /** |
| 1506 | * Apply a new active payment type to the block. Updates DOM visibility, syncs |
| 1507 | * the live data-* attributes that the rest of the JS reads, re-initializes |
| 1508 | * the Stripe Element in the new mode, and dispatches a gateway-agnostic event |
| 1509 | * so other gateways (e.g. PayPal in sureforms-pro) can react. |
| 1510 | * |
| 1511 | * @param {HTMLFormElement} form - The form element. |
| 1512 | * @param {HTMLElement} paymentBlock - The payment block wrapper. |
| 1513 | * @param {HTMLInputElement} paymentInput - The hidden payment input. |
| 1514 | * @param {string} newType - 'one-time' or 'subscription'. |
| 1515 | */ |
| 1516 | function switchActivePaymentType( form, paymentBlock, paymentInput, newType ) { |
| 1517 | const safeType = newType === 'subscription' ? 'subscription' : 'one-time'; |
| 1518 | |
| 1519 | // 1. Toggle visible amount block. |
| 1520 | const amountBlocks = paymentBlock.querySelectorAll( |
| 1521 | '.srfm-payment-amount-block' |
| 1522 | ); |
| 1523 | amountBlocks.forEach( ( el ) => { |
| 1524 | if ( el.getAttribute( 'data-payment-type' ) === safeType ) { |
| 1525 | el.removeAttribute( 'hidden' ); |
| 1526 | } else { |
| 1527 | el.setAttribute( 'hidden', '' ); |
| 1528 | } |
| 1529 | } ); |
| 1530 | |
| 1531 | // 2. Sync the live data attributes from the per-type configuration. |
| 1532 | const prefix = safeType === 'subscription' ? 'subscription' : 'one-time'; |
| 1533 | const amountType = |
| 1534 | paymentInput.getAttribute( `data-${ prefix }-amount-type` ) || 'fixed'; |
| 1535 | const fixedAmount = |
| 1536 | paymentInput.getAttribute( `data-${ prefix }-fixed-amount` ) || '0'; |
| 1537 | const minimumAmount = |
| 1538 | paymentInput.getAttribute( `data-${ prefix }-minimum-amount` ) || '0'; |
| 1539 | const variableField = |
| 1540 | paymentInput.getAttribute( `data-${ prefix }-variable-amount-field` ) || |
| 1541 | ''; |
| 1542 | |
| 1543 | paymentInput.setAttribute( 'data-payment-type', safeType ); |
| 1544 | paymentInput.setAttribute( 'data-amount-type', amountType ); |
| 1545 | paymentInput.setAttribute( 'data-fixed-amount', fixedAmount ); |
| 1546 | |
| 1547 | if ( parseFloat( minimumAmount ) > 0 ) { |
| 1548 | paymentInput.setAttribute( 'data-minimum-amount', minimumAmount ); |
| 1549 | } else { |
| 1550 | paymentInput.removeAttribute( 'data-minimum-amount' ); |
| 1551 | } |
| 1552 | |
| 1553 | if ( amountType === 'variable' && variableField ) { |
| 1554 | paymentInput.setAttribute( 'data-variable-amount-field', variableField ); |
| 1555 | } else { |
| 1556 | paymentInput.removeAttribute( 'data-variable-amount-field' ); |
| 1557 | // Also clear any cached current-amount so stale values don't leak across choices. |
| 1558 | paymentInput.removeAttribute( 'data-current-amount' ); |
| 1559 | } |
| 1560 | |
| 1561 | // 3. Re-initialize Stripe Element in the new mode. |
| 1562 | if ( typeof StripePayment.reinitForBlock === 'function' ) { |
| 1563 | StripePayment.reinitForBlock( form, paymentBlock ); |
| 1564 | } |
| 1565 | |
| 1566 | // 3b. BOTH MODE: re-wire variable-amount listeners and trigger an immediate |
| 1567 | // display update for the new type. listenAmountChanges() queries |
| 1568 | // [data-variable-amount-field] which was just updated above, so it will |
| 1569 | // find and wire the correct field. The immediate update populates the |
| 1570 | // amount span which is empty at PHP render time for variable types. |
| 1571 | if ( amountType === 'variable' && variableField ) { |
| 1572 | PAYMENT_UTILITY.listenAmountChanges(); |
| 1573 | |
| 1574 | // Read current value from the mapped field and update the display now. |
| 1575 | const mappedBlock = form.querySelector( |
| 1576 | `.srfm-block.srfm-slug-${ variableField }` |
| 1577 | ); |
| 1578 | if ( mappedBlock ) { |
| 1579 | const numberInput = mappedBlock.querySelector( |
| 1580 | 'input.srfm-input-common' |
| 1581 | ); |
| 1582 | const dropdownInput = mappedBlock.querySelector( |
| 1583 | '.srfm-input-dropdown-hidden' |
| 1584 | ); |
| 1585 | const multiChoiceInput = mappedBlock.querySelector( |
| 1586 | '.srfm-input-multi-choice-hidden' |
| 1587 | ); |
| 1588 | const hiddenFieldInput = |
| 1589 | mappedBlock.querySelector( '.srfm-hidden-input' ); |
| 1590 | |
| 1591 | let currentValue = 0; |
| 1592 | if ( numberInput ) { |
| 1593 | currentValue = numberInput.value || 0; |
| 1594 | } else if ( dropdownInput ) { |
| 1595 | currentValue = |
| 1596 | PAYMENT_UTILITY.getDropdownAmount( |
| 1597 | mappedBlock, |
| 1598 | dropdownInput |
| 1599 | ) || 0; |
| 1600 | } else if ( multiChoiceInput ) { |
| 1601 | currentValue = |
| 1602 | PAYMENT_UTILITY.getMultiChoiceAmount( |
| 1603 | mappedBlock, |
| 1604 | multiChoiceInput |
| 1605 | ) || 0; |
| 1606 | } else if ( hiddenFieldInput ) { |
| 1607 | // Mirror syncAmount() in listenAmountChanges — accept only numeric |
| 1608 | // strings, clamp negatives to 0. Without this branch, currentValue |
| 1609 | // stays 0 and overwrites the value syncAmount() just wrote during |
| 1610 | // listenAmountChanges() above, breaking initial-amount pickup on |
| 1611 | // every one-time/subscription flip. |
| 1612 | const trimmed = hiddenFieldInput.value.trim(); |
| 1613 | const rawValue = /^\d+(\.\d+)?$/.test( trimmed ) |
| 1614 | ? parseFloat( trimmed ) |
| 1615 | : NaN; |
| 1616 | currentValue = |
| 1617 | isNaN( rawValue ) || rawValue < 0 ? 0 : rawValue; |
| 1618 | } |
| 1619 | |
| 1620 | PAYMENT_UTILITY.updatePaymentBlockAmount( |
| 1621 | paymentInput, |
| 1622 | currentValue |
| 1623 | ); |
| 1624 | } else { |
| 1625 | // No mapped field found — show placeholder. |
| 1626 | PAYMENT_UTILITY.updatePaymentBlockAmount( paymentInput, 0 ); |
| 1627 | } |
| 1628 | } |
| 1629 | |
| 1630 | // 4. Dispatch gateway-agnostic event so other gateways (PayPal etc.) can react. |
| 1631 | // |
| 1632 | // Event contract — `srfm_payment_type_changed` (document-level, bubbles): |
| 1633 | // detail: { |
| 1634 | // blockId: string — Original payment block id (no instance prefix). |
| 1635 | // form: HTMLFormElement — Form element the payment block belongs to. |
| 1636 | // paymentType: 'one-time' | 'subscription' — The newly active type. |
| 1637 | // paymentInput: HTMLInputElement — The hidden .srfm-payment-input element. |
| 1638 | // } |
| 1639 | // Companion event `srfm_payment_method_changed` (dispatched in payment-manager.js) |
| 1640 | // uses a deliberately narrower shape — { blockId, paymentMethod, form } — because |
| 1641 | // gateway listeners only need the method id; if you need paymentInput on that |
| 1642 | // path, query it from the form rather than expanding the schema (avoids drift). |
| 1643 | const blockId = paymentBlock.getAttribute( 'data-block-id' ); |
| 1644 | document.dispatchEvent( |
| 1645 | new CustomEvent( 'srfm_payment_type_changed', { |
| 1646 | detail: { |
| 1647 | blockId, |
| 1648 | form, |
| 1649 | paymentType: safeType, |
| 1650 | paymentInput, |
| 1651 | }, |
| 1652 | bubbles: true, |
| 1653 | } ) |
| 1654 | ); |
| 1655 | } |
| 1656 | // BOTH MODE: end |
| 1657 |