' . esc_html__( 'All in Box is installed twice. Keep a single copy active: both share the same settings, so you can deactivate the older folder without losing anything.', 'all-in-box' ) . '

'; } ); return; } define( 'AIB_LEAD_SYNC_VERSION', '1.10.0' ); // The declaration is wrapped so that PHP cannot early-bind it: a class declared at file // level is registered when the file is linked, before any statement of the file runs, so a // second copy of the plugin would hit "cannot declare class" before the guard above could // return. Inside a condition, the declaration becomes a runtime step and is simply skipped. if ( ! class_exists( 'AIB_Lead_Sync' ) ) : class AIB_Lead_Sync { const VERSION = AIB_LEAD_SYNC_VERSION; const OPT = 'aib_lead_sync_settings'; const SEEN_FORMS = 'aib_lead_sync_seen_forms'; const LAST_ERROR = 'aib_lead_sync_last_error'; /** Slug of the top-level menu, also the slug of its first page (Connection). */ const MENU_SLUG = 'all-in-box'; /** Look of an [allinbox_form] that sets no colour nor radius of its own: the brand's. */ const FORM_ACCENT = '#101010'; const FORM_TEXT = '#101010'; const FORM_RADIUS = 20; /** * The brand mark, black on transparent. WordPress repaints a base64 SVG menu icon in * the colours of the admin scheme, which a plain image URL would not get. */ const MENU_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2MC4wMDEgNjEuMjE0Ij48cGF0aCBmaWxsPSJibGFjayIgZD0iTTEzLjI0OCwxLjI4NkEzLjU0MSwzLjU0MSwwLDAsMSwxNy42MjUuNzMyTDI4LjM4NSw3LjI3YTMuNTM1LDMuNTM1LDAsMCwwLDMuODczLS4xMjdsOS4yNzQtNi41YTMuNTQxLDMuNTQxLDAsMCwxLDQuNTcyLjQyN0w1OC45NSwxNC4xNThhMy43MDYsMy43MDYsMCwwLDEsLjM3Nyw0LjcxOWwtNS44NDksOC4yOWEzLjU0OCwzLjU0OCwwLDAsMS01LjQ1My40NTJMMzIuNTM4LDExLjg0NGEzLjU0NCwzLjU0NCwwLDAsMC01LjA3NiwwTDEyLjM0NywyNy4yMzhhMy43LDMuNywwLDAsMCwwLDUuMTdMMjguMjg2LDQ4LjY0M2EzLjU0MywzLjU0MywwLDAsMCw1LjA3NiwwTDQ4LjM3NywzMy4zNTFBMy41NTIsMy41NTIsMCwwLDEsNTQsMzQuMDYybDUuMzkxLDkuMmEzLjcwNywzLjcwNywwLDAsMS0uNTQ0LDQuNDU4bC0xMi4yLDEyLjQyMmEzLjU0MSwzLjU0MSwwLDAsMS00LjY2Ny4zNThsLTkuNDktNy4xMmEzLjUzNiwzLjUzNiwwLDAsMC00LjE2My0uMDY5bC05LjE4Miw2LjQzMmEzLjU0MSwzLjU0MSwwLDAsMS00LjUzOS0uMzk0TDEuNzg1LDQ2LjYzYTMuNzA2LDMuNzA2LDAsMCwxLS40NC00LjcwOWw2LjQ2NC05LjQ0OWEzLjcxMSwzLjcxMSwwLDAsMC0uMDU1LTQuMjU4TC43LDE4LjQ2MWEzLjcwNiwzLjcwNiwwLDAsMSwuMzUyLTQuNzUzWiIvPjwvc3ZnPg=='; /** Hook suffix WordPress gave each of our pages, by page key, so asset checks follow any slug change. */ private $page_hooks = []; /** Slugs of the pages kept out of the menu until the site is connected. */ private $hidden_pages = []; /** Data attributes of the enqueued SDK scripts, by handle. The SDKs read them from their own tag. */ private $sdk_attributes = []; public function __construct() { add_action( 'admin_menu', [ $this, 'menu' ] ); add_action( 'admin_init', [ $this, 'register_settings' ] ); // Up to 1.9 the settings lived under Settings > All in Box. WordPress still opens // that address (it falls back on the top-level page of the same slug), but under // the Settings menu: send it on to the new address instead. add_action( 'admin_init', [ $this, 'redirect_legacy_settings_url' ] ); // A page removed from the menu during admin_menu would fail the access check: // the pages stay registered, and leave the menu only once that check has passed. add_action( 'admin_head', [ $this, 'hide_unconnected_pages' ] ); add_action( 'init', [ $this, 'register_adapters' ] ); // Web Push install: serve the service worker and enqueue the SDK script. add_action( 'init', [ $this, 'maybe_serve_service_worker' ], 0 ); add_action( 'wp_enqueue_scripts', [ $this, 'webpush_script' ] ); add_filter( 'script_loader_tag', [ $this, 'sdk_script_attributes' ], 10, 2 ); // 1.8.0 also wrote a physical file at the site root. It is no longer written; a // leftover one is cleaned up, since it would shadow the route above. add_action( 'admin_init', [ $this, 'remove_legacy_service_worker' ] ); register_activation_hook( __FILE__, [ $this, 'remove_legacy_service_worker' ] ); // Webchat install: enqueue the chat widget script. Like Web Push, it only needs // the public key of the bot, so it works on a site that never syncs a contact. add_action( 'wp_enqueue_scripts', [ $this, 'webchat_script' ] ); // Embedded HTML form capture (a third-party form pasted into the page that posts // straight to an external service): a front-end script mirrors the submit of forms // marked data-aib-capture to a server-side REST endpoint, so the token stays server-side. add_action( 'rest_api_init', [ $this, 'register_rest' ] ); add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_capture_assets' ] ); // Native All in Box form generator. Drop [allinbox_form] anywhere (Divi Text // module, page, widget). Multiple instances with their own fields/styling/language. add_shortcode( 'allinbox_form', [ $this, 'shortcode' ] ); // Visual form builder on its own admin page (live preview → shortcode). add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_assets' ] ); } /** Loads the brand stylesheet on our pages, and the visual form builder on its page. */ public function enqueue_admin_assets( $hook ) { $page = '' !== $hook ? array_search( $hook, $this->page_hooks, true ) : false; if ( false === $page ) { return; } wp_enqueue_style( 'aib-lead-sync-admin', plugins_url( 'assets/admin.css', __FILE__ ), [], self::VERSION ); if ( 'form' !== $page ) { return; } wp_enqueue_script( 'aib-lead-sync-builder', plugins_url( 'assets/builder.js', __FILE__ ), [], self::VERSION, true ); $s = $this->settings(); wp_localize_script( 'aib-lead-sync-builder', 'AIBFormBuilder', [ 'i18n' => $this->form_i18n(), 'langs' => [ 'fr', 'en', 'es', 'de', 'it', 'nl', 'pt' ], 'fields' => [ 'firstname', 'lastname', 'email', 'phone', 'birthdate', 'address', 'postal_code', 'country', 'gender' ], 'defaults' => [ 'accent' => self::FORM_ACCENT, 'bg' => '#ffffff', 'text' => self::FORM_TEXT, 'radius' => self::FORM_RADIUS, 'width' => 420 ], 'trackConsent' => ! empty( $s['track_consent'] ), ] ); } /* ===================================================================== * Settings * ================================================================== */ public function menu() { $connected = $this->is_connected( $this->settings() ); $this->page_hooks['connection'] = (string) add_menu_page( 'All in Box', 'All in Box', 'manage_options', self::MENU_SLUG, [ $this, 'page_connection' ], self::MENU_ICON ); // The first submenu shares the top-level slug, so it renames "All in Box" to // "Connection" in the submenu without adding a page. foreach ( $this->admin_pages() as $key => $p ) { if ( ! empty( $p['woo'] ) && ! class_exists( 'WooCommerce' ) ) { continue; } $this->page_hooks[ $key ] = (string) add_submenu_page( self::MENU_SLUG, $p['title'] . ' - All in Box', $p['title'], 'manage_options', $p['slug'], [ $this, 'page_' . $key ] ); if ( $p['connected'] && ! $connected ) { $this->hidden_pages[] = $p['slug']; } } } public function register_settings() { register_setting( 'aib_lead_sync_group', self::OPT, [ $this, 'sanitize' ] ); } public function sanitize( $input ) { $input = is_array( $input ) ? $input : []; // A panel that is not rendered posts none of its fields (a tab hidden while the // connection is invalid, or WooCommerce deactivated). Falling back to the stored // value means saving from the Web Push or Webchat tab can no longer wipe the // contacts settings. Every checkbox is paired with a hidden "0" in the form, so // unticking one still posts and is honoured. $stored = get_option( self::OPT, [] ); $stored = is_array( $stored ) ? $stored : []; $kept = function ( $key, $default = '' ) use ( $stored ) { return array_key_exists( $key, $stored ) ? $stored[ $key ] : $default; }; $text = function ( $key, $default = '' ) use ( $input, $kept ) { return array_key_exists( $key, $input ) ? sanitize_text_field( $input[ $key ] ) : (string) $kept( $key, $default ); }; $flag = function ( $key, $default = 0 ) use ( $input, $kept ) { $value = array_key_exists( $key, $input ) ? $input[ $key ] : $kept( $key, $default ); return ! empty( $value ) ? 1 : 0; }; // The token is never printed back into the form, so an empty field means "keep the // stored one". Clearing it stays explicit, through the "forget" checkbox. $posted_token = array_key_exists( 'token', $input ) ? trim( sanitize_text_field( $input['token'] ) ) : ''; if ( ! empty( $input['token_clear'] ) ) { $token_value = ''; } elseif ( '' !== $posted_token ) { $token_value = $posted_token; } else { $token_value = (string) $kept( 'token' ); } $clean = [ 'base_url' => array_key_exists( 'base_url', $input ) ? esc_url_raw( rtrim( $input['base_url'], '/' ) ) : (string) $kept( 'base_url' ), 'token' => $token_value, 'list_id' => array_key_exists( 'list_id', $input ) ? absint( $input['list_id'] ) : absint( $kept( 'list_id', 0 ) ), 'form_ids' => $text( 'form_ids' ), 'default_subscribed' => $flag( 'default_subscribed', 0 ), 'track_consent' => $flag( 'track_consent', 1 ), 'email_field' => $text( 'email_field' ), 'firstname_field' => $text( 'firstname_field' ), 'lastname_field' => $text( 'lastname_field' ), 'phone_field' => $text( 'phone_field' ), 'postal_code_field' => $text( 'postal_code_field' ), 'country' => array_key_exists( 'country', $input ) ? strtoupper( substr( preg_replace( '/[^a-zA-Z]/', '', $input['country'] ), 0, 2 ) ) : (string) $kept( 'country' ), 'webpush_token' => array_key_exists( 'webpush_token', $input ) ? trim( sanitize_text_field( $input['webpush_token'] ) ) : (string) $kept( 'webpush_token' ), 'chat_key' => $this->sanitize_chat_key( array_key_exists( 'chat_key', $input ) ? $input['chat_key'] : $kept( 'chat_key' ) ), 'woo_order_on' => $flag( 'woo_order_on', 1 ), 'woo_order' => $text( 'woo_order', 'order_placed' ), 'woo_payment_on' => $flag( 'woo_payment_on', 1 ), 'woo_payment' => $text( 'woo_payment', 'payment_accepted' ), 'woo_cart_on' => $flag( 'woo_cart_on', 0 ), 'woo_cart' => $text( 'woo_cart', 'cart_add' ), ]; // Validate the token on every save (runs even when the value is unchanged), // so the connection badge and the other pages reflect reality immediately. $token = $clean['token'] !== '' ? $clean['token'] : ( ( defined( 'AIB_LEAD_SYNC_TOKEN' ) && AIB_LEAD_SYNC_TOKEN ) ? AIB_LEAD_SYNC_TOKEN : '' ); $clean['validated_sig'] = ''; if ( $clean['base_url'] === '' ) { add_settings_error( 'aib_lead_sync', 'aib_conn', __( 'Enter the API URL: every All in Box feature is served from it.', 'all-in-box' ), 'warning' ); } elseif ( $token === '' ) { // Web Push and Webchat need no API token. On such an install, staying quiet is // the correct behaviour rather than nagging about a connection nobody asked for. if ( '' === $clean['webpush_token'] && '' === $clean['chat_key'] ) { add_settings_error( 'aib_lead_sync', 'aib_conn', __( 'Enter the API URL and token to connect.', 'all-in-box' ), 'warning' ); } } else { $status = $this->validate_token( $clean['base_url'], $token ); if ( 'ok' === $status ) { $clean['validated_sig'] = md5( $clean['base_url'] . '|' . $token ); } elseif ( 'unauthorized' === $status ) { add_settings_error( 'aib_lead_sync', 'aib_conn', __( 'Invalid API token: All in Box rejected it. Check the token and that it has the contacts.create_or_update ability.', 'all-in-box' ), 'error' ); } elseif ( 'unreachable' === $status ) { add_settings_error( 'aib_lead_sync', 'aib_conn', __( 'Could not reach the API URL. Check the URL and that this site can make outbound HTTPS requests.', 'all-in-box' ), 'error' ); } else { add_settings_error( 'aib_lead_sync', 'aib_conn', __( 'Unexpected response from the API. Check the API URL.', 'all-in-box' ), 'error' ); } } return $clean; } private function settings() { // The token can also be defined in wp-config.php (recommended in production): // define( 'AIB_LEAD_SYNC_TOKEN', 'aib_skey_xxx' ); $s = wp_parse_args( get_option( self::OPT, [] ), [ 'validated_sig' => '', 'base_url' => 'https://app.allinbox.com', 'token' => '', 'list_id' => 0, 'form_ids' => '', 'default_subscribed' => 0, 'track_consent' => 1, 'email_field' => '', 'firstname_field' => '', 'lastname_field' => '', 'phone_field' => '', 'postal_code_field' => '', 'country' => '', 'webpush_token' => '', 'chat_key' => '', 'woo_order_on' => 1, 'woo_order' => 'order_placed', 'woo_payment_on' => 1, 'woo_payment' => 'payment_accepted', 'woo_cart_on' => 0, 'woo_cart' => 'cart_add', ] ); if ( defined( 'AIB_LEAD_SYNC_TOKEN' ) && AIB_LEAD_SYNC_TOKEN ) { $s['token'] = AIB_LEAD_SYNC_TOKEN; } return $s; } /** * The plugin's admin pages, in menu order. A "connected" page needs a validated API * token, like the tab it replaces; the WooCommerce page needs WooCommerce. */ private function admin_pages() { return [ 'connection' => [ 'slug' => self::MENU_SLUG, 'title' => __( 'Connection', 'all-in-box' ), 'lead' => __( 'Connect this site to your All in Box workspace.', 'all-in-box' ), 'connected' => false, ], 'contacts' => [ 'slug' => 'all-in-box-contacts', 'title' => __( 'Contacts', 'all-in-box' ), 'lead' => __( 'Form submissions on this site are sent to your All in Box list.', 'all-in-box' ), 'connected' => true, ], 'form' => [ 'slug' => 'all-in-box-form', 'title' => __( 'Newsletter form', 'all-in-box' ), 'lead' => __( 'Build a newsletter form, preview it, and copy its shortcode into a page.', 'all-in-box' ), 'connected' => true, ], 'webpush' => [ 'slug' => 'all-in-box-push', 'title' => __( 'Web Push', 'all-in-box' ), 'lead' => __( 'Send push notifications to the visitors of this site.', 'all-in-box' ), 'connected' => false, ], 'webchat' => [ 'slug' => 'all-in-box-chat', 'title' => __( 'Webchat', 'all-in-box' ), 'lead' => __( 'Add your All in Box chatbot to every page of this site.', 'all-in-box' ), 'connected' => false, ], 'woocommerce' => [ 'slug' => 'all-in-box-woocommerce', 'title' => 'WooCommerce', 'lead' => __( 'Orders, payments and carts reach All in Box as events on the contact.', 'all-in-box' ), 'connected' => true, 'woo' => true, ], ]; } public function page_connection() { $this->render_page( 'connection' ); } public function page_contacts() { $this->render_page( 'contacts' ); } public function page_form() { $this->render_page( 'form' ); } public function page_webpush() { $this->render_page( 'webpush' ); } public function page_webchat() { $this->render_page( 'webchat' ); } public function page_woocommerce() { $this->render_page( 'woocommerce' ); } /** Hides the pages that need a connection until the site is connected, like the tabs before. */ public function hide_unconnected_pages() { foreach ( $this->hidden_pages as $slug ) { remove_submenu_page( self::MENU_SLUG, $slug ); } } /** Sends the 1.9 address (Settings > All in Box) on to the Connection page. */ public function redirect_legacy_settings_url() { global $pagenow; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing, nothing is changed. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; if ( 'options-general.php' === $pagenow && self::MENU_SLUG === $page && current_user_can( 'manage_options' ) ) { wp_safe_redirect( admin_url( 'admin.php?page=' . self::MENU_SLUG ) ); exit; } } /** One admin page: the brand header, the title, the notices, then the page's own panel. */ private function render_page( $key ) { if ( ! current_user_can( 'manage_options' ) ) { return; } $pages = $this->admin_pages(); $p = $pages[ $key ]; $s = $this->settings(); $f = function ( $k ) { return self::OPT . '[' . $k . ']'; }; $connected = $this->is_connected( $s ); $woo_notice = ''; if ( 'woocommerce' === $key && $connected && isset( $_GET['aib_woo_create'] ) && check_admin_referer( 'aib_woo_create' ) ) { $n = $this->create_standard_woo_event_types(); $s = $this->settings(); // refresh after the handles are mapped $woo_notice = $n > 0 /* translators: %d: number of event types created or mapped. */ ? '

