array( 'email', 'e-mail', 'your-email', 'mail', 'correo', 'courriel', 'epost', 'e-post' ), 'ph' => array( 'phone', 'telephone', 'tel', 'mobile', 'your-tel', 'cell', 'telefon', 'telefono', 'movil' ), 'ln' => array( 'last-name', 'lastname', 'surname', 'family-name', 'nachname', 'apellido', 'nom-de-famille' ), 'fn' => array( 'first-name', 'firstname', 'given-name', 'your-name', 'name', 'full-name', 'vorname', 'nombre', 'prenom' ), 'ct' => array( 'city', 'town' ), 'st' => array( 'state', 'province', 'region' ), 'zp' => array( 'zip', 'postcode', 'postal' ), 'country' => array( 'country' ), ); /* ------------------------------------------------------------------ */ /* Boot */ /* ------------------------------------------------------------------ */ /** * Register hooks. * * Deferred to plugins_loaded like module 3, because the form plugins have * not been included yet when AdvisorPPC bootstraps. * * @return void */ public static function init() { if ( self::$booted ) { return; } self::$booted = true; if ( ! did_action( 'plugins_loaded' ) ) { add_action( 'plugins_loaded', array( __CLASS__, 'register_hooks' ), 20 ); return; } self::register_hooks(); } /** * Attach the form-plugin hooks and the reference injector. * * @return void */ public static function register_hooks() { if ( ! self::is_enabled() ) { return; } // The injector runs whenever the module is on, so a submission always // carries a reference both halves can agree on. add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue' ), 30 ); // Contact Form 7. add_action( 'wpcf7_mail_sent', array( __CLASS__, 'on_cf7' ), 20 ); // WPForms. add_action( 'wpforms_process_complete', array( __CLASS__, 'on_wpforms' ), 20, 4 ); // Gravity Forms. add_action( 'gform_after_submission', array( __CLASS__, 'on_gravity' ), 20, 2 ); // Fluent Forms. add_action( 'fluentform/submission_inserted', array( __CLASS__, 'on_fluent' ), 20, 3 ); // Ninja Forms. add_action( 'ninja_forms_after_submission', array( __CLASS__, 'on_ninja' ), 20 ); // Elementor Pro forms. add_action( 'elementor_pro/forms/new_record', array( __CLASS__, 'on_elementor' ), 20, 2 ); } /** * Whether the lead module may fire at all. * * Requires the `forms` module switch AND a connected workspace, because a * lead has nowhere to go without one. The free build is unaffected: tags, * consent, click IDs and Shield do not run through this class. * * @return bool */ public static function is_enabled() { if ( ! class_exists( 'AdvisorPPC_Options' ) || ! class_exists( 'AdvisorPPC_Events' ) ) { return false; } if ( ! AdvisorPPC_Options::module_enabled( 'forms' ) ) { return false; } if ( ! AdvisorPPC_Events::is_enabled() ) { return false; } /** * Filter whether form leads are captured. * * @since 0.1.0 * @param bool $enabled Enabled. */ return (bool) apply_filters( 'advisorppc_forms_enabled', true ); } /** * Load the reference injector. * * @return void */ public static function enqueue() { if ( ! class_exists( 'AdvisorPPC_Consent' ) ) { return; } $deps = array(); if ( class_exists( 'AdvisorPPC_Tags' ) ) { $handle = AdvisorPPC_Tags::HANDLE; if ( wp_script_is( $handle, 'registered' ) || wp_script_is( $handle, 'enqueued' ) ) { $deps[] = $handle; } } wp_enqueue_script( self::HANDLE, AdvisorPPC_Consent::asset_url( 'public/js/forms.js' ), $deps, AdvisorPPC_Consent::asset_version(), true ); wp_add_inline_script( self::HANDLE, 'window.advisorppcForms = ' . wp_json_encode( array( 'refField' => self::REF_FIELD, 'siteUrl' => AdvisorPPC_Events::site_url(), 'session' => AdvisorPPC_Events::session_id(), ) ) . ';', 'before' ); } /* ------------------------------------------------------------------ */ /* The one recording path */ /* ------------------------------------------------------------------ */ /** * The submission reference for this request. * * Prefers the browser-minted value so the browser twin can agree with us. * Falls back to a server-minted reference that is unique per submission, * never to the bare session id: two leads in one session must not collapse. * * @param string $fallback_seed Plugin-specific seed, e.g. 'gf_3_1187'. * @return string */ protected static function reference( $fallback_seed = '' ) { // Read-only use of a submission field posted by the form plugin, which // has already run its own nonce check by the time these hooks fire. The // value is sanitised to [A-Za-z0-9._-] below and is only ever hashed. $posted = isset( $_POST[ self::REF_FIELD ] ) ? wp_unslash( $_POST[ self::REF_FIELD ] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $posted = is_scalar( $posted ) ? (string) $posted : ''; $posted = preg_replace( '/[^A-Za-z0-9._\-]/', '', $posted ); if ( '' !== $posted ) { return substr( $posted, 0, self::REF_MAX_LENGTH ); } $seed = preg_replace( '/[^A-Za-z0-9._\-]/', '', (string) $fallback_seed ); if ( '' === $seed ) { $seed = wp_generate_password( 12, false, false ); } return substr( 'lead_srv_' . $seed, 0, self::REF_MAX_LENGTH ); } /** * Record one lead, server side, and mirror it into the browser. * * @param array $fields Raw submitted values keyed by field name or label. * @param string $fallback_seed Plugin-specific unique seed for the reference. * @param array $extra Optional overrides passed to AdvisorPPC_Events::build(). * @return string|false The event_id, or false. */ protected static function record( $fields, $fallback_seed = '', $extra = array() ) { if ( ! self::is_enabled() ) { return false; } $ref = self::reference( $fallback_seed ); if ( isset( self::$fired[ $ref ] ) ) { return false; } self::$fired[ $ref ] = true; $args = array_merge( array( 'ref' => $ref, 'user_data' => self::user_data( $fields ), ), is_array( $extra ) ? $extra : array() ); /** * Filter the arguments for a form lead before it is queued. * * @since 0.1.0 * @param array $args Arguments for AdvisorPPC_Events::record(). * @param array $fields Raw submitted fields. * @param string $ref Submission reference. */ $args = apply_filters( 'advisorppc_form_lead_args', $args, $fields, $ref ); $event_id = AdvisorPPC_Events::record( 'lead', $args ); if ( ! $event_id ) { return false; } // Mirror into the browser with the identical id. On an AJAX submission // this queue is never printed, which is why forms.js fires its own copy // from the same reference; the two paths produce the same id, so at // most one of them ever reaches a platform as a new conversion. if ( class_exists( 'AdvisorPPC_Tags' ) && ! wp_doing_ajax() ) { AdvisorPPC_Tags::enqueue_browser_event( 'lead', array(), $event_id ); } return $event_id; } /* ------------------------------------------------------------------ */ /* Field extraction */ /* ------------------------------------------------------------------ */ /** * Map submitted fields onto the contract's user_data keys. * * Returns RAW values. AdvisorPPC_Events::hash_user_data() does the * lowercase, trim and SHA-256, and it is the only place that is allowed to. * * @param array $fields Field name or label => value. * @return array */ public static function user_data( $fields ) { $out = array(); if ( ! is_array( $fields ) ) { return $out; } foreach ( $fields as $name => $value ) { if ( is_array( $value ) ) { $value = reset( $value ); } if ( ! is_scalar( $value ) || '' === trim( (string) $value ) ) { continue; } $key = self::classify( (string) $name, (string) $value ); if ( '' === $key || isset( $out[ $key ] ) ) { continue; } // The label got us to a candidate key; the value has to agree. A // field called "Emails per week" holding "weekly" matches the label // rule for `em` and is not an email address. Hashing it sends the // platform a match key that can never match anything, and quietly // puts an unrelated answer into the customer-matching payload. if ( ! self::plausible( $key, (string) $value ) ) { continue; } $out[ $key ] = (string) $value; } // A full name in one box: split it so first and last hash separately, // which is what every platform's matching expects. if ( isset( $out['fn'] ) && ! isset( $out['ln'] ) ) { $parts = preg_split( '/\s+/', trim( $out['fn'] ) ); if ( is_array( $parts ) && count( $parts ) > 1 ) { $out['ln'] = array_pop( $parts ); $out['fn'] = implode( ' ', $parts ); } } return $out; } /** * Whether a label names something other than the customer themself. * * @param string $haystack Normalised field label. * @return bool */ protected static function is_never_pii( $haystack ) { foreach ( self::$never_pii as $term ) { if ( self::has_token( $haystack, $term ) ) { return true; } } return false; } /** * Whether a value is plausibly the thing its label claimed it was. * * Only the two keys a label can plausibly get wrong are checked. Names, * cities and postcodes are free text in every country worth selling to, so * asserting a shape for them would drop more real data than it saved. * * @param string $key Contract user_data key. * @param string $value Raw submitted value. * @return bool */ protected static function plausible( $key, $value ) { $value = trim( $value ); if ( 'em' === $key ) { return (bool) is_email( $value ); } if ( 'ph' === $key ) { // At least seven digits, which is the shortest national number in // the ITU plan. Punctuation and a country code are normalised away // by the platforms, not by us. return preg_match_all( '/[0-9]/', $value ) >= 7; } return '' !== $value; } /** * Decide which contract key a field belongs to. * * @param string $name Field name or label. * @param string $value Submitted value. * @return string Contract key, or '' when the field is not a match field. */ protected static function classify( $name, $value ) { $haystack = strtolower( str_replace( array( '_', ' ' ), '-', trim( $name ) ) ); // Matched on word boundaries, not raw substrings. A plain strpos() // turns "Company Name" and "Username" into a first name, "Hotel" into a // phone number, "Statement" into a state and "Mailing list" into an // email address. Anchoring on '-' or the ends of the string keeps // "first-name" and "your-tel" matching while those stop. foreach ( self::$field_map as $key => $fragments ) { foreach ( $fragments as $fragment ) { if ( ! self::has_token( $haystack, $fragment ) ) { continue; } // A never-PII term vetoes the NAME and ADDRESS keys only. // "Company Name" is not a person's name, but "Company Email" is // still an email address and "Business Phone" is still a phone // number, and B2B lead matching runs on exactly those. Vetoing // them was a false negative, which is worse than the false // positive it replaced: the lead goes out matching nothing and // nothing in the UI says so. if ( in_array( $key, self::$vetoable_keys, true ) && self::is_never_pii( $haystack ) ) { continue; } return $key; } } // An unlabelled field that is unambiguously an address still counts. // The never-PII veto DOES apply here, because this branch is a guess // from the value: a "Subject" or "Message" box someone pasted an // address into is not the customer's contact field. An explicit // "Company Email" label above is a different matter and is kept. if ( is_email( $value ) && ! self::is_never_pii( $haystack ) ) { return 'em'; } return ''; } /** * Whether `$needle` appears in `$haystack` as a whole hyphen-delimited term. * * `$haystack` has already had spaces and underscores folded to '-', so the * delimiters are '-' and the ends of the string. A multi-word needle such as * "first-name" is matched as a unit. * * @param string $haystack Normalised field label. * @param string $needle Fragment from the field map. * @return bool */ protected static function has_token( $haystack, $needle ) { if ( $haystack === $needle ) { return true; } $offset = 0; while ( true ) { $position = strpos( $haystack, $needle, $offset ); if ( false === $position ) { return false; } $before = ( 0 === $position ) ? '-' : $haystack[ $position - 1 ]; $after_index = $position + strlen( $needle ); $after = ( $after_index >= strlen( $haystack ) ) ? '-' : $haystack[ $after_index ]; if ( '-' === $before && '-' === $after ) { return true; } $offset = $position + 1; } } /* ------------------------------------------------------------------ */ /* Adapters, one per supported form plugin */ /* ------------------------------------------------------------------ */ /** * Contact Form 7. * * @param mixed $contact_form WPCF7_ContactForm. * @return void */ public static function on_cf7( $contact_form ) { if ( ! class_exists( 'WPCF7_Submission' ) ) { return; } $submission = WPCF7_Submission::get_instance(); if ( ! $submission || ! method_exists( $submission, 'get_posted_data' ) ) { return; } $form_id = is_object( $contact_form ) && method_exists( $contact_form, 'id' ) ? (int) $contact_form->id() : 0; self::record( (array) $submission->get_posted_data(), 'cf7_' . $form_id . '_' . self::nonce_seed() ); } /** * WPForms. * * @param array $fields Processed fields. * @param array $entry Raw entry. * @param array $form_data Form definition. * @param int $entry_id Entry id, 0 when entry storage is off. * @return void */ public static function on_wpforms( $fields, $entry, $form_data, $entry_id = 0 ) { $flat = array(); foreach ( (array) $fields as $field ) { if ( ! is_array( $field ) ) { continue; } $label = isset( $field['name'] ) ? (string) $field['name'] : ( isset( $field['type'] ) ? (string) $field['type'] : '' ); $value = isset( $field['value'] ) ? $field['value'] : ''; if ( '' !== $label ) { $flat[ $label ] = $value; } } $form_id = isset( $form_data['id'] ) ? (int) $form_data['id'] : 0; $seed = 'wpf_' . $form_id . '_' . ( (int) $entry_id > 0 ? (int) $entry_id : self::nonce_seed() ); self::record( $flat, $seed ); } /** * Gravity Forms. * * @param array $entry Entry. * @param array $form Form definition. * @return void */ public static function on_gravity( $entry, $form ) { $flat = array(); foreach ( self::gravity_labels( $form ) as $field_id => $label ) { if ( isset( $entry[ $field_id ] ) ) { $flat[ $label ] = $entry[ $field_id ]; } } $entry_id = isset( $entry['id'] ) ? (int) $entry['id'] : 0; $form_id = isset( $form['id'] ) ? (int) $form['id'] : 0; self::record( $flat, 'gf_' . $form_id . '_' . ( $entry_id > 0 ? $entry_id : self::nonce_seed() ) ); } /** * Fluent Forms. * * @param int $entry_id Submission id. * @param array $form_data Submitted data. * @param mixed $form Form object. * @return void */ public static function on_fluent( $entry_id, $form_data, $form = null ) { $form_id = is_object( $form ) && isset( $form->id ) ? (int) $form->id : 0; self::record( (array) $form_data, 'ff_' . $form_id . '_' . (int) $entry_id ); } /** * Ninja Forms. * * @param array $data Submission data. * @return void */ public static function on_ninja( $data ) { $flat = array(); if ( isset( $data['fields'] ) && is_array( $data['fields'] ) ) { foreach ( $data['fields'] as $field ) { if ( ! is_array( $field ) ) { continue; } $label = isset( $field['key'] ) ? (string) $field['key'] : ( isset( $field['label'] ) ? (string) $field['label'] : '' ); if ( '' !== $label && isset( $field['value'] ) ) { $flat[ $label ] = $field['value']; } } } $form_id = isset( $data['form_id'] ) ? (int) $data['form_id'] : 0; $sub_id = isset( $data['actions']['save']['sub_id'] ) ? (int) $data['actions']['save']['sub_id'] : 0; self::record( $flat, 'nf_' . $form_id . '_' . ( $sub_id > 0 ? $sub_id : self::nonce_seed() ) ); } /** * Elementor Pro forms. * * @param mixed $record Form record. * @param mixed $handler Ajax handler. * @return void */ public static function on_elementor( $record, $handler = null ) { if ( ! is_object( $record ) || ! method_exists( $record, 'get' ) ) { return; } $flat = array(); $fields = $record->get( 'fields' ); foreach ( (array) $fields as $field ) { if ( ! is_array( $field ) ) { continue; } $label = isset( $field['title'] ) && '' !== $field['title'] ? (string) $field['title'] : ( isset( $field['id'] ) ? (string) $field['id'] : '' ); if ( '' !== $label && isset( $field['value'] ) ) { $flat[ $label ] = $field['value']; } } $settings = $record->get( 'form_settings' ); $form_id = is_array( $settings ) && isset( $settings['id'] ) ? (string) $settings['id'] : ''; self::record( $flat, 'el_' . $form_id . '_' . self::nonce_seed() ); } /** * Gravity Forms field-id to label map. * * Gravity stores composite name and address fields at sub-ids (`3.3`, * `3.6`), so those are expanded to the labels classify() understands. * * @param array $form Form definition. * @return array Field id => label. */ protected static function gravity_labels( $form ) { $out = array(); if ( ! is_array( $form ) || ! isset( $form['fields'] ) ) { return $out; } foreach ( (array) $form['fields'] as $field ) { $id = is_object( $field ) && isset( $field->id ) ? (string) $field->id : ''; if ( '' === $id ) { continue; } $out[ $id ] = is_object( $field ) && isset( $field->label ) ? (string) $field->label : $id; $type = is_object( $field ) && isset( $field->type ) ? (string) $field->type : ''; if ( 'name' === $type ) { $out[ $id . '.3' ] = 'first-name'; $out[ $id . '.6' ] = 'last-name'; } if ( 'address' === $type ) { $out[ $id . '.3' ] = 'city'; $out[ $id . '.4' ] = 'state'; $out[ $id . '.5' ] = 'zip'; $out[ $id . '.6' ] = 'country'; } } return $out; } /** * A per-request seed for form plugins that expose no entry id. * * Stable within one request so two hooks for the same submission collapse, * unique across requests so two submissions never do. * * @return string */ protected static function nonce_seed() { static $seed = null; if ( null === $seed ) { $seed = wp_generate_password( 12, false, false ); } return $seed; } } add_action( 'advisorppc_loaded', array( 'AdvisorPPC_Forms', 'init' ) );