object, 'fields' => string[] ). * * @var array */ private $injected = array(); /** * Constructor */ public function __construct() { // Check if required WordPress functions exist if ( ! function_exists( 'add_action' ) || ! function_exists( 'wp_enqueue_script' ) ) { return; } add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ) ); add_action( 'elementor_pro/forms/new_record', array( $this, 'track_submission' ), 10, 2 ); // Add response filter to inject tracking data into Elementor's response add_filter( 'elementor_pro/forms/ajax_response_data', array( $this, 'add_tracking_to_response' ), 10, 2 ); // Capture attribution once, before any submit action runs, so every // consumer sees the same payload and submitted_at is the real submit // time rather than whenever a later action got around to firing. add_filter( 'elementor_pro/forms/record/actions_before', array( $this, 'capture_attribution' ), 10, 2 ); // Strip the injected entry fields again as soon as the submission has // been stored, so they never reach the notification email. add_action( 'elementor_pro/forms/actions/after_run', array( $this, 'remove_attribution_fields' ), 10, 2 ); // Enrich the outgoing webhook payload (e.g. to n8n) with server-side // attribution, without injecting hidden form fields. (OPS-2209) add_filter( 'elementor_pro/forms/webhooks/request_args', array( $this, 'enrich_webhook_args' ), 10, 2 ); } /** * Enqueue tracking script */ public function enqueue_scripts() { // Check if required functions exist if ( ! function_exists( 'wp_enqueue_script' ) || ! class_exists( 'CUFT_Form_Detector' ) ) { return; } if ( ! CUFT_Form_Detector::is_framework_detected( 'elementor' ) ) { return; } // Enqueue dataLayer utilities first (required by all framework scripts) wp_enqueue_script( 'cuft-dataLayer-utils', CUFT_URL . '/assets/cuft-dataLayer-utils.js', array(), CUFT_VERSION, false // Load in header for availability ); // Enqueue UTM utilities with proper dependency wp_enqueue_script( 'cuft-utm-utils', CUFT_URL . '/assets/cuft-utm-utils.js', array( 'cuft-utm-tracker' ), CUFT_VERSION, true ); // Enqueue Elementor forms script with dependencies wp_enqueue_script( 'cuft-elementor-forms', CUFT_URL . '/assets/forms/cuft-elementor-forms.js', array( 'cuft-dataLayer-utils', 'cuft-utm-utils' ), CUFT_VERSION, true ); $this->localize_script(); } /** * Track form submission via hook */ public function track_submission( $record, $ajax_handler ) { $form_data = $this->extract_form_data( $record ); CUFT_Logger::log_form_submission( 'elementor', $form_data ); // ── Phone validation & lead scoring ────────────────────────────────── if ( ! empty( $form_data['user_phone'] ) && class_exists( 'CUFT_Phone_Validator' ) ) { $phone_quality = CUFT_Phone_Validator::get_phone_quality( $form_data['user_phone'] ); if ( $phone_quality ) { $form_data['phone_quality'] = $phone_quality; $click_id = class_exists( 'CUFT_Click_Integration' ) ? CUFT_Click_Integration::get_current_click_id() : ''; if ( ! empty( $click_id ) && class_exists( 'CUFT_Click_Tracker' ) ) { $qualified = $phone_quality['is_valid'] ? 1 : 0; CUFT_Click_Tracker::update_click_status( $click_id, $qualified, $phone_quality['quality_score'] ); } } } // No dataLayer script is emitted from PHP. This hook runs on the // submission request (Elementor Pro forms submit only over admin-ajax), // before any front-end script is registered, so a // wp_add_inline_script() here never reached a page. The front-end // script pushes form_submit, generate_lead and qualify_lead. } /** * Extract form data from record using native Elementor methods */ private function extract_form_data( $record ) { // Get form settings using Elementor's native methods $meta = $record->get( 'meta' ); $form_settings = $meta['form_settings'] ?? array(); $fields = $record->get( 'fields' ); // Extract comprehensive form metadata $data = array( 'form_id' => $form_settings['id'] ?? '', 'form_name' => $form_settings['form_name'] ?? '', 'custom_id' => $form_settings['custom_id'] ?? '', 'widget_id' => $record->get( 'meta' )['widget_id'] ?? '', 'user_email' => '', 'user_phone' => '', 'field_data' => array() // Store all field data for frontend use ); // Enhanced field extraction with better type detection foreach ( $fields as $field_id => $field ) { $field_type = $field['type'] ?? ''; $field_value = $field['value'] ?? ''; // Store field data for potential frontend access $data['field_data'][$field_id] = array( 'type' => $field_type, 'value' => $field_value, 'raw_value' => $field['raw_value'] ?? $field_value ); // Extract email fields if ( 'email' === $field_type ) { $data['user_email'] = sanitize_email( $field_value ); } // Extract phone fields - by type or by field ID containing phone-like keywords elseif ( in_array( $field_type, array( 'tel', 'phone', 'number' ), true ) || preg_match( '/phone|mobile|phonesms|cel/i', $field_id ) ) { $data['user_phone'] = preg_replace( '/[^0-9+\-\(\)\s]/', '', $field_value ); } // Also check for email-like values in text fields elseif ( 'text' === $field_type && is_email( $field_value ) ) { $data['user_email'] = sanitize_email( $field_value ); } // Check for phone-like values in text fields elseif ( 'text' === $field_type && preg_match( '/[\d\+\-\(\)\s]{7,}/', $field_value ) ) { // Only set if we don't already have a phone number if ( empty( $data['user_phone'] ) ) { $data['user_phone'] = preg_replace( '/[^0-9+\-\(\)\s]/', '', $field_value ); } } } // Clean up phone number if ( ! empty( $data['user_phone'] ) ) { $data['user_phone'] = trim( $data['user_phone'] ); } return $data; } /** * Localize script with configuration */ private function localize_script() { CUFT_Utils::localize( 'cuft-elementor-forms', 'cuftElementor', array( 'debug' => apply_filters( 'cuft_debug', false ), 'console_logging' => CUFT_Console_Logger::get_console_logging_setting(), 'generate_lead_enabled' => get_option( 'cuft_generate_lead_enabled', false ), 'lead_currency' => get_option( 'cuft_lead_currency', 'CAD' ), 'lead_value' => get_option( 'cuft_lead_value', 100 ), 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'cuft_elementor_tracking' ), 'use_native_methods' => true, // Flag to indicate improved methods 'field_selectors' => array( 'email' => 'input[type="email"], input[name*="email"], input[id*="email"]', 'phone' => 'input[type="tel"], input[name*="phone"], input[name*="tel"], input[id*="phone"], input[id*="tel"]' ) ) ); } /** * Add tracking data to Elementor's AJAX response */ public function add_tracking_to_response( $response_data, $record ) { // Only add tracking data if GTM is configured if ( ! get_option( 'cuft_gtm_id' ) ) { return $response_data; } $form_data = $this->extract_form_data( $record ); // Add tracking data to response for frontend JavaScript access $response_data['cuft_tracking'] = array( 'form_id' => $form_data['form_id'], 'form_name' => $form_data['form_name'], 'widget_id' => $form_data['widget_id'], 'user_email' => $form_data['user_email'], 'user_phone' => $form_data['user_phone'], 'timestamp' => gmdate( 'c' ), 'source' => 'elementor_pro_native' ); return $response_data; } /** * Assemble the attribution payload for a submission record. * * @param object $record Elementor Pro form record. * @return array Flat attribution payload (may be empty). */ private function get_attribution_payload( $record ) { $key = is_object( $record ) ? spl_object_hash( $record ) : ''; if ( '' !== $key && isset( $this->captured[ $key ] ) ) { return $this->captured[ $key ]; } return $this->build_attribution_payload( $record ); } /** * Assemble the attribution payload from the current request. * * @param object $record Elementor Pro form record. * @return array Flat attribution payload (may be empty). */ private function build_attribution_payload( $record ) { if ( ! class_exists( 'CUFT_Form_Attribution' ) ) { return array(); } // Use Elementor's native accessor for form identity (form_settings is a // top-level record property, not nested under get('meta')). $form_id = ''; $form_name = ''; if ( is_object( $record ) && method_exists( $record, 'get_form_settings' ) ) { $form_id = (string) $record->get_form_settings( 'id' ); $form_name = (string) $record->get_form_settings( 'form_name' ); } // During an AJAX submit the page is admin-ajax.php; the form's page URL // is the referer. Elementor posts it as $_POST['referrer'] too. $page_url = ''; if ( ! empty( $_POST['referrer'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Read-only; this callback only runs from the elementor_pro/forms/record/actions_before hook, fired after Elementor Pro's own AJAX form-submit handler has already verified its nonce. $page_url = esc_url_raw( wp_unslash( $_POST['referrer'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Same request as above; Elementor Pro already verified the nonce before this hook fires. } elseif ( function_exists( 'wp_get_referer' ) ) { $page_url = wp_get_referer(); } // The submitted email/phone drive the shared lead_id (OPS-2210). The // assembler does not read form fields itself, so pass them in context. $email = ''; $phone = ''; if ( is_object( $record ) && method_exists( $record, 'get' ) ) { $form_data = $this->extract_form_data( $record ); $email = isset( $form_data['user_email'] ) ? $form_data['user_email'] : ''; $phone = isset( $form_data['user_phone'] ) ? $form_data['user_phone'] : ''; } return CUFT_Form_Attribution::get_payload( array( 'form_id' => $form_id, 'form_name' => $form_name, 'page_url' => $page_url, 'email' => $email, 'phone' => $phone, ) ); } /** * Inject server-side attribution into the outgoing webhook payload. * * Hooked on elementor_pro/forms/webhooks/request_args. Purely additive: * attribution is added under a `cuft_attribution` key and also flattened to * the body top level for keys that are not already present, so downstream * (e.g. n8n) can map whichever shape it expects. No-op if the form has no * webhook action configured (this filter simply never fires). * * @param array $args wp_remote_post args ('body' may be array or JSON string). * @param object $record Elementor Pro form record. * @return array Filtered args. */ public function enrich_webhook_args( $args, $record ) { try { if ( ! is_array( $args ) ) { return $args; } $attribution = $this->get_attribution_payload( $record ); if ( empty( $attribution ) ) { return $args; } // JSON string body (advanced webhook config). if ( isset( $args['body'] ) && is_string( $args['body'] ) ) { $decoded = json_decode( $args['body'], true ); if ( is_array( $decoded ) ) { $decoded['cuft_attribution'] = $attribution; foreach ( $attribution as $key => $value ) { if ( ! isset( $decoded[ $key ] ) ) { $decoded[ $key ] = $value; } } $args['body'] = wp_json_encode( $decoded ); } return $args; } // Array body (default Elementor webhook). if ( ! isset( $args['body'] ) || ! is_array( $args['body'] ) ) { $args['body'] = array(); } $args['body']['cuft_attribution'] = $attribution; foreach ( $attribution as $key => $value ) { if ( ! isset( $args['body'][ $key ] ) ) { $args['body'][ $key ] = $value; } } } catch ( \Throwable $e ) { if ( class_exists( 'CUFT_Logger' ) ) { CUFT_Logger::log( 'error', 'Elementor webhook enrichment failed: ' . $e->getMessage() ); } } return $args; } /** * Capture attribution while the visitor's submit request is still live. * * Hooked on elementor_pro/forms/record/actions_before, which Elementor Pro * fires in the submit request before any action runs. Two reasons this is * the right moment: * * 1. submitted_at becomes the real submit time. Actions run in sequence, * and a slow one ahead of the webhook (a mail service taking seconds) * used to push the timestamp that far out. * 2. Fields added here are on the record when the Submissions module * stores it. elementor_pro/forms/new_record, used previously, fires * after every action has already run, so nothing added there ever * reached the stored entry or the webhook. * * @param object $record Elementor Pro form record. * @param object $ajax_handler Elementor Pro ajax handler. * @return object The record, unchanged except for any injected fields. */ public function capture_attribution( $record, $ajax_handler = null ) { try { if ( ! is_object( $record ) ) { return $record; } $attribution = $this->build_attribution_payload( $record ); if ( empty( $attribution ) ) { return $record; } $this->captured[ spl_object_hash( $record ) ] = $attribution; if ( $this->entry_injection_is_safe( $record ) ) { $this->add_attribution_to_record( $record, $attribution ); } } catch ( \Throwable $e ) { if ( class_exists( 'CUFT_Logger' ) ) { CUFT_Logger::log( 'error', 'Elementor attribution capture failed: ' . $e->getMessage() ); } } return $record; } /** * Whether attribution fields can be added to the record without leaking * into a notification email. * * Elementor's [all-fields] shortcode prints every field on the record, so * the injected fields are only safe when the Submissions action both runs * and runs before any email action. That is Elementor Pro's own * registration order, but a third party can change it, so it is verified * per submission rather than assumed. * * @param object $record Elementor Pro form record. * @return bool */ private function entry_injection_is_safe( $record ) { if ( ! method_exists( $record, 'get_form_settings' ) ) { return false; } $selected = (array) $record->get_form_settings( 'submit_actions' ); if ( ! in_array( 'save-to-database', $selected, true ) ) { return false; // Nothing stores the entry, so nothing to persist to. } $order = $this->get_registered_action_order(); if ( empty( $order ) ) { return false; } $run_order = array_values( array_intersect( $order, $selected ) ); $save_position = array_search( 'save-to-database', $run_order, true ); if ( false === $save_position ) { return false; } foreach ( array( 'email', 'email2' ) as $email_action ) { $email_position = array_search( $email_action, $run_order, true ); if ( false !== $email_position && $email_position < $save_position ) { return false; // An email would be sent before the fields are removed. } } return true; } /** * The order Elementor Pro will run its registered actions in. * * Separated so the ordering guard can be tested without Elementor loaded. * * @return string[] Action names in registration order, empty if unknown. */ protected function get_registered_action_order() { if ( ! class_exists( '\ElementorPro\Modules\Forms\Module' ) ) { return array(); } $module = \ElementorPro\Modules\Forms\Module::instance(); if ( ! isset( $module->actions_registrar ) || ! method_exists( $module->actions_registrar, 'get' ) ) { return array(); } return array_keys( (array) $module->actions_registrar->get() ); } /** * Remove the injected attribution fields once the entry has been stored. * * Hooked on elementor_pro/forms/actions/after_run. The fields exist only * for the Submissions action; every later consumer (email, webhook) sees * the record exactly as the visitor submitted it. The webhook still gets * attribution, through enrich_webhook_args. * * @param object $action The action that just ran. * @param \Exception|null $exception Exception thrown by that action, if any. */ public function remove_attribution_fields( $action = null, $exception = null ) { if ( empty( $this->injected ) ) { return; } if ( is_object( $action ) && method_exists( $action, 'get_name' ) && 'save-to-database' !== $action->get_name() ) { return; } try { foreach ( $this->injected as $key => $entry ) { $record = $entry['record']; if ( ! is_object( $record ) || ! method_exists( $record, 'get' ) || ! method_exists( $record, 'set' ) ) { continue; } $fields = (array) $record->get( 'fields' ); foreach ( $entry['fields'] as $field_id ) { unset( $fields[ $field_id ] ); } $record->set( 'fields', $fields ); unset( $this->injected[ $key ] ); } } catch ( \Throwable $e ) { if ( class_exists( 'CUFT_Logger' ) ) { CUFT_Logger::log( 'error', 'Elementor attribution cleanup failed: ' . $e->getMessage() ); } } } /** * Add attribution to the record as hidden fields, so the stored Elementor * submission entry carries it. Removed again by remove_attribution_fields() * once the entry is saved. Fully guarded: a no-op if the record API * differs, never fatal. * * @param object $record Elementor Pro form record. * @param array $attribution Captured attribution payload. */ private function add_attribution_to_record( $record, $attribution ) { try { if ( ! is_object( $record ) || ! method_exists( $record, 'get' ) || ! method_exists( $record, 'set' ) ) { return; } if ( empty( $attribution ) ) { return; } // Elementor Pro has no add_field(); fields are an id-keyed array // managed via get/set( 'fields' ). Merge attribution as hidden // fields without clobbering real form fields that share a key. $fields = (array) $record->get( 'fields' ); $added = array(); foreach ( $attribution as $key => $value ) { $field_id = 'cuft_' . $key; if ( isset( $fields[ $key ] ) || isset( $fields[ $field_id ] ) ) { continue; } $fields[ $field_id ] = array( 'id' => $field_id, 'type' => 'hidden', 'title' => $key, 'value' => (string) $value, 'raw_value' => (string) $value, 'required' => false, ); $added[] = $field_id; } $record->set( 'fields', $fields ); if ( ! empty( $added ) ) { $this->injected[ spl_object_hash( $record ) ] = array( 'record' => $record, 'fields' => $added, ); } } catch ( \Throwable $e ) { if ( class_exists( 'CUFT_Logger' ) ) { CUFT_Logger::log( 'error', 'Elementor entry attribution failed: ' . $e->getMessage() ); } } } }