' . sprintf( esc_html__( '%d event type(s) created/mapped in All in Box.', 'all-in-box' ), $n ) . '

' : '

' . esc_html__( 'Could not create event types. Check the API URL and token first.', 'all-in-box' ) . '

'; } ?>
All in Box


panel_connect_first(); ?> panel_form(); ?>
$method( $s, $f ); ?>

is_connected( $s ); ?>

API URL and token

Enter the address of All in Box and the token of your workspace. The Contacts, Newsletter form and WooCommerce pages appear once the token is validated.

No trailing slash. Usually https://app.allinbox.com.

placeholder="">

Workspace key aib_skey_… with the contacts.create_or_update ability. It is never displayed again once saved. Defined in wp-config.php.'; ?>

Enter your URL and token, then click Save settings below. The token is validated on save, and the other pages appear in the menu.

detected_builders(); $seen = get_option( self::SEEN_FORMS, [] ); ?>

List

ID of the All in Box list that receives the contacts.

Which forms

By default every form on the site is synced.

Optional. Comma-separated form identifiers (see the list below). Leave empty to sync every form. IDs are unique per builder, so if two builders share an ID use the qualified form like metform:11.

Newsletter form

Add a native All in Box form anywhere with the [allinbox_form] shortcode.

Drop [allinbox_form] into a page, a widget or a Divi Text module (a Divi Code module does not run shortcodes). Choose the language, fields, colors and target list via attributes, e.g. [allinbox_form lang="fr" fields="firstname,email" accent="#101010" redirect="/thank-you"]. You can place several, each with its own settings.

Prefer to keep your own HTML markup? Add data-aib-capture to its <form> tag and the plugin handles the submit (sends to All in Box, then shows a success message or redirects). Email/consent fields are auto-detected, or mark them with data-aib-email / data-aib-optin; bots are filtered via a honeypot (data-aib-hp). The API token never reaches the browser.

Subscription

Opt-in only. A contact is always created; it is only subscribed with consent.

Ticking the consent box subscribes the contact. Leaving it unticked never unsubscribes an existing subscriber. A new contact is simply created without a subscription.

Field mapping optional

Leave empty for auto-detection. Set the exact field name to force it.

A postal code is only sent with a country (API requirement). The country is auto-detected from a country/pays field, or this default is used.

Forms seen on submission

Each form that triggers a sync appears here. Copy the qualified identifier (e.g. metform:11) into “Restrict to forms”.

None yet. Submit a form once and it will show up here.

BuilderIdentifierNameLast submission
builder_slug( $row['builder'] ) . ':' . $row['id'] ); ?>
or

Last problem reported

WooCommerce events

Send purchase events to the contact. Each event maps to an All in Box event-type handle.

