array( 'modules' ), 'tags' => array( 'tag_ids', 'video_tracking', 'verification' ), 'consent' => array( 'consent_mode' ), 'logs' => array( 'debug_log' ), ); /** * The Settings API page slug that holds one tab's sections. * * @param string $tab Tab key. * @return string */ public static function page_for( $tab ) { return 'overview' === $tab ? self::PAGE : self::PAGE . '-' . $tab; } /** * The stored keys a submission may change. * * A form from this version names its tab in `_tab`. A submission with no * tab (an older form, or a script) is treated as the whole screen, as in * 1.0.0. A tab this screen does not know changes nothing. * * @param array $input Raw submission. * @return string[] */ public static function submitted_keys( $input ) { if ( ! is_array( $input ) || ! isset( $input['_tab'] ) ) { return array( 'modules', 'tag_ids', 'video_tracking', 'verification', 'consent_mode', 'debug_log' ); } $tab = is_string( $input['_tab'] ) ? sanitize_key( $input['_tab'] ) : ''; return isset( self::TAB_KEYS[ $tab ] ) ? self::TAB_KEYS[ $tab ] : array(); } /** * May this submission change one stored key? * * For modules that fold their own fields in through the * advisorppc_sanitize_settings filter, so they honour the same per-tab * allowlist as the fields saved here. * * @param string $key Stored settings key. * @param array $input Raw submission. * @return bool */ public static function submission_allows( $key, $input ) { return in_array( $key, self::submitted_keys( $input ), true ); } /** * Register hooks. * * @param AdvisorPPC_Loader $loader Hook loader. * @return void */ public function register( $loader ) { $loader->add_action( 'admin_init', $this, 'guard_save', 5 ); $loader->add_action( 'admin_init', $this, 'register_settings' ); } /** * Belt-and-braces authorisation for the Settings API save. * * options.php already checks the nonce and the option page capability. * This runs the same two checks explicitly, because "nonce and capability * on every admin write" is a rule we would rather enforce than assume. * * @return void */ public function guard_save() { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- The nonce is verified two lines later; this only identifies the request. if ( ! isset( $_POST['option_page'] ) || self::GROUP !== sanitize_text_field( wp_unslash( $_POST['option_page'] ) ) ) { return; } if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to change AdvisorPPC settings.', 'advisorppc' ), esc_html__( 'Permission denied', 'advisorppc' ), array( 'response' => 403 ) ); } check_admin_referer( self::GROUP . '-options' ); } /** * Register the option, its sections and its fields. * * @return void */ public function register_settings() { register_setting( self::GROUP, AdvisorPPC_Options::OPTION_KEY, array( 'type' => 'array', 'sanitize_callback' => array( $this, 'sanitize' ), 'show_in_rest' => false, 'default' => AdvisorPPC_Options::defaults(), ) ); add_settings_section( 'advisorppc_modules', __( 'Modules', 'advisorppc' ), array( $this, 'render_modules_intro' ), self::PAGE ); foreach ( self::visible_modules( $this->module_labels(), AdvisorPPC_Options::workspace_enabled() ) as $key => $label ) { add_settings_field( 'advisorppc_module_' . $key, esc_html( $label['title'] ), array( $this, 'render_module_field' ), self::PAGE, 'advisorppc_modules', array( 'key' => $key, 'description' => $label['description'], 'needs_plan' => $label['needs_plan'], 'tab' => isset( $label['tab'] ) ? $label['tab'] : '', 'label_for' => 'advisorppc_module_' . $key, 'class' => 'advisorppc-module advisorppc-module--' . $key, ) ); } add_settings_section( 'advisorppc_tag_ids', __( 'Tag IDs', 'advisorppc' ), array( $this, 'render_tag_ids_intro' ), self::page_for( 'tags' ) ); foreach ( $this->tag_labels() as $key => $label ) { add_settings_field( 'advisorppc_tag_' . $key, esc_html( $label['title'] ), array( $this, 'render_tag_field' ), self::page_for( 'tags' ), 'advisorppc_tag_ids', array( 'key' => $key, 'placeholder' => $label['placeholder'], 'description' => isset( $label['description'] ) ? $label['description'] : '', 'label_for' => 'advisorppc_tag_' . $key, 'class' => 'advisorppc-tag-row advisorppc-tag-row--' . $key, ) ); } add_settings_section( 'advisorppc_consent', __( 'Consent', 'advisorppc' ), array( $this, 'render_consent_intro' ), self::page_for( 'consent' ) ); add_settings_field( 'advisorppc_consent_mode', __( 'Consent Mode', 'advisorppc' ), array( $this, 'render_consent_field' ), self::page_for( 'consent' ), 'advisorppc_consent', array( 'label_for' => 'advisorppc_consent_mode' ) ); add_settings_section( 'advisorppc_advanced', __( 'Advanced', 'advisorppc' ), array( $this, 'render_advanced_intro' ), self::page_for( 'logs' ) ); add_settings_field( 'advisorppc_debug_log', __( 'Debug log', 'advisorppc' ), array( $this, 'render_debug_field' ), self::page_for( 'logs' ), 'advisorppc_advanced', array( 'label_for' => 'advisorppc_debug_log' ) ); } /* --------------------------------------------------------------------- * Sanitizing * ------------------------------------------------------------------ */ /** * The module switches the settings screen shows. * * With workspace features off (ADVISORPPC_WORKSPACE) only the modules that * work without an account are listed; the rest keep their stored value * untouched and are not rendered. * * @param array $labels module_labels(): key => array( 'needs_plan' => bool, ... ). * @param bool $workspace Whether workspace features are switched on. * @return array The subset of $labels to render, keys preserved. */ public static function visible_modules( $labels, $workspace ) { if ( $workspace || ! is_array( $labels ) ) { return $labels; } return array_filter( $labels, function ( $label ) { return empty( $label['needs_plan'] ); } ); } /** * A module that needs a workspace cannot be switched on while the site is * not connected. * * Without this, a box ticked before connecting would go live the moment * Connect succeeds, which is not the explicit opt-in the readme promises. * Modules with no `needs_plan` entry are left as they are. * * @param array $modules Module key => bool, as about to be stored. * @param array $labels module_labels(): key => array( 'needs_plan' => bool, ... ). * @param bool $connected Whether the site is connected right now. * @return array The same keys, with workspace modules forced off when not connected. */ public static function lock_unconnected_modules( $modules, $labels, $connected ) { if ( $connected || ! is_array( $modules ) ) { return $modules; } foreach ( $modules as $key => $on ) { if ( ! empty( $labels[ $key ]['needs_plan'] ) ) { $modules[ $key ] = false; } } return $modules; } /** * Sanitize the submitted settings and merge them into what is stored. * * @param mixed $input Raw submission. * @return array The complete settings array to store. */ public function sanitize( $input ) { $current = AdvisorPPC_Options::all(); /* * update_option() runs a registered sanitize_callback for EVERY write * to the option, not just for a Settings API form submission. The * connect flow and the health refresh write this option too, and they * must pass through untouched. Only treat the payload as a form * submission when it actually is one. */ if ( ! $this->is_form_submission() ) { return is_array( $input ) ? $input : $current; } if ( ! current_user_can( 'manage_options' ) ) { return $current; } if ( ! is_array( $input ) ) { return $current; } $defaults = AdvisorPPC_Options::defaults(); $keys = self::submitted_keys( $input ); if ( in_array( 'modules', $keys, true ) ) { $current = $this->sanitize_modules( $input, $current, $defaults ); } if ( in_array( 'tag_ids', $keys, true ) ) { $posted_tag_ids = isset( $input['tag_ids'] ) && is_array( $input['tag_ids'] ) ? $input['tag_ids'] : array(); $current['tag_ids'] = self::sanitize_tag_ids( $posted_tag_ids, $defaults['tag_ids'] ); $this->report_rejected_tag_ids( $posted_tag_ids, $current['tag_ids'] ); } if ( in_array( 'consent_mode', $keys, true ) ) { $mode = isset( $input['consent_mode'] ) ? sanitize_key( $input['consent_mode'] ) : 'off'; $current['consent_mode'] = in_array( $mode, array( 'off', 'basic', 'advanced' ), true ) ? $mode : 'off'; } if ( in_array( 'debug_log', $keys, true ) ) { $current['debug_log'] = ! empty( $input['debug_log'] ); } /* * site_key, site_secret, connected, workspace_name, plan and * last_health are never form fields. They keep whatever the connect * flow stored. */ AdvisorPPC_Options::flush_cache(); /** * Filter the settings array immediately before it is stored. * * @param array $current Sanitized settings. * @param array $input Raw submission. */ return apply_filters( 'advisorppc_sanitize_settings', $current, $input ); } /** * Say which posted tag IDs were not saved, and why. * * sanitize_tag_ids() drops an ID that does not match its platform's * format rather than store it malformed. Without a message the field just * comes back empty, so name each one on the settings screen. * * @param array $posted Submitted tag_ids. * @param array $saved The tag_ids about to be stored. * @return void */ protected function report_rejected_tag_ids( $posted, $saved ) { $labels = $this->tag_labels(); foreach ( self::rejected_tag_ids( $posted, $saved ) as $key ) { $example = class_exists( 'AdvisorPPC_Tags' ) && method_exists( 'AdvisorPPC_Tags', 'tag_id_example' ) ? AdvisorPPC_Tags::tag_id_example( $key ) : ( isset( $labels[ $key ]['placeholder'] ) ? $labels[ $key ]['placeholder'] : '' ); add_settings_error( AdvisorPPC_Options::OPTION_KEY, 'advisorppc_invalid_' . $key, sprintf( /* translators: 1: tag ID field name, 2: an example of a valid ID. */ __( '%1$s was not saved because it is not in the format that platform uses, for example %2$s.', 'advisorppc' ), isset( $labels[ $key ]['title'] ) ? $labels[ $key ]['title'] : $key, $example ), 'error' ); } } /** * The tag ID keys that were posted with a value but not stored. * * @param array $posted Submitted tag_ids. * @param array $saved Sanitized tag_ids. * @return string[] */ public static function rejected_tag_ids( $posted, $saved ) { $rejected = array(); if ( ! is_array( $posted ) || ! is_array( $saved ) ) { return $rejected; } foreach ( $posted as $key => $value ) { if ( ! is_string( $key ) || ! is_scalar( $value ) || '' === trim( (string) $value ) ) { continue; } if ( array_key_exists( $key, $saved ) && '' === (string) $saved[ $key ] ) { $rejected[] = $key; } } return $rejected; } /** * Apply the posted module switches to the stored settings. * * @param array $input Raw submission. * @param array $current Stored settings. * @param array $defaults Default settings. * @return array Stored settings with `modules` replaced. */ protected function sanitize_modules( $input, $current, $defaults ) { /* * Modules. A checkbox that is off is simply absent from the POST, so * "absent" means off -- but only for a module this screen actually * renders. `seo` and `gravity` keep their place in the stored shape for * 1.1.0 and have no switch in 1.0.0, so a save must leave them exactly * as they are rather than reading their absence as a decision. */ $rendered = array_keys( self::visible_modules( $this->module_labels(), AdvisorPPC_Options::workspace_enabled() ) ); $modules = array(); foreach ( array_keys( $defaults['modules'] ) as $module ) { if ( ! in_array( $module, $rendered, true ) ) { $modules[ $module ] = isset( $current['modules'][ $module ] ) ? (bool) $current['modules'][ $module ] : (bool) $defaults['modules'][ $module ]; continue; } $modules[ $module ] = ! empty( $input['modules'][ $module ] ); } $modules = self::lock_unconnected_modules( $modules, self::visible_modules( $this->module_labels(), AdvisorPPC_Options::workspace_enabled() ), AdvisorPPC_Options::is_connected() ); /* * Record a deliberate switch of click protection. Nothing in the plugin * switches that module on by itself, so this is the audit trail for the * one module that sends a visitor's IP address to a third party. */ $stored_shield = ! empty( $current['modules']['shield'] ); if ( $modules['shield'] !== $stored_shield ) { AdvisorPPC_Options::set_shield_user_choice( $modules['shield'] ? 'on' : 'off' ); } $stored_tracktor = ! empty( $current['modules']['tracktor'] ); if ( $modules['tracktor'] !== $stored_tracktor && class_exists( 'AdvisorPPC_Tracktor' ) && method_exists( 'AdvisorPPC_Tracktor', 'set_user_choice' ) ) { AdvisorPPC_Tracktor::set_user_choice( $modules['tracktor'] ? 'on' : 'off' ); } $current['modules'] = $modules; return $current; } /** * Whether the current request is a submission of our settings form. * * @return bool */ protected function is_form_submission() { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- guard_save() verified the nonce for this request before anything was written. if ( ! isset( $_POST['option_page'] ) ) { return false; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- See above. return self::GROUP === sanitize_text_field( wp_unslash( $_POST['option_page'] ) ); } /** * Sanitize the whole tag ID map. * * AdvisorPPC_Tags owns the format rules for every identifier, so when the * tag module is present its sanitizer is the one that decides. It returns * only the values it considers valid, so the result is laid over the full * default shape: a key is never missing, and a value that failed * validation comes back empty rather than being stored malformed. * * @param array $posted Submitted tag_ids. * @param array $defaults Default tag_ids, defining the shape. * @return array */ public static function sanitize_tag_ids( $posted, $defaults ) { if ( class_exists( 'AdvisorPPC_Tags' ) && method_exists( 'AdvisorPPC_Tags', 'sanitize_tag_ids' ) ) { $clean = AdvisorPPC_Tags::sanitize_tag_ids( $posted ); if ( ! is_array( $clean ) ) { $clean = array(); } return array_merge( $defaults, array_intersect_key( $clean, $defaults ) ); } // Fallback for a build without the tag module. $out = array(); foreach ( array_keys( $defaults ) as $network ) { $value = isset( $posted[ $network ] ) ? $posted[ $network ] : ''; $out[ $network ] = self::sanitize_tag_id( $value ); } return $out; } /** * Sanitize one measurement / tag identifier. * * @param mixed $value Raw value. * @return string */ public static function sanitize_tag_id( $value ) { if ( ! is_string( $value ) ) { return ''; } $value = sanitize_text_field( $value ); $value = preg_replace( '/[^A-Za-z0-9_.\-]/', '', $value ); if ( ! is_string( $value ) ) { return ''; } return substr( trim( $value ), 0, 64 ); } /* --------------------------------------------------------------------- * Field definitions * ------------------------------------------------------------------ */ /** * Module toggles and their copy. * * @return array */ public function module_labels() { return array( 'tags' => array( 'title' => __( 'Tracking tags and consent', 'advisorppc' ), 'description' => __( 'Load your measurement tags, apply Consent Mode and capture advertising click IDs at landing. Works without an AdvisorPPC account.', 'advisorppc' ), 'needs_plan' => false, 'tab' => 'tags', ), 'detect' => array( 'title' => __( 'Auto setup', 'advisorppc' ), 'description' => __( 'Show the Auto setup card, which looks for tags your site already has and offers to set them up here. It only runs when you click Scan, reads other plugins\' settings without changing them, and contacts nothing but your own home page.', 'advisorppc' ), 'needs_plan' => false, 'tab' => 'auto-setup', ), 'tracktor' => array( 'title' => __( 'TrackTor tag', 'advisorppc' ), 'description' => __( 'Load the TrackTor tag so visits, sources and conversions appear in your AdvisorPPC workspace. It is off until you switch it on here; connecting a workspace does not switch it on.', 'advisorppc' ), 'needs_plan' => true, 'tab' => 'tracktor', ), 'shield' => array( 'title' => __( 'Click protection', 'advisorppc' ), 'description' => __( 'Record ad clicks so repeated or automated clicks can be excluded from your campaigns. A recorded click has to belong to a workspace, so this needs a connection, though it is not metered. It is off until you switch it on here; connecting a workspace does not switch it on. While it is on, each visitor\'s IP address and user agent are sent to the Shield service.', 'advisorppc' ), 'needs_plan' => true, 'tab' => 'shield', ), 'server_events' => array( 'title' => __( 'Server-side conversions', 'advisorppc' ), 'description' => __( 'Send conversions from your server to your advertising platforms through your connected AdvisorPPC workspace, so you never paste a platform access token into WordPress.', 'advisorppc' ), 'needs_plan' => true, 'tab' => 'tracktor', ), 'woo' => array( 'title' => __( 'WooCommerce sales and refunds', 'advisorppc' ), 'description' => __( 'Send orders and refunds so revenue is attributed to the campaign that earned it, net of refunds.', 'advisorppc' ), 'needs_plan' => true, 'tab' => 'tracktor', ), 'forms' => array( 'title' => __( 'Form leads', 'advisorppc' ), 'description' => __( 'Send form submissions as leads so enquiries are attributed to the campaign that produced them. Works with Contact Form 7, WPForms, Gravity Forms, Fluent Forms, Ninja Forms and Elementor forms. Contact details are hashed before they leave your server.', 'advisorppc' ), 'needs_plan' => true, 'tab' => 'tracktor', ), ); } /** * Tag ID fields and their copy. * * @return array */ public function tag_labels() { return array( 'ga4' => array( 'title' => __( 'Analytics measurement ID', 'advisorppc' ), 'placeholder' => 'G-XXXXXXXXXX', ), 'google_ads' => array( 'title' => __( 'Google Ads conversion ID', 'advisorppc' ), 'placeholder' => 'AW-123456789', ), 'google_ads_label' => array( 'title' => __( 'Google Ads conversion label', 'advisorppc' ), 'placeholder' => 'AbCdEfGhIjKlMnOpQrS', 'description' => __( 'Required for Google Ads conversions. Without the label the conversion cannot fire; the ID on its own only does remarketing.', 'advisorppc' ), ), 'gtm' => array( 'title' => __( 'Tag Manager container ID', 'advisorppc' ), 'placeholder' => 'GTM-XXXXXXX', ), 'meta' => array( 'title' => __( 'Meta pixel ID', 'advisorppc' ), 'placeholder' => '000000000000000', ), 'tiktok' => array( 'title' => __( 'TikTok pixel ID', 'advisorppc' ), 'placeholder' => 'XXXXXXXXXXXXXXXXXXXX', ), 'uet' => array( 'title' => __( 'Microsoft UET tag ID', 'advisorppc' ), 'placeholder' => '00000000', ), 'linkedin' => array( 'title' => __( 'LinkedIn partner ID', 'advisorppc' ), 'placeholder' => '0000000', ), 'pinterest' => array( 'title' => __( 'Pinterest tag ID', 'advisorppc' ), 'placeholder' => '0000000000000', ), ); } /* --------------------------------------------------------------------- * Renderers * ------------------------------------------------------------------ */ /** * Modules section intro. * * @return void */ public function render_modules_intro() { if ( ! AdvisorPPC_Options::workspace_enabled() ) { echo '

' . esc_html__( 'Tracking tags, Consent Mode and click-ID capture work on their own, with no account.', 'advisorppc' ) . '

'; return; } echo '

' . esc_html__( 'Tracking tags work on their own, with no account. Click protection, server-side conversions, WooCommerce revenue and form leads need a connected AdvisorPPC workspace, and each stays off until you switch it on.', 'advisorppc' ) . '

'; } /** * Tag IDs section intro. * * @return void */ public function render_tag_ids_intro() { echo '

' . esc_html__( 'Leave a field empty to skip that platform. Nothing is loaded for a platform you have not filled in.', 'advisorppc' ) . '

'; } /** * Consent section intro. * * @return void */ public function render_consent_intro() { echo '

' . esc_html__( 'Consent Mode tells your tags what a visitor has agreed to. Set it to match the consent banner you already use.', 'advisorppc' ) . '

'; } /** * Advanced section intro. * * @return void */ public function render_advanced_intro() { echo '

' . esc_html__( 'Diagnostics. The debug log is written to this site only and is never sent anywhere.', 'advisorppc' ) . '

'; } /** * One module checkbox. * * @param array $args Field arguments. * @return void */ public function render_module_field( $args ) { $key = isset( $args['key'] ) ? $args['key'] : ''; $enabled = AdvisorPPC_Options::module_enabled( $key ); $name = AdvisorPPC_Options::OPTION_KEY . '[modules][' . $key . ']'; $id = 'advisorppc_module_' . $key; $locked = ! empty( $args['needs_plan'] ) && ! AdvisorPPC_Options::is_connected(); $tab = isset( $args['tab'] ) ? (string) $args['tab'] : ''; if ( class_exists( 'AdvisorPPC_Admin' ) ) { if ( $locked ) { AdvisorPPC_Admin::pill( __( 'Not connected', 'advisorppc' ), 'off' ); } elseif ( $enabled ) { AdvisorPPC_Admin::pill( __( 'On', 'advisorppc' ), 'ok' ); } else { AdvisorPPC_Admin::pill( __( 'Off', 'advisorppc' ), 'muted' ); } } ?>

__( 'Off - do not send consent signals', 'advisorppc' ), 'basic' => __( 'Basic - tags load only after consent', 'advisorppc' ), 'advanced' => __( 'Advanced - tags load with consent signals and update on choice', 'advisorppc' ), ); ?>