PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.8
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.8
2.12.8 2.12.7 2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 All 98 releases
← All changes | assets/js/stripe-payment.js +409 -33 2.8.0 → 2.12.8 View file →
@@ -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