fetch_event_types() as $t ) { if ( ! empty( $t['handle'] ) && ! empty( $t['schema'] ) ) { $complete[ $t['handle'] ] = ! empty( $t['display_name'] ) ? $t['display_name'] . ' (' . $t['handle'] . ')' : $t['handle']; } } $create_url = wp_nonce_url( add_query_arg( 'aib_woo_create', 1 ), 'aib_woo_create' ); ?>

Create standard event types in All in Box   Creates Order placed / Payment accepted / Add to cart and maps them below.

woo_event_defs() as $def ) : $cur = (string) $s[ $def['key'] ]; ?>

Must exist in All in Box. Set the API token then use the button above.

Your push channel

Install the All in Box Web Push SDK without touching the theme or uploading files. No API token needed: this works on its own, even without contacts sync.

From your All in Box Web Push channel (the data-aib-token value). When set, the SDK script is injected on every page and the service worker is served at . sw_scope() ) : ?> This site is not at the root of its domain, so notifications cover sw_scope() ); ?> and its pages only.

check_requested( 'aib_check' ) ) : $st = $this->webpush_status( $s ); ?>
SDK script on the homepage
Service worker file

Check setup. Runs the same verification as All in Box, for the script and the worker.

Your chatbot

Add the All in Box chat bubble to every page of the site. No API token needed: this works on its own, even without contacts sync.

From your All in Box chatbot, section Integrate on your website (the data-aib-chatbot-key value). Once saved, the chat script is added to every page of the site.

Your chatbot only answers the site declared in All in Box. Copy into its Your website field and publish the chatbot, otherwise the bubble stays silent.
check_requested( 'aib_chat_check' ) ) : $cst = $this->webchat_status( $s ); ?>
Chatbot answers this site

Check setup. Verifies the key, the published status and the declared domain, from this site.

