| @@ -24,8 +24,12 @@ | ||
| 24 | 24 | static stripeInstances = {}; |
| 25 | 25 | static paymentElements = {}; |
| 26 | 26 | static paymentIntents = {}; |
| 27 | 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(); | |
| 28 | 32 | |
| 29 | 33 | // Initialize on page load |
| 30 | 34 | static { |
| 31 | 35 | window.srfmPaymentElements = this.paymentElements; |
| @@ -278,8 +282,17 @@ | ||
| 278 | 282 | |
| 279 | 283 | // Add type-specific configuration |
| 280 | 284 | if ( paymentType === 'one-time' ) { |
| 281 | 285 | elementsConfig.captureMethod = 'manual'; |
| 286 | + // Manual capture is incompatible with some account-enabled payment methods | |
| 287 | + // (e.g. Bacs Direct Debit, Link, Cash App, BNPL). When any of those are enabled, | |
| 288 | + // Stripe rejects the deferred elements/sessions request with HTTP 400 and the | |
| 289 | + // Payment Element fails to render — which is why the card field does not load in | |
| 290 | + // live mode while test mode (card-only) works. Scope the element to card so only | |
| 291 | + // capture-compatible methods are offered. Apple Pay / Google Pay still appear | |
| 292 | + // (they are surfaced through `card`); the methods dropped here could never be used | |
| 293 | + // with manual capture anyway, so no working checkout is lost. | |
| 294 | + elementsConfig.paymentMethodTypes = [ 'card' ]; | |
| 282 | 295 | } |
| 283 | 296 | |
| 284 | 297 | // Create and mount payment element |
| 285 | 298 | const elements = stripe.elements( elementsConfig ); |
| @@ -300,9 +313,9 @@ | ||
| 300 | 313 | // Update window object |
| 301 | 314 | window.srfmPaymentElements = StripePayment.paymentElements; |
| 302 | 315 | |
| 303 | 316 | // Setup event handlers |
| 304 | - this.setupPaymentElementEvents( paymentElement ); | |
| 317 | + this.setupPaymentElementEvents( paymentElement, compositeKey ); | |
| 305 | 318 | } |
| 306 | 319 | |
| 307 | 320 | /** |
| 308 | 321 | * Setup event handlers for payment element. |
| @@ -307,17 +320,19 @@ | ||
| 307 | 320 | /** |
| 308 | 321 | * Setup event handlers for payment element. |
| 309 | 322 | * |
| 310 | 323 | * @param {Object} paymentElement - The Stripe payment element. |
| 324 | + * @param {string} compositeKey - Composite key (instanceId-blockId) used to clear the reinit-in-flight flag. | |
| 311 | 325 | * @return {void} This function does not return a value. |
| 312 | 326 | */ |
| 313 | - setupPaymentElementEvents( paymentElement ) { | |
| 314 | - // Ready event | |
| 315 | - paymentElement.on( 'ready', () => {} ); | |
| 316 | - | |
| 317 | - // Change event (handling differs by payment type) | |
| 318 | - paymentElement.on( 'change', () => { | |
| 319 | - // Lets manage the event.error, event.complete events if required. | |
| 327 | + setupPaymentElementEvents( paymentElement, compositeKey ) { | |
| 328 | + // Ready event — clears the reinit-in-flight flag so subsequent type | |
| 329 | + // flips can proceed. Without this, a stale flag would block all future | |
| 330 | + // reinits for this block. | |
| 331 | + paymentElement.on( 'ready', () => { | |
| 332 | + if ( compositeKey ) { | |
| 333 | + StripePayment.reinitInProgress.delete( compositeKey ); | |
| 334 | + } | |
| 320 | 335 | } ); |
| 321 | 336 | } |
| 322 | 337 | |
| 323 | 338 | /** |
| @@ -646,9 +661,9 @@ | ||
| 646 | 661 | } |
| 647 | 662 | } |
| 648 | 663 | |
| 649 | 664 | static async confirmStripePayment( compositeKey, paymentData, form ) { |
| 650 | - const { stripe, elements, clientSecret, paymentType } = paymentData; | |
| 665 | + const { stripe, elements, clientSecret } = paymentData; | |
| 651 | 666 | |
| 652 | 667 | // Extract original blockId from compositeKey for DOM queries |
| 653 | 668 | // compositeKey format is always "numericInstanceId-blockId" |
| 654 | 669 | const separatorIndex = compositeKey.indexOf( '-' ); |
| @@ -665,8 +680,19 @@ | ||
| 665 | 680 | const paymentInput = paymentBlock.querySelector( |
| 666 | 681 | '.srfm-payment-input' |
| 667 | 682 | ); |
| 668 | 683 | |
| 684 | + // BOTH MODE: read paymentType fresh from the live data attribute rather | |
| 685 | + // than the cached paymentData. reinitForBlock() rebuilds the cache on | |
| 686 | + // type flip, and a confirm captured before the flip would otherwise | |
| 687 | + // branch on a stale type. If paymentType and clientSecret ever truly | |
| 688 | + // diverge, Stripe itself rejects the wrong confirmSetup/confirmPayment | |
| 689 | + // call — no need for a redundant client-side guard here. | |
| 690 | + const paymentType = | |
| 691 | + paymentInput.getAttribute( 'data-payment-type' ) || | |
| 692 | + paymentData.paymentType || | |
| 693 | + 'one-time'; | |
| 694 | + | |
| 669 | 695 | const amountType = |
| 670 | 696 | paymentInput.getAttribute( 'data-amount-type' ) || 'fixed'; |
| 671 | 697 | |
| 672 | 698 | // Prepare billing details using StripePayment class methods |
| @@ -809,8 +835,56 @@ | ||
| 809 | 835 | paymentInput.value = JSON.stringify( value ); |
| 810 | 836 | } |
| 811 | 837 | } |
| 812 | 838 | |
| 839 | +// BOTH MODE: start — helper to re-mount Stripe Elements when the user flips the | |
| 840 | +// payment-type chooser between one-time and subscription. Stripe's elements.update() | |
| 841 | +// does not reliably support switching `mode`, so we unmount, drop the cached | |
| 842 | +// element, and let the existing initializer pathway recreate everything. | |
| 843 | +StripePayment.reinitForBlock = function ( form, paymentBlock ) { | |
| 844 | + const paymentInput = paymentBlock.querySelector( 'input.srfm-payment-input' ); | |
| 845 | + if ( ! paymentInput ) { | |
| 846 | + return; | |
| 847 | + } | |
| 848 | + | |
| 849 | + const blockId = paymentBlock.getAttribute( 'data-block-id' ); | |
| 850 | + const compositeKey = getPaymentKey( form, blockId ); | |
| 851 | + | |
| 852 | + // Bail if a reinit is already in flight for this block. Stripe's iframe | |
| 853 | + // mount is async, and a second pass before 'ready' fires would race two | |
| 854 | + // elements.create('payment') instances on the same DOM container. The | |
| 855 | + // caller (radio change handler) reverts the radio UI when this guard | |
| 856 | + // trips so the visible state stays consistent. | |
| 857 | + if ( StripePayment.reinitInProgress.has( compositeKey ) ) { | |
| 858 | + return; | |
| 859 | + } | |
| 860 | + | |
| 861 | + const existing = StripePayment.paymentElements[ compositeKey ]; | |
| 862 | + if ( existing && existing.paymentElement ) { | |
| 863 | + try { | |
| 864 | + existing.paymentElement.unmount(); | |
| 865 | + } catch ( err ) { | |
| 866 | + // Already unmounted — safe to ignore. | |
| 867 | + } | |
| 868 | + } | |
| 869 | + delete StripePayment.paymentElements[ compositeKey ]; | |
| 870 | + delete StripePayment.paymentIntents[ compositeKey ]; | |
| 871 | + delete StripePayment.subscriptionIntents[ compositeKey ]; | |
| 872 | + window.srfmPaymentElements = StripePayment.paymentElements; | |
| 873 | + | |
| 874 | + StripePayment.reinitInProgress.add( compositeKey ); | |
| 875 | + | |
| 876 | + // Re-init only the target block. Previously this used `new StripePayment(form)` | |
| 877 | + // which walked every payment block in the form and re-ran processPayment on | |
| 878 | + // each — wasted work on multi-block forms. Object.create gives us a stub | |
| 879 | + // bound to the form (the only `this` field processPayment touches indirectly | |
| 880 | + // via initializePaymentElements) without triggering the constructor walk. | |
| 881 | + const stub = Object.create( StripePayment.prototype ); | |
| 882 | + stub.form = form; | |
| 883 | + stub.processPayment( paymentBlock ); | |
| 884 | +}; | |
| 885 | +// BOTH MODE: end | |
| 886 | + | |
| 813 | 887 | // Make StripePayment available globally for form submission |
| 814 | 888 | window.StripePayment = StripePayment; |
| 815 | 889 | |
| 816 | 890 | const PAYMENT_UTILITY = { |
| @@ -903,11 +977,16 @@ | ||
| 903 | 977 | paymentInput, |
| 904 | 978 | amount, |
| 905 | 979 | inputFormatType = 'us-style' |
| 906 | 980 | ) => { |
| 907 | - const getPlaceHolderElement = paymentInput | |
| 908 | - .closest( '.srfm-block' ) | |
| 909 | - .querySelector( '.srfm-payment-value' ); | |
| 981 | + // BOTH MODE: in "both" payment-type mode two .srfm-payment-value spans | |
| 982 | + // exist (one per amount block). Target the VISIBLE one so the correct | |
| 983 | + // type's amount updates. Falls back to the first match for non-both mode. | |
| 984 | + const paymentBlock = paymentInput.closest( '.srfm-block' ); | |
| 985 | + const getPlaceHolderElement = | |
| 986 | + paymentBlock.querySelector( | |
| 987 | + '.srfm-payment-amount-block:not([hidden]) .srfm-payment-value' | |
| 988 | + ) || paymentBlock.querySelector( '.srfm-payment-value' ); | |
| 910 | 989 | if ( getPlaceHolderElement ) { |
| 911 | 990 | const getCurrencySymbol = getPlaceHolderElement.getAttribute( |
| 912 | 991 | 'data-currency-symbol' |
| 913 | 992 | ); |
| @@ -965,8 +1044,22 @@ | ||
| 965 | 1044 | const getBlockMappedSlug = paymentInput.getAttribute( |
| 966 | 1045 | 'data-variable-amount-field' |
| 967 | 1046 | ); |
| 968 | 1047 | |
| 1048 | + // BOTH MODE: tear down any listeners previously bound for this | |
| 1049 | + // payment input. switchActivePaymentType() calls this method again | |
| 1050 | + // each time the user flips one-time/subscription, which previously | |
| 1051 | + // piled up duplicate listeners on the source field. Round-trip | |
| 1052 | + // flips compounded the leak (field A → field B → field A → ...). | |
| 1053 | + // Each prior listener still fires on input events and can overwrite | |
| 1054 | + // the displayed amount with a stale source field's value. | |
| 1055 | + if ( paymentInput._srfmAmountListenerController ) { | |
| 1056 | + paymentInput._srfmAmountListenerController.abort(); | |
| 1057 | + } | |
| 1058 | + const controller = new AbortController(); | |
| 1059 | + paymentInput._srfmAmountListenerController = controller; | |
| 1060 | + const listenerOpts = { signal: controller.signal }; | |
| 1061 | + | |
| 969 | 1062 | if ( getBlockMappedSlug ) { |
| 970 | 1063 | const getMappedBlock = |
| 971 | 1064 | PAYMENT_UTILITY.currentForm.querySelector( |
| 972 | 1065 | `.srfm-block.srfm-slug-${ getBlockMappedSlug }` |
| @@ -997,9 +1090,10 @@ | ||
| 997 | 1090 | paymentInput, |
| 998 | 1091 | getMappedBlockInputValue, |
| 999 | 1092 | inputFormatType |
| 1000 | 1093 | ); |
| 1001 | - } | |
| 1094 | + }, | |
| 1095 | + listenerOpts | |
| 1002 | 1096 | ); |
| 1003 | 1097 | // Get initial format type for the initial value |
| 1004 | 1098 | const inputFormatType = |
| 1005 | 1099 | getMappedBlockInput.getAttribute( |
| @@ -1019,19 +1113,23 @@ | ||
| 1019 | 1113 | const hiddenInput = getMappedBlock.querySelector( |
| 1020 | 1114 | '.srfm-input-dropdown-hidden' |
| 1021 | 1115 | ); |
| 1022 | 1116 | if ( hiddenInput ) { |
| 1023 | - hiddenInput.addEventListener( 'change', () => { | |
| 1024 | - const amount = | |
| 1025 | - PAYMENT_UTILITY.getDropdownAmount( | |
| 1026 | - getMappedBlock, | |
| 1027 | - hiddenInput | |
| 1117 | + hiddenInput.addEventListener( | |
| 1118 | + 'change', | |
| 1119 | + () => { | |
| 1120 | + const amount = | |
| 1121 | + PAYMENT_UTILITY.getDropdownAmount( | |
| 1122 | + getMappedBlock, | |
| 1123 | + hiddenInput | |
| 1124 | + ); | |
| 1125 | + PAYMENT_UTILITY.updatePaymentBlockAmount( | |
| 1126 | + paymentInput, | |
| 1127 | + amount | |
| 1028 | 1128 | ); |
| 1029 | - PAYMENT_UTILITY.updatePaymentBlockAmount( | |
| 1030 | - paymentInput, | |
| 1031 | - amount | |
| 1032 | - ); | |
| 1033 | - } ); | |
| 1129 | + }, | |
| 1130 | + listenerOpts | |
| 1131 | + ); | |
| 1034 | 1132 | // Set initial value |
| 1035 | 1133 | const initialAmount = |
| 1036 | 1134 | PAYMENT_UTILITY.getDropdownAmount( |
| 1037 | 1135 | getMappedBlock, |
| @@ -1050,19 +1148,23 @@ | ||
| 1050 | 1148 | const hiddenInput = getMappedBlock.querySelector( |
| 1051 | 1149 | '.srfm-input-multi-choice-hidden' |
| 1052 | 1150 | ); |
| 1053 | 1151 | if ( hiddenInput ) { |
| 1054 | - hiddenInput.addEventListener( 'change', () => { | |
| 1055 | - const amount = | |
| 1056 | - PAYMENT_UTILITY.getMultiChoiceAmount( | |
| 1057 | - getMappedBlock, | |
| 1058 | - hiddenInput | |
| 1152 | + hiddenInput.addEventListener( | |
| 1153 | + 'change', | |
| 1154 | + () => { | |
| 1155 | + const amount = | |
| 1156 | + PAYMENT_UTILITY.getMultiChoiceAmount( | |
| 1157 | + getMappedBlock, | |
| 1158 | + hiddenInput | |
| 1159 | + ); | |
| 1160 | + PAYMENT_UTILITY.updatePaymentBlockAmount( | |
| 1161 | + paymentInput, | |
| 1162 | + amount | |
| 1059 | 1163 | ); |
| 1060 | - PAYMENT_UTILITY.updatePaymentBlockAmount( | |
| 1061 | - paymentInput, | |
| 1062 | - amount | |
| 1063 | - ); | |
| 1064 | - } ); | |
| 1164 | + }, | |
| 1165 | + listenerOpts | |
| 1166 | + ); | |
| 1065 | 1167 | // Set initial value |
| 1066 | 1168 | const initialAmount = |
| 1067 | 1169 | PAYMENT_UTILITY.getMultiChoiceAmount( |
| 1068 | 1170 | getMappedBlock, |
| @@ -1072,8 +1174,49 @@ | ||
| 1072 | 1174 | paymentInput, |
| 1073 | 1175 | initialAmount |
| 1074 | 1176 | ); |
| 1075 | 1177 | } |
| 1178 | + } else if ( | |
| 1179 | + getMappedBlock.classList.contains( | |
| 1180 | + 'srfm-hidden-block' | |
| 1181 | + ) | |
| 1182 | + ) { | |
| 1183 | + const hiddenFieldInput = | |
| 1184 | + getMappedBlock.querySelector( | |
| 1185 | + '.srfm-hidden-input' | |
| 1186 | + ); | |
| 1187 | + if ( hiddenFieldInput ) { | |
| 1188 | + // Hidden inputs don't fire native input/change | |
| 1189 | + // events when set programmatically, so listen | |
| 1190 | + // for both — integrators that set the value | |
| 1191 | + // via JS should dispatch a 'change' event. | |
| 1192 | + const syncAmount = () => { | |
| 1193 | + const trimmed = | |
| 1194 | + hiddenFieldInput.value.trim(); | |
| 1195 | + const rawValue = | |
| 1196 | + /^\d+(\.\d+)?$/.test( trimmed ) | |
| 1197 | + ? parseFloat( trimmed ) | |
| 1198 | + : NaN; | |
| 1199 | + const amount = | |
| 1200 | + isNaN( rawValue ) || rawValue < 0 | |
| 1201 | + ? 0 | |
| 1202 | + : rawValue; | |
| 1203 | + PAYMENT_UTILITY.updatePaymentBlockAmount( | |
| 1204 | + paymentInput, | |
| 1205 | + amount | |
| 1206 | + ); | |
| 1207 | + }; | |
| 1208 | + hiddenFieldInput.addEventListener( | |
| 1209 | + 'change', | |
| 1210 | + syncAmount | |
| 1211 | + ); | |
| 1212 | + hiddenFieldInput.addEventListener( | |
| 1213 | + 'input', | |
| 1214 | + syncAmount | |
| 1215 | + ); | |
| 1216 | + // Set initial value from defaultValue. | |
| 1217 | + syncAmount(); | |
| 1218 | + } | |
| 1076 | 1219 | } |
| 1077 | 1220 | } |
| 1078 | 1221 | } |
| 1079 | 1222 | } |
| @@ -1273,7 +1416,240 @@ | ||
| 1273 | 1416 | ); |
| 1274 | 1417 | if ( paymentBlocks.length > 0 ) { |
| 1275 | 1418 | new StripePayment( form ); |
| 1276 | 1419 | PAYMENT_UTILITY.init( form ); |
| 1420 | + // BOTH MODE: wire the one-time / subscription radio chooser, if present. | |
| 1421 | + initPaymentTypeChoosers( form ); | |
| 1277 | 1422 | } |
| 1278 | 1423 | } |
| 1279 | 1424 | } ); |
| 1425 | + | |
| 1426 | +// BOTH MODE: start — payment-type chooser (one-time vs subscription) wiring. | |
| 1427 | + | |
| 1428 | +/** | |
| 1429 | + * Wire the payment-type chooser radios for any payment blocks in this form | |
| 1430 | + * that were saved with paymentType === 'both'. | |
| 1431 | + * | |
| 1432 | + * @param {HTMLFormElement} form - The form element. | |
| 1433 | + */ | |
| 1434 | +function initPaymentTypeChoosers( form ) { | |
| 1435 | + const paymentBlocks = form.querySelectorAll( | |
| 1436 | + '.srfm-block.srfm-payment-block' | |
| 1437 | + ); | |
| 1438 | + | |
| 1439 | + paymentBlocks.forEach( ( paymentBlock ) => { | |
| 1440 | + const paymentInput = paymentBlock.querySelector( | |
| 1441 | + 'input.srfm-payment-input' | |
| 1442 | + ); | |
| 1443 | + if ( ! paymentInput ) { | |
| 1444 | + return; | |
| 1445 | + } | |
| 1446 | + | |
| 1447 | + // Only wire when the admin chose "both" mode. | |
| 1448 | + if ( | |
| 1449 | + paymentInput.getAttribute( 'data-original-payment-type' ) !== | |
| 1450 | + 'both' | |
| 1451 | + ) { | |
| 1452 | + return; | |
| 1453 | + } | |
| 1454 | + | |
| 1455 | + const radios = paymentBlock.querySelectorAll( | |
| 1456 | + '.srfm-payment-type-choice-radio' | |
| 1457 | + ); | |
| 1458 | + if ( radios.length === 0 ) { | |
| 1459 | + return; | |
| 1460 | + } | |
| 1461 | + | |
| 1462 | + radios.forEach( ( radio ) => { | |
| 1463 | + radio.addEventListener( 'change', ( event ) => { | |
| 1464 | + if ( ! event.target.checked ) { | |
| 1465 | + return; | |
| 1466 | + } | |
| 1467 | + | |
| 1468 | + // BOTH MODE: block type-flip while a payment is in flight. | |
| 1469 | + // reinitForBlock() unmounts the Stripe Element and deletes the | |
| 1470 | + // cached intent the pending confirmPayment is still using — | |
| 1471 | + // flipping mid-submit produces double charges or wrong-type | |
| 1472 | + // completions. Revert the radio to whatever data-payment-type | |
| 1473 | + // currently is so the UI does not lie about the user's choice. | |
| 1474 | + const blockId = | |
| 1475 | + paymentBlock.getAttribute( 'data-block-id' ); | |
| 1476 | + const compositeKey = getPaymentKey( form, blockId ); | |
| 1477 | + const reinitInFlight = | |
| 1478 | + StripePayment.reinitInProgress.has( compositeKey ); | |
| 1479 | + | |
| 1480 | + if ( | |
| 1481 | + form.dataset.srfmPaymentInFlight === 'true' || | |
| 1482 | + reinitInFlight | |
| 1483 | + ) { | |
| 1484 | + const activeType = | |
| 1485 | + paymentInput.getAttribute( 'data-payment-type' ) || | |
| 1486 | + 'one-time'; | |
| 1487 | + radios.forEach( ( r ) => { | |
| 1488 | + r.checked = r.value === activeType; | |
| 1489 | + } ); | |
| 1490 | + return; | |
| 1491 | + } | |
| 1492 | + | |
| 1493 | + switchActivePaymentType( | |
| 1494 | + form, | |
| 1495 | + paymentBlock, | |
| 1496 | + paymentInput, | |
| 1497 | + event.target.value | |
| 1498 | + ); | |
| 1499 | + } ); | |
| 1500 | + } ); | |
| 1501 | + } ); | |
| 1502 | +} | |
| 1503 | + | |
| 1504 | +/** | |
| 1505 | + * Apply a new active payment type to the block. Updates DOM visibility, syncs | |
| 1506 | + * the live data-* attributes that the rest of the JS reads, re-initializes | |
| 1507 | + * the Stripe Element in the new mode, and dispatches a gateway-agnostic event | |
| 1508 | + * so other gateways (e.g. PayPal in sureforms-pro) can react. | |
| 1509 | + * | |
| 1510 | + * @param {HTMLFormElement} form - The form element. | |
| 1511 | + * @param {HTMLElement} paymentBlock - The payment block wrapper. | |
| 1512 | + * @param {HTMLInputElement} paymentInput - The hidden payment input. | |
| 1513 | + * @param {string} newType - 'one-time' or 'subscription'. | |
| 1514 | + */ | |
| 1515 | +function switchActivePaymentType( form, paymentBlock, paymentInput, newType ) { | |
| 1516 | + const safeType = newType === 'subscription' ? 'subscription' : 'one-time'; | |
| 1517 | + | |
| 1518 | + // 1. Toggle visible amount block. | |
| 1519 | + const amountBlocks = paymentBlock.querySelectorAll( | |
| 1520 | + '.srfm-payment-amount-block' | |
| 1521 | + ); | |
| 1522 | + amountBlocks.forEach( ( el ) => { | |
| 1523 | + if ( el.getAttribute( 'data-payment-type' ) === safeType ) { | |
| 1524 | + el.removeAttribute( 'hidden' ); | |
| 1525 | + } else { | |
| 1526 | + el.setAttribute( 'hidden', '' ); | |
| 1527 | + } | |
| 1528 | + } ); | |
| 1529 | + | |
| 1530 | + // 2. Sync the live data attributes from the per-type configuration. | |
| 1531 | + const prefix = safeType === 'subscription' ? 'subscription' : 'one-time'; | |
| 1532 | + const amountType = | |
| 1533 | + paymentInput.getAttribute( `data-${ prefix }-amount-type` ) || 'fixed'; | |
| 1534 | + const fixedAmount = | |
| 1535 | + paymentInput.getAttribute( `data-${ prefix }-fixed-amount` ) || '0'; | |
| 1536 | + const minimumAmount = | |
| 1537 | + paymentInput.getAttribute( `data-${ prefix }-minimum-amount` ) || '0'; | |
| 1538 | + const variableField = | |
| 1539 | + paymentInput.getAttribute( `data-${ prefix }-variable-amount-field` ) || | |
| 1540 | + ''; | |
| 1541 | + | |
| 1542 | + paymentInput.setAttribute( 'data-payment-type', safeType ); | |
| 1543 | + paymentInput.setAttribute( 'data-amount-type', amountType ); | |
| 1544 | + paymentInput.setAttribute( 'data-fixed-amount', fixedAmount ); | |
| 1545 | + | |
| 1546 | + if ( parseFloat( minimumAmount ) > 0 ) { | |
| 1547 | + paymentInput.setAttribute( 'data-minimum-amount', minimumAmount ); | |
| 1548 | + } else { | |
| 1549 | + paymentInput.removeAttribute( 'data-minimum-amount' ); | |
| 1550 | + } | |
| 1551 | + | |
| 1552 | + if ( amountType === 'variable' && variableField ) { | |
| 1553 | + paymentInput.setAttribute( 'data-variable-amount-field', variableField ); | |
| 1554 | + } else { | |
| 1555 | + paymentInput.removeAttribute( 'data-variable-amount-field' ); | |
| 1556 | + // Also clear any cached current-amount so stale values don't leak across choices. | |
| 1557 | + paymentInput.removeAttribute( 'data-current-amount' ); | |
| 1558 | + } | |
| 1559 | + | |
| 1560 | + // 3. Re-initialize Stripe Element in the new mode. | |
| 1561 | + if ( typeof StripePayment.reinitForBlock === 'function' ) { | |
| 1562 | + StripePayment.reinitForBlock( form, paymentBlock ); | |
| 1563 | + } | |
| 1564 | + | |
| 1565 | + // 3b. BOTH MODE: re-wire variable-amount listeners and trigger an immediate | |
| 1566 | + // display update for the new type. listenAmountChanges() queries | |
| 1567 | + // [data-variable-amount-field] which was just updated above, so it will | |
| 1568 | + // find and wire the correct field. The immediate update populates the | |
| 1569 | + // amount span which is empty at PHP render time for variable types. | |
| 1570 | + if ( amountType === 'variable' && variableField ) { | |
| 1571 | + PAYMENT_UTILITY.listenAmountChanges(); | |
| 1572 | + | |
| 1573 | + // Read current value from the mapped field and update the display now. | |
| 1574 | + const mappedBlock = form.querySelector( | |
| 1575 | + `.srfm-block.srfm-slug-${ variableField }` | |
| 1576 | + ); | |
| 1577 | + if ( mappedBlock ) { | |
| 1578 | + const numberInput = mappedBlock.querySelector( | |
| 1579 | + 'input.srfm-input-common' | |
| 1580 | + ); | |
| 1581 | + const dropdownInput = mappedBlock.querySelector( | |
| 1582 | + '.srfm-input-dropdown-hidden' | |
| 1583 | + ); | |
| 1584 | + const multiChoiceInput = mappedBlock.querySelector( | |
| 1585 | + '.srfm-input-multi-choice-hidden' | |
| 1586 | + ); | |
| 1587 | + const hiddenFieldInput = | |
| 1588 | + mappedBlock.querySelector( '.srfm-hidden-input' ); | |
| 1589 | + | |
| 1590 | + let currentValue = 0; | |
| 1591 | + if ( numberInput ) { | |
| 1592 | + currentValue = numberInput.value || 0; | |
| 1593 | + } else if ( dropdownInput ) { | |
| 1594 | + currentValue = | |
| 1595 | + PAYMENT_UTILITY.getDropdownAmount( | |
| 1596 | + mappedBlock, | |
| 1597 | + dropdownInput | |
| 1598 | + ) || 0; | |
| 1599 | + } else if ( multiChoiceInput ) { | |
| 1600 | + currentValue = | |
| 1601 | + PAYMENT_UTILITY.getMultiChoiceAmount( | |
| 1602 | + mappedBlock, | |
| 1603 | + multiChoiceInput | |
| 1604 | + ) || 0; | |
| 1605 | + } else if ( hiddenFieldInput ) { | |
| 1606 | + // Mirror syncAmount() in listenAmountChanges — accept only numeric | |
| 1607 | + // strings, clamp negatives to 0. Without this branch, currentValue | |
| 1608 | + // stays 0 and overwrites the value syncAmount() just wrote during | |
| 1609 | + // listenAmountChanges() above, breaking initial-amount pickup on | |
| 1610 | + // every one-time/subscription flip. | |
| 1611 | + const trimmed = hiddenFieldInput.value.trim(); | |
| 1612 | + const rawValue = /^\d+(\.\d+)?$/.test( trimmed ) | |
| 1613 | + ? parseFloat( trimmed ) | |
| 1614 | + : NaN; | |
| 1615 | + currentValue = | |
| 1616 | + isNaN( rawValue ) || rawValue < 0 ? 0 : rawValue; | |
| 1617 | + } | |
| 1618 | + | |
| 1619 | + PAYMENT_UTILITY.updatePaymentBlockAmount( | |
| 1620 | + paymentInput, | |
| 1621 | + currentValue | |
| 1622 | + ); | |
| 1623 | + } else { | |
| 1624 | + // No mapped field found — show placeholder. | |
| 1625 | + PAYMENT_UTILITY.updatePaymentBlockAmount( paymentInput, 0 ); | |
| 1626 | + } | |
| 1627 | + } | |
| 1628 | + | |
| 1629 | + // 4. Dispatch gateway-agnostic event so other gateways (PayPal etc.) can react. | |
| 1630 | + // | |
| 1631 | + // Event contract — `srfm_payment_type_changed` (document-level, bubbles): | |
| 1632 | + // detail: { | |
| 1633 | + // blockId: string — Original payment block id (no instance prefix). | |
| 1634 | + // form: HTMLFormElement — Form element the payment block belongs to. | |
| 1635 | + // paymentType: 'one-time' | 'subscription' — The newly active type. | |
| 1636 | + // paymentInput: HTMLInputElement — The hidden .srfm-payment-input element. | |
| 1637 | + // } | |
| 1638 | + // Companion event `srfm_payment_method_changed` (dispatched in payment-manager.js) | |
| 1639 | + // uses a deliberately narrower shape — { blockId, paymentMethod, form } — because | |
| 1640 | + // gateway listeners only need the method id; if you need paymentInput on that | |
| 1641 | + // path, query it from the form rather than expanding the schema (avoids drift). | |
| 1642 | + const blockId = paymentBlock.getAttribute( 'data-block-id' ); | |
| 1643 | + document.dispatchEvent( | |
| 1644 | + new CustomEvent( 'srfm_payment_type_changed', { | |
| 1645 | + detail: { | |
| 1646 | + blockId, | |
| 1647 | + form, | |
| 1648 | + paymentType: safeType, | |
| 1649 | + paymentInput, | |
| 1650 | + }, | |
| 1651 | + bubbles: true, | |
| 1652 | + } ) | |
| 1653 | + ); | |
| 1654 | +} | |
| 1655 | +// BOTH MODE: end | |