PluginProbe
Contact Forms by Cimatti / 2.2.32
Contact Forms by Cimatti v2.2.32
2.3.6 2.3.5 2.3.0 2.2.32 2.2.4 2.2.0 2.1.2 2.1.1 trunk 1.0 1.1 1.2 1.2.1 1.3 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 1.3.9 1.4.0 1.4.1 All 62 releases
contact-forms / AccuaForm.php

AccuaForm.php in Contact Forms by Cimatti 2.2.32, at AccuaForm.php

2,275 lines 88.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if ( ! defined( 'ABSPATH' ) ) exit;
3
4 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped, WordPress.Security.EscapeOutput.HeredocOutputNotEscaped, PluginCheck.CodeAnalysis.Heredoc.NotAllowed, WordPress.PHP.DevelopmentFunctions.error_log_trigger_error, WordPress.PHP.DevelopmentFunctions.error_log_error_log, WordPress.WP.AlternativeFunctions.rename_rename -- PFBC Form extension class with controlled HTML output, deprecation notices, debug logging, file operations
5 class AccuaForm extends Form {
6 protected static $submitted = null;
7 protected static $valid = null;
8 protected static $submittedID = null;
9 protected static $submittedBuildID = null;
10 protected static $submittedForm = null;
11 protected static $submittedFormUsed = false;
12 protected static $submittedData = null;
13 protected static $rawData = null;
14 /**
15 * Per-form submitted messages array.
16 * Keys are form IDs, values are message strings.
17 * This allows multiple forms on the same page to have separate messages.
18 * @var array<string, string>
19 */
20 protected static $submittedMessages = array();
21
22 protected $formID = null;
23 protected $buildID = null;
24 protected $validate_functions = array();
25 protected $submit_functions = array();
26 protected $elements_sleep;
27 public $stats = array();
28 protected $accua_ajax;
29 protected $locale = null;
30 protected $language = null;
31 protected $files = array();
32 protected $ga_track = array();
33 protected $gads_conversion_tracking_code = '';
34
35 protected $original_locale = null;
36 protected $original_language = null;
37 protected $original_l10n = null;
38 protected $forced_language = false;
39 protected $elementsByName = array();
40 protected $elementCounter = 0;
41
42 public function force_language() {
43 if ((!$this->forced_language) && $this->language && function_exists('qtrans_getLanguage')) {
44 global $q_config;
45 $this->original_language = $q_config['language'];
46
47 if ($this->language != $this->original_language) {
48 $this->original_locale =& $GLOBALS['wp_locale'];
49 $this->original_l10n =& $GLOBALS['l10n'];
50
51 unset($GLOBALS['wp_locale']);
52 unset($GLOBALS['l10n']);
53 $GLOBALS['l10n'] = array();
54
55 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- qTranslate plugin integration
56 $GLOBALS['q_config']['language'] = $this->language;
57 load_default_textdomain();
58 // phpcs:ignore PluginCheck.CodeAnalysis.DiscouragedFunctions.load_plugin_textdomainFound -- Intentionally forces language at runtime for qTranslate email delivery
59 load_plugin_textdomain( 'contact-forms', false, ACCUA_FORM_API_PLUGIN_TEXTDOMAIN_PATH);
60 require_once( ABSPATH . WPINC . '/locale.php' );
61 $GLOBALS['wp_locale'] = new WP_Locale();
62 $GLOBALS['wp_locale']->register_globals();
63
64 $this->forced_language = true;
65 }
66 }
67 }
68
69 public function restore_language() {
70 if ($this->forced_language) {
71 unset($GLOBALS['wp_locale']);
72 unset($GLOBALS['l10n']);
73
74 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- qTranslate plugin integration
75 $GLOBALS['q_config']['language'] = $this->original_language;
76 $GLOBALS['l10n'] =& $this->original_l10n;
77 $GLOBALS['wp_locale'] =& $this->original_locale;
78 if ($GLOBALS['wp_locale']) {
79 $GLOBALS['wp_locale']->register_globals();
80 }
81
82 $this->forced_language = false;
83 }
84 }
85
86 public function __sleep() {
87 $this->elements_sleep = $this->getElements();
88 return array('attributes', 'elements_sleep', 'error', 'view', 'prefix', 'widthSuffix', 'ajax', 'ajaxCallback', 'jQueryUITheme', 'resourcesPath', 'prevent', 'width', 'formID', 'buildID', 'validate_functions', 'submit_functions', 'stats', 'accua_ajax', 'locale', 'language', 'files');
89 }
90
91 public function __wakeup() {
92 foreach ($this->elements_sleep as $element) {
93 $this->addElement($element);
94 }
95 unset($this->elements_sleep);
96 if ($this->view) {
97 $this->view->setForm($this);
98 }
99 if ($this->error) {
100 $this->error->setForm($this);
101 }
102 }
103
104 public function addElement(Element $element) {
105 $name = $element->getName();
106 if ($name) {
107 $this->elementsByName[$name] = $element;
108 }
109 $id = $element->getID();
110 if(empty($id)) {
111 $element->setID($this->attributes["id"] . "-element-" . $this->elementCounter);
112 }
113 $this->elementCounter++;
114 return parent::addElement($element);
115 }
116
117 public function getElementByName($name) {
118 if (isset($this->elementsByName[$name])) {
119 return $this->elementsByName[$name];
120 } else {
121 return null;
122 }
123 }
124
125 public function removeElement($element) {
126 foreach ($this->elements as $k => $e) {
127 if ($e === $element) {
128 $name = $element->getName();
129 if ($name) {
130 unset ($this->elementsByName[$name]);
131 }
132 unset($this->elements[$k]);
133 return true;
134 }
135 }
136 return false;
137 }
138
139 public static function sessionID() {
140 $sessionid = session_id();
141 if ($sessionid === '' && !defined('DOING_CRON')) {
142 $started = session_start();
143 if (!$started) {
144 $file = $line = '';
145 if(headers_sent($file,$line)) {
146 error_log("headers already sent at {$file}:{$line}");
147 }
148 }
149 $sessionid = session_id();
150 // Close the session immediately after getting the ID to prevent REST API interference
151 session_write_close();
152 }
153 return $sessionid;
154 }
155
156 public static function getBaseURL() {
157 static $ret = null;
158 if ($ret === null) {
159 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Building internal URL from server variables
160 $s = empty($_SERVER["HTTPS"]) ? '' : (($_SERVER["HTTPS"] == "on") ? "s" : "");
161 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Building internal URL from server variables
162 $sp = isset($_SERVER["SERVER_PROTOCOL"]) ? strtolower(sanitize_text_field(wp_unslash($_SERVER["SERVER_PROTOCOL"]))) : 'http/1.1';
163 $protocol = substr($sp, 0, strpos($sp, "/")) . $s;
164 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Building internal URL from server variables
165 $port = (!isset($_SERVER["SERVER_PORT"]) || $_SERVER["SERVER_PORT"] == "80") ? "" : (":" . absint($_SERVER["SERVER_PORT"]));
166 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Building internal URL from server variables
167 $ret = $protocol . "://" . (isset($_SERVER['SERVER_NAME']) ? sanitize_text_field(wp_unslash($_SERVER['SERVER_NAME'])) : 'localhost') . $port;
168 }
169 return $ret;
170 }
171
172 public static function create($baseid = 'pfbc', $params = array()) {
173 if (self::isSubmit() && (!self::isValid()) && ($baseid === self::$submittedID) && (!self::$submittedFormUsed)) {
174 self::$submittedFormUsed = true;
175 if (!(self::isValid() || empty(self::$submittedForm))) {
176 $form = self::$submittedForm;
177 $form->setValues(self::$submittedData);
178 return $form;
179 }
180 $form = new AccuaForm ($baseid, $params, self::$submittedBuildID);
181 $form->setValues(self::$submittedData);
182 } else {
183 $form = new AccuaForm($baseid, $params);
184 }
185 if (function_exists($baseid)) {
186 call_user_func($baseid, $form);
187 }
188 do_action('accua_form_alter', $baseid, $form);
189 return $form;
190 }
191
192 function __construct($id = 'pfbc', $params = array()) {
193 if (func_num_args() > 2) {
194 $buildid = (string) func_get_arg(2);
195 } else {
196 $buildid = '';
197 }
198 if ($buildid === '') {
199 $buildid = 'accua-form_' . $id . '_' . uniqid();
200 }
201
202 $predefined_params = array(
203 'width' => '',
204 'layout' => 'sidebyside',
205 'title' => '',
206 'track_submit' => false,
207 'track_fields' => false,
208 'gads_conversion_tracking_code' => '',
209 );
210 if (is_array($params)) {
211 $params += $predefined_params;
212 $width = $params['width'];
213 } else {
214 $width = $params;
215 $params = $predefined_params;
216 $params['width'] = $width;
217 }
218 // Always initialize the property to avoid undefined property warnings
219 $this->gads_conversion_tracking_code = $params['gads_conversion_tracking_code'];
220
221 $class = 'accua-form ' . $id;
222 switch ($params['layout']) {
223 case 'toplabel':
224 $this->view = new AccuaForm_View_Standard();
225 $class .= ' accua-form-view-standard';
226 break;
227 case 'inlinelabel':
228 $this->view = new AccuaForm_View_InlineLabel();
229 $class .= ' accua-form-view-inlinelabel';
230 // Prevent PFBC from adding inline width styles for inline label layout
231 $width = '';
232 break;
233 case 'sidebyside':
234 default:
235 $this->view = new AccuaForm_View_SideBySide(
236 '19',
237 array('labelPaddingRight' => '1')
238 );
239 $class .= ' accua-form-view-sidebyside';
240 }
241 $this->error = new AccuaForm_Error_Standard(array(
242 'errorfound' => '',
243 'errorsfound' => '',
244 ));
245 // For inline label view, we don't set default width
246 // The CSS handles all widths with width: 100%
247 if ($width === "" && $params['layout'] !== 'inlinelabel'){
248 $width = "100%";
249 }
250 $this->attributes = array(
251 'class' => $class,
252 'novalidate' => 'novalidate',
253 );
254 foreach (array('title', 'track_submit', 'track_fields') as $i) {
255 $this->ga_track[$i] = $params[$i];
256 }
257
258 parent::__construct($buildid, $width);
259
260
261 $this->formID = $id;
262 $this->buildID = $buildid;
263 $this->addElement(new Element_Hidden('_AccuaForm_ID', $id));
264 $this->addElement(new Element_Hidden('_AccuaForm_buildID', $buildid));
265 $this->addElement(new Element_Hidden('_AccuaForm_wpnonce', wp_create_nonce( $buildid )));
266 $this->addElement(new Element_Hidden('_AccuaForm_jsuuid', ''));
267 $this->addElement(new Element_Hidden('_AccuaForm_referrer', ''));
268 $this->addElement(new Element_Hidden('_AccuaForm_user_agent', ''));
269 $this->addElement(new Element_Hidden('_AccuaForm_platform', ''));
270 $this->addElement(new Element_Hidden('_AccuaForm_tentatives', '0'));
271 $this->addElement(new Element_Hidden('_AccuaForm_submit_method', 'normal'));
272 $this->addElement(new Element_Hidden('_AccuaForm_hash', ''));
273 $this->addElement(new Element_Hidden('_AccuaForm_iv', ''));
274 $this->addElement(new Element_Hidden('_AccuaForm_data', ''));
275 /*
276 * - Prima di generare l'html del form, salva una copia serializzata compreso di codice SHA2, criptato, in _AccuaForm_serialized
277 * - Quando ricevi il form, decripta _AccuaForm_serialized e controlla che sia valido
278 *
279 * */
280
281 $this->prevent = array('jQuery', 'jQueryUI', 'jQueryUIButtons', 'focus', 'style');
282 $this->configure(array('action' => '#'));
283
284 if (function_exists('qtrans_getLanguage')) {
285 $this->language = qtrans_getLanguage();
286 $this->locale = $GLOBALS['q_config']['locale'][$this->language];
287 } else {
288 $this->locale = get_locale();
289 $this->language = explode('_', $this->locale);
290 $this->language = $this->language[0];
291 }
292
293 global $post;
294 $pid = empty($post->ID) ? 0 : $post->ID;
295
296 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- URL stored for logging, escaped on output
297 $uri = isset($GLOBALS['q_config']['url_info']['original_url']) ? $GLOBALS['q_config']['url_info']['original_url'] : (isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '');
298
299 $url = self::getBaseURL() . $uri ;
300 // Set the form action to the current page URL so non-AJAX forms submit cleanly
301 // (AJAX forms override this later in the JS to admin-ajax.php)
302 $this->configure(array('action' => $url));
303
304 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Referrer stored for logging, escaped on output
305 $referrer = isset($_SERVER['HTTP_REFERER']) ? esc_url_raw(wp_unslash($_SERVER['HTTP_REFERER'])) : '';
306
307 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored for logging, escaped on output
308 $remote_addr = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
309
310 $this->stats = array(
311 'pid' => $pid,
312 'ip' => $remote_addr, //updated after submit
313 'original_ip' => $remote_addr, //unreliable if using a static page caching system
314 'uri' => $uri,
315 'url' => $url,
316 'referrer' => $referrer, //updated after submit using javascript
317 'original_referrer' => $referrer, //unreliable if using a static page caching system
318 'lang' => $this->language,
319 'locale' => $this->locale,
320 'created' => time(),
321 'submitted' => null,
322 'user_agent' => '',
323 'platform' => '',
324 'tentatives' => '',
325 'submit_method' => '',
326 );
327
328 $this->view->setForm($this);
329 $this->error->setForm($this);
330 }
331
332 public static function isSubmit() {
333 if (self::$submitted !== null) {
334 return self::$submitted;
335 }
336 self::$submitted = false;
337 $sessid = self::sessionID();
338
339 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Checking request method for form handling
340 $request_method = isset($_SERVER['REQUEST_METHOD']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])) : 'GET';
341 if ($request_method === 'POST') {
342 self::$rawData = stripslashes_deep($_POST);
343 } else {
344 self::$rawData = stripslashes_deep($_GET);
345 }
346
347 // Ensure basic fields for nonce verification are present.
348 if (empty(self::$rawData['_AccuaForm_buildID']) || empty(self::$rawData['_AccuaForm_wpnonce'])) {
349 return false;
350 }
351
352 // Verify nonce for CSRF protection (moved earlier).
353 // The $id variable (action for nonce) is self::$rawData['_AccuaForm_buildID'].
354 $requested_buildID = self::$rawData['_AccuaForm_buildID'];
355 if (!wp_verify_nonce(self::$rawData['_AccuaForm_wpnonce'], $requested_buildID)) {
356 return false;
357 }
358
359 // Now check for fields required for form recovery and other validations.
360 if (empty(self::$rawData['_AccuaForm_ID'])
361 || empty(self::$rawData['_AccuaForm_hash']) || empty(self::$rawData['_AccuaForm_iv']) || empty(self::$rawData['_AccuaForm_data'])) {
362 return false;
363 }
364
365 // $id was self::$rawData['_AccuaForm_buildID'], now using $requested_buildID for clarity.
366 $form = self::wp_recover(self::$rawData['_AccuaForm_hash'], self::$rawData['_AccuaForm_iv'], self::$rawData['_AccuaForm_data']);
367
368 if (empty($form)) {
369 return false;
370 }
371
372 if ($form->formID !== self::$rawData['_AccuaForm_ID'] || $form->buildID !== $requested_buildID ) {
373 return false;
374 }
375
376 /* check if already submitted from uuid and other parameters */
377 $already_submitted = false;
378 if (!empty(self::$rawData['_AccuaForm_jsuuid'])) {
379 if (preg_match("/^[a-z0-9]{25}$/", self::$rawData['_AccuaForm_jsuuid'])) {
380 $already_submitted_data = get_transient('accuaformsub_'.self::$rawData['_AccuaForm_jsuuid']);
381 if ($already_submitted_data && $already_submitted_data['buildID'] == $form->buildID) {
382 // Only use cached result for successful submissions to prevent duplicate processing
383 if (!empty($already_submitted_data['valid'])) {
384 $form = $already_submitted_data['form'];
385 self::$submittedMessages = $already_submitted_data['submittedMessages'];
386 self::$valid = true;
387 $already_submitted = true;
388 } else {
389 // For failed validations, delete the transient so form can be re-validated
390 // This fixes the bug where correcting a field and resubmitting caused form to disappear
391 delete_transient('accuaformsub_'.self::$rawData['_AccuaForm_jsuuid']);
392 }
393 }
394 } else {
395 return false;
396 }
397 }
398
399 if (!$already_submitted) {
400 $form->stats['submitted'] = time();
401 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored for logging, escaped on output
402 $form->stats['ip'] = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
403 foreach (array('referrer', 'jsuuid', 'user_agent', 'platform', 'tentatives', 'submit_method') as $i){
404 $form->stats[$i] = isset(self::$rawData["_AccuaForm_{$i}"]) ? ((string)self::$rawData["_AccuaForm_{$i}"]) : '';
405 }
406 }
407 self::$submitted = true;
408 self::$submittedID = $form->formID;
409 self::$submittedBuildID = $form->buildID;
410 self::$submittedForm = $form;
411 return true;
412 }
413
414 public static function isValid($id = "pfbc", $clearValues = true) {
415 if (!self::isSubmit()){
416 return null;
417 }
418 if (self::$valid !== null) {
419 return self::$valid;
420 }
421
422 //$subdata = array();
423 $id = self::$submittedBuildID;
424 //$form = self::wp_recover($id);
425 $form = self::$submittedForm;
426 $valid = true;
427
428 $form->force_language();
429
430 /*Any values/errors stored in the session for this form are cleared.*/
431 self::clearValues($id);
432 self::clearErrors($id);
433
434 self::$submittedData = array();
435 /*Each element's value is saved in the session and checked against any validation rules applied
436 to the element.*/
437 $elements = $form->getElements();
438 if(!empty($elements)) {
439 foreach($elements as $element) {
440 $invalidFile = false;
441 $name = $element->getName();
442 if(substr($name, -2) == "[]") {
443 $name = substr($name, 0, -2);
444 }
445
446 /*The File element must be handled differently b/c it uses the $_FILES superglobal and
447 not $_GET or $_POST.*/
448 if($element instanceof AccuaForm_Element_File) {
449 if (!empty($form->files[$name]['name'])) {
450 $value = $form->files[$name]['name'];
451 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- File handling uses $_FILES, nonce verified earlier
452 } else if ((!empty($_FILES[$name])) && (isset($_FILES[$name]['error']) && $_FILES[$name]['error'] != UPLOAD_ERR_NO_FILE)) {
453 //$file = $_FILES[$name];
454 //include_once( ABSPATH . '/wp-admin/includes/file.php' );
455 //$overrides = array( 'test_form' => false );
456 //$file = wp_handle_upload( $file, $overrides );
457
458 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- File array passed to WordPress handler
459 $file = $element->handle_upload($_FILES[$name]);
460 $value = $file['name'];
461
462 if (empty($file['errors'])) {
463 $form->files[$name] = $file;
464 } else {
465 self::setError($id, $file['errors'] , $name);
466 $valid = false;
467 $invalidFile = true;
468 }
469 } else {
470 $value = null;
471 }
472 } else if ($element instanceof Element_File){
473 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Legacy file handling
474 $value = isset($_FILES[$name]["name"]) ? sanitize_file_name($_FILES[$name]["name"]) : '';
475 } else if (isset(self::$rawData[$name])) {
476 $value = self::$rawData[$name];
477 if(is_array($value)) {
478 foreach($value as $key => $value_i) {
479 // Sanitize null bytes to prevent mail() ValueError in PHP 8+
480 $value[$key] = str_replace("\0", '', (string) $value_i);
481 }
482 } else {
483 // Sanitize null bytes to prevent mail() ValueError in PHP 8+
484 $value = str_replace("\0", '', (string) $value);
485 }
486 } else {
487 $value = null;
488 }
489
490 //self::setSessionValue($id, $name, $value);
491
492 /*If a validation error is found, the error message is saved in the session along with
493 the element's name.*/
494 if(!$element->isValid($value)) {
495 self::setError($id, $element->getErrors(), $name);
496 $valid = false;
497 if ($element instanceof AccuaForm_Element_File) {
498 $invalidFile = true;
499 }
500 }
501
502 if($invalidFile) {
503 // File cleanup is handled in handle_upload() when validation fails
504 // The uploaded tmp file is already deleted by PHP after request completes
505 unset($form->files[$name]);
506 } else if($name !== '') {
507 self::$submittedData[$name] = $value;
508 }
509 }
510 }
511 $_SESSION["pfbc"][$id]["values"] = self::$submittedData;
512
513
514 if (function_exists(self::$submittedID.'_validate')) {
515 $valid = call_user_func(self::$submittedID.'_validate', $valid, self::$submittedID, self::$submittedData, $form);
516 }
517 $valid = apply_filters('accua_form_validate', $valid, self::$submittedID, self::$submittedData, $form);
518
519 asort($form->validate_functions);
520 foreach($form->validate_functions as $func => $priority){
521 if (function_exists($func)) {
522 $valid = call_user_func($func, $valid, self::$submittedID, self::$submittedData, $form);
523 }
524 }
525
526 if ($valid) {
527 if (function_exists(self::$submittedID.'_submit')) {
528 call_user_func(self::$submittedID.'_submit', self::$submittedID, self::$submittedData, $form);
529 }
530 do_action('accua_form_submit', self::$submittedID, self::$submittedData, $form);
531 asort($form->submit_functions);
532 foreach($form->submit_functions as $func => $priority){
533 if (function_exists($func)) {
534 call_user_func($func, self::$submittedID, self::$submittedData, $form);
535 }
536 }
537 }
538
539 /*Apply errors from session to form elements for accessibility (ARIA attributes).
540 This must be done BEFORE session_write_close() is called, so errors persist in element objects.*/
541 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Session data for form error handling
542 if (!$valid && !empty($_SESSION["pfbc"][$id]["errors"])) {
543 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Session data for form error handling
544 $sessionErrors = $_SESSION["pfbc"][$id]["errors"];
545 $elements = $form->getElements();
546 if (!empty($elements)) {
547 foreach ($elements as $element) {
548 $name = $element->getName();
549 if (substr($name, -2) == "[]")
550 $name = substr($name, 0, -2);
551
552 if (isset($sessionErrors[$name]) && is_array($sessionErrors[$name])) {
553 $element->setErrors($sessionErrors[$name]);
554 }
555 }
556 }
557 }
558
559 $form->restore_language();
560
561 /*Store form with errors in transient for accessibility on redirect.
562 Both successful and failed submissions are stored so the form can be properly re-rendered with ARIA attributes.*/
563 if (self::$rawData['_AccuaForm_jsuuid'] ) {
564 set_transient('accuaformsub_'.self::$rawData['_AccuaForm_jsuuid'], array(
565 'buildID' => self::$submittedBuildID,
566 'form' => self::$submittedForm,
567 'submittedMessages' => self::$submittedMessages,
568 'valid' => $valid,
569 ), 86400);
570 }
571
572 return self::$valid = (bool)$valid;
573 }
574
575 public static function getSubmittedData() {
576 self::isValid();
577 return self::$submittedData;
578 }
579
580 public static function getSumbittedID() {
581 trigger_error('Use getSubmittedID() instead', (defined('E_USER_DEPRECATED')?E_USER_DEPRECATED:E_USER_NOTICE));
582 return self::getSubmittedID();
583 }
584
585 public static function getSubmittedID() {
586 if (self::isSubmit()) {
587 return self::$submittedID;
588 } else {
589 return null;
590 }
591 }
592
593 public static function getSubmittedForm() {
594 if (self::isSubmit()) {
595 return self::$submittedForm;
596 } else {
597 return null;
598 }
599 }
600
601 /*This method restores the serialized form instance.*/
602 protected static function wp_recover($hash,$iv,$data) {
603 /*
604 if(!empty($_SESSION["pfbc"][$id]["form"]))
605 return unserialize($_SESSION["pfbc"][$id]["form"]);
606 */
607 /*
608 $storename = 'accua_form_' . md5($id);
609 if ($stored = get_transient($storename)) {
610 return unserialize($stored);
611 }
612 */
613
614 $keys = get_option('accua_form_api_keys', array());
615 @ $hash = base64_decode($hash);
616 @ $iv = base64_decode($iv);
617 @ $data = base64_decode($data);
618
619 if (!($hash && $iv && $data)) {
620 return;
621 }
622
623 if (!class_exists('Crypt_Hash')) {
624 require_once('phpseclib-crypt/Hash.php');
625 }
626 $hasher = new Crypt_Hash('sha1');
627 $hasher->setKey($keys['hash']);
628 @ $hash2 = $hasher->hash($iv.$data);
629
630 if ($hash !== $hash2) {
631 return;
632 }
633
634 if (!class_exists('Crypt_AES')) {
635 require_once('phpseclib-crypt/AES.php');
636 }
637
638 $cipher = new Crypt_AES();
639 $cipher->setPassword($keys['aes']);
640 @ $cipher->setIV($iv);
641 @ $data = $cipher->decrypt($data);
642
643 if ($data) {
644 @ $form = unserialize($data);
645 if ($form) {
646 return $form;
647 }
648 }
649 }
650
651 protected function wp_save() {
652 /*
653 $storename = 'accua_form_' . md5($this->buildID);
654 //$serialized = isset($_SESSION["pfbc"][$this->buildID]["form"]) ? $_SESSION["pfbc"][$this->buildID]["form"] : serialize($this);
655 $serialized = serialize($this);
656 set_transient($storename, $serialized, 2764800); // 32 days
657 */
658
659 if (!class_exists('Crypt_Hash')) {
660 require_once('phpseclib-crypt/Hash.php');
661 }
662 if (!class_exists('Crypt_AES')) {
663 require_once('phpseclib-crypt/AES.php');
664 }
665
666 $keys = get_option('accua_form_api_keys', array());
667 if (!(isset($keys['aes']) && isset($keys['hash']))) {
668 accua_form_api_install();
669 $keys = get_option('accua_form_api_keys', array());
670 }
671
672 $data = serialize($this);
673 $iv = wp_generate_password(64,true,true);
674
675 $cipher = new Crypt_AES();
676 $cipher->setPassword($keys['aes']);
677 $cipher->setIV($iv);
678 $data = $cipher->encrypt($data);
679
680 $hasher = new Crypt_Hash('sha1');
681 $hasher->setKey($keys['hash']);
682 $hash = $hasher->hash($iv.$data);
683
684 $ret = array(
685 '_AccuaForm_hash' => base64_encode($hash),
686 '_AccuaForm_iv' => base64_encode($iv),
687 '_AccuaForm_data' => base64_encode($data),
688 );
689 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Session data for form values
690 if (isset($_SESSION["pfbc"][$this->buildID]["values"])) {
691 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Session data for form values
692 $_SESSION["pfbc"][$this->buildID]["values"] = $ret + $_SESSION["pfbc"][$this->buildID]["values"];
693 }
694 $this->setValues($ret);
695 return $ret;
696 }
697
698 public function getLocale() {
699 return $this->locale;
700 }
701
702 public function getLanguage() {
703 return $this->language;
704 }
705
706 public function addValidateFunction($function_name, $priority = 0) {
707 $this->validate_functions[$function_name] = $priority;
708 }
709
710 public function addSubmitFunction($function_name, $priority = 0) {
711 $this->submit_functions[$function_name] = $priority;
712 }
713
714 /**
715 * Get submitted messages for a specific form or all forms.
716 * @param string|null $formId Optional form ID to get message for specific form
717 * @return string|array If $formId provided, returns string message (or empty string). Otherwise returns all messages as array.
718 */
719 public static function getSubmittedMessages($formId = null){
720 if ($formId !== null) {
721 return isset(self::$submittedMessages[$formId]) ? self::$submittedMessages[$formId] : '';
722 }
723 // Backwards compatibility: if old code expects a string, join all messages
724 if (is_array(self::$submittedMessages)) {
725 return implode('', self::$submittedMessages);
726 }
727 return self::$submittedMessages;
728 }
729
730 /**
731 * Set submitted message for a specific form.
732 * @param string $msg The message content
733 * @param string|null $formId Optional form ID. If null, uses currently submitted form ID.
734 * @return string The message that was set
735 */
736 public static function setSubmittedMessages($msg, $formId = null){
737 if ($formId === null) {
738 $formId = self::$submittedID ?: '__default__';
739 }
740 self::$submittedMessages[$formId] = $msg;
741 return $msg;
742 }
743
744 /**
745 * Append to submitted message for a specific form.
746 * @param string $msg The message content to append
747 * @param string|null $formId Optional form ID. If null, uses currently submitted form ID.
748 * @return string The full message after appending
749 */
750 public static function appendSubmittedmessages($msg, $formId = null){
751 if ($formId === null) {
752 $formId = self::$submittedID ?: '__default__';
753 }
754 if (!isset(self::$submittedMessages[$formId])) {
755 self::$submittedMessages[$formId] = '';
756 }
757 self::$submittedMessages[$formId] .= $msg;
758 return self::$submittedMessages[$formId];
759 }
760
761 protected static function get_anchor_id($id) {
762 static $used_anchor_id = array();
763 if (substr($id, 0, 14) === '__accua-form__') {
764 $id = substr($id, 14);
765 }
766 $id = preg_replace('/[^a-zA-Z0-9]+/m','_',$id);
767 if (empty($used_anchor_id[$id])) {
768 $used_anchor_id[$id] = 1;
769 } else {
770 $used_anchor_id[$id]++;
771 $id = $id . '-' . $used_anchor_id[$id];
772 }
773 return $id;
774 }
775 public function render($returnHTML = false) {
776 $this->wp_save();
777 if($returnHTML) {
778 ob_start();
779 }
780
781 parent::render(false);
782 if (!empty($this->accua_ajax)) {
783 $ajax_url = _accua_forms_json_encode(admin_url('admin-ajax.php?action=accua_form_submit'));
784 $submit_fail_message = _accua_forms_json_encode('<li>'.__('Form submission failed. Please try again.', 'contact-forms').'</li>');
785
786 /* translators: %s is the field name/label */
787 $required_message_template = _accua_forms_json_encode(__('%s is a required field', 'contact-forms'));
788 /* translators: %s is the field name/label */
789 $valid_mail_message_template = _accua_forms_json_encode(__('%s: please enter a valid email address', 'contact-forms'));
790 /* translators: %s is the field name/label */
791 $valid_phone_message_template = _accua_forms_json_encode(__('%s: please enter a valid phone number (e.g. +39 333 1234567)', 'contact-forms'));
792 $error_summary_header = _accua_forms_json_encode(__('Check the following fields to continue:', 'contact-forms'));
793 $error_type_required = _accua_forms_json_encode(__('required field', 'contact-forms'));
794 $error_type_invalid_email = _accua_forms_json_encode(__('invalid email address', 'contact-forms'));
795 $error_type_invalid_phone = _accua_forms_json_encode(__('invalid phone number', 'contact-forms'));
796 $success_message = _accua_forms_json_encode(__('All fields are valid. Ready to submit!', 'contact-forms'));
797 $sending_message = _accua_forms_json_encode(__('Sending...', 'contact-forms'));
798 $loading_summary_message = _accua_forms_json_encode(__('Submitting your form, please wait...', 'contact-forms'));
799 $js_buildid = preg_replace('/[^a-zA-Z0-9_]/m','_',$this->buildID);
800 $formid = $this->getId();
801 // $hostname = _accua_forms_json_encode($_SERVER['SERVER_NAME']); - rimosso perché non affidabile con proxy reversi e CDN
802 // Extract hostname from ajax_url for proper cross-domain detection
803 $ajax_url_parsed = wp_parse_url(admin_url('admin-ajax.php?action=accua_form_submit'));
804 $ajax_hostname = _accua_forms_json_encode($ajax_url_parsed['host']);
805
806 $post_url = _accua_forms_json_encode($this->stats['url']);
807 $js_ga_track = _accua_forms_json_encode($this->ga_track);
808
809 $anchor_id = _accua_forms_json_encode(self::get_anchor_id($this->formID));
810
811 echo <<<JS
812 <script type="text/javascript">
813 <!--
814 var _handle_ajax_submit_{$js_buildid} = function() {return true;}
815 var _handle_ajax_submit_complete_{$js_buildid} = function() {return false;}
816 var _handle_ajax_submit_timeout_{$js_buildid} = function() {return false;}
817 var _handle_ajax_submit_message_{$js_buildid} = function() {}
818 var _handle_ajax_submit_response_{$js_buildid} = function() {}
819
820 jQuery(function($) {
821 var thisform = $("#{$this->buildID}");
822 var ajax_enabled = {$ajax_hostname} == location.hostname ;
823 var anchor_id = $anchor_id ;
824
825 var response_messages = $("#_response_messages_{$this->buildID}");
826 if (! response_messages.length) {
827 // Create all three anchors for success, invalid, and error states
828 thisform.before('\\x3Ca id="formSubmitSuccess-'+anchor_id+'" name="formSubmitSuccess-'+anchor_id+'" class="accua-form-anchor" /\\x3E');
829 thisform.before('\\x3Ca id="formSubmitInvalid-'+anchor_id+'" name="formSubmitInvalid-'+anchor_id+'" class="accua-form-anchor" /\\x3E');
830 thisform.before('\\x3Ca id="formSubmitError-'+anchor_id+'" name="formSubmitError-'+anchor_id+'" class="accua-form-anchor" /\\x3E');
831 response_messages = $('\\x3Cdiv id="_response_messages_{$this->buildID}" class="accua-form-messages"\\x3E\\x3C/div\\x3E');
832 thisform.before(response_messages);
833 }
834
835 // Smooth scroll helper function
836 var smoothScrollToElement = function(elementId) {
837 var target = document.getElementById(elementId);
838 if (target) {
839 target.scrollIntoView({ behavior: 'smooth', block: 'start' });
840 }
841 };
842
843 var _ajax_submitting_{$js_buildid} = false;
844 var timeout_handler = false;
845 var timeout_count = 0;
846 var fail_count = 0;
847 var disabled_fields = false;
848 var submitBtn = $('button[type="submit"]', thisform);
849 var submitBtnOriginalText = submitBtn.text();
850 var submitBtnSendingText = $sending_message;
851
852 var jsuuid_field = $('input[name="_AccuaForm_jsuuid"]', thisform);
853 var jsuuid = jsuuid_field.val();
854 if (jsuuid == '') {
855 var chars = '0123456789abcdefghijklmnopqrstuvwxyz'.split('');
856 var radix = chars.length
857 for (i = 0; i < 25; i++) {
858 jsuuid += chars[0 | Math.random()*radix];
859 }
860 jsuuid_field.val(jsuuid);
861 }
862
863 var ga_track = {$js_ga_track} ;
864 var ga_event, ga_submit_event, ga_field_event, ga_field_events_fired = {};
865 ga_event = function(eventCategory, eventAction){
866 /* matomo */
867 if (window._mtm && (typeof window._mtm.push == 'function')) {
868 window._mtm.push({'event': 'ContactForms', 'eventAction': eventAction, 'eventCategory': eventCategory, 'eventLabel': ga_track.title});
869 }
870
871 if (typeof window.gtag == 'function') {
872 window.gtag('event', eventAction, {'event_category': eventCategory, 'event_label': ga_track.title});
873 } else if (window.dataLayer && (typeof window.dataLayer.push == 'function')) {
874 dataLayer.push({'event': 'ContactForms', 'eventAction': eventAction, 'eventCategory': eventCategory, 'eventLabel': ga_track.title});
875 //backward compatibility
876 var gtag = function(){window.dataLayer.push(arguments);}
877 gtag('event', eventAction, {'event_category': eventCategory, 'event_label': ga_track.title});
878 } else if (typeof window.ga == 'function') {
879 window.ga('send', 'event', eventCategory, eventAction, ga_track.title);
880 } else if (window._gaq && (typeof window._gaq.push == 'function')) {
881 window._gaq.push(['_trackEvent', eventCategory, eventAction, ga_track.title]);
882 } else if (window.gaq && (typeof window.gaq.push == 'function')) {
883 window.gaq.push(['_trackEvent', eventCategory, eventAction, ga_track.title]);
884 } else if (window.pageTracker && (typeof window.pageTracker._trackEvent == 'function')) {
885 window.pageTracker._trackEvent(eventCategory, eventAction, ga_track.title);
886 }
887 }
888 ga_submit_event = function(eventAction){
889 if (ga_track.track_submit) {
890 ga_event('ContactFormsSubmit', eventAction);
891 }
892 thisform.trigger('ContactFormsSubmit', [eventAction]);
893 }
894 ga_field_event = function(field_name){
895 if (!ga_field_events_fired[field_name]){
896 if (ga_track.track_fields) {
897 ga_event('ContactFormsFieldFilledIn', field_name);
898 ga_field_events_fired[field_name] = true;
899 }
900 thisform.trigger('ContactFormsFieldFilledIn', [field_name]);
901 }
902 }
903 $('input, textarea, select', thisform).change(function(){
904 ga_field_event($(this).attr('name'));
905 });
906
907 // Disable submit button during form submission
908 var disableSubmitButton = function() {
909 submitBtn.prop('disabled', true).attr('aria-busy', 'true').text(submitBtnSendingText);
910 };
911
912 // Re-enable submit button (on failure or completion)
913 var enableSubmitButton = function() {
914 submitBtn.prop('disabled', false).removeAttr('aria-busy').text(submitBtnOriginalText);
915 };
916
917 // Get field label from the field's container element
918 var getFieldLabel = function(field) {
919 var container = field.closest('.pfbc-element');
920 if (!container.length) {
921 return '';
922 }
923
924 // Try floating label first (inline labels mode)
925 var label = container.find('.pfbc-floating-label').first();
926 if (!label.length) {
927 // Try standard label in .pfbc-label
928 label = container.find('.pfbc-label label').first();
929 }
930 if (!label.length) {
931 // Try any label element
932 label = container.find('label').first();
933 }
934
935 if (label.length) {
936 // Get text content, removing the required asterisk
937 var text = label.clone().find('.pfbc-required').remove().end().text().trim();
938 return text;
939 }
940
941 return '';
942 };
943
944 // Build required field error message with field name
945 // Checks per-field data-custom-required-msg attribute first, then falls back to global template
946 var getRequiredMessage = function(fieldLabel, field) {
947 var template = $required_message_template;
948 if (field) {
949 var custom = field.attr('data-custom-required-msg');
950 if (custom) {
951 template = custom;
952 }
953 }
954 if (fieldLabel) {
955 return template.replace('%s', fieldLabel);
956 }
957 return template.replace('%s', '');
958 };
959
960 // Build email validation error message with field name
961 // Checks per-field data-custom-format-msg attribute first, then falls back to global template
962 var getEmailMessage = function(fieldLabel, field) {
963 var template = $valid_mail_message_template;
964 if (field) {
965 var custom = field.attr('data-custom-format-msg');
966 if (custom) {
967 template = custom;
968 }
969 }
970 if (fieldLabel) {
971 return template.replace('%s', fieldLabel);
972 }
973 return template.replace('%s', '');
974 };
975
976 // Build phone validation error message with field name
977 // Checks per-field data-custom-format-msg attribute first, then falls back to global template
978 var getPhoneMessage = function(fieldLabel, field) {
979 var template = $valid_phone_message_template;
980 if (field) {
981 var custom = field.attr('data-custom-format-msg');
982 if (custom) {
983 template = custom;
984 }
985 }
986 if (fieldLabel) {
987 return template.replace('%s', fieldLabel);
988 }
989 return template.replace('%s', '');
990 };
991
992 // Check if a telephone field contains only a country prefix (e.g. "+39")
993 // These values should be treated as empty — the user hasn't entered a real number.
994 var isTelephonePrefixOnly = function(field) {
995 if (!field.hasClass('accuaform-telephone')) return false;
996 var val = field.val();
997 if (!val) return false;
998 val = val.trim();
999 if (val === '' || val.charAt(0) !== '+') return false;
1000 return val.replace(/\D/g, '').length <= 4;
1001 };
1002
1003 // Scroll to first invalid field, then focus after scroll completes
1004 var focusFirstInvalidField = function() {
1005 var firstInvalidContainer = $('#{$this->buildID} .pfbc-element-has-error').first();
1006 if (!firstInvalidContainer.length) {
1007 return;
1008 }
1009
1010 // Try to find a focusable element in the invalid container
1011 var focusTarget = null;
1012
1013 // Check for radio/checkbox groups first
1014 var radioOrCheckbox = firstInvalidContainer.find('input[type="radio"], input[type="checkbox"]').first();
1015 if (radioOrCheckbox.length) {
1016 focusTarget = radioOrCheckbox;
1017 }
1018
1019 // Check for file input (focus the dropzone button if available)
1020 if (!focusTarget) {
1021 var fileDropzone = firstInvalidContainer.find('.accua-file-dropzone');
1022 if (fileDropzone.length) {
1023 focusTarget = fileDropzone;
1024 } else {
1025 var fileInput = firstInvalidContainer.find('input[type="file"]');
1026 if (fileInput.length) {
1027 focusTarget = fileInput;
1028 }
1029 }
1030 }
1031
1032 // Check for custom select buttons (post select, etc.)
1033 if (!focusTarget) {
1034 var customSelectBtn = firstInvalidContainer.find('.pfbc-post-select-trigger, button[aria-haspopup="listbox"]').first();
1035 if (customSelectBtn.length) {
1036 focusTarget = customSelectBtn;
1037 }
1038 }
1039
1040 // Standard inputs (text, email, select, textarea)
1041 if (!focusTarget) {
1042 focusTarget = firstInvalidContainer.find('input:not([type="hidden"]), textarea, select').first();
1043 }
1044
1045 if (focusTarget && focusTarget.length) {
1046 // Scroll into view first with smooth animation
1047 if (focusTarget[0].scrollIntoView) {
1048 focusTarget[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
1049 }
1050 // Focus after scroll animation completes (typical scroll animation is ~300-500ms)
1051 setTimeout(function() {
1052 focusTarget.focus();
1053 }, 500);
1054 }
1055 };
1056
1057 // Scroll to a specific field by ID and focus it
1058 var scrollToFieldAndFocus = function(fieldId) {
1059 var element = $('#' + fieldId);
1060 if (!element.length) {
1061 return;
1062 }
1063
1064 var focusTarget = element;
1065
1066 // If it's already an input/select/textarea, use it directly
1067 if (element.is('input, select, textarea')) {
1068 focusTarget = element;
1069 }
1070 // For containers (pfbc-element), find the first focusable element
1071 else if (element.hasClass('pfbc-element')) {
1072 var radioOrCheckbox = element.find('input[type="radio"], input[type="checkbox"]').first();
1073 if (radioOrCheckbox.length) {
1074 focusTarget = radioOrCheckbox;
1075 } else {
1076 var fileDropzone = element.find('.accua-file-dropzone');
1077 if (fileDropzone.length) {
1078 focusTarget = fileDropzone;
1079 } else {
1080 focusTarget = element.find('input:not([type="hidden"]), textarea, select').first();
1081 }
1082 }
1083 }
1084 // For file dropzone wrapper, focus the dropzone
1085 else if (element.find('.accua-file-dropzone').length) {
1086 focusTarget = element.find('.accua-file-dropzone').first();
1087 }
1088
1089 if (focusTarget && focusTarget.length) {
1090 if (focusTarget[0].scrollIntoView) {
1091 focusTarget[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
1092 }
1093 setTimeout(function() {
1094 focusTarget.focus();
1095 }, 500);
1096 }
1097 };
1098
1099 // Track if form submission has been attempted
1100 var submitAttempted = false;
1101
1102 // Array to collect field errors for summary
1103 var fieldErrorsList = [];
1104
1105 // Get the error ID for a radio/checkbox group (DRY helper)
1106 var getGroupErrorId = function(field) {
1107 var groupWrapper = field.closest('.pfbc-radio-buttons, .pfbc-checkboxes').parent();
1108 var groupId = groupWrapper.attr('id') || field.attr('id');
1109 return groupId + '-error';
1110 };
1111
1112 // Remove an error element with smooth animation (generic helper for any field type)
1113 var removeErrorAnimated = function(errorId) {
1114 var errorEl = $('#' + errorId);
1115 if (errorEl.length) {
1116 errorEl.addClass('pfbc-error-removing');
1117 setTimeout(function() {
1118 errorEl.remove();
1119 }, 150); // Match CSS animation duration
1120 }
1121 };
1122
1123 // Remove all existing errors for a group with smooth fade-out (handles both potential IDs)
1124 var removeGroupErrors = function(field, fieldName, animate) {
1125 var groupContainer = field.closest('.pfbc-element, .pfbc-fieldwrap');
1126 var errors = groupContainer.find('.pfbc-inline-error');
1127
1128 // Also collect errors by potential IDs
1129 var groupWrapper = field.closest('.pfbc-radio-buttons, .pfbc-checkboxes').parent();
1130 var wrapperId = groupWrapper.attr('id');
1131 if (wrapperId) {
1132 errors = errors.add($('#' + wrapperId + '-error'));
1133 }
1134 var firstField = $("[name='"+fieldName+"']", thisform).first();
1135 if (firstField.attr('id')) {
1136 errors = errors.add($('#' + firstField.attr('id') + '-error'));
1137 }
1138
1139 if (animate && errors.length) {
1140 // Smooth fade-out animation
1141 errors.addClass('pfbc-error-removing');
1142 setTimeout(function() {
1143 errors.remove();
1144 }, 150); // Match CSS transition duration
1145 } else {
1146 errors.remove();
1147 }
1148 };
1149
1150 // Show or update the error/success/loading summary area
1151 // state: true (success), false (error), 'loading' (submitting)
1152 var updateSummaryArea = function(state) {
1153 var summaryArea = $('#{$this->buildID}-validation-summary');
1154
1155 if (!submitAttempted && state !== 'loading') {
1156 summaryArea.remove();
1157 return;
1158 }
1159
1160 // Ensure summary area exists
1161 if (!summaryArea.length) {
1162 summaryArea = $('<div id="{$this->buildID}-validation-summary" class="pfbc-validation-summary" role="status" aria-live="polite"></div>');
1163 thisform.find('.pfbc-error').remove();
1164 thisform.append(summaryArea);
1165 }
1166
1167 if (state === 'loading') {
1168 // Show loading state - neutral blue with spinner and text
1169 summaryArea
1170 .removeClass('pfbc-validation-error pfbc-validation-success')
1171 .addClass('pfbc-validation-loading')
1172 .attr('role', 'status')
1173 .attr('aria-live', 'polite')
1174 .attr('aria-busy', 'true')
1175 .html('<span class="pfbc-summary-spinner" aria-hidden="true"></span>' + $loading_summary_message);
1176 } else if (state === true) {
1177 // Show success state
1178 summaryArea
1179 .removeClass('pfbc-validation-error pfbc-validation-loading')
1180 .addClass('pfbc-validation-success')
1181 .attr('role', 'status')
1182 .attr('aria-live', 'polite')
1183 .removeAttr('aria-busy')
1184 .html('<span class="pfbc-summary-icon">✓</span> ' + $success_message);
1185 } else {
1186 // Show error state with field list
1187 summaryArea
1188 .removeClass('pfbc-validation-success pfbc-validation-loading')
1189 .addClass('pfbc-validation-error')
1190 .attr('role', 'alert')
1191 .attr('aria-live', 'assertive')
1192 .removeAttr('aria-busy');
1193
1194 var html = '<p class="pfbc-summary-header">' + $error_summary_header + '</p><ul class="pfbc-summary-list">';
1195 for (var i = 0; i < fieldErrorsList.length; i++) {
1196 var err = fieldErrorsList[i];
1197 html += '<li><a href="#' + err.fieldId + '" class="pfbc-summary-link" data-field-id="' + err.fieldId + '">' + err.label + '</a> – ' + err.errorType + '</li>';
1198 }
1199 html += '</ul>';
1200 summaryArea.html(html);
1201
1202 // Attach click handlers to links
1203 summaryArea.find('.pfbc-summary-link').on('click', function(e) {
1204 e.preventDefault();
1205 var fieldId = $(this).data('field-id');
1206 scrollToFieldAndFocus(fieldId);
1207 });
1208 }
1209 };
1210
1211 var show_error_messages = function(message) {
1212 // Show error message in the validation summary area
1213 // Used when AJAX submission fails (network error, server error, etc.)
1214 var summaryArea = thisform.find('.pfbc-validation-summary');
1215 if (summaryArea.length) {
1216 summaryArea.html('<div class="pfbc-validation-error" role="alert"><strong>' + accua_forms_i18n.check_fields + '</strong><ul>' + message + '</ul></div>').show();
1217 }
1218 }
1219
1220 // Update summary area with server-side errors (called from AJAX error response)
1221 var updateSummaryWithServerErrors = function(errors, elementErrors) {
1222 // Collect server errors for the summary
1223 fieldErrorsList = [];
1224
1225 if (elementErrors) {
1226 jQuery.each(elementErrors, function(fieldName, fieldErrors) {
1227 var field = jQuery('[name="' + fieldName + '"]', thisform);
1228 var fieldId = field.attr('id') || fieldName;
1229 var fieldLabel = getFieldLabel(field);
1230
1231 // If no label found, try to get from the field container or use field name
1232 if (!fieldLabel || fieldLabel === fieldName) {
1233 var container = field.closest('.pfbc-element, .pfbc-fieldwrap');
1234 fieldLabel = container.find('label').first().text().replace(/\s*\*\s*$/, '').trim();
1235 if (!fieldLabel) {
1236 // Fallback: humanize the field name
1237 fieldLabel = fieldName.replace(/[-_]/g, ' ').replace(/\b\w/g, function(l){ return l.toUpperCase(); });
1238 }
1239 }
1240
1241 for (var i = 0; i < fieldErrors.length; i++) {
1242 fieldErrorsList.push({
1243 fieldId: fieldId,
1244 label: fieldLabel,
1245 errorType: fieldErrors[i]
1246 });
1247 }
1248 });
1249 }
1250
1251 // Also add any general errors from the errors array
1252 if (errors && errors.length > 0) {
1253 for (var i = 0; i < errors.length; i++) {
1254 // Check if this error is already in fieldErrorsList
1255 var alreadyAdded = false;
1256 for (var j = 0; j < fieldErrorsList.length; j++) {
1257 if (fieldErrorsList[j].errorType === errors[i]) {
1258 alreadyAdded = true;
1259 break;
1260 }
1261 }
1262 if (!alreadyAdded) {
1263 fieldErrorsList.push({
1264 fieldId: '',
1265 label: '',
1266 errorType: errors[i]
1267 });
1268 }
1269 }
1270 }
1271
1272 // Update the summary area to show errors
1273 if (fieldErrorsList.length > 0) {
1274 updateSummaryArea(false);
1275 }
1276 }
1277
1278 _handle_ajax_submit_{$js_buildid} = function() {
1279 if (_ajax_submitting_{$js_buildid}) {
1280 return false;
1281 }
1282
1283 JS;
1284 $this->error->clear();
1285 echo <<<JS
1286
1287 // Mark that submit was attempted
1288 submitAttempted = true;
1289
1290 var valid_empty = true;
1291 var valid_mail = true;
1292 var valid_phone = true;
1293 var fieldErrors = {};
1294
1295 // Reset field errors list for summary
1296 fieldErrorsList = [];
1297
1298 $("#{$this->buildID} .pfbc-element").removeClass('pfbc-invalid');
1299 $("#{$this->buildID} .pfbc-element").removeClass('pfbc-element-has-error');
1300 $("#{$this->buildID} input, #{$this->buildID} textarea, #{$this->buildID} select").attr('aria-invalid', 'false');
1301 $("#{$this->buildID} .pfbc-inline-error").remove();
1302
1303 var processedGroups = {}; // Track radio/checkbox groups to avoid duplicate errors
1304
1305 $('.accuaforms-field-required', thisform).each(function(){
1306 var field = $(this);
1307 var type = field.attr('type');
1308 var fieldName = field.attr('name');
1309
1310 if (type === 'checkbox' || type === 'radio') {
1311 // Skip if we've already processed this group
1312 if (processedGroups[fieldName]) {
1313 return true;
1314 }
1315 processedGroups[fieldName] = true;
1316
1317 if ($("[name='"+fieldName+"']:checked", "#{$this->buildID}").length > 0) {
1318 return true;
1319 }
1320
1321 valid_empty = false;
1322
1323 // Find the container for the radio/checkbox group
1324 var groupContainer = field.closest('.pfbc-element, .pfbc-fieldwrap');
1325 groupContainer.addClass('pfbc-invalid pfbc-element-has-error');
1326
1327 // Remove any existing errors before adding new one (prevents duplicates)
1328 removeGroupErrors(field, fieldName);
1329
1330 // Get consistent error ID using helper
1331 var errorId = getGroupErrorId(field);
1332
1333 // Find the last radio/checkbox in the group
1334 var lastInGroup = $("[name='"+fieldName+"']", "#{$this->buildID}").last();
1335
1336 // Apply ARIA attributes to all inputs in the group
1337 $("[name='"+fieldName+"']", "#{$this->buildID}").attr('aria-invalid', 'true');
1338 $("[name='"+fieldName+"']", "#{$this->buildID}").attr('aria-describedby', errorId);
1339
1340 // Get field label and build error message
1341 var fieldLabel = getFieldLabel(field);
1342 var firstInput = $("[name='"+fieldName+"']", "#{$this->buildID}").first();
1343 var errorMessage = getRequiredMessage(fieldLabel, firstInput);
1344
1345 // Add to field errors list for summary (use first input ID for focusing)
1346 fieldErrorsList.push({
1347 fieldId: firstInput.attr('id'),
1348 label: fieldLabel,
1349 errorType: $error_type_required
1350 });
1351
1352 // Add error message after the last item in the group
1353 var inlineError = $('<div class="pfbc-inline-error" id="' + errorId + '" role="alert" aria-live="polite"><div class="pfbc-error-message">' + errorMessage + '</div></div>');
1354
1355 // Insert after the last radio/checkbox wrapper
1356 var lastWrapper = lastInGroup.closest('.pfbc-radio, .pfbc-checkbox');
1357 if (lastWrapper.length) {
1358 lastWrapper.after(inlineError);
1359 } else {
1360 lastInGroup.after(inlineError);
1361 }
1362 } else {
1363 var val = field.val();
1364 if (typeof(val) == "string") {
1365 // Treat "-" and "Select..." as invalid only for dropdowns
1366 // For telephone fields, prefix-only values (e.g. "+39") are also empty
1367 var isSelect = field.is('select');
1368 if (! val.match(/^\s*$/) && (!isSelect || (val !== "Select..." && val !== "-")) && !isTelephonePrefixOnly(field)) {
1369 return true;
1370 }
1371 } else if (Array.isArray(val)) {
1372 // Multiselect: empty array means no selection
1373 if (val.length > 0) {
1374 return true;
1375 }
1376 } else if (val) {
1377 return true;
1378 }
1379
1380 valid_empty = false;
1381 var parent = field.closest('.pfbc-element, .pfbc-fieldwrap');
1382 parent.addClass('pfbc-invalid pfbc-element-has-error');
1383
1384 // Apply ARIA attributes
1385 field.attr('aria-invalid', 'true');
1386 var errorId = field.attr('id') + '-error';
1387 field.attr('aria-describedby', errorId);
1388
1389 // Get field label and build error message
1390 var fieldLabel = getFieldLabel(field);
1391 var errorMessage = getRequiredMessage(fieldLabel, field);
1392 fieldErrorsList.push({
1393 fieldId: field.attr('id'),
1394 label: fieldLabel,
1395 errorType: $error_type_required
1396 });
1397
1398 var inlineError = $('<div class="pfbc-inline-error" id="' + errorId + '" role="alert" aria-live="polite"><div class="pfbc-error-message">' + errorMessage + '</div></div>');
1399
1400 // For file inputs with help text, insert error after help text
1401 var helpText = field.siblings('.pfbc-help').last();
1402 if (field.is('[type="file"]') && helpText.length) {
1403 helpText.after(inlineError);
1404 } else {
1405 field.after(inlineError);
1406 }
1407 }
1408 });
1409
1410 $('.pfbc-textbox[type="email"]', thisform).each(function(){
1411 var field = $(this);
1412
1413 if (field.val().match(/^\s*$/)) {
1414 return true;
1415 }
1416
1417 if (field.val().match( /^([a-zA-Z0-9_.+%-])+@(([a-zA-Z0-9-])+\.)+([a-zA-Z0-9])+$/ )) {
1418 return true;
1419 }
1420
1421 valid_mail = false;
1422 var parent = field.closest('.pfbc-element, .pfbc-fieldwrap');
1423 parent.addClass('pfbc-invalid pfbc-element-has-error');
1424
1425 // Apply ARIA attributes
1426 field.attr('aria-invalid', 'true');
1427 var errorId = field.attr('id') + '-error';
1428 field.attr('aria-describedby', errorId);
1429
1430 // Get field label and build error message
1431 var fieldLabel = getFieldLabel(field);
1432 var errorMessage = getEmailMessage(fieldLabel, field);
1433
1434 // Add to field errors list for summary (use field ID for focusing)
1435 fieldErrorsList.push({
1436 fieldId: field.attr('id'),
1437 label: fieldLabel,
1438 errorType: $error_type_invalid_email
1439 });
1440
1441 var inlineError = $('<div class=\"pfbc-inline-error\" id=\"' + errorId + '\" role=\"alert\" aria-live=\"polite\"><div class=\"pfbc-error-message\">' + errorMessage + '</div></div>');
1442
1443 // For file inputs with help text, insert error after help text
1444 var helpText = field.siblings('.pfbc-help').last();
1445 if (field.is('[type=\"file\"]') && helpText.length) {
1446 helpText.after(inlineError);
1447 } else {
1448 field.after(inlineError);
1449 }
1450
1451 });
1452
1453 // Phone validation - runs for all non-empty phone fields
1454 $('.accuaform-telephone', thisform).each(function(){
1455 var field = $(this);
1456 var value = field.val();
1457
1458 // Skip empty fields (Required validation handles mandatory)
1459 if (!value || value.trim() === '') {
1460 return true;
1461 }
1462
1463 // Skip prefix-only values (e.g. "+39") — treated as empty
1464 var phoneTrimmed = value.trim();
1465 if (phoneTrimmed.charAt(0) === '+' && phoneTrimmed.replace(/\D/g, '').length <= 4) {
1466 return true;
1467 }
1468
1469 // Get country code from data attribute
1470 var countryCode = field.attr('data-country') || 'IT';
1471
1472 // Use AccuaPhoneValidation if available, otherwise skip validation
1473 if (typeof window.AccuaPhoneValidation !== 'undefined' && window.AccuaPhoneValidation.isValid) {
1474 if (window.AccuaPhoneValidation.isValid(value, countryCode)) {
1475 return true;
1476 }
1477 } else {
1478 // Fallback: basic validation matching server-side Phone.php
1479 var trimmed = value.trim();
1480 // Check for invalid characters
1481 if (!/^[\d\s\-\.\/\(\)\+]+$/.test(trimmed)) {
1482 // Invalid characters - fail validation
1483 } else {
1484 var plusIndex = trimmed.indexOf('+');
1485 if (plusIndex > 0 || (trimmed.match(/\+/g) || []).length > 1) {
1486 // Plus in wrong position or multiple plus signs - fail validation
1487 } else {
1488 var digitCount = trimmed.replace(/\D/g, '').length;
1489 // Prefix-only (1-4 digits with +) treated as empty
1490 if (trimmed.charAt(0) === '+' && digitCount <= 4) {
1491 return true;
1492 }
1493 // Valid if 5-15 digits (matches server-side Phone.php)
1494 if (digitCount >= 5 && digitCount <= 15) {
1495 return true;
1496 }
1497 }
1498 }
1499 }
1500
1501 valid_phone = false;
1502 var parent = field.closest('.pfbc-element, .pfbc-fieldwrap');
1503 parent.addClass('pfbc-invalid pfbc-element-has-error');
1504
1505 // Remove any existing blur validation error to avoid duplicates
1506 $('#' + field.attr('id') + '-phone-error').remove();
1507
1508 // Apply ARIA attributes
1509 field.attr('aria-invalid', 'true');
1510 var errorId = field.attr('id') + '-error';
1511 field.attr('aria-describedby', errorId);
1512
1513 // Get field label and build error message
1514 var fieldLabel = getFieldLabel(field);
1515 var errorMessage = getPhoneMessage(fieldLabel, field);
1516
1517 // Add to field errors list for summary
1518 fieldErrorsList.push({
1519 fieldId: field.attr('id'),
1520 label: fieldLabel,
1521 errorType: $error_type_invalid_phone
1522 });
1523
1524 var inlineError = $('<div class="pfbc-inline-error pfbc-phone-format-error" id="' + errorId + '" role="alert" aria-live="polite"><div class="pfbc-error-message">' + errorMessage + '</div></div>');
1525
1526 var helpText = field.siblings('.pfbc-help').last();
1527 if (helpText.length) {
1528 helpText.after(inlineError);
1529 } else {
1530 field.after(inlineError);
1531 }
1532 });
1533
1534 if (valid_empty && valid_mail && valid_phone) {
1535 // Show loading state in summary during AJAX submission
1536 updateSummaryArea('loading');
1537
1538 _ajax_submitting_{$js_buildid} = true;
1539 disableSubmitButton();
1540 $('input[name="_AccuaForm_tentatives"]', thisform).val(fail_count);
1541 disabled_fields = $("input, textarea, button, select", thisform).not('[type="submit"]').not(':disabled');
1542 disabled_fields.attr('readonly','readonly');
1543 timeout_count = 0;
1544 if (ajax_enabled) {
1545 $("#submit_target_{$js_buildid}").attr('src','').removeAttr('src');
1546 timeout_handler = setTimeout(_handle_ajax_submit_timeout_{$js_buildid}, 5000);
1547 }
1548 return true;
1549 } else {
1550 ga_submit_event('formSubmitInvalid');
1551
1552 // Update URL hash to reflect invalid state (for GA tracking and bookmarkability)
1553 if (history.replaceState) {
1554 history.replaceState(null, '', '#formSubmitInvalid-'+anchor_id);
1555 }
1556
1557 // Update summary area with error list
1558 updateSummaryArea(false);
1559
1560 // Focus on first invalid field for accessibility
1561 focusFirstInvalidField();
1562
1563 return false;
1564 }
1565 }
1566
1567
1568 _handle_ajax_submit_timeout_{$js_buildid} = function() {
1569 if (_ajax_submitting_{$js_buildid}) {
1570 if (timeout_count < 60) {
1571 timeout_count++;
1572 timeout_handler = setTimeout(_handle_ajax_submit_timeout_{$js_buildid}, 500);
1573 _handle_ajax_submit_complete_{$js_buildid}();
1574 } else {
1575 timeout_handler = false;
1576 _handle_ajax_submit_complete_{$js_buildid}();
1577 if (_ajax_submitting_{$js_buildid}) {
1578 _handle_ajax_submit_response_{$js_buildid}(false);
1579 }
1580 }
1581 }
1582 }
1583
1584 _handle_ajax_submit_complete_{$js_buildid} = function() {
1585 if (_ajax_submitting_{$js_buildid}) {
1586 var response = false;
1587 try {
1588 var responsedoc = frames['submit_target_{$js_buildid}'].document;
1589 if (responsedoc.getElementById("accua-form-ajax-response-loaded")) {
1590 response = $.parseJSON(responsedoc.getElementById("accua-form-ajax-response").innerHTML);
1591 }
1592 } catch (err) {
1593 response = false;
1594 }
1595 if (response) {
1596 return _handle_ajax_submit_response_{$js_buildid} (response);
1597 }
1598 }
1599 }
1600
1601 _handle_ajax_submit_message_{$js_buildid} = function(message) {
1602 if (_ajax_submitting_{$js_buildid}) {
1603 var response = false;
1604 try {
1605 response = $.parseJSON(message.data);
1606 // Accept response if jsuuid matches AND buildID matches or is null (server rejection)
1607 if (response.jsuuid != jsuuid || (response.buildID != null && response.buildID != "{$this->buildID}")) {
1608 response = false;
1609 }
1610 } catch (err) {
1611 response = false;
1612 }
1613 if (response) {
1614 return _handle_ajax_submit_response_{$js_buildid} (response);
1615 }
1616 }
1617 }
1618
1619 _handle_ajax_submit_response_{$js_buildid} = function(response) {
1620 if (_ajax_submitting_{$js_buildid}) {
1621 if(response && typeof(response) == "object" && typeof(response.submitted) == "boolean") {
1622 // Only show message container if there's actual content
1623 if (response.messages && response.messages.trim() !== '') {
1624 response_messages.html(response.messages).show();
1625 } else {
1626 response_messages.empty().hide();
1627 }
1628 if (response.submitted) {
1629 if (response.valid) {
1630 var gads_track_code = "{$this->gads_conversion_tracking_code}";
1631 if(gads_track_code != ''){
1632 gtag('event', 'conversion', {'send_to': gads_track_code});
1633 }
1634
1635 ga_submit_event('formSubmitSuccess');
1636 smoothScrollToElement('formSubmitSuccess-'+anchor_id);
1637
1638
1639 JS;
1640 /*A callback function can be specified to handle any post submission events.*/
1641 if(!empty($this->ajaxCallback)) {
1642 echo $this->ajaxCallback, "(response);";
1643 } else {
1644 echo "$('#{$this->buildID}').hide();";
1645 }
1646 echo <<<JS
1647 } else {
1648 ga_submit_event('formSubmitInvalid');
1649 smoothScrollToElement('formSubmitInvalid-'+anchor_id);
1650 JS;
1651 if (method_exists($this->error,'applyAjaxErrorResponseUsingShowErrorMessages')) {
1652 $this->error->applyAjaxErrorResponseUsingShowErrorMessages();
1653 } else {
1654 $this->error->applyAjaxErrorResponse();
1655 }
1656 echo <<<JS
1657
1658 for (var name in response.files) {
1659 $(".pfbc-fieldwrap:has(input[type='file'][name='"+name+"'])", thisform).html(response.files[name]);
1660 }
1661
1662 $("input[name='_AccuaForm_hash']",thisform).val(response._AccuaForm_hash);
1663 $("input[name='_AccuaForm_iv']", thisform).val(response._AccuaForm_iv);
1664 $("input[name='_AccuaForm_data']",thisform).val(response._AccuaForm_data);
1665
1666 disabled_fields.removeAttr('readonly');
1667 enableSubmitButton();
1668 }
1669 } else {
1670 // Server did not recognize the form submission (e.g. expired nonce, stale cached page)
1671 ga_submit_event('formSubmitError');
1672 smoothScrollToElement('formSubmitError-'+anchor_id);
1673 fail_count++;
1674 if (fail_count > 2) {
1675 ajax_enabled = false;
1676 thisform.attr("action", {$post_url} );
1677 thisform.removeAttr("target");
1678 $('input[name="_AccuaForm_submit_method"]', thisform).val('fallback');
1679 }
1680 show_error_messages( $submit_fail_message );
1681 disabled_fields.removeAttr('readonly');
1682 enableSubmitButton();
1683 }
1684 } else {
1685 ga_submit_event('formSubmitError');
1686 smoothScrollToElement('formSubmitError-'+anchor_id);
1687 fail_count++;
1688 if (fail_count > 2) {
1689 ajax_enabled = false;
1690 thisform.attr("action", {$post_url} );
1691 thisform.removeAttr("target");
1692 $('input[name="_AccuaForm_submit_method"]', thisform).val('fallback');
1693 }
1694 show_error_messages( $submit_fail_message );
1695 enableSubmitButton();
1696 }
1697 $('.accua_forms_show_recaptcha_button', thisform).click();
1698 if (((typeof accuaform_recaptcha2_initialized) != 'undefined') && accuaform_recaptcha2_initialized) {
1699 $('.accua_forms_recaptcha2_container', thisform).each(function(){
1700 accua_forms_reload_recaptcha2($(this).attr('id'));
1701 });
1702 }
1703 _ajax_submitting_{$js_buildid} = false;
1704 if (timeout_handler) {
1705 clearTimeout(timeout_handler);
1706 timeout_handler = false;
1707 }
1708 }
1709 }
1710
1711 if (ajax_enabled) {
1712 thisform.attr("action", {$ajax_url} );
1713 thisform.attr("target","submit_target_{$js_buildid}");
1714 try {
1715 window.addEventListener('message', _handle_ajax_submit_message_{$js_buildid}, false);
1716 } catch (e) { }
1717 $('input[name="_AccuaForm_submit_method"]', thisform).val('iframe');
1718 } else {
1719 thisform.attr("action", {$post_url} );
1720 }
1721 thisform.attr("onsubmit","return _handle_ajax_submit_{$js_buildid}()");
1722
1723 // Real-time validation for better UX - use change only for checkbox/radio to avoid double-firing
1724 $('.accuaforms-field-required', thisform).on('change', function() {
1725 var field = $(this);
1726 var type = field.attr('type');
1727 if (type !== 'checkbox' && type !== 'radio') return; // Only handle checkbox/radio on change
1728
1729 var fieldName = field.attr('name');
1730 var isChecked = $("[name='"+fieldName+"']:checked", thisform).length > 0;
1731 var groupContainer = field.closest('.pfbc-element, .pfbc-fieldwrap');
1732 var hasError = groupContainer.hasClass('pfbc-element-has-error');
1733
1734 // Only act if state actually changed to avoid flashing
1735 if (isChecked && hasError) {
1736 // Valid now - remove error with animation
1737 groupContainer.removeClass('pfbc-invalid pfbc-element-has-error');
1738 $("[name='"+fieldName+"']", thisform).attr('aria-invalid', 'false');
1739 $("[name='"+fieldName+"']", thisform).removeAttr('aria-describedby');
1740 removeGroupErrors(field, fieldName, true); // animate=true
1741 } else if (!isChecked && !hasError && submitAttempted) {
1742 // Invalid now and we've attempted submit - show error
1743 var errorId = getGroupErrorId(field);
1744 groupContainer.addClass('pfbc-invalid pfbc-element-has-error');
1745 $("[name='"+fieldName+"']", thisform).attr('aria-invalid', 'true');
1746 $("[name='"+fieldName+"']", thisform).attr('aria-describedby', errorId);
1747
1748 var fieldLabel = getFieldLabel(groupContainer);
1749 var firstField = $("[name='"+fieldName+"']", thisform).first();
1750 var errorMessage = getRequiredMessage(fieldLabel, firstField);
1751 var inlineError = $('<div class="pfbc-inline-error" id="' + errorId + '" role="alert" aria-live="polite"><div class="pfbc-error-message">' + errorMessage + '</div></div>');
1752
1753 var lastInGroup = $("[name='"+fieldName+"']", thisform).last();
1754 var lastWrapper = lastInGroup.closest('.pfbc-radio, .pfbc-checkbox');
1755 if (lastWrapper.length) {
1756 lastWrapper.after(inlineError);
1757 } else {
1758 lastInGroup.after(inlineError);
1759 }
1760 }
1761 });
1762
1763 // Blur handler for text-like fields only
1764 $('.accuaforms-field-required', thisform).on('blur', function() {
1765 var field = $(this);
1766 var type = field.attr('type');
1767 var fieldName = field.attr('name');
1768 var isEmpty = false;
1769
1770 // Skip checkbox/radio - handled by change event above
1771 if (type === 'checkbox' || type === 'radio') return;
1772
1773 var val = field.val();
1774 if (typeof(val) == "string") {
1775 // Treat "-" and "Select..." as invalid only for dropdowns
1776 // For telephone fields, prefix-only values (e.g. "+39") are also empty
1777 var isSelect = field.is('select');
1778 isEmpty = val.match(/^\s*$/) || (isSelect && (val === "Select..." || val === "-")) || isTelephonePrefixOnly(field);
1779 } else if (typeof(val) == "object") {
1780 isEmpty = !val || val.length === 0;
1781 } else {
1782 isEmpty = !val;
1783 }
1784
1785 var parent = field.closest('.pfbc-element, .pfbc-fieldwrap');
1786 var errorId = field.attr('id') + '-error';
1787
1788 if (isEmpty) {
1789 // For telephone: phone-validation.js blur handler runs AFTER this one and may
1790 // still have pfbc-element-has-error set from a previous format error. Check for
1791 // the specific required error div instead of the parent class to avoid skipping.
1792 var alreadyHasError = field.hasClass('accuaform-telephone')
1793 ? $('#' + errorId).length > 0
1794 : parent.hasClass('pfbc-element-has-error');
1795 if (!alreadyHasError) {
1796 // For telephone: remove leftover phone format error since the field is now
1797 // empty (required error takes priority). phone-validation.js will also clean
1798 // up on its blur, but this handler runs first.
1799 if (field.hasClass('accuaform-telephone')) {
1800 $('#' + field.attr('id') + '-phone-error').remove();
1801 }
1802 parent.addClass('pfbc-invalid pfbc-element-has-error');
1803 field.attr('aria-invalid', 'true');
1804 field.attr('aria-describedby', errorId);
1805
1806 // Get field label for error message
1807 var fieldLabel = getFieldLabel(parent);
1808 var errorMessage = getRequiredMessage(fieldLabel, field);
1809 var inlineError = $('<div class="pfbc-inline-error" id="' + errorId + '" role="alert" aria-live="polite"><div class="pfbc-error-message">' + errorMessage + '</div></div>');
1810
1811 // For file inputs with help text, insert error after help text
1812 var helpText = field.siblings('.pfbc-help').last();
1813 if (field.is('[type="file"]') && helpText.length) {
1814 helpText.after(inlineError);
1815 } else {
1816 field.after(inlineError);
1817 }
1818 }
1819 } else {
1820 // Field is not empty — clear required-related errors.
1821 if (field.hasClass('accuaform-telephone')) {
1822 // For telephone fields: clear only the required error ({id}-error).
1823 // phone-validation.js has already run on this same blur event and set the
1824 // correct error state (phone-error or clean). We must not undo its work.
1825 // Only remove the required-error div; preserve phone-validation.js state.
1826 $('#' + errorId).remove();
1827 // If phone-validation.js left no errors, clear the container state too
1828 if (!parent.find('.pfbc-inline-error').length) {
1829 parent.removeClass('pfbc-invalid pfbc-element-has-error');
1830 field.attr('aria-invalid', 'false');
1831 field.removeAttr('aria-describedby');
1832 }
1833 } else {
1834 // For non-telephone fields: animated removal for smoother UX
1835 removeErrorAnimated(errorId);
1836 parent.removeClass('pfbc-invalid pfbc-element-has-error');
1837 field.attr('aria-invalid', 'false');
1838 field.removeAttr('aria-describedby');
1839 }
1840 }
1841 });
1842
1843 $('.pfbc-textbox[type="email"]', thisform).on('blur change', function() {
1844 var field = $(this);
1845 var val = field.val();
1846
1847 if (val.match(/^\s*$/)) {
1848 return; // Empty is handled by required validation
1849 }
1850
1851 var isValid = val.match(/^([a-zA-Z0-9_.+%-])+@(([a-zA-Z0-9-])+\.)+([a-zA-Z0-9])+$/);
1852 var parent = field.closest('.pfbc-element, .pfbc-fieldwrap');
1853 var errorId = field.attr('id') + '-error';
1854
1855 if (!isValid) {
1856 if (!$('#' + errorId + ':not(.pfbc-error-removing)').length) {
1857 $('#' + errorId).remove();
1858 parent.addClass('pfbc-invalid pfbc-element-has-error');
1859 field.attr('aria-invalid', 'true');
1860 field.attr('aria-describedby', errorId);
1861
1862 // Get field label for error message
1863 var fieldLabel = getFieldLabel(parent);
1864 var errorMessage = getEmailMessage(fieldLabel, field);
1865 var inlineError = $('<div class=\"pfbc-inline-error\" id=\"' + errorId + '\" role=\"alert\" aria-live=\"polite\"><div class=\"pfbc-error-message\">' + errorMessage + '</div></div>');
1866
1867 // For file inputs with help text, insert error after help text
1868 var helpText = field.siblings('.pfbc-help').last();
1869 if (field.is('[type=\"file\"]') && helpText.length) {
1870 helpText.after(inlineError);
1871 } else {
1872 field.after(inlineError);
1873 }
1874 }
1875 } else {
1876 parent.removeClass('pfbc-invalid pfbc-element-has-error');
1877 field.attr('aria-invalid', 'false');
1878 field.removeAttr('aria-describedby');
1879 removeErrorAnimated(errorId);
1880 }
1881 });
1882 });
1883 // -->
1884 </script>
1885 <iframe id="submit_target_{$js_buildid}" title="Notification Message" name="submit_target_{$js_buildid}" onload="_handle_ajax_submit_complete_{$js_buildid}()" onerror="_handle_ajax_submit_complete_{$js_buildid}()" style="width:0;height:0;border:0px solid #fff"></iframe>
1886 JS;
1887 }
1888
1889 echo <<<JSREFERRER
1890 <script type="text/javascript">
1891 <!--
1892 jQuery(function($){
1893 var referrerfield = $("#{$this->buildID} input[name='_AccuaForm_referrer']");
1894 if (referrerfield.val() == '') {
1895 referrerfield.val(document.referrer);
1896 }
1897 $("#{$this->buildID} input[name='_AccuaForm_user_agent']").val(navigator.userAgent);
1898 $("#{$this->buildID} input[name='_AccuaForm_platform']").val(navigator.platform);
1899 });
1900 // -->
1901 </script>
1902 JSREFERRER;
1903
1904 if($returnHTML) {
1905 $html = ob_get_contents();
1906 ob_end_clean();
1907 return $html;
1908 }
1909 }
1910
1911 protected function renderJS() {
1912 $this->renderJSFiles();
1913
1914 echo <<<JS
1915 <script type="text/javascript">
1916 <!--
1917
1918 JS;
1919 $this->view->renderJS();
1920 foreach($this->elements as $element)
1921 $element->renderJS();
1922
1923 $id = $this->attributes["id"];
1924
1925 echo 'jQuery(document).ready(function() {';
1926 /*jQuery is used to set the focus of the form's initial element.*/
1927 if(!in_array("focus", $this->prevent))
1928 echo 'jQuery("#', $id, ' :input:visible:enabled:first").focus();';
1929
1930 // Accessibility: Focus management for validation errors on page load
1931 echo <<<JS
1932
1933 // If there are errors on page load, focus the error summary or first invalid field
1934 if (jQuery('.pfbc-error', '#{$id}').length > 0) {
1935 setTimeout(function() {
1936 var errorContainer = jQuery('.pfbc-error', '#{$id}').first();
1937 errorContainer.attr('tabindex', '-1').focus();
1938 }, 100);
1939 } else if (jQuery('.pfbc-element-has-error', '#{$id}').length > 0) {
1940 setTimeout(function() {
1941 var firstInvalidField = jQuery('.pfbc-element-has-error :input:visible:enabled:first', '#{$id}').first();
1942 if (firstInvalidField.length) {
1943 firstInvalidField.focus();
1944 }
1945 }, 100);
1946 }
1947
1948 JS;
1949
1950 $this->view->jQueryDocumentReady();
1951 foreach($this->elements as $element) {
1952 $element->jQueryDocumentReady();
1953 }
1954
1955 /*For ajax, an anonymous onsubmit javascript function is bound to the form using jQuery. jQuery's
1956 serialize function is used to grab each element's name/value pair.* /
1957 if(!empty($this->ajax)) {
1958 echo 'jQuery("#', $id, '").bind("submit", function() {';
1959 $this->error->clear();
1960 echo <<<JS
1961 jQuery.ajax({
1962 url: "{$this->attributes["action"]}",
1963 type: "{$this->attributes["method"]}",
1964 data: jQuery("#$id").serialize(),
1965 success: function(response) {
1966 if(response != undefined && typeof response == "object" && response.errors) {
1967 JS;
1968 $this->error->applyAjaxErrorResponse();
1969 echo <<<JS
1970 jQuery("html, body").animate({ scrollTop: jQuery("#$id").offset().top }, 500 );
1971 }
1972 else {
1973 JS;
1974 /*A callback function can be specified to handle any post submission events.* /
1975 if(!empty($this->ajaxCallback))
1976 echo $this->ajaxCallback, "(response);";
1977 echo <<<JS
1978 }
1979 }
1980 });
1981 return false;
1982 });
1983
1984 JS;
1985 }
1986 */
1987 echo <<<JS
1988 });
1989 // -->
1990 </script>
1991 JS;
1992
1993 // Add JavaScript for inline label floating behavior
1994 if (strpos($this->attributes['class'], 'accua-form-view-inlinelabel') !== false) {
1995 echo <<<INLINELABELJS
1996 <script type="text/javascript">
1997 <!--
1998 jQuery(function($) {
1999 var form = $('#{$this->attributes["id"]}');
2000
2001 /**
2002 * Inline Label Floating Behavior
2003 *
2004 * Handles the Material Design floating label animation:
2005 * - Floats label up when field is focused
2006 * - Keeps label up when field has value
2007 * - Returns label to inline position when field is empty and unfocused
2008 *
2009 * Accessibility features:
2010 * - Maintains proper ARIA relationships
2011 * - Works with keyboard navigation
2012 * - Compatible with screen readers
2013 * - Supports autofill detection
2014 */
2015
2016 // Function to check if field has value
2017 function hasValue(field) {
2018 var val = field.val();
2019 // For select elements, check if selected value is not empty
2020 if (field.is('select')) {
2021 return val !== null && val !== '' && val !== undefined;
2022 }
2023 // For date inputs, check if value is set (format: YYYY-MM-DD)
2024 if (field.attr('type') === 'date') {
2025 return val !== null && val !== '' && val !== undefined;
2026 }
2027 // For text inputs and textareas
2028 return val !== null && val !== '' && val.trim() !== '';
2029 }
2030
2031 // Function to update wrapper state
2032 function updateWrapperState(wrapper) {
2033 var field = wrapper.find('.pfbc-textbox, .pfbc-textarea, .pfbc-select').first();
2034 var postSelectWrapper = wrapper.find('.pfbc-post-select-wrapper');
2035
2036 // Check if this is a post-select field
2037 if (postSelectWrapper.length && postSelectWrapper.attr('data-enhanced') === 'true') {
2038 // For enhanced post-select, check the hidden native select for value
2039 var nativeSelect = postSelectWrapper.find('select');
2040 var trigger = postSelectWrapper.find('.pfbc-post-select-trigger');
2041 var container = postSelectWrapper.find('.pfbc-post-select-container');
2042
2043 var isFocused = trigger.is(':focus') || container.hasClass('open');
2044 var fieldHasValue = nativeSelect.length && hasValue(nativeSelect);
2045
2046 wrapper.toggleClass('is-focused', isFocused);
2047 wrapper.toggleClass('has-value', fieldHasValue);
2048 return;
2049 }
2050
2051 if (!field.length) return;
2052
2053 var isFocused = field.is(':focus');
2054 var fieldHasValue = hasValue(field);
2055
2056 // Update wrapper classes
2057 wrapper.toggleClass('is-focused', isFocused);
2058 wrapper.toggleClass('has-value', fieldHasValue);
2059
2060 // For date inputs, add class directly to field for Firefox CSS support
2061 if (field.attr('type') === 'date') {
2062 field.toggleClass('has-value', fieldHasValue);
2063 }
2064
2065 // Accessibility: Update ARIA state
2066 var label = wrapper.find('.pfbc-floating-label');
2067 if (label.length) {
2068 // Ensure label is always associated with field
2069 var fieldId = field.attr('id');
2070 if (fieldId && !field.attr('aria-labelledby')) {
2071 // Label is already associated via for/id
2072 // Additional ARIA not needed, but we ensure proper semantics
2073 }
2074 }
2075 }
2076
2077 // Initialize all inline label wrappers
2078 form.find('.pfbc-inline-label-wrapper').each(function() {
2079 var wrapper = $(this);
2080 updateWrapperState(wrapper);
2081 });
2082
2083 // Handle focus events
2084 form.on('focus', '.pfbc-inline-label-wrapper .pfbc-textbox, .pfbc-inline-label-wrapper .pfbc-textarea, .pfbc-inline-label-wrapper .pfbc-select', function() {
2085 var wrapper = $(this).closest('.pfbc-inline-label-wrapper');
2086 updateWrapperState(wrapper);
2087 });
2088
2089 // Handle blur events
2090 form.on('blur', '.pfbc-inline-label-wrapper .pfbc-textbox, .pfbc-inline-label-wrapper .pfbc-textarea, .pfbc-inline-label-wrapper .pfbc-select', function() {
2091 var wrapper = $(this).closest('.pfbc-inline-label-wrapper');
2092 // Small delay to allow value to be set
2093 setTimeout(function() {
2094 updateWrapperState(wrapper);
2095 }, 10);
2096 });
2097
2098 // Handle input/change events to detect value changes
2099 form.on('input change', '.pfbc-inline-label-wrapper .pfbc-textbox, .pfbc-inline-label-wrapper .pfbc-textarea, .pfbc-inline-label-wrapper .pfbc-select', function() {
2100 var wrapper = $(this).closest('.pfbc-inline-label-wrapper');
2101 updateWrapperState(wrapper);
2102 });
2103
2104 // Handle post-select trigger focus/blur events
2105 form.on('focus', '.pfbc-inline-label-wrapper .pfbc-post-select-trigger', function() {
2106 var wrapper = $(this).closest('.pfbc-inline-label-wrapper');
2107 updateWrapperState(wrapper);
2108 });
2109
2110 form.on('blur', '.pfbc-inline-label-wrapper .pfbc-post-select-trigger', function() {
2111 var wrapper = $(this).closest('.pfbc-inline-label-wrapper');
2112 setTimeout(function() {
2113 updateWrapperState(wrapper);
2114 }, 50);
2115 });
2116
2117 // Handle post-select value changes (native select change event)
2118 form.on('change', '.pfbc-inline-label-wrapper .pfbc-post-select-wrapper select', function() {
2119 var wrapper = $(this).closest('.pfbc-inline-label-wrapper');
2120 updateWrapperState(wrapper);
2121 });
2122
2123 // Observe post-select container for open/close state changes
2124 if (window.MutationObserver) {
2125 form.find('.pfbc-inline-label-wrapper .pfbc-post-select-container').each(function() {
2126 var container = this;
2127 var wrapper = $(container).closest('.pfbc-inline-label-wrapper');
2128 var containerObserver = new MutationObserver(function(mutations) {
2129 mutations.forEach(function(mutation) {
2130 if (mutation.attributeName === 'class') {
2131 updateWrapperState(wrapper);
2132 }
2133 });
2134 });
2135 containerObserver.observe(container, {
2136 attributes: true,
2137 attributeFilter: ['class']
2138 });
2139 });
2140 }
2141
2142 // Handle browser autofill (multiple browser support)
2143 // Chrome/Safari autofill detection
2144 if (window.MutationObserver) {
2145 var observer = new MutationObserver(function(mutations) {
2146 mutations.forEach(function(mutation) {
2147 if (mutation.attributeName === 'value' || mutation.attributeName === 'class') {
2148 var target = $(mutation.target);
2149 if (target.hasClass('pfbc-textbox') || target.hasClass('pfbc-textarea') || target.hasClass('pfbc-select')) {
2150 var wrapper = target.closest('.pfbc-inline-label-wrapper');
2151 if (wrapper.length) {
2152 updateWrapperState(wrapper);
2153 }
2154 }
2155 }
2156 });
2157 });
2158
2159 form.find('.pfbc-inline-label-wrapper .pfbc-textbox, .pfbc-inline-label-wrapper .pfbc-textarea, .pfbc-inline-label-wrapper .pfbc-select').each(function() {
2160 observer.observe(this, {
2161 attributes: true,
2162 attributeFilter: ['value', 'class']
2163 });
2164 });
2165 }
2166
2167 // Fallback autofill detection with animation frame checking
2168 setTimeout(function() {
2169 form.find('.pfbc-inline-label-wrapper').each(function() {
2170 updateWrapperState($(this));
2171 });
2172 }, 100);
2173
2174 // Additional check for autofill after a short delay
2175 setTimeout(function() {
2176 form.find('.pfbc-inline-label-wrapper').each(function() {
2177 updateWrapperState($(this));
2178 });
2179 }, 500);
2180 });
2181 // -->
2182 </script>
2183 INLINELABELJS;
2184 }
2185 }
2186
2187 protected function renderCSS() {
2188
2189 }
2190
2191 public function getAjax() {
2192 return $this->accua_ajax;
2193 }
2194
2195 public function getFile($fieldname) {
2196 if (isset($this->files[$fieldname])) {
2197 return $this->files[$fieldname];
2198 }
2199 }
2200
2201 public function renameFile($fieldname, $newname) {
2202 if (isset($this->files[$fieldname])) {
2203 $file = $this->files[$fieldname];
2204 @ $renamed = rename($file['dest_path'].$file['tmp_name'], $file['dest_path'].$newname);
2205 if ($renamed) {
2206 $this->files[$fieldname]['new_name'] = $newname;
2207 return true;
2208 }
2209 }
2210 return false;
2211 }
2212
2213 public static function renderAjaxErrorResponse($unused = 'pfbc') {
2214 if ($form = self::$submittedForm) {
2215 $form->error->setForm($form);
2216 return $form->error->renderAjaxErrorResponse();
2217 }
2218 }
2219
2220 public static function getAjaxErrorResponse() {
2221 if ($form = self::$submittedForm) {
2222 $form->error->setForm($form);
2223 return $form->error->getAjaxErrorResponse();
2224 }
2225 }
2226
2227 public function setFormError($errors, $element = '') {
2228 return self::setError($this->buildID, $errors, $element);
2229 }
2230
2231 public function setClass($class) {
2232 if(!empty($this->attributes["class"]))
2233 $this->attributes["class"] .= " " . $class;
2234 else
2235 $this->attributes["class"] = $class;
2236 }
2237
2238 public static function ajaxSubmit() {
2239 $ret = array(
2240 'valid' => false,
2241 'jsuuid' => self::$rawData['_AccuaForm_jsuuid'],
2242 'buildID' => self::$submittedBuildID,
2243 'files' => array(),
2244 );
2245 if ($ret['submitted'] = self::isSubmit()){
2246 if ($ret['valid'] = self::isValid()) {
2247
2248 } else {
2249 $errorResponse = self::getAjaxErrorResponse();
2250 // Support modern error format with both flat list and structured data for ARIA
2251 if (is_array($errorResponse) && isset($errorResponse['errors'])) {
2252 $ret = array_merge($ret, $errorResponse);
2253 } else {
2254 // Backwards compatibility: old format returned just the flat array
2255 $ret['errors'] = $errorResponse;
2256 }
2257 }
2258 $form = self::$submittedForm;
2259 foreach ($form->files as $fieldname => $file) {
2260 if (!empty($file['name'])) {
2261 $ret['files'][$fieldname] = $form->getElementByName($fieldname)->getAlreadySubmittedText();
2262 }
2263 }
2264 $ret += $form->wp_save();
2265 // Get messages AFTER isValid() and wp_save() have completed
2266 // This ensures email sending results are captured in the messages
2267 $ret['messages'] = self::getSubmittedMessages(self::$submittedID);
2268 } else {
2269 // Form not submitted yet - no messages to show
2270 $ret['messages'] = '';
2271 }
2272 return $ret;
2273 }
2274 }
2275