1/** 2 * Cayden Form Manager â form runtime. 3 * 4 * CRITICAL (spec §7.5 / §13): the nonce and time-trap timestamp are fetched 5 * AFTER load from an uncached endpoint and injected into the form. They are 6 * never printed into the (edge-cached) HTML, so they can't go stale. If you 7 * bake a nonce into the markup, submissions fail intermittently in production. 8 * 9 * Also owns accessible validation UI: an error summary that receives focus, 10 * inline messages announced via role="alert", conditional field visibility, 11 * and multi-page navigation. 12 */ 13( function () { 14 'use strict'; 15 16 var cfg = window.ACPS_ST || {}; 17 var strings = cfg.strings || {}; 18 var restUrl = ( cfg.restUrl || '' ).replace( /\/$/, '' ); 19 20 function ready( fn ) { 21 if ( document.readyState === 'loading' ) { 22 document.addEventListener( 'DOMContentLoaded', fn ); 23 } else { 24 fn(); 25 } 26 } 27 28 ready( function () { 29 var forms = document.querySelectorAll( '.acps-form' ); 30 Array.prototype.forEach.call( forms, function ( form ) { 31 new ACPSForm( form ); 32 } ); 33 } ); 34 35 function ACPSForm( form ) { 36 this.form = form; 37 this.errorSummary = form.querySelector( '.acps-error-summary' ); 38 this.statusRegion = form.querySelector( '.acps-status' ); 39 this.multipage = form.getAttribute( 'data-multipage' ) === '1'; 40 this.pages = form.querySelectorAll( '.acps-page' ); 41 this.current = 0; 42 43 this.bindLazyTokens(); 44 this.bindHoneypot(); 45 this.bindConditional(); 46 if ( this.multipage && this.pages.length > 1 ) { 47 this.bindPaging(); 48 } 49 this.bindSubmit(); 50 } 51 52 /* MINIMAL REQUESTS: the session token and visitor id are free (already in 53 memory / cookie), so set them now. But the nonce/timestamp need an uncached 54 request, so DON'T fetch on page load â only when the visitor actually 55 engages with the form (first focus). Pages where nobody touches a form make 56 zero extra requests. submit() also guarantees the token is present. */ 57 ACPSForm.prototype.bindLazyTokens = function () { 58 var self = this; 59 var form = this.form; 60 61 var rt = window.ACPS_ST_RT || {}; 62 if ( rt.token ) { 63 setVal( form, 'acps_session', rt.token ); 64 } 65 // Visitor identity is derived server-side (IP + user agent) on submit â 66 // no client id is sent. 67 68 var onFirst = function () { 69 form.removeEventListener( 'focusin', onFirst ); 70 self.ensureTokens(); 71 }; 72 form.addEventListener( 'focusin', onFirst ); 73 }; 74 75 /* Fetch the nonce + timestamp once (cached promise) and inject them. */ 76 ACPSForm.prototype.ensureTokens = function () { 77 if ( this._tokenPromise ) { 78 return this._tokenPromise; 79 } 80 var form = this.form; 81 this._tokenPromise = fetch( restUrl + '/token', { credentials: 'same-origin' } ) 82 .then( function ( r ) { return r.json(); } ) 83 .then( function ( data ) { 84 setVal( form, 'acps_nonce', data.nonce ); 85 setVal( form, 'acps_ts', data.ts ); 86 return data; 87 } ) 88 .catch( function () { return null; } ); 89 return this._tokenPromise; 90 }; 91 92 /* The honeypot: give it a submit name only in JS, keep it empty. */ 93 ACPSForm.prototype.bindHoneypot = function () { 94 var hp = this.form.querySelector( '[data-acps-hp]' ); 95 if ( hp ) { 96 hp.setAttribute( 'name', 'acps_hp' ); 97 hp.value = ''; 98 } 99 }; 100 101 /* Conditional visibility (spec §7.3) â multi-rule AND/OR with operators. */ 102 ACPSForm.prototype.bindConditional = function () { 103 var form = this.form; 104 var conditionals = form.querySelectorAll( '[data-acps-cond]' ); 105 if ( ! conditionals.length ) { 106 return; 107 } 108 109 function ruleMatches( rule, vals ) { 110 var want = ( rule.value == null ? '' : String( rule.value ) ).toLowerCase(); 111 var joined = vals.join( '�' ).toLowerCase(); 112 switch ( rule.op ) { 113 case 'is_not': 114 return vals.map( lc ).indexOf( want ) === -1; 115 case 'contains': 116 return joined.indexOf( want ) !== -1; 117 case 'not_contains': 118 return joined.indexOf( want ) === -1; 119 case 'gt': 120 return vals.some( function ( v ) { return parseFloat( v ) > parseFloat( rule.value ); } ); 121 case 'lt': 122 return vals.some( function ( v ) { return parseFloat( v ) < parseFloat( rule.value ); } ); 123 case 'is_empty': 124 return vals.length === 0 || ( vals.length === 1 && vals[ 0 ] === '' ); 125 case 'is_not_empty': 126 return vals.length > 0 && ! ( vals.length === 1 && vals[ 0 ] === '' ); 127 case 'is': 128 default:
129 return vals.map( lc ).indexOf( want ) !== -1; 130 } 131 } 132 function lc( s ) { return String( s ).toLowerCase(); } 133 134 var evaluate = function () { 135 Array.prototype.forEach.call( conditionals, function ( el ) { 136 var cfgAttr = el.getAttribute( 'data-acps-cond' ); 137 var conf; 138 try { conf = JSON.parse( cfgAttr ); } catch ( e ) { return; } 139 if ( ! conf || ! conf.rules || ! conf.rules.length ) { return; } 140 141 var results = conf.rules.map( function ( rule ) { 142 return ruleMatches( rule, collectValues( form, rule.field ) ); 143 } ); 144 var conditionMet = ( conf.logic === 'or' ) 145 ? results.some( Boolean ) 146 : results.every( Boolean ); 147 148 // action "show": visible only when met. "hide": hidden when met. 149 var visible = ( conf.action === 'hide' ) ? ! conditionMet : conditionMet; 150 el.hidden = ! visible; 151 } ); 152 }; 153 form.addEventListener( 'change', evaluate ); 154 form.addEventListener( 'input', evaluate ); 155 evaluate(); 156 }; 157 158 /* Multi-page navigation with a live step indicator. */ 159 ACPSForm.prototype.bindPaging = function () { 160 var self = this; 161 var prev = this.form.querySelector( '.acps-prev' ); 162 var next = this.form.querySelector( '.acps-next' ); 163 var submit = this.form.querySelector( '.acps-submit' ); 164 var stepCur = this.form.querySelector( '.acps-step-current' ); 165 166 var show = function ( idx ) { 167 Array.prototype.forEach.call( self.pages, function ( p, i ) { 168 p.hidden = i !== idx; 169 } ); 170 self.current = idx; 171 if ( stepCur ) { 172 stepCur.textContent = idx + 1; 173 } 174 if ( prev ) { prev.hidden = idx === 0; } 175 var last = idx === self.pages.length - 1; 176 if ( next ) { next.hidden = last; } 177 if ( submit ) { submit.hidden = ! last; } 178 // Move focus to the first control on the newly shown page. 179 var focusable = self.pages[ idx ].querySelector( 'input, select, textarea, button' ); 180 if ( focusable ) { focusable.focus(); } 181 }; 182 183 if ( next ) { 184 next.addEventListener( 'click', function () { 185 if ( self.validatePage( self.current ) ) { 186 show( Math.min( self.current + 1, self.pages.length - 1 ) ); 187 } 188 } ); 189 } 190 if ( prev ) { 191 prev.addEventListener( 'click', function () { 192 show( Math.max( self.current - 1, 0 ) ); 193 } ); 194 } 195 show( 0 ); 196 }; 197 198 ACPSForm.prototype.validatePage = function ( idx ) { 199 var page = this.pages[ idx ]; 200 var errors = this.collectErrors( page ); 201 this.renderErrors( errors ); 202 return errors.length === 0; 203 }; 204 205 /* Client-side required/format checks â mirrors the server, for fast UX. 206 The server remains the source of truth. */ 207 ACPSForm.prototype.collectErrors = function ( scope ) { 208 scope = scope || this.form; 209 var errors = []; 210 var fields = scope.querySelectorAll( '.acps-field' ); 211 Array.prototype.forEach.call( fields, function ( field ) { 212 if ( field.hidden ) { 213 return; 214 } 215 var key = field.getAttribute( 'data-key' ); 216 var required = field.querySelector( '[aria-required="true"], [required]' ); 217 if ( ! required ) { 218 return; 219 } 220 var controls = field.querySelectorAll( 'input, select, textarea' ); 221 var hasValue = false; 222 Array.prototype.forEach.call( controls, function ( c ) { 223 if ( c.type === 'checkbox' || c.type === 'radio' ) { 224 if ( c.checked ) { hasValue = true; } 225 } else if ( c.value && c.value.trim() !== '' ) { 226 hasValue = true; 227 } 228 } ); 229 if ( ! hasValue ) { 230 var label = field.querySelector( '.acps-label, .acps-legend' ); 231 errors.push( { 232 field: key, 233 message: ( label ? label.textContent.replace( /\s*\(required\)\s*$/, '' ) : 'This field' ) + ' is required.' 234 } ); 235 } 236 } ); 237 return errors; 238 }; 239 240 ACPSForm.prototype.bindSubmit = function () { 241 var self = this; 242 this.form.addEventListener( 'submit', function ( e ) { 243 e.preventDefault(); 244 self.submit(); 245 } ); 246 }; 247 248 ACPSForm.prototype.submit = function () { 249 var self = this; 250 var form = this.form; 251 252 var errors = this.collectErrors(); 253 if ( errors.length ) { 254 this.renderErrors( errors ); 255 return; 256 } 257 this.clearErrors(); 258 259 var submitBtn = form.querySelector( '.acps-submit' ); 260 if ( submitBtn ) { 261 submitBtn.disabled = true; 262 } 263 this.announce( strings.submitting || 'Sendingâ¦' ); 264 265 // Make sure the nonce/timestamp are loaded (they may not be if the visitor 266 // never focused the form, e.g. an autofill submit), then send. 267 this.ensureTokens().then( function () { 268 var data = new FormData( form ); 269 fetch( restUrl + '/submit', { 270 method: 'POST', 271 body: data, 272 credentials: 'same-origin' 273 } ) 274 .then( function ( r ) { return r.json(); } ) 275 .then( function ( res ) { 276 if ( submitBtn ) { submitBtn.disabled = false; } 277 if ( res && res.success ) { 278 self.onSuccess( res.confirmation ); 279 } else if ( res && res.summary ) { 280 self.renderServerErrors( res ); 281 } else { 282 self.announce( strings.genericError || 'Something went wrong.' ); 283 } 284 } ) 285 .catch( function () { 286 if ( submitBtn ) { submitBtn.disabled = false; } 287 self.announce( strings.genericError || 'Something went wrong.' ); 288 } ); 289 } ); 290 }; 291 292 ACPSForm.prototype.onSuccess = function ( confirmation ) { 293 confirmation = confirmation || {}; 294 if ( ( confirmation.type === 'redirect' || confirmation.type === 'both' ) && confirmation.redirect ) { 295 if ( confirmation.type === 'both' && confirmation.message ) { 296 this.showMessage( confirmation.message ); 297 } 298 window.location.href = confirmation.redirect; 299 return; 300 } 301 this.showMessage( confirmation.message || 'Thank you.' ); 302 }; 303 304 ACPSForm.prototype.showMessage = function ( msg ) { 305 // Replace the form body with the confirmation, announced via role=status. 306 var wrap = this.form.closest( '.acps-form-wrap' ) || this.form; 307 var box = document.createElement( 'div' ); 308 box.className = 'acps-confirmation'; 309 box.setAttribute( 'role', 'status' ); 310 box.setAttribute( 'tabindex', '-1' ); 311 box.innerHTML = msg; 312 this.form.parentNode.replaceChild( box, this.form ); 313 box.focus(); 314 }; 315 316 /* Render a server error payload: field map + summary. */ 317 ACPSForm.prototype.renderServerErrors = function ( res ) { 318 this.clearErrors(); 319 var errors = res.summary || []; 320 // Inline field errors from res.errors map. 321 if ( res.errors ) { 322 for ( var key in res.errors ) { 323 if ( res.errors.hasOwnProperty( key ) ) { 324 this.setFieldError( key, res.errors[ key ] ); 325 } 326 } 327 } 328 this.renderSummary( errors ); 329 }; 330 331 ACPSForm.prototype.renderErrors = function ( errors ) { 332 this.clearErrors(); 333 var self = this;
334 errors.forEach( function ( err ) { 335 if ( err.field ) { 336 self.setFieldError( err.field, err.message ); 337 } 338 } ); 339 this.renderSummary( errors ); 340 }; 341 342 ACPSForm.prototype.setFieldError = function ( key, message ) { 343 var field = this.form.querySelector( '.acps-field[data-key="' + cssEscape( key ) + '"]' ); 344 if ( ! field ) { 345 return; 346 } 347 var errEl = field.querySelector( '.acps-field-error' ); 348 if ( errEl ) { 349 errEl.textContent = message; 350 } 351 field.classList.add( 'acps-has-error' ); 352 }; 353 354 ACPSForm.prototype.renderSummary = function ( errors ) { 355 if ( ! this.errorSummary || ! errors.length ) { 356 return; 357 } 358 var list = this.errorSummary.querySelector( '.acps-error-summary__list' ); 359 list.innerHTML = ''; 360 var self = this; 361 errors.forEach( function ( err ) { 362 var li = document.createElement( 'li' ); 363 if ( err.field ) { 364 var a = document.createElement( 'a' ); 365 a.href = '#'; 366 a.textContent = err.message; 367 a.addEventListener( 'click', function ( e ) { 368 e.preventDefault(); 369 self.focusField( err.field ); 370 } ); 371 li.appendChild( a ); 372 } else { 373 li.textContent = err.message; 374 } 375 list.appendChild( li ); 376 } ); 377 this.errorSummary.hidden = false; 378 // Move focus to the summary on failed submit (spec §8.2). 379 this.errorSummary.focus(); 380 }; 381 382 ACPSForm.prototype.focusField = function ( key ) { 383 var field = this.form.querySelector( '.acps-field[data-key="' + cssEscape( key ) + '"]' ); 384 if ( ! field ) { 385 return; 386 } 387 var control = field.querySelector( 'input, select, textarea' ); 388 if ( control ) { 389 control.focus(); 390 } 391 }; 392 393 ACPSForm.prototype.clearErrors = function () { 394 if ( this.errorSummary ) { 395 this.errorSummary.hidden = true; 396 var list = this.errorSummary.querySelector( '.acps-error-summary__list' ); 397 if ( list ) { list.innerHTML = ''; } 398 } 399 var errs = this.form.querySelectorAll( '.acps-field-error' ); 400 Array.prototype.forEach.call( errs, function ( e ) { e.textContent = ''; } ); 401 var flagged = this.form.querySelectorAll( '.acps-has-error' ); 402 Array.prototype.forEach.call( flagged, function ( f ) { f.classList.remove( 'acps-has-error' ); } ); 403 }; 404 405 ACPSForm.prototype.announce = function ( msg ) { 406 if ( this.statusRegion ) { 407 this.statusRegion.textContent = msg; 408 } 409 }; 410 411 /* ---- helpers ----------------------------------------------------- */ 412 function setVal( form, name, value ) { 413 var el = form.querySelector( '[name="' + name + '"]' ); 414 if ( el ) { 415 el.value = value; 416 } 417 } 418 function collectValues( form, key ) { 419 var out = []; 420 var els = form.querySelectorAll( '[name="fields[' + key + ']"], [name="fields[' + key + '][]"]' ); 421 Array.prototype.forEach.call( els, function ( el ) { 422 if ( el.type === 'checkbox' || el.type === 'radio' ) { 423 if ( el.checked ) { out.push( el.value ); } 424 } else if ( el.value !== '' ) { 425 out.push( el.value ); 426 } 427 } ); 428 return out; 429 } 430 function cssEscape( s ) { 431 return String( s ).replace( /"/g, '\\"' ); 432 } 433 434 // Expose for feedback.js. 435 window.ACPSForm = ACPSForm; 436} )();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.