normalized config. */ const OPTION_CF7 = 'joryio_cf7_forms'; /** The workspace's lists, cached briefly for the dropdowns. */ const LISTS_TRANSIENT = 'joryio_subscription_lists'; const LISTS_TTL = 300; /** * The workspace's attribute names for the mapping picker (1.6.37), cached * briefly; the "Refresh from Joryio" link / button reads them again. */ const ATTRIBUTES_TRANSIENT = 'joryio_form_attributes'; const ATTRIBUTES_TTL = 600; /** The plugin's background sender (class-joryio-events.php). */ const SEND_HOOK = 'joryio_send_event'; const EVENT_TYPE = 'form_submission'; /** * 1.6.41: signup forms (no box) that subscribe nobody, for the admin * notices. Key ("cf7:" or "elementor::") => * array('source', 'form_id', 'form_name', 'post_id', 'reason'): * - reason 'text_missing': the subscribe switch is on but the consent * text is empty. Written when the form is saved or submitted that * way, removed when it has a text or the switch is off. Its notice * cannot be dismissed (scope: the site; until fixed). * - reason 'switched_off': the 1.6.41 update turned the switch OFF on a * signup form that used to subscribe Email without any consent text. * Removed when the switch is turned on (or the form leaves signup * mode / is turned off). Its notice can be dismissed per user * (Joryio::DISMISSIBLE_NOTICES, user meta - for good). */ const OPTION_SIGNUP_TEXT_GAPS = 'joryio_forms_signup_text_gaps'; /** At most this many forms are remembered for the notice. */ const MAX_SIGNUP_TEXT_GAPS = 50; /** Set once the update moved the saved forms to the 1.6.41 signup rule. */ const OPTION_SIGNUP_RULE = 'joryio_forms_signup_rule'; /** Set once the Elementor action is registered, so the legacy path never adds it twice. */ private static $elementor_registered = false; public function __construct() { if (self::cf7_active()) { // Fires only after Contact Form 7 accepted the submission and sent its mail. add_action('wpcf7_mail_sent', array($this, 'on_cf7_mail_sent'), 10, 1); } add_action('admin_post_joryio_save_forms', array($this, 'save_cf7_settings')); add_action('admin_post_joryio_refresh_forms_data', array($this, 'refresh_forms_data')); // 1.6.37: the Elementor panel's attribute picker and field suggestions. add_action('wp_ajax_joryio_form_attributes', array($this, 'ajax_form_attributes')); add_action('elementor/editor/after_enqueue_scripts', array($this, 'enqueue_elementor_editor')); // The Joryio action's field IDs and attribute rows are stored sanitized. add_filter('elementor/document/save/data', array($this, 'sanitize_elementor_save'), 10, 2); // 1.6.41: signup forms with no consent text subscribe nobody - named in a notice. if (self::signup_text_gaps()) { add_action('admin_notices', array($this, 'signup_text_gap_notice')); } // Elementor Pro 3.5+ registers form actions through this hook... add_action('elementor_pro/forms/actions/register', array($this, 'register_elementor_action')); // ...older versions through the forms module. add_action('elementor_pro/init', array($this, 'register_elementor_action_legacy')); } /** * @return bool */ public static function cf7_active() { return class_exists('WPCF7_ContactForm'); } /** * @return bool */ public static function elementor_pro_active() { return defined('ELEMENTOR_PRO_VERSION'); } // ---- 1.6.41: a signup form subscribes only with its consent text ---------- /** * @return array OPTION_SIGNUP_TEXT_GAPS, or an empty array. */ public static function signup_text_gaps() { $gaps = get_option(self::OPTION_SIGNUP_TEXT_GAPS, array()); return is_array($gaps) ? $gaps : array(); } /** * Replace the entries a filter selects with $add (key => entry); the * option is written only when it changes, and deleted when it empties. * * @param callable|null $drop function ($key, $entry): bool - true drops the entry * @param array $add */ private static function update_signup_text_gaps($drop, array $add) { $before = self::signup_text_gaps(); $after = array(); foreach ($before as $key => $entry) { if ($drop === null || !call_user_func($drop, (string) $key, $entry)) { $after[$key] = $entry; } } foreach ($add as $key => $entry) { $after[$key] = $entry; } if (count($after) > self::MAX_SIGNUP_TEXT_GAPS) { $after = array_slice($after, -self::MAX_SIGNUP_TEXT_GAPS, null, true); } if ($after === $before) { return; } if ($after) { update_option(self::OPTION_SIGNUP_TEXT_GAPS, $after, true); } else { delete_option(self::OPTION_SIGNUP_TEXT_GAPS); } } /** * The entries of one reason. * * @param string $reason 'text_missing' or 'switched_off' * @return array */ public static function signup_gaps_of($reason) { $out = array(); foreach (self::signup_text_gaps() as $key => $entry) { $r = isset($entry['reason']) ? $entry['reason'] : 'text_missing'; if ($r === $reason) { $out[$key] = $entry; } } return $out; } /** * A submission's check: a signup form with the switch on and no text * subscribed nobody - log it and remember it for the notice; a form the * update switched off - log it while it stays off; otherwise forget it. * * @param string $key * @param array $config normalize_config output * @param array $entry array('source', 'form_id', 'form_name', 'post_id') */ public static function check_signup_text_gap($key, array $config, array $entry) { $gaps = self::signup_text_gaps(); $known = isset($gaps[$key]) ? (isset($gaps[$key]['reason']) ? $gaps[$key]['reason'] : 'text_missing') : null; $label = ($entry['source'] === 'elementor' ? 'Elementor' : 'Contact Form 7') . ' signup form "' . ($entry['form_name'] !== '' ? $entry['form_name'] : $entry['form_id']) . '"'; $signup_off = isset($config['mode']) && $config['mode'] === Joryio_Forms_Payload::MODE_SIGNUP && empty($config['signup_subscribe']); if (Joryio_Forms_Payload::signup_text_missing($config)) { joryio()->log($label . ': nobody was subscribed on any channel (Email included) - "Subscribe visitors when they submit this form" is on but "Consent text shown on this form" is empty. The contact was saved and the Form Submitted event recorded. Add the exact sentence the form shows.', 'warning'); if ($known !== 'text_missing') { $entry['reason'] = 'text_missing'; self::update_signup_text_gaps(null, array($key => $entry)); } return; } if ($known === 'switched_off' && $signup_off) { joryio()->log($label . ': nobody was subscribed - the 1.6.41 update turned "Subscribe visitors when they submit this form" off because the form shows no consent text (before, it subscribed Email on every submission). Turn it on and add the consent text the form shows.', 'warning'); return; } if ($known !== null) { self::update_signup_text_gaps(function ($k) use ($key) { return $k === $key; }, array()); } } /** * admin_notices: the signup forms whose switch is on with no consent * text (not dismissible), and the forms the update switched off * (dismissible per user). Plugins screen and the Joryio page only * (show_notice_here), for the users who can fix them. */ public function signup_text_gap_notice() { $names = function (array $gaps) { $out = array(); foreach ($gaps as $entry) { $source = isset($entry['source']) && $entry['source'] === 'elementor' ? 'Elementor' : 'Contact Form 7'; $name = isset($entry['form_name']) && $entry['form_name'] !== '' ? $entry['form_name'] : (isset($entry['form_id']) ? $entry['form_id'] : ''); $out[] = $source . ': ' . $name; } return implode(', ', $out); }; $missing = self::signup_gaps_of('text_missing'); if ($missing && joryio()->show_notice_here()) { ?>

" or "Elementor:

", comma separated */ echo esc_html(sprintf(__('Joryio: these signup forms are set to subscribe visitors but have no consent text, so they subscribe nobody on any channel, email included (contacts are still saved): %s. Add the exact sentence each form shows under "Consent text shown on this form (required)".', 'joryio'), $names($missing))); ?>

show_notice_here('signup_switched_off')) { ?>

" or "Elementor: ", comma separated */ echo esc_html(sprintf(__('Joryio 1.6.41: signup forms now need the consent text to subscribe on any channel, email included. These signup forms subscribed visitors by email before but show no consent text, so they are not subscribing anyone now (contacts are still saved): %s. To subscribe again, turn on "Subscribe visitors when they submit this form" and add the consent text the form shows.', 'joryio'), $names($off))); ?>

entry, the forms with the switch on and no text * @param array $off_keys the covered forms still in signup mode with the switch off */ private static function refresh_signup_gaps($mine, array $missing, array $off_keys) { $add = array(); foreach ($missing as $key => $entry) { $entry['reason'] = 'text_missing'; $add[$key] = $entry; } foreach (self::signup_gaps_of('switched_off') as $key => $entry) { if (call_user_func($mine, (string) $key) && in_array((string) $key, $off_keys, true)) { $add[$key] = $entry; } } self::update_signup_text_gaps($mine, $add); } /** * 1.6.41, once (OPTION_SIGNUP_RULE), from Joryio::maybe_upgrade: the * saved Contact Form 7 and Elementor forms move to the one signup rule * (Joryio_Forms_Payload::migrate_signup_config says how), and every * signup form without a consent text is listed for the notice at once - * before its first submission stops subscribing. */ public static function migrate_signup_rule() { if (get_option(self::OPTION_SIGNUP_RULE) === '1.6.41') { return; } $gaps = array(); $cf7 = get_option(self::OPTION_CF7, array()); if (is_array($cf7) && $cf7) { foreach ($cf7 as $form_id => $raw) { if (!is_array($raw)) { continue; } $cf7[$form_id] = Joryio_Forms_Payload::migrate_signup_config($raw); $config = Joryio_Forms_Payload::normalize_config($cf7[$form_id]); if (!$config['enabled'] || $config['mode'] !== Joryio_Forms_Payload::MODE_SIGNUP) { continue; } $reason = !$config['signup_subscribe'] ? 'switched_off' : (Joryio_Forms_Payload::signup_text_missing($config) ? 'text_missing' : ''); if ($reason !== '') { $name = function_exists('get_the_title') ? (string) get_the_title((int) $form_id) : ''; $gaps['cf7:' . $form_id] = array('source' => 'cf7', 'form_id' => (string) $form_id, 'form_name' => $name, 'post_id' => (int) $form_id, 'reason' => $reason); } } update_option(self::OPTION_CF7, $cf7, false); } global $wpdb; if (isset($wpdb) && is_object($wpdb) && method_exists($wpdb, 'get_col') && function_exists('get_post_meta')) { try { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- once, on the 1.6.41 update: the pages holding a Joryio form (no WordPress API searches post meta values); nothing to cache $ids = $wpdb->get_col($wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value LIKE %s", '_elementor_data', '%' . $wpdb->esc_like('joryio_mode') . '%' )); foreach ((array) $ids as $post_id) { $post_id = (int) $post_id; $raw = get_post_meta($post_id, '_elementor_data', true); $data = is_string($raw) ? json_decode($raw, true) : (is_array($raw) ? $raw : null); if (!is_array($data)) { continue; } $changed = false; $data = Joryio_Forms_Payload::migrate_elementor_elements($data, $changed); if ($changed) { update_post_meta($post_id, '_elementor_data', wp_slash(wp_json_encode($data))); } foreach (Joryio_Forms_Payload::elementor_signup_text_gaps($data) as $gap) { $gaps['elementor:' . $post_id . ':' . $gap['id']] = array('source' => 'elementor', 'form_id' => $gap['id'], 'form_name' => $gap['name'], 'post_id' => $post_id, 'reason' => 'text_missing'); } foreach (Joryio_Forms_Payload::elementor_signup_off_forms($data) as $gap) { $gaps['elementor:' . $post_id . ':' . $gap['id']] = array('source' => 'elementor', 'form_id' => $gap['id'], 'form_name' => $gap['name'], 'post_id' => $post_id, 'reason' => 'switched_off'); } } } catch (\Throwable $e) { joryio()->log('Elementor forms: the 1.6.41 signup-rule update failed: ' . $e->getMessage(), 'error'); } } if ($gaps) { self::update_signup_text_gaps(null, $gaps); } update_option(self::OPTION_SIGNUP_RULE, '1.6.41', true); } /** * The help under "Consent subscribes to channels" (Elementor and Contact * Form 7 show the same words). * * @return string */ public static function channels_help() { return __('One setting for both kinds of form, kept when the form changes mode. With a consent checkbox: a ticked consent box subscribes the contact on these channels; the box\'s wording is recorded as proof. A signup form (no checkbox): the submission does, with "Subscribe visitors when they submit this form" on and the consent text below. SMS, WhatsApp and Viber also need a phone field. With a list, the list\'s own channels apply. Someone who unsubscribed: see the option below. Web push is not offered: browsers ask for it themselves.', 'joryio'); } /** * @return string the help under the signup subscribe switch (1.6.41) */ public static function signup_switch_help() { return __('Use only if the form shows a sentence telling visitors they agree to receive messages. Off (the default): the form saves the contact but subscribes nobody.', 'joryio'); } /** * @return string the help under the consent text box (1.6.41: one rule) */ public static function consent_text_help() { return __('The exact sentence visitors see on the form, for example "By submitting you agree to receive news by email and SMS". Without it nobody is subscribed. One rule for every channel, email included: the text is the proof of what the visitor saw and agreed to, recorded with each subscription as form text (not a ticked box). SMS, WhatsApp and Viber also need a phone field. Text-only consent is weaker than a ticked box - check the wording with your lawyer (in Israel, section 30A of the Communications Law for SMS; Meta\'s WhatsApp opt-in policy). Never used to re-subscribe someone who unsubscribed. At most 500 characters.', 'joryio'); } /** * @return string the warning while a signup form has no consent text */ public static function missing_text_warning() { return __('The consent text is empty, so this form subscribes nobody on any channel, email included (contacts are still saved). Enter the exact sentence the form shows.', 'joryio'); } // ---- sending ------------------------------------------------------------ /** * Queue one submission for the background sender. Never blocks: no remote * call happens in the visitor's request. * * @param array $payload Joryio_Forms_Payload::build output * @return bool queued */ public static function enqueue(array $payload) { if (!joryio()->is_connected()) { return false; } wp_schedule_single_event(time(), self::SEND_HOOK, array(self::EVENT_TYPE, $payload)); joryio()->log(sprintf( 'Queued %s form %s submission (%s)', $payload['source'], $payload['formId'], isset($payload['email']) ? Joryio_Forms_Payload::mask_email($payload['email']) : 'phone only' ), 'info'); return true; } /** * A fresh idempotency key: one per submission, kept by every retry. * * @return string */ public static function new_idempotency_key() { return 'wpf-' . wp_generate_uuid4(); } /** * The signed-in customer's proof for one submission (1.6.34, D337), or * null: no logged-in user, a request that did not come from this site * (Joryio_Forms_Payload::same_site_request), or nothing to sign with. * Signed here, in the submitting request, for get_current_user_id() as * WordPress resolved it (a Contact Form 7 REST submission without the * REST nonce is a logged-out request - then no proof). Bound to the * submission's idempotency key, so it is good for this submission and * its retries only. Joryio lets the form's phone replace the contact's * phone only when the proof names the contact the form's email matched. * * @param string $idempotency_key * @return string|null */ public static function submitter_proof($idempotency_key) { if (!function_exists('is_user_logged_in') || !is_user_logged_in()) { return null; } $origin = isset($_SERVER['HTTP_ORIGIN']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_ORIGIN'])) : ''; $referer = isset($_SERVER['HTTP_REFERER']) ? esc_url_raw(wp_unslash($_SERVER['HTTP_REFERER'])) : ''; if (!Joryio_Forms_Payload::same_site_request($origin, $referer, home_url('/'))) { return null; } return Joryio_Customer_Proof::issue(joryio()->get_api_key(), 'woocommerce', get_current_user_id(), $idempotency_key, time()); } // ---- lists ---------------------------------------------------------------- /** * The workspace's subscription lists (id, name, requiresDoubleOptIn, * channel), cached for five minutes. Admin screens only. * * @param bool $refresh skip the cache * @return array|WP_Error */ public static function get_lists($refresh = false) { if (!joryio()->is_connected()) { return array(); } if (!$refresh) { $cached = get_transient(self::LISTS_TRANSIENT); if (is_array($cached)) { return $cached; } } $response = wp_remote_get(joryio()->get_api_endpoint() . '/api/woocommerce/plugin/lists', array( 'timeout' => 5, 'headers' => array('X-Joryio-Api-Key' => joryio()->get_api_key()), )); if (is_wp_error($response)) { return $response; } if (wp_remote_retrieve_response_code($response) !== 200) { return new WP_Error('joryio_lists', __('Joryio did not return your lists.', 'joryio')); } $body = json_decode(wp_remote_retrieve_body($response), true); $lists = array(); foreach ((isset($body['lists']) && is_array($body['lists'])) ? $body['lists'] : array() as $l) { if (!is_array($l) || !Joryio_Forms_Payload::is_uuid(isset($l['id']) ? $l['id'] : null)) { continue; } $lists[] = array( 'id' => strtolower($l['id']), 'name' => sanitize_text_field(isset($l['name']) ? $l['name'] : ''), 'requiresDoubleOptIn' => !empty($l['requiresDoubleOptIn']), 'channel' => sanitize_key(isset($l['channel']) ? $l['channel'] : 'email'), // 1.6.38: every channel the list covers (an older Joryio sends only `channel`). 'channels' => self::list_channels($l), ); } set_transient(self::LISTS_TRANSIENT, $lists, self::LISTS_TTL); return $lists; } /** * A list's channels from Joryio's answer (`channels`, else `channel`). * * @param array $l * @return array */ private static function list_channels(array $l) { $raw = isset($l['channels']) && is_array($l['channels']) ? $l['channels'] : array(isset($l['channel']) ? $l['channel'] : 'email'); $out = array(); foreach ($raw as $c) { $c = is_scalar($c) ? sanitize_key((string) $c) : ''; if ($c !== '' && !in_array($c, $out, true)) { $out[] = $c; } } return $out ? $out : array('email'); } /** * Channel labels for the form settings. * * @return array */ public static function channel_labels() { return array( 'email' => __('Email', 'joryio'), 'sms' => __('SMS', 'joryio'), 'whatsapp' => __('WhatsApp', 'joryio'), 'viber' => __('Viber', 'joryio'), 'push' => __('Web push', 'joryio'), ); } /** * "This list covers: Email, SMS" for each list (id => text), from the * same cached lists as the dropdown, so the two never disagree. * * @return array */ public static function list_covers() { $lists = self::get_lists(); $labels = self::channel_labels(); $out = array(); if (is_wp_error($lists)) { return $out; } foreach ($lists as $l) { $names = array(); foreach (isset($l['channels']) ? $l['channels'] : array($l['channel']) as $c) { $names[] = isset($labels[$c]) ? $labels[$c] : $c; } /* translators: %s: the list's channels, comma separated (Email, SMS) */ $out[$l['id']] = sprintf(__('This list covers: %s. A ticked consent (or a signup form) subscribes the contact on these channels, as the list is set up in Joryio.', 'joryio'), implode(', ', $names)); } return $out; } /** * Dropdown options: '' => "No list", id => "Name (double opt-in)". * * @return array */ public static function list_options() { $options = array('' => __('No list - only save the contact', 'joryio')); $lists = self::get_lists(); if (is_wp_error($lists)) { return $options; } foreach ($lists as $l) { $options[$l['id']] = $l['requiresDoubleOptIn'] /* translators: %s: list name */ ? sprintf(__('%s (double opt-in)', 'joryio'), $l['name']) : $l['name']; } return $options; } // ---- attributes (the mapping picker, 1.6.37) ------------------------------- /** * The workspace's attribute names a form may write (name, type), cached * for ten minutes. Admin screens and the Elementor editor only; never in * a visitor's request. A WP_Error when Joryio cannot be reached - the * picker then is a plain text box. * * @param bool $refresh skip the cache * @return array|WP_Error */ public static function get_attributes($refresh = false) { if (!joryio()->is_connected()) { return array(); } if (!$refresh) { $cached = get_transient(self::ATTRIBUTES_TRANSIENT); if (is_array($cached)) { return $cached; } } $response = wp_remote_get(joryio()->get_api_endpoint() . '/api/woocommerce/plugin/attributes', array( 'timeout' => 5, 'headers' => array('X-Joryio-Api-Key' => joryio()->get_api_key()), )); if (is_wp_error($response)) { return $response; } if (wp_remote_retrieve_response_code($response) !== 200) { return new WP_Error('joryio_attributes', __('Joryio did not return your attributes.', 'joryio')); } $attributes = Joryio_Forms_Payload::parse_attribute_list(json_decode(wp_remote_retrieve_body($response), true)); set_transient(self::ATTRIBUTES_TRANSIENT, $attributes, self::ATTRIBUTES_TTL); return $attributes; } /** * The picker's options: attribute name => "name (type)". * * @return array */ public static function attribute_options() { $attributes = self::get_attributes(); $out = array(); if (is_wp_error($attributes)) { return $out; } foreach ($attributes as $a) { $out[$a['name']] = $a['name'] . ' (' . $a['type'] . ')'; } return $out; } /** * admin-post: read the lists and attributes from Joryio again (manage_options + nonce). */ public function refresh_forms_data() { if (!current_user_can('manage_options')) { wp_die(esc_html__('You do not have permission to change these settings.', 'joryio'), '', array('response' => 403)); } check_admin_referer('joryio_refresh_forms_data'); delete_transient(self::LISTS_TRANSIENT); delete_transient(self::ATTRIBUTES_TRANSIENT); wp_safe_redirect(add_query_arg('joryio_forms_refreshed', '1', joryio()->settings_url('forms'))); exit; } /** * admin-ajax (Elementor panel "Refresh from Joryio"): the attribute list, * read again. For anyone who can edit with Elementor; names and types only. */ public function ajax_form_attributes() { check_ajax_referer('joryio_elementor_forms', 'nonce'); if (!current_user_can('edit_posts')) { wp_send_json_error(array('message' => __('You do not have permission to do this.', 'joryio')), 403); } $attributes = self::get_attributes(true); if (is_wp_error($attributes)) { wp_send_json_error(array('message' => __('Your Joryio attributes could not be loaded. Type the attribute name instead.', 'joryio'))); } wp_send_json_success(array('attributes' => $attributes)); } /** * elementor/document/save/data: the Joryio action's settings of every * form widget, sanitized before Elementor stores them. * * 1.6.41: also refreshes this document's entries of the "no consent * text" notice (signup_text_gaps) from the forms being saved. * * @param mixed $data * @param object|null $document \Elementor\Core\Base\Document * @return mixed */ public function sanitize_elementor_save($data, $document = null) { if (is_array($data) && isset($data['elements']) && is_array($data['elements'])) { $data['elements'] = Joryio_Forms_Payload::sanitize_elementor_elements($data['elements']); $post_id = is_object($document) && method_exists($document, 'get_main_id') ? (int) $document->get_main_id() : 0; if ($post_id > 0) { $gaps = array(); foreach (Joryio_Forms_Payload::elementor_signup_text_gaps($data['elements']) as $gap) { $gaps['elementor:' . $post_id . ':' . $gap['id']] = array('source' => 'elementor', 'form_id' => $gap['id'], 'form_name' => $gap['name'], 'post_id' => $post_id); } $off = array(); foreach (Joryio_Forms_Payload::elementor_signup_off_forms($data['elements']) as $gap) { $off[] = 'elementor:' . $post_id . ':' . $gap['id']; } self::refresh_signup_gaps(function ($key) use ($post_id) { return strpos($key, 'elementor:' . $post_id . ':') === 0; }, $gaps, $off); } } return $data; } /** * The Elementor editor: the script that lists the form's own fields and * Joryio's attributes beside the Joryio action's text boxes. */ public function enqueue_elementor_editor() { if (!self::elementor_pro_active()) { return; } wp_enqueue_script( 'joryio-elementor-forms', JORYIO_PLUGIN_URL . 'assets/js/elementor-forms.js', array('jquery'), JORYIO_VERSION, true ); $attributes = self::get_attributes(); wp_localize_script('joryio-elementor-forms', 'joryioElementorForms', array( 'ajaxUrl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('joryio_elementor_forms'), 'attributes' => is_wp_error($attributes) ? array() : $attributes, 'attributesError' => is_wp_error($attributes), 'patterns' => Joryio_Forms_Payload::SUGGEST_LABEL_PATTERNS, 'strings' => array( 'suggested' => __('Suggested fields filled in. Check them, then click Update to save.', 'joryio'), 'nothingToSuggest' => __('No empty field to suggest for.', 'joryio'), 'refreshed' => __('Attributes refreshed from Joryio.', 'joryio'), 'refreshFailed' => __('Your Joryio attributes could not be loaded. Type the attribute name instead.', 'joryio'), ), )); } // ---- Contact Form 7 --------------------------------------------------------- /** * Every Contact Form 7 form (id => title). * * @return array */ public static function cf7_forms() { $posts = get_posts(array( 'post_type' => 'wpcf7_contact_form', 'post_status' => 'publish', 'numberposts' => 200, // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_numberposts -- admin settings page only: the site's Contact Form 7 forms 'orderby' => 'title', 'order' => 'ASC', )); $forms = array(); foreach ($posts as $post) { $forms[(int) $post->ID] = $post->post_title; } return $forms; } /** * One Contact Form 7 form's fields and acceptance tags. * * @param int $form_id * @return array array('fields' => name => type, 'acceptance' => names) */ public static function cf7_form_fields($form_id) { if (!self::cf7_active()) { return array('fields' => array(), 'acceptance' => array()); } $form = WPCF7_ContactForm::get_instance($form_id); if (!$form) { return array('fields' => array(), 'acceptance' => array()); } return Joryio_Forms_Payload::cf7_fields($form->scan_form_tags()); } /** * The saved config of one Contact Form 7 form. * * @param int $form_id * @return array */ public static function cf7_config($form_id) { $all = get_option(self::OPTION_CF7, array()); return Joryio_Forms_Payload::normalize_config(is_array($all) && isset($all[$form_id]) ? $all[$form_id] : array()); } /** * wpcf7_mail_sent: queue the submission of a mapped, enabled form. * * @param WPCF7_ContactForm $contact_form */ public function on_cf7_mail_sent($contact_form) { try { if (!is_object($contact_form) || !method_exists($contact_form, 'id') || !class_exists('WPCF7_Submission')) { return; } $form_id = (int) $contact_form->id(); $config = self::cf7_config($form_id); if (!$config['enabled']) { return; } $submission = WPCF7_Submission::get_instance(); if (!$submission) { return; } // The consent field's visible text (its acceptance tag's content), read from the form itself. $consent_text = ''; if ($config['consent_field'] !== '' && method_exists($contact_form, 'scan_form_tags')) { $tag = Joryio_Forms_Payload::cf7_find_tag((array) $contact_form->scan_form_tags(), $config['consent_field']); $consent_text = $tag !== null ? Joryio_Forms_Payload::cf7_consent_text($tag) : ''; } $idempotency_key = self::new_idempotency_key(); $payload = Joryio_Forms_Payload::build(array( 'source' => 'cf7', 'form_id' => (string) $form_id, 'form_name' => $contact_form->title(), 'page_url' => esc_url_raw((string) $submission->get_meta('url')), 'values' => (array) $submission->get_posted_data(), 'config' => $config, 'idempotency_key' => $idempotency_key, 'submitted_at' => gmdate('c'), 'consent_text' => $consent_text, 'shopper_ip' => Joryio_Consent_Limiter::shopper_ip(), // 1.6.34: a signed-in customer may update their phone (D337). 'customer_proof' => self::submitter_proof($idempotency_key), )); if ($payload === null) { joryio()->log("Contact Form 7 form $form_id: no mapped email or phone in the submission, nothing sent", 'warning'); return; } self::enqueue($payload); self::check_signup_text_gap('cf7:' . $form_id, $config, array( 'source' => 'cf7', 'form_id' => (string) $form_id, 'form_name' => (string) $contact_form->title(), 'post_id' => $form_id, )); } catch (\Throwable $e) { // The visitor's submission already succeeded; never break it. joryio()->log('Contact Form 7 hand-off failed: ' . $e->getMessage(), 'error'); } } /** * admin-post: save the Contact Form 7 mappings (manage_options + nonce). */ public function save_cf7_settings() { if (!current_user_can('manage_options')) { wp_die(esc_html__('You do not have permission to change these settings.', 'joryio'), '', array('response' => 403)); } check_admin_referer('joryio_save_forms', 'joryio_forms_nonce'); $posted = isset($_POST['joryio_forms']) && is_array($_POST['joryio_forms']) ? wp_unslash($_POST['joryio_forms']) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every field is sanitized one by one below $known = self::cf7_forms(); $saved = array(); $refused = array(); foreach ($posted as $form_id => $raw) { $form_id = absint($form_id); if (!$form_id || !isset($known[$form_id]) || !is_array($raw)) { continue; } $fields = self::cf7_form_fields($form_id); $clean = array( 'enabled' => !empty($raw['enabled']), 'list_id' => sanitize_text_field(isset($raw['list_id']) ? $raw['list_id'] : ''), 'mode' => sanitize_key(isset($raw['mode']) ? $raw['mode'] : ''), 'consent_field' => sanitize_text_field(isset($raw['consent_field']) ? $raw['consent_field'] : ''), 'channels' => isset($raw['channels']) && is_array($raw['channels']) ? array_map('sanitize_key', $raw['channels']) : array(), 'confirm_resubscribe' => !empty($raw['confirm_resubscribe']), 'signup_subscribe' => !empty($raw['signup_subscribe']), 'fixed_consent_text' => sanitize_textarea_field(isset($raw['fixed_consent_text']) ? $raw['fixed_consent_text'] : ''), 'mapping' => array(), ); if (isset($raw['map']) && is_array($raw['map'])) { foreach ($raw['map'] as $field => $map) { if (!is_array($map)) { continue; } $clean['mapping'][sanitize_text_field($field)] = array( 'target' => sanitize_key(isset($map['target']) ? $map['target'] : 'ignore'), 'key' => sanitize_text_field(isset($map['key']) ? $map['key'] : ''), ); } } // The keys were just typed: the 1.6.36 key rule applies (case kept, // a standard field routed to its target, a Joryio-managed key refused). $saved[$form_id] = Joryio_Forms_Payload::normalize_config($clean, array_keys($fields['fields']), $fields['acceptance'], true, $refused); } update_option(self::OPTION_CF7, $saved, false); delete_transient(self::LISTS_TRANSIENT); // 1.6.41: the notices follow what was just saved. $gaps = array(); $off = array(); foreach ($saved as $form_id => $config) { if (!$config['enabled'] || $config['mode'] !== Joryio_Forms_Payload::MODE_SIGNUP) { continue; } if (Joryio_Forms_Payload::signup_text_missing($config)) { $gaps['cf7:' . $form_id] = array('source' => 'cf7', 'form_id' => (string) $form_id, 'form_name' => (string) $known[$form_id], 'post_id' => (int) $form_id); } elseif (!$config['signup_subscribe']) { $off[] = 'cf7:' . $form_id; } } self::refresh_signup_gaps(function ($key) { return strpos($key, 'cf7:') === 0; }, $gaps, $off); $args = array('joryio_forms_saved' => '1'); if ($refused) { // Shown once on the redirect; every key already matched ATTRIBUTE_KEY_PATTERN or starts with $. $args['joryio_forms_refused'] = rawurlencode(implode(',', array_slice(array_unique($refused), 0, 10))); } wp_safe_redirect(add_query_arg($args, joryio()->settings_url('forms'))); exit; } /** * The "Forms" card on the settings page. */ public function render_settings_section() { $cf7 = self::cf7_active(); $elementor = self::elementor_pro_active(); ?>

render_cf7_settings(); endif; ?>
__('Do not send', 'joryio'), 'email' => __('Email', 'joryio'), 'phone' => __('Phone', 'joryio'), 'first_name' => __('First name', 'joryio'), 'last_name' => __('Last name', 'joryio'), 'attribute' => __('Custom attribute', 'joryio'), ); ?>

$label): ?>

$title): $config = self::cf7_config($form_id); $fields = self::cf7_form_fields($form_id); $base = 'joryio_forms[' . $form_id . ']'; ?>
>

#


> $text): ?>

>

>

$type): if ($type === 'acceptance') { continue; } $map = isset($config['mapping'][$name]) ? $config['mapping'][$name] : array('target' => 'ignore'); $hint = isset($fields['hints'][$name]) ? $fields['hints'][$name] : ''; // A suggestion only for a field not mapped yet; the merchant applies it and saves. $suggest = $map['target'] === 'ignore' ? Joryio_Forms_Payload::suggest_target($type, $hint !== '' ? $hint . ' ' . $name : $name) : ''; ?>

Attributes - it is case-sensitive (vipTier and viptier are two attributes). Other text becomes a lower case key (Company name becomes company_name). Email, phone, firstName and lastName map the field to the contact\'s own field.', 'joryio'); ?>

register($action); self::$elementor_registered = true; } } /** * Elementor Pro before 3.5: add the action to the forms module directly. */ public function register_elementor_action_legacy() { if (self::$elementor_registered || !defined('ELEMENTOR_PRO_VERSION') || version_compare(ELEMENTOR_PRO_VERSION, '3.5.0', '>=')) { return; } if (!class_exists('\ElementorPro\Plugin')) { return; } $module = \ElementorPro\Plugin::instance()->modules_manager->get_modules('forms'); $action = self::make_elementor_action(); if ($module && $action && method_exists($module, 'add_form_action')) { $module->add_form_action($action->get_name(), $action); self::$elementor_registered = true; } } /** * The action instance, or null when Elementor Pro's base class is missing. * * @return object|null */ private static function make_elementor_action() { if (!class_exists('\ElementorPro\Modules\Forms\Classes\Action_Base')) { return null; } require_once JORYIO_PLUGIN_DIR . 'includes/class-joryio-elementor-action.php'; return new Joryio_Elementor_Action(); } }