defined( 'METFORM_VERSION' ) || class_exists( '\MetForm\Core\Entries\Action' ), 'Contact Form 7' => defined( 'WPCF7_VERSION' ), 'WPForms' => function_exists( 'wpforms' ), 'Gravity Forms' => class_exists( 'GFForms' ), 'Elementor Pro' => defined( 'ELEMENTOR_PRO_VERSION' ), 'Fluent Forms' => defined( 'FLUENTFORM_VERSION' ), ]; } /* ===================================================================== * Adapters (one per form builder) * ================================================================== */ public function register_adapters() { // Each adapter is wrapped in safe(): any error in our code is logged and // swallowed so it can NEVER break the host form submission. // --- MetForm ----------------------------------------------------- add_action( 'metform_after_store_form_data', function ( $form_id = null, $form_data = [] ) { $this->safe( 'MetForm', function () use ( $form_id, $form_data ) { $data = (array) $form_data; $consent = array_key_exists( 'mf-gdpr-consent', $data ) ? ! empty( $data['mf-gdpr-consent'] ) : null; $this->handle( 'MetForm', $form_id, get_the_title( $form_id ), $data, $data['mf-email'] ?? null, $consent ); } ); }, 10, 2 ); // --- Contact Form 7 --------------------------------------------- // We hook before_send_mail (fires on any valid, non-spam submission) rather // than mail_sent, so a contact still syncs even if the site's email delivery // fails. Consent = null: detection looks for an "acceptance" field in the // data (checked → subscribed), otherwise the default setting applies. add_action( 'wpcf7_before_send_mail', function ( $cf7 = null ) { $this->safe( 'Contact Form 7', function () use ( $cf7 ) { if ( ! $cf7 || ! class_exists( 'WPCF7_Submission' ) ) { return; } $sub = WPCF7_Submission::get_instance(); if ( ! $sub ) { return; } $this->handle( 'Contact Form 7', $cf7->id(), $cf7->title(), $this->flatten( (array) $sub->get_posted_data() ), null, null ); } ); } ); // --- WPForms ----------------------------------------------------- add_action( 'wpforms_process_complete', function ( $fields = [], $entry = [], $form_data = [] ) { $this->safe( 'WPForms', function () use ( $fields, $form_data ) { $flat = []; $email = null; $consent = null; foreach ( (array) $fields as $field ) { $name = ! empty( $field['name'] ) ? $field['name'] : 'field_' . ( $field['id'] ?? '' ); $value = $field['value'] ?? ''; $flat[ $name ] = $value; $type = $field['type'] ?? ''; if ( 'email' === $type && $value ) { $email = $value; } if ( 'gdpr-checkbox' === $type ) { $consent = ! empty( $value ); } } $id = $form_data['id'] ?? ''; $label = $form_data['settings']['form_title'] ?? ''; $this->handle( 'WPForms', $id, $label, $flat, $email, $consent ); } ); }, 10, 3 ); // --- Gravity Forms ---------------------------------------------- add_action( 'gform_after_submission', function ( $entry = [], $form = [] ) { $this->safe( 'Gravity Forms', function () use ( $entry, $form ) { $flat = []; $email = null; $consent = null; foreach ( (array) ( $form['fields'] ?? [] ) as $field ) { $id = (string) $field->id; $value = isset( $entry[ $id ] ) ? $entry[ $id ] : ''; $label = ! empty( $field->label ) ? $field->label : $id; $flat[ $label ] = $value; if ( 'email' === $field->type && $value ) { $email = $value; } if ( 'consent' === $field->type ) { $consent = ! empty( $entry[ $id . '.1' ] ) || ! empty( $value ); } } $this->handle( 'Gravity Forms', $form['id'] ?? '', $form['title'] ?? '', $flat, $email, $consent ); } ); }, 10, 2 ); // --- Elementor Pro Forms ---------------------------------------- add_action( 'elementor_pro/forms/new_record', function ( $record = null ) { $this->safe( 'Elementor', function () use ( $record ) { if ( ! is_object( $record ) ) { return; } $fields = $record->get( 'fields' ); $flat = []; $email = null; $consent = null; foreach ( (array) $fields as $id => $field ) { $value = $field['value'] ?? ''; $flat[ $id ] = $value; $type = $field['type'] ?? ''; if ( 'email' === $type && $value ) { $email = $value; } if ( 'acceptance' === $type ) { $consent = ! empty( $value ); } } $name = $record->get_form_settings( 'form_name' ); $form_id = $record->get_form_settings( 'id' ); $this->handle( 'Elementor', $form_id ?: $name, $name, $flat, $email, $consent ); } ); }, 10, 1 ); // --- Fluent Forms ------------------------------------------------ add_action( 'fluentform/submission_inserted', function ( $entry_id = null, $form_data = [], $form = null ) { $this->safe( 'Fluent Forms', function () use ( $form_data, $form ) { $data = (array) $form_data; $id = is_object( $form ) && isset( $form->id ) ? $form->id : ''; $label = is_object( $form ) && isset( $form->title ) ? $form->title : ''; $this->handle( 'Fluent Forms', $id, $label, $this->flatten( $data ), $data['email'] ?? null, null ); } ); }, 10, 3 ); // --- WooCommerce (e-commerce events + contact enrichment) -------- if ( class_exists( 'WooCommerce' ) ) { // Order placed: classic checkout + block (Store API) checkout. add_action( 'woocommerce_checkout_order_processed', function ( $order_id = 0, $posted = [], $order = null ) { $this->safe( 'WooCommerce', function () use ( $order_id, $order ) { $this->woo_order( $order ?: $order_id ); } ); }, 10, 3 ); add_action( 'woocommerce_store_api_checkout_order_processed', function ( $order = null ) { $this->safe( 'WooCommerce', function () use ( $order ) { $this->woo_order( $order ); } ); }, 10, 1 ); // Payment complete. add_action( 'woocommerce_payment_complete', function ( $order_id = 0 ) { $this->safe( 'WooCommerce', function () use ( $order_id ) { $this->woo_payment( $order_id ); } ); }, 10, 1 ); // Add to cart (logged-in customers only). add_action( 'woocommerce_add_to_cart', function ( $key = '', $product_id = 0, $quantity = 0 ) { $this->safe( 'WooCommerce', function () use ( $product_id, $quantity ) { $this->woo_cart( $product_id, $quantity ); } ); }, 10, 3 ); // New customer account. add_action( 'woocommerce_created_customer', function ( $customer_id = 0 ) { $this->safe( 'WooCommerce', function () use ( $customer_id ) { $this->woo_customer( $customer_id ); } ); }, 10, 1 ); } } /* ===================================================================== * WooCommerce * ================================================================== */ public function woo_order( $order_or_id ) { $s = $this->settings(); if ( empty( $s['woo_order_on'] ) ) { return; } $order = ( $order_or_id instanceof WC_Order ) ? $order_or_id : wc_get_order( $order_or_id ); if ( ! $order ) { return; } $email = $order->get_billing_email(); if ( ! is_email( $email ) ) { return; } $contact = $this->woo_order_contact( $order ); $contact['locale'] = $this->request_language(); $this->woo_send_event( $s['woo_order'], $email, $this->woo_order_props( $order ), $contact ); } /** * No language here: a payment can be confirmed by a call from the payment provider, * whose language says nothing about the customer's. */ public function woo_payment( $order_id ) { $s = $this->settings(); if ( empty( $s['woo_payment_on'] ) ) { return; } $order = wc_get_order( $order_id ); if ( ! $order ) { return; } $email = $order->get_billing_email(); if ( ! is_email( $email ) ) { return; } $this->woo_send_event( $s['woo_payment'], $email, $this->woo_order_props( $order ), $this->woo_order_contact( $order ) ); } public function woo_cart( $product_id, $quantity ) { $s = $this->settings(); if ( empty( $s['woo_cart_on'] ) ) { return; } $user = wp_get_current_user(); if ( empty( $user->ID ) || ! is_email( $user->user_email ) ) { return; // anonymous cart has no email to re-engage } $cart_total = ( function_exists( 'WC' ) && WC()->cart ) ? (float) WC()->cart->get_cart_contents_total() : 0; $props = [ 'product_id' => (int) $product_id, 'quantity' => (int) $quantity, 'cart_total' => $cart_total, ]; $this->woo_send_event( $s['woo_cart'], $user->user_email, $props, [ 'firstname' => $user->user_firstname, 'lastname' => $user->user_lastname, 'locale' => $this->request_language(), ] ); } public function woo_customer( $customer_id ) { if ( ! class_exists( 'WC_Customer' ) ) { return; } $c = new WC_Customer( (int) $customer_id ); $email = $c->get_email(); if ( ! is_email( $email ) ) { return; } // Registration carries no newsletter consent → create the contact without // subscription and with email tracking explicitly OFF. $this->woo_push( $email, [ 'firstname' => $c->get_first_name(), 'lastname' => $c->get_last_name(), 'locale' => $this->request_language(), 'track' => false, ] ); } /** Order/payment properties (keys match the standard event schema). */ private function woo_order_props( $order ) { $products = []; foreach ( $order->get_items() as $item ) { $products[] = $item->get_name(); } return [ 'reference' => $order->get_order_number(), 'total_paid' => (float) $order->get_total(), 'currency' => $order->get_currency(), 'payment' => $order->get_payment_method_title(), 'products' => implode( ', ', array_filter( $products ) ), 'items' => (int) $order->get_item_count(), 'order_id' => (int) $order->get_id(), ]; } /** Contact fields taken from the order billing details. */ private function woo_order_contact( $order ) { $opts = [ 'firstname' => $order->get_billing_first_name(), 'lastname' => $order->get_billing_last_name(), 'phone' => $order->get_billing_phone(), 'address' => trim( $order->get_billing_address_1() . ' ' . $order->get_billing_address_2() ), 'postal_code' => $order->get_billing_postcode(), 'country' => $order->get_billing_country(), 'is_customer' => true, ]; if ( $order->get_billing_city() ) { $opts['custom_attributes'] = [ 'city' => $order->get_billing_city() ]; } return $opts; } /** Attaches a custom event to a contact (handle must exist in All in Box). */ private function woo_send_event( $handle, $email, array $properties, array $contact = [] ) { $handle = trim( (string) $handle ); if ( $handle === '' ) { return; } $contact['custom_events'] = [ $handle => [ 'properties' => $properties, 'occurred_at' => gmdate( 'c' ) ], ]; $this->woo_push( $email, $contact ); } /** Builds and sends a WooCommerce contact payload (opt-in only; events never touch the pixel). */ private function woo_push( $email, array $opts = [] ) { $s = $this->settings(); if ( empty( $s['token'] ) || empty( $s['list_id'] ) || empty( $s['base_url'] ) ) { return; } $email = sanitize_email( (string) $email ); if ( ! is_email( $email ) ) { return; } $payload = [ 'list_id' => (int) $s['list_id'], 'email' => $email, 'custom_attributes' => [ 'source' => 'WooCommerce ' . wp_parse_url( home_url(), PHP_URL_HOST ) ], ]; if ( ! empty( $opts['firstname'] ) ) { $payload['firstname'] = sanitize_text_field( $opts['firstname'] ); } if ( ! empty( $opts['lastname'] ) ) { $payload['lastname'] = sanitize_text_field( $opts['lastname'] ); } if ( ! empty( $opts['address'] ) ) { $payload['address'] = substr( sanitize_text_field( $opts['address'] ), 0, 190 ); } if ( ! empty( $opts['phone'] ) ) { $phone = substr( preg_replace( '/[^0-9+ ]/', '', (string) $opts['phone'] ), 0, 20 ); if ( $phone !== '' ) { $payload['phone'] = $phone; } } if ( ! empty( $opts['postal_code'] ) && ! empty( $opts['country'] ) ) { $payload['postal_code'] = substr( (string) $opts['postal_code'], 0, 20 ); $payload['country'] = strtoupper( substr( (string) $opts['country'], 0, 2 ) ); } if ( ! empty( $opts['is_customer'] ) ) { $payload['is_customer'] = true; } if ( ! empty( $opts['locale'] ) ) { $payload['locale'] = $opts['locale']; } if ( ! empty( $opts['custom_events'] ) ) { $payload['custom_events'] = $opts['custom_events']; } if ( ! empty( $opts['custom_attributes'] ) && is_array( $opts['custom_attributes'] ) ) { $payload['custom_attributes'] = array_merge( $payload['custom_attributes'], $opts['custom_attributes'] ); } // Opt-in only: never force a subscription here. track=false keeps the pixel off // for new contacts (registration) without revoking it on events. if ( array_key_exists( 'track', $opts ) && $opts['track'] === false ) { $payload['is_email_tracking_authorized'] = false; } $payload = apply_filters( 'aib_lead_sync_payload', $payload, [], 'woocommerce' ); $this->send( $s, $payload ); } private function woo_event_defs() { return [ [ 'key' => 'woo_order', 'on' => 'woo_order_on', 'label' => 'Order placed', 'desc' => 'When a customer places an order (classic & block checkout).' ], [ 'key' => 'woo_payment', 'on' => 'woo_payment_on', 'label' => 'Payment accepted', 'desc' => 'When an order payment completes.' ], [ 'key' => 'woo_cart', 'on' => 'woo_cart_on', 'label' => 'Add to cart (logged-in only)', 'desc' => 'Fires often. Off by default.' ], ]; } /** Property schema sent when creating each standard WooCommerce event type. */ private function woo_event_schemas() { $order = [ [ 'name' => 'reference', 'label' => 'Reference', 'type' => 'text' ], [ 'name' => 'total_paid', 'label' => 'Total paid', 'type' => 'number' ], [ 'name' => 'currency', 'label' => 'Currency', 'type' => 'text' ], [ 'name' => 'payment', 'label' => 'Payment', 'type' => 'text' ], [ 'name' => 'products', 'label' => 'Products', 'type' => 'text' ], [ 'name' => 'items', 'label' => 'Items', 'type' => 'number' ], [ 'name' => 'order_id', 'label' => 'Order ID', 'type' => 'number' ], ]; return [ 'Order placed' => $order, 'Payment accepted' => $order, 'Add to cart' => [ [ 'name' => 'product_id', 'label' => 'Product ID', 'type' => 'number' ], [ 'name' => 'quantity', 'label' => 'Quantity', 'type' => 'number' ], [ 'name' => 'cart_total', 'label' => 'Cart total', 'type' => 'number' ], ], ]; } /** Fetches the workspace event types from All in Box: [['handle'=>,'display_name'=>,'schema'=>], ...]. */ private function fetch_event_types() { $s = $this->settings(); if ( empty( $s['token'] ) || empty( $s['base_url'] ) ) { return []; } $r = wp_remote_get( rtrim( $s['base_url'], '/' ) . '/api/v3/contact-event-types', [ 'timeout' => 8, 'headers' => [ 'Authorization' => 'Bearer ' . $s['token'], 'Accept' => 'application/json' ], ] ); if ( is_wp_error( $r ) || wp_remote_retrieve_response_code( $r ) >= 300 ) { return []; } $j = json_decode( wp_remote_retrieve_body( $r ), true ); return ( isset( $j['data'] ) && is_array( $j['data'] ) ) ? $j['data'] : []; } /** Creates the standard WooCommerce event types in All in Box and maps the handles. */ private function create_standard_woo_event_types() { $s = $this->settings(); if ( empty( $s['token'] ) || empty( $s['base_url'] ) ) { return 0; } $standard = [ 'Order placed' => 'woo_order', 'Payment accepted' => 'woo_payment', 'Add to cart' => 'woo_cart' ]; $schemas = $this->woo_event_schemas(); $existing = []; foreach ( $this->fetch_event_types() as $t ) { if ( ! empty( $t['display_name'] ) && ! empty( $t['handle'] ) && ! empty( $t['schema'] ) ) { $existing[ $t['display_name'] ] = $t['handle']; } } $opt = get_option( self::OPT, [] ); $count = 0; foreach ( $standard as $name => $key ) { if ( isset( $existing[ $name ] ) ) { $opt[ $key ] = $existing[ $name ]; $count++; continue; } $r = wp_remote_post( rtrim( $s['base_url'], '/' ) . '/api/v3/contact-event-types', [ 'timeout' => 10, 'headers' => [ 'Authorization' => 'Bearer ' . $s['token'], 'Content-Type' => 'application/json', 'Accept' => 'application/json' ], 'body' => wp_json_encode( [ 'display_name' => $name, 'schema' => $schemas[ $name ] ?? null ] ), ] ); if ( ! is_wp_error( $r ) ) { $j = json_decode( wp_remote_retrieve_body( $r ), true ); if ( ! empty( $j['data']['handle'] ) ) { $opt[ $key ] = $j['data']['handle']; $count++; } } } update_option( self::OPT, $opt ); return $count; } /** True when the current URL+token were successfully validated by a connection test. */ private function is_connected( $s ) { if ( empty( $s['base_url'] ) || empty( $s['token'] ) ) { return false; } return ! empty( $s['validated_sig'] ) && hash_equals( $s['validated_sig'], md5( $s['base_url'] . '|' . $s['token'] ) ); } /** * Checks the token against the API. Side-effect free. Returns a status string: * 'ok' | 'unauthorized' (token rejected) | 'unreachable' (URL/network) | 'unexpected'. * Prefers the event-types endpoint; falls back to contacts (422 = authenticated). */ private function validate_token( $base_url, $token ) { $base = rtrim( $base_url, '/' ); $headers = [ 'Authorization' => 'Bearer ' . $token, 'Accept' => 'application/json' ]; $r = wp_remote_get( $base . '/api/v3/contact-event-types', [ 'timeout' => 8, 'headers' => $headers ] ); if ( is_wp_error( $r ) ) { return 'unreachable'; } $code = (int) wp_remote_retrieve_response_code( $r ); if ( $code >= 200 && $code < 300 ) { return 'ok'; } if ( 401 === $code || 403 === $code ) { return 'unauthorized'; } if ( 404 === $code ) { // Endpoint not deployed → fall back to the contacts endpoint. $r2 = wp_remote_post( $base . '/api/v3/contacts', [ 'timeout' => 8, 'headers' => array_merge( $headers, [ 'Content-Type' => 'application/json' ] ), 'body' => '{}', ] ); if ( is_wp_error( $r2 ) ) { return 'unreachable'; } $c2 = (int) wp_remote_retrieve_response_code( $r2 ); if ( 422 === $c2 || ( $c2 >= 200 && $c2 < 300 ) ) { return 'ok'; } if ( 401 === $c2 || 403 === $c2 ) { return 'unauthorized'; } } return 'unexpected'; } /** Runs an adapter callback, swallowing any error so the host form is never broken. */ private function safe( $builder, callable $cb ) { try { $cb(); } catch ( \Throwable $e ) { $this->log_error( $builder . ' adapter error: ' . $e->getMessage() ); } } /** * Keeps the last problem where the site owner will actually see it (the Contacts tab), * and writes to the PHP log only when debugging is on: nobody reads debug.log, and a * plugin should not chatter into it on a production site. */ private function log_error( $message ) { $message = substr( (string) $message, 0, 500 ); set_transient( self::LAST_ERROR, [ 'message' => $message, 'time' => time() ], DAY_IN_SECONDS ); if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( '[All in Box] ' . $message ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions -- debug builds only } } /* ===================================================================== * Core: normalization + API call * ================================================================== */ private function handle( $builder, $form_id, $form_label, array $fields, $email = null, $consent = null ) { // Remember the form we saw (helps with targeting), even if we don't send anything. $this->record_form( $builder, (string) $form_id, (string) $form_label ); $s = $this->settings(); if ( empty( $s['token'] ) || empty( $s['list_id'] ) || empty( $s['base_url'] ) ) { return; } // Optional per-form restriction. Form IDs are only unique WITHIN a builder, // so an entry can be a bare id ("11", matches that id on any builder) or a // builder-qualified id ("metform:11", matches only that builder + id). if ( ! empty( $s['form_ids'] ) ) { $allowed = array_map( 'strtolower', array_filter( array_map( 'trim', explode( ',', $s['form_ids'] ) ) ) ); if ( $allowed ) { $bare = strtolower( (string) $form_id ); $qualified = strtolower( $this->builder_slug( $builder ) . ':' . $form_id ); if ( ! in_array( $bare, $allowed, true ) && ! in_array( $qualified, $allowed, true ) ) { return; } } } // Email is required: forced field, then adapter-provided, then detected. if ( ! empty( $s['email_field'] ) && ! empty( $fields[ $s['email_field'] ] ) ) { $email = $fields[ $s['email_field'] ]; } if ( ! $email ) { $email = $this->detect_email( $fields ); } $email = sanitize_email( (string) $email ); if ( ! is_email( $email ) ) { return; // no email → skip } // Newsletter subscription = consent state (we no longer skip, we reflect it). $subscribed = $this->resolve_consent( $fields, $consent, ! empty( $s['default_subscribed'] ) ); // Additional fields. $firstname_key = $this->find_field_key( $fields, $s['firstname_field'], [ 'firstname', 'prenom', 'prénom', 'first' ] ); $lastname_key = $this->find_field_key( $fields, $s['lastname_field'], [ 'lastname', 'surname', 'nom', 'last' ], [ 'prenom', 'prénom', 'first', 'mail', 'user' ], $firstname_key ); $phone_key = $this->find_field_key( $fields, $s['phone_field'], [ 'phone', 'mobile', 'portable', 'telephone', 'téléphone', 'tel' ], [ 'mail' ] ); $postal_key = $this->find_field_key( $fields, $s['postal_code_field'], [ 'postal', 'postcode', 'zipcode', 'zip' ], [ 'mail' ] ); $payload = [ 'list_id' => (int) $s['list_id'], 'email' => $email, 'custom_attributes' => [ 'source' => 'Website ' . wp_parse_url( home_url(), PHP_URL_HOST ), ], ]; $locale = $this->request_language(); if ( '' !== $locale ) { $payload['locale'] = $locale; } // Opt-in only. We only send is_subscribed_email when consent is given, and // never send it as false: the API treats any value sent as a FORCED // overwrite (optins_force_update_subscription = true), so sending false // would unsubscribe an existing subscriber who re-submits a form without // ticking consent. Omitting the field leaves the subscription untouched // for existing contacts, and a brand-new contact defaults to not subscribed. if ( $subscribed ) { $payload['is_subscribed_email'] = true; $payload['is_email_tracking_authorized'] = ! empty( $s['track_consent'] ); } if ( $firstname_key ) { $payload['firstname'] = sanitize_text_field( $fields[ $firstname_key ] ); } if ( $lastname_key ) { $payload['lastname'] = sanitize_text_field( $fields[ $lastname_key ] ); } if ( $phone_key ) { $phone = $this->clean_phone( $fields[ $phone_key ] ); if ( $phone ) { $payload['phone'] = $phone; } } // Postal code requires a country (API: country is required_with postal_code). // Resolve the country from a field, then the default setting; if neither yields // a 2-letter code, we OMIT the postal code rather than risk a 422. if ( $postal_key && '' !== trim( (string) $fields[ $postal_key ] ) ) { $country_key = $this->find_field_key( $fields, '', [ 'country', 'pays' ], [ 'mail' ] ); $country = $country_key ? $fields[ $country_key ] : $s['country']; $country = strtoupper( substr( preg_replace( '/[^a-zA-Z]/', '', (string) $country ), 0, 2 ) ); if ( 2 === strlen( $country ) ) { $payload['postal_code'] = sanitize_text_field( substr( trim( (string) $fields[ $postal_key ] ), 0, 20 ) ); $payload['country'] = $country; } } /** * Customize the payload (e.g. per-form list_id, custom_attributes…). * * @param array $payload Data sent to the API. * @param array $fields Raw form fields. * @param string $form_id Source form identifier. */ $payload = apply_filters( 'aib_lead_sync_payload', $payload, $fields, (string) $form_id ); $this->send( $s, $payload ); } private function send( array $s, array $payload ) { $response = wp_remote_post( $s['base_url'] . '/api/v3/contacts', [ 'timeout' => 15, 'headers' => [ 'Authorization' => 'Bearer ' . $s['token'], 'Content-Type' => 'application/json', 'Accept' => 'application/json', ], 'body' => wp_json_encode( $payload ), ] ); if ( is_wp_error( $response ) ) { $this->log_error( 'HTTP: ' . $response->get_error_message() ); return; } $code = wp_remote_retrieve_response_code( $response ); if ( $code < 200 || $code >= 300 ) { $this->log_error( 'API ' . $code . ': ' . wp_remote_retrieve_body( $response ) ); } else { delete_transient( self::LAST_ERROR ); } } /* ===================================================================== * Embedded HTML form capture (REST relay + front-end script) * ================================================================== */ public function register_rest() { register_rest_route( 'aib-lead-sync/v1', '/capture', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_capture' ], 'permission_callback' => '__return_true', ] ); } /** Loads the front-end capture script only when the connection is set up. */ public function enqueue_capture_assets() { $s = $this->settings(); if ( ! $this->is_connected( $s ) ) { return; } $handle = 'aib-lead-sync-capture'; wp_enqueue_script( $handle, plugins_url( 'assets/capture.js', __FILE__ ), [], self::VERSION, true ); wp_localize_script( $handle, 'AIBLeadSyncCapture', [ 'url' => esc_url_raw( rest_url( 'aib-lead-sync/v1/capture' ) ), // No REST nonce: a page served from a full-page cache carries an expired one, and // core rejects a request with an invalid nonce (403) before our callback runs. // Signed for the configured list: a hand-built form has no server-side render // step of its own, so it borrows this one. 'token' => $this->capture_token( 0 ), ] ); } /** * Guard rails for the public capture endpoint. Filterable, because the right ceiling * depends on the traffic and on whether the site sits behind a shared proxy IP. */ private function capture_limits() { return wp_parse_args( (array) apply_filters( 'aib_lead_sync_capture_limits', [] ), [ // A form lives in a page that may be served from a full-page cache for a long // time, so the window is generous. It still proves the token was minted here. 'token_ttl' => 30 * DAY_IN_SECONDS, 'ip_max' => 20, 'ip_window' => 10 * MINUTE_IN_SECONDS, 'email_max' => 3, 'email_window' => HOUR_IN_SECONDS, ] ); } /** * Signs the (timestamp, list) pair of a form. The endpoint recomputes it, so a POST * that never loaded a page of this site is rejected, and the target list cannot be * swapped for another list of the same workspace. */ private function capture_token( $list_id ) { $ts = time(); return $ts . '.' . $this->capture_sig( $ts, $list_id ); } private function capture_sig( $ts, $list_id ) { return hash_hmac( 'sha256', (int) $ts . '|' . (int) $list_id, wp_salt( 'aib_lead_sync_capture' ) ); } private function capture_token_valid( $token, $list_id ) { $parts = explode( '.', (string) $token, 2 ); if ( 2 !== count( $parts ) ) { return false; } $ts = (int) $parts[0]; $now = time(); // Tolerate a little clock skew forward, and the cache window backwards. if ( $ts <= 0 || $ts > $now + 300 || ( $now - $ts ) > (int) $this->capture_limits()['token_ttl'] ) { return false; } return hash_equals( $this->capture_sig( $ts, $list_id ), (string) $parts[1] ); } /** Caller IP, only when it is a valid address. Never taken from a client header. */ private function client_ip() { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : ''; } /** * Counts a hit against a sliding window and says whether it is still allowed. * Transients, so it works with or without a persistent object cache. */ private function capture_hit_allowed( $key, $max, $window ) { $hits = (int) get_transient( $key ); if ( $hits >= (int) $max ) { return false; } set_transient( $key, $hits + 1, (int) $window ); return true; } /** Receives {email, optin, firstname, lastname, phone, hp, token} from the front-end script. */ public function rest_capture( WP_REST_Request $req ) { $s = $this->settings(); if ( empty( $s['token'] ) || empty( $s['base_url'] ) ) { return new WP_REST_Response( [ 'ok' => false ], 200 ); } // Honeypot: if filled, it's a bot. Answer 200 so it learns nothing, but do nothing. if ( trim( (string) $req->get_param( 'hp' ) ) !== '' ) { return new WP_REST_Response( [ 'ok' => true ], 200 ); } // CSRF: the browser must say the request comes from this site. A script outside a // browser forges that easily, hence the signed form token and the rate limits below. if ( ! $this->capture_request_allowed( $req ) ) { return new WP_REST_Response( [ 'ok' => false ], 403 ); } // The list is part of what the form signed, so it cannot be swapped for another // list of the workspace. 0 means "the configured list". $list_id = (int) $req->get_param( 'list_id' ); if ( ! $this->capture_token_valid( $req->get_param( 'token' ), $list_id ) ) { return new WP_REST_Response( [ 'ok' => false, 'error' => 'expired' ], 403 ); } if ( $list_id <= 0 ) { $list_id = (int) $s['list_id']; } if ( $list_id <= 0 ) { return new WP_REST_Response( [ 'ok' => false ], 200 ); // nothing configured to write to } $limits = $this->capture_limits(); // Per-IP ceiling: what actually caps bulk injection through this endpoint. $ip = $this->client_ip(); if ( '' !== $ip && ! $this->capture_hit_allowed( 'aib_cap_ip_' . md5( $ip ), $limits['ip_max'], $limits['ip_window'] ) ) { return new WP_REST_Response( [ 'ok' => false, 'error' => 'rate_limited' ], 429 ); } $email = sanitize_email( (string) $req->get_param( 'email' ) ); if ( ! is_email( $email ) ) { return new WP_REST_Response( [ 'ok' => false ], 400 ); } // Per-address ceiling, whatever the IP: this is what stops someone from using the // form to bombard a third party with subscriptions. The answer stays a success so // a genuine visitor re-submitting still sees the thank-you message, and so an // abuser learns nothing about which addresses are already known. if ( ! $this->capture_hit_allowed( 'aib_cap_em_' . md5( strtolower( $email ) ), $limits['email_max'], $limits['email_window'] ) ) { return new WP_REST_Response( [ 'ok' => true ], 200 ); } $subscribed = filter_var( $req->get_param( 'optin' ), FILTER_VALIDATE_BOOLEAN ); $payload = [ 'list_id' => $list_id, 'email' => $email, 'custom_attributes' => [ 'source' => 'Website ' . wp_parse_url( home_url(), PHP_URL_HOST ) ], ]; if ( $subscribed ) { $payload['is_subscribed_email'] = true; $payload['is_email_tracking_authorized'] = ! empty( $s['track_consent'] ); } else { // Captured without consent → create the contact but keep the pixel off. $payload['is_email_tracking_authorized'] = false; } // The language the visitor read the form in: the lang of a plugin form, else the // lang of the page. A REST request runs in the site language, which on a // multilingual site is not necessarily the one of the page. $locale = $this->language_code( (string) $req->get_param( 'locale' ) ); if ( '' === $locale ) { $locale = $this->request_language(); } if ( '' !== $locale ) { $payload['locale'] = $locale; } // Structured field map from a plugin-generated form takes precedence over the // heuristic top-level firstname/lastname/phone (which serve the piggyback mode). $fields = $req->get_param( 'fields' ); $fields = is_array( $fields ) ? $fields : []; $get = function ( $key, $fallback = '' ) use ( $fields, $req ) { if ( isset( $fields[ $key ] ) && '' !== trim( (string) $fields[ $key ] ) ) { return (string) $fields[ $key ]; } return (string) $req->get_param( $fallback !== '' ? $fallback : $key ); }; $fn = sanitize_text_field( $get( 'firstname' ) ); $ln = sanitize_text_field( $get( 'lastname' ) ); if ( $fn !== '' ) { $payload['firstname'] = $fn; } if ( $ln !== '' ) { $payload['lastname'] = $ln; } $phone = $this->clean_phone( $get( 'phone' ) ); if ( $phone ) { $payload['phone'] = $phone; } // Birthdate (Y-m-d). $birthdate = trim( (string) ( $fields['birthdate'] ?? '' ) ); if ( $birthdate !== '' && preg_match( '/^\d{4}-\d{2}-\d{2}$/', $birthdate ) ) { $payload['birthdate'] = $birthdate; } // Gender (m|f). $gender = strtolower( trim( (string) ( $fields['gender'] ?? '' ) ) ); if ( in_array( $gender, [ 'm', 'f' ], true ) ) { $payload['gender'] = $gender; } // Address line. $address = sanitize_text_field( (string) ( $fields['address'] ?? '' ) ); if ( $address !== '' ) { $payload['address'] = substr( $address, 0, 190 ); } // Postal code requires a country (API: country is required_with postal_code). $postal = trim( (string) ( $fields['postal_code'] ?? '' ) ); $country = strtoupper( substr( preg_replace( '/[^a-zA-Z]/', '', (string) ( $fields['country'] ?? $s['country'] ) ), 0, 2 ) ); if ( $postal !== '' && 2 === strlen( $country ) ) { $payload['postal_code'] = sanitize_text_field( substr( $postal, 0, 20 ) ); $payload['country'] = $country; } /** Mirror of aib_lead_sync_payload for the embedded-form capture endpoint. */ $payload = apply_filters( 'aib_lead_sync_capture_payload', $payload, $req ); $this->send( $s, $payload ); return new WP_REST_Response( [ 'ok' => true ], 200 ); } /** * Accepts the request on a same-origin Origin/Referer. Browsers send Origin on every * POST, same-origin fetch included. A REST nonce would add nothing for anonymous * visitors (it is printed in the page, the same for all of them) and breaks cached pages. */ private function capture_request_allowed( WP_REST_Request $req ) { // The host the browser sent this request to counts as much as the site address: a // site also reachable under a second domain would otherwise refuse its own visitors. // A cross-site page cannot change that host, so this stays a same-origin check. $hosts = [ (string) wp_parse_url( home_url(), PHP_URL_HOST ) ]; if ( ! empty( $_SERVER['HTTP_HOST'] ) ) { $hosts[] = (string) wp_parse_url( 'http://' . sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ), PHP_URL_HOST ); } foreach ( [ 'origin', 'referer' ] as $h ) { $v = $req->get_header( $h ); if ( ! $v ) { continue; } $from = (string) wp_parse_url( $v, PHP_URL_HOST ); foreach ( $hosts as $host ) { if ( '' !== $host && 0 === strcasecmp( $from, $host ) ) { return true; } } } return false; } /* ===================================================================== * Native form generator — [allinbox_form] shortcode * ================================================================== */ /** * Renders a native All in Box newsletter form (handled by the capture endpoint). * Multiple instances are supported, each with its own fields, styling, language * and (optional) target list. */ public function shortcode( $atts ) { $a = shortcode_atts( [ 'lang' => '', 'fields' => 'firstname,email', 'list_id' => '', 'accent' => self::FORM_ACCENT, 'bg' => '#ffffff', 'text' => self::FORM_TEXT, 'radius' => (string) self::FORM_RADIUS, 'width' => '420', 'font_size' => '16', 'title' => '', 'description' => '', 'button' => '', 'consent' => '', 'consent_required' => '1', 'policy' => '', 'success' => '', 'error' => '', 'redirect' => '', ], $atts, 'allinbox_form' ); $s = $this->settings(); // The form is a front-end for the capture endpoint: with no working connection // there is nothing to submit to, and rendering it anyway would leave a form that // falls back to a plain page reload. Say so to an administrator, stay silent for // visitors rather than showing them something broken. $list_id = (int) $a['list_id'] > 0 ? (int) $a['list_id'] : 0; if ( ! $this->is_connected( $s ) || ( 0 === $list_id && empty( $s['list_id'] ) ) ) { if ( current_user_can( 'manage_options' ) ) { return '

' . esc_html__( 'All in Box: finish the setup in the All in Box menu (API URL and token on Connection, list on Contacts) for this form to appear.', 'all-in-box' ) . '

'; } return ''; } $i18n = $this->form_i18n(); $lang = strtolower( substr( preg_replace( '/[^a-zA-Z]/', '', $a['lang'] ), 0, 2 ) ); if ( ! isset( $i18n[ $lang ] ) ) { $loc = strtolower( substr( get_locale(), 0, 2 ) ); $lang = isset( $i18n[ $loc ] ) ? $loc : 'en'; } $t = $i18n[ $lang ]; // Selected fields (email is always present). $allowed = [ 'firstname', 'lastname', 'email', 'phone', 'birthdate', 'address', 'postal_code', 'country', 'gender' ]; $fields = array_values( array_intersect( $allowed, array_map( 'trim', explode( ',', strtolower( $a['fields'] ) ) ) ) ); if ( ! in_array( 'email', $fields, true ) ) { $fields[] = 'email'; } $id = wp_unique_id( 'aib-form-' ); $accent = $this->sanitize_color( $a['accent'], self::FORM_ACCENT ); $bg = $this->sanitize_color( $a['bg'], '#ffffff' ); $text = $this->sanitize_color( $a['text'], self::FORM_TEXT ); $radius = max( 0, (int) $a['radius'] ); $width = max( 200, (int) $a['width'] ); $fs = 'inherit' === strtolower( trim( (string) $a['font_size'] ) ) ? 'inherit' : ( (int) $a['font_size'] > 0 ? (int) $a['font_size'] . 'px' : '16px' ); $type_map = [ 'email' => 'email', 'phone' => 'tel', 'birthdate' => 'date' ]; $inputs = ''; foreach ( $fields as $f ) { $label = isset( $t[ $f ] ) ? $t[ $f ] : ucfirst( $f ); if ( 'gender' === $f ) { $inputs .= ''; } else { $type = isset( $type_map[ $f ] ) ? $type_map[ $f ] : 'text'; $extra = ( 'country' === $f ? ' maxlength="2" placeholder="FR"' : '' ) . ( 'email' === $f ? ' required' : '' ); $inputs .= ''; } } $button = '' !== $a['button'] ? $a['button'] : $t['button']; $success = '' !== $a['success'] ? $a['success'] : $t['success']; $error = '' !== $a['error'] ? $a['error'] : $t['error']; $consent_html = ''; if ( 'no' !== strtolower( $a['consent'] ) ) { $is_default = ( '' === $a['consent'] || 'yes' === strtolower( $a['consent'] ) ); // The default text mentions open-tracking only when the pixel is actually enabled. $default_key = ! empty( $s['track_consent'] ) ? 'consent_track' : 'consent'; $consent_text = $is_default ? $t[ $default_key ] : $a['consent']; // Optional link to the privacy policy: turn the policy phrase into a link. $policy_url = trim( (string) $a['policy'] ); if ( '' !== $policy_url ) { $inner = $this->linkify_policy( $consent_text, $t['policy_phrase'], $policy_url ); } else { $inner = esc_html( $consent_text ); } $req = ! in_array( strtolower( (string) $a['consent_required'] ), [ 'no', '0', 'false' ], true ) ? ' required' : ''; $consent_html = ''; } // data-aib-t carries the signature of this form's target list, so the endpoint can // trust data-aib-list. Both are emitted together, never one without the other. $list_attr = ' data-aib-list="' . $list_id . '" data-aib-t="' . esc_attr( $this->capture_token( $list_id ) ) . '"'; // Only a path or a URL on this site: wp_validate_redirect() drops javascript: and // any other host, so a shortcode cannot turn into a redirect to a third party. $redirect = wp_validate_redirect( trim( (string) $a['redirect'] ), '' ); $redir_attr = '' !== $redirect ? ' data-aib-redirect="' . esc_attr( $redirect ) . '"' : ''; // Fields and button stay at 10px at most: a card of 20px with fields of 18px reads // as pills. A radius of 12px or less keeps the fields 2px under the card, as before. $ir = min( max( 0, $radius - 2 ), 10 ); // The brand's ink accent gets the brand's lime ring and a dark hover; any other // accent keeps its own translucent ring and the lighter hover it always had. $brand = 0 === strcasecmp( $accent, self::FORM_ACCENT ); $ring = $brand ? '#E2F87E' : $accent . '22'; $hover = $brand ? 'background:#2d2a2a;' : 'filter:brightness(1.08);'; $css = ''; $title_html = '' !== trim( (string) $a['title'] ) ? '

' . esc_html( $a['title'] ) . '

' : ''; $desc_html = '' !== trim( (string) $a['description'] ) ? '

' . esc_html( $a['description'] ) . '

' : ''; // method/action matter for the day the script does not run (a JS optimiser, an // error earlier in the page): the browser then reloads the page instead of putting // the address in the query string. $form = '
' . $title_html . $desc_html . $inputs . $consent_html . '' . '' . '
'; return $css . $form; } private function sanitize_color( $value, $default ) { $v = trim( (string) $value ); return preg_match( '/^#[0-9a-fA-F]{3,8}$/', $v ) ? $v : $default; } /** Turns the privacy-policy phrase inside a consent sentence into a link (escaped). */ private function linkify_policy( $text, $phrase, $url ) { $link = '' . esc_html( $phrase ) . ''; $pos = $phrase !== '' ? mb_stripos( $text, $phrase ) : false; if ( false === $pos ) { // Phrase not found (custom text): append the link. return esc_html( $text ) . ' ' . $link; } return esc_html( mb_substr( $text, 0, $pos ) ) . $link . esc_html( mb_substr( $text, $pos + mb_strlen( $phrase ) ) ); } /** Field labels + messages for the 7 All in Box languages. */ private function form_i18n() { return [ 'fr' => [ 'email' => 'E-mail', 'firstname' => 'Prénom', 'lastname' => 'Nom', 'phone' => 'Téléphone', 'birthdate' => 'Date de naissance', 'address' => 'Adresse', 'postal_code' => 'Code postal', 'country' => 'Pays', 'gender' => 'Civilité', 'gender_m' => 'Homme', 'gender_f' => 'Femme', 'consent' => "J'accepte de recevoir des e-mails et j'ai pris connaissance de la politique de confidentialité.", 'consent_track' => "J'accepte de recevoir des e-mails et le suivi de leur ouverture, et j'ai pris connaissance de la politique de confidentialité.", 'policy_phrase' => 'politique de confidentialité', 'button' => "Je m'inscris", 'success' => 'Merci, votre inscription est bien enregistrée.', 'error' => 'Une erreur est survenue, merci de réessayer.' ], 'en' => [ 'email' => 'Email', 'firstname' => 'First name', 'lastname' => 'Last name', 'phone' => 'Phone', 'birthdate' => 'Date of birth', 'address' => 'Address', 'postal_code' => 'Postal code', 'country' => 'Country', 'gender' => 'Title', 'gender_m' => 'Male', 'gender_f' => 'Female', 'consent' => 'I agree to receive emails and have read the privacy policy.', 'consent_track' => 'I agree to receive emails and the associated open tracking, and have read the privacy policy.', 'policy_phrase' => 'privacy policy', 'button' => 'Subscribe', 'success' => "Thanks, you're subscribed!", 'error' => 'Something went wrong, please try again.' ], 'es' => [ 'email' => 'Correo electrónico', 'firstname' => 'Nombre', 'lastname' => 'Apellido', 'phone' => 'Teléfono', 'birthdate' => 'Fecha de nacimiento', 'address' => 'Dirección', 'postal_code' => 'Código postal', 'country' => 'País', 'gender' => 'Tratamiento', 'gender_m' => 'Hombre', 'gender_f' => 'Mujer', 'consent' => 'Acepto recibir correos y he leído la política de privacidad.', 'consent_track' => 'Acepto recibir correos y el seguimiento de su apertura, y he leído la política de privacidad.', 'policy_phrase' => 'política de privacidad', 'button' => 'Suscribirme', 'success' => '¡Gracias, su suscripción se ha registrado!', 'error' => 'Se produjo un error, inténtelo de nuevo.' ], 'de' => [ 'email' => 'E-Mail', 'firstname' => 'Vorname', 'lastname' => 'Nachname', 'phone' => 'Telefon', 'birthdate' => 'Geburtsdatum', 'address' => 'Adresse', 'postal_code' => 'Postleitzahl', 'country' => 'Land', 'gender' => 'Anrede', 'gender_m' => 'Mann', 'gender_f' => 'Frau', 'consent' => 'Ich willige ein, E-Mails zu erhalten, und habe die Datenschutzerklärung gelesen.', 'consent_track' => 'Ich willige ein, E-Mails und das damit verbundene Öffnungs-Tracking zu erhalten, und habe die Datenschutzerklärung gelesen.', 'policy_phrase' => 'Datenschutzerklärung', 'button' => 'Abonnieren', 'success' => 'Danke, Ihre Anmeldung wurde gespeichert!', 'error' => 'Ein Fehler ist aufgetreten, bitte versuchen Sie es erneut.' ], 'it' => [ 'email' => 'Email', 'firstname' => 'Nome', 'lastname' => 'Cognome', 'phone' => 'Telefono', 'birthdate' => 'Data di nascita', 'address' => 'Indirizzo', 'postal_code' => 'Codice postale', 'country' => 'Paese', 'gender' => 'Titolo', 'gender_m' => 'Uomo', 'gender_f' => 'Donna', 'consent' => "Accetto di ricevere email e ho letto l'informativa sulla privacy.", 'consent_track' => "Accetto di ricevere email e il monitoraggio della loro apertura, e ho letto l'informativa sulla privacy.", 'policy_phrase' => 'informativa sulla privacy', 'button' => 'Iscriviti', 'success' => 'Grazie, la tua iscrizione è stata registrata!', 'error' => 'Si è verificato un errore, riprova.' ], 'nl' => [ 'email' => 'E-mail', 'firstname' => 'Voornaam', 'lastname' => 'Achternaam', 'phone' => 'Telefoon', 'birthdate' => 'Geboortedatum', 'address' => 'Adres', 'postal_code' => 'Postcode', 'country' => 'Land', 'gender' => 'Aanhef', 'gender_m' => 'Man', 'gender_f' => 'Vrouw', 'consent' => 'Ik ga akkoord met het ontvangen van e-mails en heb het privacybeleid gelezen.', 'consent_track' => 'Ik ga akkoord met het ontvangen van e-mails en de bijbehorende openingstracking, en heb het privacybeleid gelezen.', 'policy_phrase' => 'privacybeleid', 'button' => 'Inschrijven', 'success' => 'Bedankt, je inschrijving is geregistreerd!', 'error' => 'Er is iets misgegaan, probeer het opnieuw.' ], 'pt' => [ 'email' => 'E-mail', 'firstname' => 'Nome', 'lastname' => 'Apelido', 'phone' => 'Telefone', 'birthdate' => 'Data de nascimento', 'address' => 'Endereço', 'postal_code' => 'Código postal', 'country' => 'País', 'gender' => 'Tratamento', 'gender_m' => 'Homem', 'gender_f' => 'Mulher', 'consent' => 'Aceito receber e-mails e li a política de privacidade.', 'consent_track' => 'Aceito receber e-mails e o acompanhamento da sua abertura, e li a política de privacidade.', 'policy_phrase' => 'política de privacidade', 'button' => 'Subscrever', 'success' => 'Obrigado, a sua inscrição foi registada!', 'error' => 'Ocorreu um erro, tente novamente.' ], ]; } /* ===================================================================== * Web Push install * ================================================================== */ /** Exact body expected by All in Box for the service worker. */ private function sw_content( array $s ) { return 'importScripts("' . rtrim( $s['base_url'], '/' ) . '/sdk/webpush/v1/AIB.sw.js");'; } /** * Scope the worker has to cover, taken from the site address: "/" on an ordinary * install, "/grasse/" on a subdirectory multisite or a WordPress living in a subfolder. * A service worker cannot claim a scope above its own directory, so serving it next to * the pages it covers is what makes those installs work at all, and it keeps one * sub-site of a network from stealing the notifications of another. */ private function sw_scope() { $path = wp_parse_url( home_url( '/' ), PHP_URL_PATH ); return trailingslashit( ( is_string( $path ) && '' !== $path ) ? $path : '/' ); } /** Path the worker answers on. "/AIBSDKWorker.js" on an ordinary install. */ private function sw_request_path() { return $this->sw_scope() . 'AIBSDKWorker.js'; } /** Serves the service worker from PHP, so nothing has to be written to the filesystem. */ public function maybe_serve_service_worker() { if ( empty( $_SERVER['REQUEST_URI'] ) ) { return; } $path = wp_parse_url( esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ), PHP_URL_PATH ); if ( $this->sw_request_path() !== $path ) { return; } $s = $this->settings(); if ( empty( $s['webpush_token'] ) || empty( $s['base_url'] ) ) { return; // not configured → let WordPress handle it (404) } header( 'Content-Type: application/javascript; charset=UTF-8' ); header( 'Service-Worker-Allowed: ' . $this->sw_scope() ); nocache_headers(); echo $this->sw_content( $s ); // phpcs:ignore -- exact body required, no escaping exit; } /** Enqueues the SDK script in the page head on the front-end. */ public function webpush_script() { $s = $this->settings(); if ( empty( $s['webpush_token'] ) || empty( $s['base_url'] ) ) { return; } $handle = 'aib-lead-sync-webpush'; $attrs = [ 'data-aib-token' => $s['webpush_token'], 'data-aib-subscriber-id' => '', ]; // The SDK defaults to /AIBSDKWorker.js with scope "/". Off the root, tell it where // the worker actually is; on an ordinary install the markup stays untouched. $scope = $this->sw_scope(); if ( '/' !== $scope ) { $attrs['data-sw-path'] = $this->sw_request_path(); $attrs['data-sw-scope'] = $scope; } $attrs['charset'] = 'utf-8'; $attrs['defer'] = true; $this->sdk_attributes[ $handle ] = $attrs; wp_enqueue_script( $handle, rtrim( $s['base_url'], '/' ) . '/sdk/webpush/v1/AIB.sdk.js', [], self::VERSION, false ); } /** * Adds the data attributes the SDKs read from their own script tag. wp_enqueue_script() * cannot set arbitrary attributes, and "defer" only from WordPress 6.3 on. */ public function sdk_script_attributes( $tag, $handle ) { if ( empty( $this->sdk_attributes[ $handle ] ) ) { return $tag; } $attrs = ''; foreach ( $this->sdk_attributes[ $handle ] as $name => $value ) { $attrs .= true === $value ? ' ' . $name : ' ' . $name . '="' . esc_attr( $value ) . '"'; } return preg_replace( '/settings(); if ( empty( $s['chat_key'] ) || empty( $s['base_url'] ) ) { return; } $handle = 'aib-lead-sync-webchat'; $this->sdk_attributes[ $handle ] = [ 'data-aib-chatbot-key' => $s['chat_key'], 'defer' => true, ]; wp_enqueue_script( $handle, rtrim( $s['base_url'], '/' ) . '/sdk/chatbot/v1/AIB.sdk.js', [], self::VERSION, false ); } /** * Chatbot public keys are opaque (cbk_... today). We only strip what could break an * HTML attribute rather than pinning a format the app is free to change. */ private function sanitize_chat_key( $value ) { return substr( preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $value ), 0, 64 ); } /** * Asks the chatbot config endpoint, from this site, with this site's Origin: that * single call proves the key exists, the bot is published AND the domain is declared. * Returns [ 'ok' => bool, 'note' => string ]. */ private function webchat_status( array $s ) { $key = trim( (string) $s['chat_key'] ); $host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); if ( '' === $key || empty( $s['base_url'] ) ) { return [ 'ok' => false, 'note' => __( 'Enter the API URL and the chatbot key first.', 'all-in-box' ) ]; } $r = wp_remote_get( rtrim( $s['base_url'], '/' ) . '/api/chatbot/' . rawurlencode( $key ) . '/config', [ 'timeout' => 8, 'headers' => [ 'Origin' => home_url(), 'Accept' => 'application/json' ], ] ); if ( is_wp_error( $r ) ) { return [ 'ok' => false, 'note' => __( 'Could not reach All in Box. Check the API URL and that this site can make outbound HTTPS requests.', 'all-in-box' ) ]; } $code = (int) wp_remote_retrieve_response_code( $r ); $body = json_decode( wp_remote_retrieve_body( $r ), true ); $err = ( is_array( $body ) && ! empty( $body['error'] ) ) ? (string) $body['error'] : ''; if ( 200 === $code ) { $name = ( is_array( $body ) && ! empty( $body['name'] ) ) ? (string) $body['name'] : ''; return [ 'ok' => true, 'note' => $name !== '' /* translators: 1: chatbot name, 2: site host name. */ ? sprintf( __( 'Ready: "%1$s" answers on %2$s.', 'all-in-box' ), $name, $host ) /* translators: %s: site host name. */ : sprintf( __( 'Ready: the chatbot answers on %s.', 'all-in-box' ), $host ), ]; } if ( 'origin_not_allowed' === $err ) { /* translators: %s: site host name. */ return [ 'ok' => false, 'note' => sprintf( __( 'The chatbot refuses this site. Add %s to its "Your website" field in All in Box.', 'all-in-box' ), $host ) ]; } if ( 'chatbot_unavailable' === $err ) { return [ 'ok' => false, 'note' => __( 'Unknown key, or the chatbot is not published yet. Check the key and publish the chatbot in All in Box.', 'all-in-box' ) ]; } if ( 429 === $code ) { return [ 'ok' => false, 'note' => __( 'Too many checks in a row. Wait a minute, then try again.', 'all-in-box' ) ]; } /* translators: %d: HTTP status code. */ return [ 'ok' => false, 'note' => sprintf( __( 'Unexpected response from All in Box (HTTP %d). Check the API URL.', 'all-in-box' ), $code ) ]; } /** Re-runs the All in Box verification (script + worker) from the site itself. */ private function webpush_status( array $s ) { $home = home_url( '/' ); $script_ok = false; $worker_ok = false; $notes = []; $r = wp_remote_get( $home, [ 'timeout' => 6 ] ); if ( ! is_wp_error( $r ) && wp_remote_retrieve_response_code( $r ) < 400 ) { $html = wp_remote_retrieve_body( $r ); $src = rtrim( $s['base_url'], '/' ) . '/sdk/webpush/v1/AIB.sdk.js'; $script_ok = ( false !== strpos( $html, $src ) && false !== strpos( $html, 'data-aib-token="' . $s['webpush_token'] . '"' ) ); } else { $notes[] = 'Homepage unreachable for the script check.'; } $worker_url = rtrim( $home, '/' ) . '/AIBSDKWorker.js'; $r2 = wp_remote_get( $worker_url, [ 'timeout' => 6 ] ); if ( ! is_wp_error( $r2 ) && wp_remote_retrieve_response_code( $r2 ) < 400 ) { $worker_ok = ( trim( wp_remote_retrieve_body( $r2 ) ) === $this->sw_content( $s ) ); } else { $notes[] = 'AIBSDKWorker.js not reachable at the site root (a subdirectory install cannot serve it there).'; } return [ 'script_ok' => $script_ok, 'worker_ok' => $worker_ok, 'note' => implode( ' ', $notes ), ]; } /* ===================================================================== * Helpers * ================================================================== */ /** Builder name → slug used to qualify form IDs (e.g. "Gravity Forms" → "gravityforms"). */ private function builder_slug( $builder ) { return preg_replace( '/[^a-z0-9]/', '', strtolower( (string) $builder ) ); } /** Remembers the forms seen (max 30) to help with targeting. */ private function record_form( $builder, $id, $label ) { if ( '' === $id && '' === $label ) { return; } $seen = get_option( self::SEEN_FORMS, [] ); $key = $builder . ':' . $id; $seen[ $key ] = [ 'builder' => $builder, 'id' => $id, 'label' => $label, 'last' => current_time( 'mysql' ), ]; if ( count( $seen ) > 30 ) { $seen = array_slice( $seen, -30, null, true ); } update_option( self::SEEN_FORMS, $seen, false ); } /** * Resolves the newsletter subscription from the consent value. * true / false provided by the adapter take precedence; otherwise we look for a * consent checkbox; if none exists, the default value applies. */ private function resolve_consent( array $fields, $consent, $default ) { if ( true === $consent ) { return true; } if ( false === $consent ) { return false; } $detected = $this->detect_consent( $fields ); if ( true === $detected ) { return true; } if ( false === $detected ) { return false; } return (bool) $default; } /** Flattens array values (multiple checkboxes, etc.). */ private function flatten( array $fields ) { $out = []; foreach ( $fields as $k => $v ) { $out[ $k ] = is_array( $v ) ? implode( ', ', array_map( 'strval', $v ) ) : $v; } return $out; } /** First value that is a valid email. */ private function detect_email( array $fields ) { foreach ( $fields as $value ) { if ( is_string( $value ) && is_email( trim( $value ) ) ) { return trim( $value ); } } return null; } /** * Looks for a consent checkbox by keyword in the field name. * Returns true (checked), false (present but empty), null (none found). */ private function detect_consent( array $fields ) { $keywords = [ 'gdpr', 'rgpd', 'consent', 'consentement', 'accept', 'optin', 'opt-in', 'newsletter', 'privacy' ]; foreach ( $fields as $key => $value ) { $haystack = strtolower( (string) $key ); foreach ( $keywords as $kw ) { if ( false !== strpos( $haystack, $kw ) ) { return ! empty( $value ); } } } return null; } /** Normalizes a phone number (digits, +, spaces), or '' if not plausible. */ /** * Two-letter language of the current request: the current language of Polylang or * WPML, else the site language. Empty when it has no two-letter code. */ private function request_language() { $lang = function_exists( 'pll_current_language' ) ? (string) pll_current_language( 'slug' ) : ''; if ( '' === $lang ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML's own filter. $lang = (string) apply_filters( 'wpml_current_language', null ); } if ( '' === $lang ) { $lang = get_locale(); } return $this->language_code( $lang ); } /** * "fr_FR", "fr-FR" or "fr" give "fr". A language with no two-letter code ("ast", * "ckb") gives an empty string: the API refuses anything but two letters, and no * language is better than a wrong one. */ private function language_code( $value ) { $value = trim( (string) $value ); if ( ! preg_match( '/^([a-zA-Z]{2})(?:[-_]|$)/', $value, $m ) ) { return ''; } return strtolower( $m[1] ); } private function clean_phone( $value ) { $value = trim( (string) $value ); $clean = preg_replace( '/[^0-9+ ]/', '', $value ); $digits = preg_replace( '/\D/', '', $clean ); if ( strlen( $digits ) < 6 ) { return ''; } return substr( trim( $clean ), 0, 20 ); } /** * Finds the KEY of a field: forced name, otherwise keyword search in the key. * * @param array $fields Fields (key => value). * @param string $forced Forced field name (takes precedence). * @param array $keywords Keywords searched in the key. * @param array $exclude Keywords that disqualify a key. * @param string|null $skip_key Key to skip (e.g. the one already used by the first name). * @return string|null */ private function find_field_key( array $fields, $forced, array $keywords, array $exclude = [], $skip_key = null ) { if ( $forced && isset( $fields[ $forced ] ) && '' !== trim( (string) $fields[ $forced ] ) ) { return $forced; } foreach ( $fields as $key => $value ) { if ( null !== $skip_key && $key === $skip_key ) { continue; } if ( ! is_string( $value ) || '' === trim( $value ) ) { continue; } $haystack = strtolower( (string) $key ); foreach ( $exclude as $ex ) { if ( false !== strpos( $haystack, $ex ) ) { continue 2; } } foreach ( $keywords as $kw ) { if ( false !== strpos( $haystack, $kw ) ) { return $key; } } } return null; } } endif; new AIB_Lead_Sync();