| 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 |
'[email protected]' |
| 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 |
|