| @@ -281,11 +281,14 @@ | ||
| 281 | 281 | if ( field.type === 'url' ) { |
| 282 | 282 | maybeAddHttpsToUrl( field ); |
| 283 | 283 | } |
| 284 | 284 | const form = field.closest( 'form' ); |
| 285 | - if ( form && hasClass( form, 'frm_js_validate' ) ) { | |
| 286 | - validateField( field ); | |
| 285 | + if ( ! form ) { | |
| 286 | + return; | |
| 287 | 287 | } |
| 288 | + | |
| 289 | + // Removing stale errors is universal. Adding errors only happens when JS validation is enabled. | |
| 290 | + validateField( field, hasClass( form, 'frm_js_validate' ) ); | |
| 288 | 291 | } |
| 289 | 292 | |
| 290 | 293 | /** |
| 291 | 294 | * @param {HTMLElement} field |
| @@ -300,13 +303,18 @@ | ||
| 300 | 303 | |
| 301 | 304 | /** |
| 302 | 305 | * Validate a field with JS. |
| 303 | 306 | * |
| 307 | + * Removing stale errors is universal. Adding errors only happens when JS validation is enabled. | |
| 308 | + * | |
| 309 | + * @since 6.32 Added the `addErrors` parameter. | |
| 310 | + * | |
| 304 | 311 | * @param {HTMLElement} field |
| 312 | + * @param {boolean} addErrors Whether to add new errors. Defaults to `true`. | |
| 305 | 313 | * |
| 306 | 314 | * @return {void} |
| 307 | 315 | */ |
| 308 | - function validateField( field ) { | |
| 316 | + function validateField( field, addErrors = true ) { | |
| 309 | 317 | let errors; |
| 310 | 318 | let key; |
| 311 | 319 | |
| 312 | 320 | errors = []; |
| @@ -324,13 +332,20 @@ | ||
| 324 | 332 | if ( errors.length < 1 ) { |
| 325 | 333 | validateFieldValue( field, errors, false ); |
| 326 | 334 | } |
| 327 | 335 | |
| 328 | - removeFieldError( fieldContainer ); | |
| 329 | - if ( Object.keys( errors ).length > 0 ) { | |
| 330 | - for ( key in errors ) { | |
| 331 | - addFieldError( fieldContainer, key, errors ); | |
| 336 | + const hasErrors = Object.keys( errors ).length > 0; | |
| 337 | + | |
| 338 | + if ( addErrors ) { | |
| 339 | + removeFieldError( fieldContainer ); | |
| 340 | + if ( hasErrors ) { | |
| 341 | + for ( key in errors ) { | |
| 342 | + addFieldError( fieldContainer, key, errors ); | |
| 343 | + } | |
| 332 | 344 | } |
| 345 | + } else if ( ! hasErrors ) { | |
| 346 | + // JS validation is off, so only remove existing errors once the field passes validation. | |
| 347 | + removeFieldError( fieldContainer ); | |
| 333 | 348 | } |
| 334 | 349 | } |
| 335 | 350 | |
| 336 | 351 | /** |
| @@ -605,9 +620,9 @@ | ||
| 605 | 620 | const strippedId = field.id.replace( 'conf_', '' ); |
| 606 | 621 | const strippedFieldID = fieldID.replace( 'conf_', '' ); |
| 607 | 622 | const confirmField = document.getElementById( strippedId.replace( 'field_', 'field_conf_' ) ); |
| 608 | 623 | |
| 609 | - if ( confirmField === null || errors[ `conf_${ strippedFieldID }` ] !== undefined ) { | |
| 624 | + if ( ! confirmField || errors[ `conf_${ strippedFieldID }` ] !== undefined ) { | |
| 610 | 625 | return; |
| 611 | 626 | } |
| 612 | 627 | |
| 613 | 628 | if ( fieldID !== strippedFieldID ) { |
| @@ -763,9 +778,9 @@ | ||
| 763 | 778 | } |
| 764 | 779 | |
| 765 | 780 | if ( response.length === 0 ) { |
| 766 | 781 | const fieldContainer = recaptcha.closest( '.frm_form_field' ); |
| 767 | - if ( fieldContainer && fieldContainer.id ) { | |
| 782 | + if ( fieldContainer?.id ) { | |
| 768 | 783 | const fieldID = fieldContainer.id.replace( 'frm_field_', '' ).replace( '_container', '' ); |
| 769 | 784 | errors[ fieldID ] = ''; |
| 770 | 785 | } |
| 771 | 786 | } |
| @@ -882,8 +897,10 @@ | ||
| 882 | 897 | response = defaultResponse; |
| 883 | 898 | } |
| 884 | 899 | } |
| 885 | 900 | |
| 901 | + let willRedirect = false; | |
| 902 | + | |
| 886 | 903 | if ( response.redirect !== undefined ) { |
| 887 | 904 | if ( shouldTriggerEvent ) { |
| 888 | 905 | triggerCustomEvent( object, 'frmSubmitEvent' ); |
| 889 | 906 | return; |
| @@ -895,8 +912,10 @@ | ||
| 895 | 912 | }, 1000 * response.delay ); |
| 896 | 913 | } else { |
| 897 | 914 | doRedirect( response ); |
| 898 | 915 | } |
| 916 | + | |
| 917 | + willRedirect = true; | |
| 899 | 918 | } |
| 900 | 919 | |
| 901 | 920 | if ( 'string' === typeof response.content && response.content !== '' ) { |
| 902 | 921 | // the form or success message was returned |
| @@ -939,9 +958,9 @@ | ||
| 939 | 958 | afterFormSubmitted( object, response ); |
| 940 | 959 | }, |
| 941 | 960 | delay |
| 942 | 961 | ); |
| 943 | - } else if ( Object.keys( response.errors ).length ) { | |
| 962 | + } else if ( response.errors !== undefined && Object.keys( response.errors ).length ) { | |
| 944 | 963 | // errors were returned |
| 945 | 964 | removeSubmitLoading( jQuery( object ), 'enable' ); |
| 946 | 965 | |
| 947 | 966 | //show errors |
| @@ -955,12 +974,12 @@ | ||
| 955 | 974 | $fieldCont = fieldContEl ? jQuery( fieldContEl ) : jQuery(); |
| 956 | 975 | |
| 957 | 976 | if ( $fieldCont.length ) { |
| 958 | 977 | if ( ! $fieldCont.is( ':visible' ) ) { // eslint-disable-line no-jquery/no-is |
| 959 | - const inCollapsedSection = $fieldCont.closest( '.frm_toggle_container' ); // eslint-disable-line no-jquery/no-closest | |
| 978 | + const inCollapsedSection = $fieldCont.closest( '.frm_toggle_container' ); // eslint-disable-line no-jquery/no-closest, formidable/no-jquery-variable-methods | |
| 960 | 979 | if ( inCollapsedSection.length ) { |
| 961 | 980 | let frmTrigger = inCollapsedSection.prev(); |
| 962 | - if ( ! frmTrigger.hasClass( 'frm_trigger' ) ) { | |
| 981 | + if ( ! frmTrigger.hasClass( 'frm_trigger' ) ) { // eslint-disable-line formidable/no-jquery-variable-methods | |
| 963 | 982 | // If the frmTrigger object is the section description, check to see if the previous element is the trigger |
| 964 | 983 | frmTrigger = frmTrigger.prev( '.frm_trigger' ); |
| 965 | 984 | } |
| 966 | 985 | frmTrigger.trigger( 'click' ); |
| @@ -1006,10 +1025,10 @@ | ||
| 1006 | 1025 | } else { |
| 1007 | 1026 | object.insertAdjacentHTML( 'afterbegin', response.error_message ); |
| 1008 | 1027 | checkForErrorsAndMaybeSetFocus(); |
| 1009 | 1028 | } |
| 1010 | - } else { | |
| 1011 | - // there may have been a plugin conflict, or the form is not set to submit with ajax | |
| 1029 | + } else if ( ! willRedirect ) { // Avoid double submission if redirecting to a page. | |
| 1030 | + // There may have been a plugin conflict, or the form is not set to submit with ajax. | |
| 1012 | 1031 | |
| 1013 | 1032 | showFileLoading( object ); |
| 1014 | 1033 | |
| 1015 | 1034 | object.submit(); |
| @@ -1139,12 +1158,12 @@ | ||
| 1139 | 1158 | return; |
| 1140 | 1159 | } |
| 1141 | 1160 | |
| 1142 | 1161 | container.classList.add( 'frm_blank_field' ); |
| 1143 | - const input = container.querySelector( 'input, select, textarea' ); | |
| 1144 | - const id = getErrorElementId( key, input ); | |
| 1162 | + const inputs = container.querySelectorAll( 'input, select, textarea' ); | |
| 1163 | + const id = getErrorElementId( key, inputs[ 0 ] ); | |
| 1145 | 1164 | |
| 1146 | - let describedBy = input ? input.getAttribute( 'aria-describedby' ) : null; | |
| 1165 | + let describedBy; | |
| 1147 | 1166 | |
| 1148 | 1167 | if ( typeof frmThemeOverride_frmPlaceError === 'function' ) { // eslint-disable-line camelcase |
| 1149 | 1168 | frmThemeOverride_frmPlaceError( key, jsErrors ); |
| 1150 | 1169 | } else { |
| @@ -1155,10 +1174,10 @@ | ||
| 1155 | 1174 | const roleString = frm_js.include_alert_role ? 'role="alert"' : ''; |
| 1156 | 1175 | errorHtml = `<div class="frm_error" ${ roleString } id="${ id }">${ jsErrors[ key ] }</div>`; |
| 1157 | 1176 | } |
| 1158 | 1177 | container.insertAdjacentHTML( 'beforeend', errorHtml ); |
| 1159 | - | |
| 1160 | - if ( input ) { | |
| 1178 | + inputs.forEach( input => { | |
| 1179 | + describedBy = input.getAttribute( 'aria-describedby' ); | |
| 1161 | 1180 | if ( ! describedBy ) { |
| 1162 | 1181 | describedBy = id; |
| 1163 | 1182 | } else if ( ! describedBy.includes( id ) && ! describedBy.includes( 'frm_error_field_' ) ) { |
| 1164 | 1183 | const { errorFirst } = input.dataset; |
| @@ -1168,12 +1187,12 @@ | ||
| 1168 | 1187 | describedBy = `${ id } ${ describedBy }`; |
| 1169 | 1188 | } |
| 1170 | 1189 | } |
| 1171 | 1190 | input.setAttribute( 'aria-describedby', describedBy ); |
| 1172 | - } | |
| 1191 | + } ); | |
| 1173 | 1192 | } |
| 1174 | 1193 | |
| 1175 | - if ( input ) { | |
| 1194 | + inputs.forEach( input => { | |
| 1176 | 1195 | if ( [ 'radio', 'checkbox' ].includes( input.type ) ) { |
| 1177 | 1196 | const group = input.closest( '[role="radiogroup"], [role="group"]' ); |
| 1178 | 1197 | if ( group ) { |
| 1179 | 1198 | group.setAttribute( 'aria-invalid', 'true' ); |
| @@ -1180,9 +1199,9 @@ | ||
| 1180 | 1199 | } |
| 1181 | 1200 | } else { |
| 1182 | 1201 | input.setAttribute( 'aria-invalid', 'true' ); |
| 1183 | 1202 | } |
| 1184 | - } | |
| 1203 | + } ); | |
| 1185 | 1204 | |
| 1186 | 1205 | jQuery( document ).trigger( 'frmAddFieldError', [ jQuery( container ), key, jsErrors ] ); |
| 1187 | 1206 | } |
| 1188 | 1207 | |
| @@ -1214,11 +1233,9 @@ | ||
| 1214 | 1233 | return; |
| 1215 | 1234 | } |
| 1216 | 1235 | |
| 1217 | 1236 | const errorMessage = container.querySelector( '.frm_error' ); |
| 1218 | - const errorId = errorMessage ? errorMessage.id : ''; | |
| 1219 | 1237 | const input = container.querySelector( 'input, select, textarea' ); |
| 1220 | - let describedBy = input ? input.getAttribute( 'aria-describedby' ) : null; | |
| 1221 | 1238 | |
| 1222 | 1239 | container.classList.remove( 'frm_blank_field', 'has-error' ); |
| 1223 | 1240 | |
| 1224 | 1241 | if ( input ) { |
| @@ -1232,20 +1249,35 @@ | ||
| 1232 | 1249 | } |
| 1233 | 1250 | } |
| 1234 | 1251 | |
| 1235 | 1252 | if ( errorMessage ) { |
| 1253 | + removeElementFromInputDescribedBy( errorMessage ); | |
| 1236 | 1254 | errorMessage.remove(); |
| 1237 | 1255 | } |
| 1256 | + } | |
| 1238 | 1257 | |
| 1239 | - if ( input ) { | |
| 1258 | + /** | |
| 1259 | + * Updates the aria-describedby attribute, removing the target element ID. | |
| 1260 | + * | |
| 1261 | + * @since 6.32 | |
| 1262 | + * | |
| 1263 | + * @param {HTMLElement} el The target element that is removed from the aria-describedby data. | |
| 1264 | + * @return {void} | |
| 1265 | + */ | |
| 1266 | + function removeElementFromInputDescribedBy( el ) { | |
| 1267 | + document.querySelectorAll( `[aria-describedby*="${ el.id }"]` ).forEach( input => { | |
| 1268 | + let ariaDescribedBy = input.getAttribute( 'aria-describedby' ).split( ' ' ); | |
| 1269 | + ariaDescribedBy = ariaDescribedBy.filter( value => { | |
| 1270 | + const trimmedValue = value.trim(); | |
| 1271 | + return trimmedValue && trimmedValue !== el.id; | |
| 1272 | + } ); | |
| 1273 | + | |
| 1274 | + if ( ariaDescribedBy.length ) { | |
| 1275 | + input.setAttribute( 'aria-describedby', ariaDescribedBy.join( ' ' ) ); | |
| 1276 | + return; | |
| 1277 | + } | |
| 1240 | 1278 | input.removeAttribute( 'aria-describedby' ); |
| 1241 | - if ( describedBy ) { | |
| 1242 | - describedBy = describedBy.replace( errorId, '' ).trim(); | |
| 1243 | - if ( describedBy ) { | |
| 1244 | - input.setAttribute( 'aria-describedby', describedBy ); | |
| 1245 | - } | |
| 1246 | - } | |
| 1247 | - } | |
| 1279 | + } ); | |
| 1248 | 1280 | } |
| 1249 | 1281 | |
| 1250 | 1282 | function removeAllErrors() { |
| 1251 | 1283 | document.querySelectorAll( '.form-field' ).forEach( field => { |
| @@ -1250,9 +1282,12 @@ | ||
| 1250 | 1282 | function removeAllErrors() { |
| 1251 | 1283 | document.querySelectorAll( '.form-field' ).forEach( field => { |
| 1252 | 1284 | field.classList.remove( 'frm_blank_field', 'has-error' ); |
| 1253 | 1285 | } ); |
| 1254 | - document.querySelectorAll( '.form-field .frm_error' ).forEach( error => error.remove() ); | |
| 1286 | + document.querySelectorAll( '.form-field .frm_error' ).forEach( el => { | |
| 1287 | + removeElementFromInputDescribedBy( el ); | |
| 1288 | + el.remove(); | |
| 1289 | + } ); | |
| 1255 | 1290 | document.querySelectorAll( '.frm_error_style' ).forEach( error => error.remove() ); |
| 1256 | 1291 | } |
| 1257 | 1292 | |
| 1258 | 1293 | /** |
| @@ -1276,9 +1311,9 @@ | ||
| 1276 | 1311 | disableSaveDraft( $object ); |
| 1277 | 1312 | } |
| 1278 | 1313 | |
| 1279 | 1314 | function showLoadingIndicator( $object ) { |
| 1280 | - if ( ! $object.hasClass( 'frm_loading_form' ) && ! $object.hasClass( 'frm_loading_prev' ) ) { // eslint-disable-line no-jquery/no-class | |
| 1315 | + if ( ! $object.hasClass( 'frm_loading_form' ) && ! $object.hasClass( 'frm_loading_prev' ) ) { // eslint-disable-line no-jquery/no-class, formidable/no-jquery-variable-methods | |
| 1281 | 1316 | addLoadingClass( $object ); |
| 1282 | 1317 | $object.trigger( 'frmStartFormLoading' ); |
| 1283 | 1318 | } |
| 1284 | 1319 | } |
| @@ -1285,13 +1320,13 @@ | ||
| 1285 | 1320 | |
| 1286 | 1321 | function addLoadingClass( $object ) { |
| 1287 | 1322 | const loadingClass = isGoingToPrevPage( $object ) ? 'frm_loading_prev' : 'frm_loading_form'; |
| 1288 | 1323 | |
| 1289 | - $object.addClass( loadingClass ); // eslint-disable-line no-jquery/no-class | |
| 1324 | + $object.addClass( loadingClass ); // eslint-disable-line no-jquery/no-class, formidable/no-jquery-variable-methods | |
| 1290 | 1325 | } |
| 1291 | 1326 | |
| 1292 | 1327 | function isGoingToPrevPage( $object ) { |
| 1293 | - return ( typeof frmProForm !== 'undefined' && frmProForm.goingToPreviousPage( $object ) ); | |
| 1328 | + return typeof frmProForm !== 'undefined' && frmProForm.goingToPreviousPage( $object ); | |
| 1294 | 1329 | } |
| 1295 | 1330 | |
| 1296 | 1331 | function removeSubmitLoading( _, enable, processesRunning ) { |
| 1297 | 1332 | if ( processesRunning > 0 ) { |
| @@ -1310,9 +1345,9 @@ | ||
| 1310 | 1345 | } |
| 1311 | 1346 | |
| 1312 | 1347 | function showFileLoading( object ) { |
| 1313 | 1348 | const loading = document.getElementById( 'frm_loading' ); |
| 1314 | - if ( loading === null ) { | |
| 1349 | + if ( ! loading ) { | |
| 1315 | 1350 | return; |
| 1316 | 1351 | } |
| 1317 | 1352 | |
| 1318 | 1353 | const fileInput = object.querySelector( 'input[type=file]' ); |
| @@ -1341,9 +1376,9 @@ | ||
| 1341 | 1376 | */ |
| 1342 | 1377 | function onHoneypotFieldChange() { |
| 1343 | 1378 | /*jshint validthis:true */ |
| 1344 | 1379 | const css = window.getComputedStyle( this ).boxShadow; |
| 1345 | - if ( css && css.match( /inset/ ) ) { | |
| 1380 | + if ( css?.match( /inset/ ) ) { | |
| 1346 | 1381 | this.remove(); |
| 1347 | 1382 | } |
| 1348 | 1383 | } |
| 1349 | 1384 | |
| @@ -1555,8 +1590,9 @@ | ||
| 1555 | 1590 | ); |
| 1556 | 1591 | } |
| 1557 | 1592 | |
| 1558 | 1593 | checkDropdownLabel(); |
| 1594 | + calcProductsTotal(); | |
| 1559 | 1595 | }; |
| 1560 | 1596 | |
| 1561 | 1597 | runOnLoad( true ); |
| 1562 | 1598 | |
| @@ -1730,8 +1766,431 @@ | ||
| 1730 | 1766 | label.htmlFor = captchaResponse.id; |
| 1731 | 1767 | } |
| 1732 | 1768 | } |
| 1733 | 1769 | |
| 1770 | + /** | |
| 1771 | + * Check to make sure the quantity field value is within the min and max values. | |
| 1772 | + * | |
| 1773 | + * @param {HTMLElement} input | |
| 1774 | + * @return {number} The quantity value. | |
| 1775 | + */ | |
| 1776 | + function checkQuantityFieldMinMax( input ) { | |
| 1777 | + if ( '' === input.value ) { | |
| 1778 | + // Leave the value if it is empty. | |
| 1779 | + return 0; | |
| 1780 | + } | |
| 1781 | + | |
| 1782 | + const val = parseFloat( input.value ? input.value.trim() : 0 ); | |
| 1783 | + if ( isNaN( val ) ) { | |
| 1784 | + return 0; | |
| 1785 | + } | |
| 1786 | + | |
| 1787 | + let max = input.hasAttribute( 'max' ) ? parseFloat( input.getAttribute( 'max' ) ) : 0; | |
| 1788 | + let min = input.hasAttribute( 'min' ) ? parseFloat( input.getAttribute( 'min' ) ) : 0; | |
| 1789 | + | |
| 1790 | + max = isNaN( max ) ? 0 : max; | |
| 1791 | + min = isNaN( min ) ? 0 : Math.max( 0, min ); | |
| 1792 | + | |
| 1793 | + if ( val < min ) { | |
| 1794 | + input.value = min; | |
| 1795 | + return min; | |
| 1796 | + } | |
| 1797 | + | |
| 1798 | + if ( 0 !== max && val > max ) { | |
| 1799 | + input.value = max; | |
| 1800 | + return max; | |
| 1801 | + } | |
| 1802 | + | |
| 1803 | + return val; | |
| 1804 | + } | |
| 1805 | + | |
| 1806 | + function triggerChange( input, fieldKey ) { | |
| 1807 | + if ( fieldKey === undefined ) { | |
| 1808 | + fieldKey = 'dependent'; | |
| 1809 | + } | |
| 1810 | + | |
| 1811 | + jQuery( input ).trigger( { type: 'change', selfTriggered: true, frmTriggered: fieldKey } ); | |
| 1812 | + } | |
| 1813 | + | |
| 1814 | + /** | |
| 1815 | + * Calculates the total price. | |
| 1816 | + * | |
| 1817 | + * @param {Event|undefined} e The event object. | |
| 1818 | + * @return {void} | |
| 1819 | + */ | |
| 1820 | + function calcProductsTotal( e ) { | |
| 1821 | + if ( 'object' === typeof frmProForm ) { | |
| 1822 | + // Pro is installed, use the Pro JS. | |
| 1823 | + return; | |
| 1824 | + } | |
| 1825 | + | |
| 1826 | + if ( typeof __FRMCURR === 'undefined' ) { | |
| 1827 | + return; | |
| 1828 | + } | |
| 1829 | + | |
| 1830 | + const totalFields = document.querySelectorAll( '[data-frmtotal]' ); | |
| 1831 | + if ( ! totalFields.length ) { | |
| 1832 | + return; | |
| 1833 | + } | |
| 1834 | + | |
| 1835 | + const formTotals = []; | |
| 1836 | + | |
| 1837 | + totalFields.forEach( totalField => { | |
| 1838 | + let total = 0; | |
| 1839 | + const form = totalField.closest( 'form' ); | |
| 1840 | + | |
| 1841 | + if ( ! form ) { | |
| 1842 | + return; | |
| 1843 | + } | |
| 1844 | + | |
| 1845 | + const formId = form.querySelector( 'input[name="form_id"]' ).value; | |
| 1846 | + const currency = getCurrency( formId ); | |
| 1847 | + | |
| 1848 | + if ( undefined !== formTotals[ formId ] ) { | |
| 1849 | + total = formTotals[ formId ]; | |
| 1850 | + } else { | |
| 1851 | + form.querySelectorAll( 'input[data-frmprice],select:has([data-frmprice])' ).forEach( function( input ) { | |
| 1852 | + let quantity = 0; | |
| 1853 | + let price = 0; | |
| 1854 | + const isSingle = 'hidden' === input.type; | |
| 1855 | + | |
| 1856 | + if ( input.tagName === 'SELECT' ) { | |
| 1857 | + if ( input.selectedIndex !== -1 ) { | |
| 1858 | + price = input.options[ input.selectedIndex ].getAttribute( 'data-frmprice' ); | |
| 1859 | + } | |
| 1860 | + } else { | |
| 1861 | + if ( ! isSingle && ! input.matches( ':checked' ) ) { | |
| 1862 | + return; | |
| 1863 | + } | |
| 1864 | + price = input.getAttribute( 'data-frmprice' ); | |
| 1865 | + } | |
| 1866 | + | |
| 1867 | + if ( ! price ) { | |
| 1868 | + price = 0; | |
| 1869 | + } else { | |
| 1870 | + price = preparePrice( price, currency ); | |
| 1871 | + quantity = getQuantity( input ); | |
| 1872 | + price = parseFloat( quantity ) * parseFloat( price ); | |
| 1873 | + } | |
| 1874 | + | |
| 1875 | + if ( 'true' === input.getAttribute( 'data-frmdiscount' ) ) { | |
| 1876 | + price = price * -1; | |
| 1877 | + } | |
| 1878 | + | |
| 1879 | + total += price; | |
| 1880 | + } ); | |
| 1881 | + | |
| 1882 | + formTotals[ formId ] = total; | |
| 1883 | + } | |
| 1884 | + | |
| 1885 | + total = isNaN( total ) ? 0 : total; | |
| 1886 | + | |
| 1887 | + // Set a decimal separator for currency if no default for it | |
| 1888 | + currency.decimal_separator = currency.decimal_separator.trim(); // first remove unnecessary space(s) | |
| 1889 | + if ( ! currency.decimal_separator.length ) { | |
| 1890 | + currency.decimal_separator = '.'; | |
| 1891 | + } | |
| 1892 | + | |
| 1893 | + totalField.value = roundTotal( total, currency ); | |
| 1894 | + total = normalizeTotal( total, currency ); | |
| 1895 | + | |
| 1896 | + // because of e.g. fields that might be using this field for calculations | |
| 1897 | + triggerChange( totalField ); | |
| 1898 | + | |
| 1899 | + total = formatCurrency( total, currency ); | |
| 1900 | + const formatted = totalField.previousElementSibling; | |
| 1901 | + if ( formatted?.matches( '.frm_total_formatted' ) ) { | |
| 1902 | + // Use innerHTML so that currency symbols like Euros can render and not their encoded string value. | |
| 1903 | + formatted.innerHTML = total; | |
| 1904 | + return; | |
| 1905 | + } | |
| 1906 | + | |
| 1907 | + const formattedEls = totalField.closest( '.frm_form_field' ).querySelectorAll( '.frm_total_formatted' ); | |
| 1908 | + formattedEls.forEach( formattedEl => { | |
| 1909 | + // Use innerHTML so that currency symbols like Euros can render and not their encoded string value. | |
| 1910 | + formattedEl.innerHTML = total; | |
| 1911 | + } ); | |
| 1912 | + } ); | |
| 1913 | + } | |
| 1914 | + | |
| 1915 | + /** | |
| 1916 | + * Round total and maybe add trailing zeros so formatCurrency has a proper format to work with. | |
| 1917 | + * | |
| 1918 | + * @param {number} total The total amount to normalize. | |
| 1919 | + * @param {Object} currency The currency object containing decimal information. | |
| 1920 | + * @return {string} The normalized total amount. | |
| 1921 | + */ | |
| 1922 | + function normalizeTotal( total, currency ) { | |
| 1923 | + const isLargeTotal = total > Number.MAX_SAFE_INTEGER; | |
| 1924 | + | |
| 1925 | + total = roundTotal( total, currency ); | |
| 1926 | + | |
| 1927 | + return maybeAddTrailingZeroToPrice( total, currency, isLargeTotal ); | |
| 1928 | + } | |
| 1929 | + | |
| 1930 | + function roundTotal( total, currency ) { | |
| 1931 | + const isLargeTotal = total > Number.MAX_SAFE_INTEGER; | |
| 1932 | + | |
| 1933 | + if ( ! isLargeTotal ) { | |
| 1934 | + const { decimals } = currency; | |
| 1935 | + total = decimals > 0 ? round10( total, decimals ) : Math.ceil( total ); | |
| 1936 | + } | |
| 1937 | + | |
| 1938 | + return total; | |
| 1939 | + } | |
| 1940 | + | |
| 1941 | + function round10( value, decimals ) { | |
| 1942 | + return Number( `${ Math.round( `${ value }e${ decimals }` ) }e-${ decimals }` ); | |
| 1943 | + } | |
| 1944 | + | |
| 1945 | + /** | |
| 1946 | + * Format a numeric value according to the specified currency format settings. | |
| 1947 | + * | |
| 1948 | + * @param {string} total The numeric value to format. | |
| 1949 | + * @param {Object} currency The currency formatting configuration. | |
| 1950 | + * @return {string} The formatted currency string. | |
| 1951 | + */ | |
| 1952 | + function formatCurrency( total, currency ) { | |
| 1953 | + total = maybeAddTrailingZeroToPrice( total, currency ); | |
| 1954 | + if ( total.length && ( total[ total.length - 1 ] === '.' || total[ total.length - 1 ] === currency.decimal_separator ) ) { | |
| 1955 | + total = total.substr( 0, total.length - 1 ); | |
| 1956 | + } | |
| 1957 | + | |
| 1958 | + total = maybeRemoveTrailingZerosFromPrice( total, currency ); | |
| 1959 | + total = addThousands( total, currency ); | |
| 1960 | + | |
| 1961 | + const leftSymbol = currency.symbol_left ? ( currency.symbol_left + currency.symbol_padding ) : ''; | |
| 1962 | + const rightSymbol = currency.symbol_right ? ( currency.symbol_padding + currency.symbol_right ) : ''; | |
| 1963 | + | |
| 1964 | + return `${ leftSymbol }${ total }${ rightSymbol }`; | |
| 1965 | + } | |
| 1966 | + | |
| 1967 | + /** | |
| 1968 | + * Gets currency from form id. | |
| 1969 | + * | |
| 1970 | + * @param {number} formId Form ID. | |
| 1971 | + * @return {Object} Currency object. | |
| 1972 | + */ | |
| 1973 | + function getCurrency( formId ) { | |
| 1974 | + if ( undefined !== window.__FRMCURR && undefined !== window.__FRMCURR[ formId ] ) { | |
| 1975 | + return window.__FRMCURR[ formId ]; | |
| 1976 | + } | |
| 1977 | + | |
| 1978 | + return { | |
| 1979 | + symbol_left: '$', | |
| 1980 | + symbol_right: '', | |
| 1981 | + symbol_padding: '', | |
| 1982 | + thousand_separator: ',', | |
| 1983 | + decimal_separator: '.', | |
| 1984 | + decimals: 2, | |
| 1985 | + }; | |
| 1986 | + } | |
| 1987 | + | |
| 1988 | + /** | |
| 1989 | + * Gets quantity. | |
| 1990 | + * | |
| 1991 | + * @param {HTMLElement} field The field element. | |
| 1992 | + * @return {number} The quantity. | |
| 1993 | + */ | |
| 1994 | + function getQuantity( field ) { | |
| 1995 | + const fieldID = frmFrontForm.getFieldId( field, false ); | |
| 1996 | + if ( ! fieldID ) { | |
| 1997 | + return 0; | |
| 1998 | + } | |
| 1999 | + | |
| 2000 | + const quantityField = getQuantityField( field, fieldID ); | |
| 2001 | + if ( ! quantityField ) { | |
| 2002 | + // If there is no quantity field, assume 1. | |
| 2003 | + return 1; | |
| 2004 | + } | |
| 2005 | + | |
| 2006 | + return checkQuantityFieldMinMax( quantityField ); | |
| 2007 | + } | |
| 2008 | + | |
| 2009 | + /** | |
| 2010 | + * Gets quantity field. | |
| 2011 | + * | |
| 2012 | + * @param {HTMLElement} element The element. | |
| 2013 | + * @param {number} fieldID The field ID. | |
| 2014 | + * @return {HTMLElement|null} The quantity field. | |
| 2015 | + */ | |
| 2016 | + function getQuantityField( element, fieldID ) { | |
| 2017 | + const quantityFields = element.closest( 'form' ).querySelectorAll( '[data-frmproduct]' ); | |
| 2018 | + if ( ! quantityFields.length ) { | |
| 2019 | + return null; | |
| 2020 | + } | |
| 2021 | + | |
| 2022 | + fieldID = fieldID.toString(); | |
| 2023 | + | |
| 2024 | + return Array.from( quantityFields ).find( element => { | |
| 2025 | + let ids; | |
| 2026 | + | |
| 2027 | + ids = JSON.parse( element.getAttribute( 'data-frmproduct' ).trim() ); | |
| 2028 | + if ( '' === ids ) { | |
| 2029 | + return false; | |
| 2030 | + } | |
| 2031 | + | |
| 2032 | + // Convert to array if necessary because of existing fields that are already using single product fields. | |
| 2033 | + ids = 'string' === typeof ids ? [ ids ] : ids; | |
| 2034 | + return ids.includes( fieldID ); | |
| 2035 | + } ); | |
| 2036 | + } | |
| 2037 | + | |
| 2038 | + /** | |
| 2039 | + * Prepare a price for calculation. | |
| 2040 | + * | |
| 2041 | + * @param {number|string} price The price to prepare. | |
| 2042 | + * @param {Object} currency The currency object containing decimal information. | |
| 2043 | + * @return {string} The prepared price. | |
| 2044 | + */ | |
| 2045 | + function preparePrice( price, currency ) { | |
| 2046 | + if ( ! price ) { | |
| 2047 | + return 0; | |
| 2048 | + } | |
| 2049 | + price = `${ price }`; // convert to string just to be sure | |
| 2050 | + | |
| 2051 | + const regex = getRegexForPrice( currency ); | |
| 2052 | + | |
| 2053 | + const matches = price.match( regex ); | |
| 2054 | + if ( null === matches ) { | |
| 2055 | + return 0; | |
| 2056 | + } | |
| 2057 | + | |
| 2058 | + price = matches.length ? matches[ matches.length - 1 ] : 0; | |
| 2059 | + price = price.trim(); | |
| 2060 | + | |
| 2061 | + // Fix issues with parsing Fr.15.00. The regex catches .15.00. | |
| 2062 | + // This checks for the leading decimal and removes it. | |
| 2063 | + if ( currency.decimal_separator === '.' && 3 === price.split( '.' ).length && price[ 0 ] === '.' ) { | |
| 2064 | + price = price.substr( 1 ); | |
| 2065 | + } | |
| 2066 | + | |
| 2067 | + if ( price ) { | |
| 2068 | + price = maybeUseDecimal( price, currency ); | |
| 2069 | + price = price.split( currency.thousand_separator ).join( '' ).replace( currency.decimal_separator, '.' ); | |
| 2070 | + } | |
| 2071 | + | |
| 2072 | + return price; | |
| 2073 | + } | |
| 2074 | + | |
| 2075 | + /** | |
| 2076 | + * @param {Object} currency The currency object. | |
| 2077 | + * @return {RegExp} The regular expression object. | |
| 2078 | + */ | |
| 2079 | + function getRegexForPrice( currency ) { | |
| 2080 | + let regexString = '[0-9,.'; | |
| 2081 | + | |
| 2082 | + if ( currency.thousand_separator !== '.' && currency.thousand_separator !== ',' ) { | |
| 2083 | + regexString += currency.thousand_separator; | |
| 2084 | + } | |
| 2085 | + if ( currency.decimal_separator !== '.' && currency.decimal_separator !== ',' ) { | |
| 2086 | + regexString += currency.decimal_separator; | |
| 2087 | + } | |
| 2088 | + | |
| 2089 | + regexString += ']*\\.?\\,?[0-9]+'; | |
| 2090 | + | |
| 2091 | + return new RegExp( regexString, 'g' ); | |
| 2092 | + } | |
| 2093 | + | |
| 2094 | + /** | |
| 2095 | + * Maybe replace the decimal separator with the currency's decimal separator. | |
| 2096 | + * | |
| 2097 | + * @param {string} price The price string. | |
| 2098 | + * @param {Object} currency The currency object. | |
| 2099 | + * @return {string} The modified price string. | |
| 2100 | + */ | |
| 2101 | + function maybeUseDecimal( price, currency ) { | |
| 2102 | + let usedForDecimal; | |
| 2103 | + let priceParts; | |
| 2104 | + if ( '.' === currency.thousand_separator ) { | |
| 2105 | + priceParts = price.split( '.' ); | |
| 2106 | + usedForDecimal = 2 === priceParts.length && 2 === priceParts[ 1 ].length; | |
| 2107 | + if ( usedForDecimal ) { | |
| 2108 | + price = price.replace( '.', currency.decimal_separator ); | |
| 2109 | + } | |
| 2110 | + } | |
| 2111 | + return price; | |
| 2112 | + } | |
| 2113 | + | |
| 2114 | + /** | |
| 2115 | + * Add trailing zeros to a price if necessary and replace the decimal separator. | |
| 2116 | + * | |
| 2117 | + * @param {number|string} price The price to format. | |
| 2118 | + * @param {Object} currency The currency object containing the decimal separator. | |
| 2119 | + * @param {boolean} [force=false] Whether to force processing even if the price is not a number. | |
| 2120 | + * @return {string} The formatted price string. | |
| 2121 | + */ | |
| 2122 | + function maybeAddTrailingZeroToPrice( price, currency, force = false ) { | |
| 2123 | + if ( 'number' !== typeof price && ! force ) { | |
| 2124 | + return price; | |
| 2125 | + } | |
| 2126 | + | |
| 2127 | + price = String( price ); // first convert to string | |
| 2128 | + const pos = price.indexOf( '.' ); | |
| 2129 | + | |
| 2130 | + if ( pos === -1 ) { | |
| 2131 | + price = `${ price }.`; | |
| 2132 | + | |
| 2133 | + for ( let n = 0; n < currency.decimals; ++n ) { | |
| 2134 | + price += '0'; | |
| 2135 | + } | |
| 2136 | + } else { | |
| 2137 | + const decimalsString = price.substring( pos + 1 ); | |
| 2138 | + if ( decimalsString.length < currency.decimals ) { | |
| 2139 | + if ( decimalsString.length < 2 ) { | |
| 2140 | + price += '0'; | |
| 2141 | + } | |
| 2142 | + | |
| 2143 | + for ( let n = 2; n < currency.decimals; ++n ) { | |
| 2144 | + price += '0'; | |
| 2145 | + } | |
| 2146 | + } | |
| 2147 | + } | |
| 2148 | + | |
| 2149 | + return price.replace( '.', currency.decimal_separator ); | |
| 2150 | + } | |
| 2151 | + | |
| 2152 | + /** | |
| 2153 | + * Format a numeric string by adding thousand separators. | |
| 2154 | + * | |
| 2155 | + * @param {string|number} price The numeric value to format. | |
| 2156 | + * @param {Object} options Formatting options. | |
| 2157 | + * @param {string} options.decimal_separator Character used as decimal separator. | |
| 2158 | + * @param {string} options.thousand_separator Character used as thousand separator. | |
| 2159 | + * | |
| 2160 | + * @return {string} The price string with thousand separators. | |
| 2161 | + */ | |
| 2162 | + function addThousands( price, options ) { | |
| 2163 | + const split = options.decimal_separator === '' | |
| 2164 | + ? [ price.toString() ] | |
| 2165 | + : price.split( options.decimal_separator ); | |
| 2166 | + | |
| 2167 | + if ( options.thousand_separator ) { | |
| 2168 | + split[ 0 ] = split[ 0 ].replace( /\B(?=(\d{3})+(?!\d))/g, options.thousand_separator ); | |
| 2169 | + } | |
| 2170 | + | |
| 2171 | + return split.join( options.decimal_separator ); | |
| 2172 | + } | |
| 2173 | + | |
| 2174 | + /** | |
| 2175 | + * Maybe remove trailing zeros from a price string. | |
| 2176 | + * | |
| 2177 | + * @param {string} price The price string. | |
| 2178 | + * @param {Object} currency The currency data. | |
| 2179 | + * | |
| 2180 | + * @return {string} The price string with trailing zeros removed. | |
| 2181 | + */ | |
| 2182 | + function maybeRemoveTrailingZerosFromPrice( price, currency ) { | |
| 2183 | + const split = price.split( currency.decimal_separator ); | |
| 2184 | + if ( 2 !== split.length || split[ 1 ].length <= currency.decimals ) { | |
| 2185 | + return price; | |
| 2186 | + } | |
| 2187 | + if ( 0 === currency.decimals ) { | |
| 2188 | + return split[ 0 ]; | |
| 2189 | + } | |
| 2190 | + return `${ split[ 0 ] }${ currency.decimal_separator }${ split[ 1 ].substr( 0, currency.decimals ) }`; | |
| 2191 | + } | |
| 2192 | + | |
| 1734 | 2193 | return { |
| 1735 | 2194 | init() { |
| 1736 | 2195 | jQuery( document ).off( 'submit.formidable', '.frm-show-form' ); |
| 1737 | 2196 | jQuery( document ).on( 'submit.formidable', '.frm-show-form', frmFrontForm.submitForm ); |
| @@ -1763,8 +2222,13 @@ | ||
| 1763 | 2222 | jQuery( document ).on( |
| 1764 | 2223 | 'frmPageChanged', |
| 1765 | 2224 | destroyhCaptcha |
| 1766 | 2225 | ); |
| 2226 | + | |
| 2227 | + jQuery( document ).on( 'frmAfterAddRow frmAfterRemoveRow', calcProductsTotal ); | |
| 2228 | + jQuery( document ).on( 'change', '[type="checkbox"][data-frmprice],[type="radio"][data-frmprice],[type="hidden"][data-frmprice],select:has([data-frmprice])', calcProductsTotal ); | |
| 2229 | + jQuery( document ).on( 'keyup change', '[data-frmproduct],[type="text"][data-frmprice]', calcProductsTotal ); | |
| 2230 | + calcProductsTotal(); | |
| 1767 | 2231 | }, |
| 1768 | 2232 | |
| 1769 | 2233 | getFieldId, |
| 1770 | 2234 | |
| @@ -1900,9 +2364,9 @@ | ||
| 1900 | 2364 | * @return {Array} List of errors. |
| 1901 | 2365 | */ |
| 1902 | 2366 | validateFormSubmit( object ) { |
| 1903 | 2367 | const form = object instanceof jQuery ? object.get( 0 ) : object; |
| 1904 | - if ( typeof tinyMCE !== 'undefined' && form && form.querySelector( '.wp-editor-wrap' ) ) { | |
| 2368 | + if ( typeof tinyMCE !== 'undefined' && form?.querySelector( '.wp-editor-wrap' ) ) { | |
| 1905 | 2369 | tinyMCE.triggerSave(); |
| 1906 | 2370 | } |
| 1907 | 2371 | |
| 1908 | 2372 | jsErrors = []; |
| @@ -2106,9 +2570,9 @@ | ||
| 2106 | 2570 | const captcha = captchas[ c ]; |
| 2107 | 2571 | if ( ! formIsVisible ) { |
| 2108 | 2572 | // If the form is not visible, try again later in 400ms. |
| 2109 | 2573 | // This fixes issues where the form fades visible on page load. |
| 2110 | - // Or whne the form is inside of a modal. | |
| 2574 | + // Or when the form is inside of a modal. | |
| 2111 | 2575 | const interval = setInterval( |
| 2112 | 2576 | function() { |
| 2113 | 2577 | if ( closestForm && closestForm.offsetParent !== null ) { |
| 2114 | 2578 | frmFrontForm.renderCaptcha( captcha, captchaSelector ); |