Browser events queued for the footer. */ private static $events = array(); /** * Cookie that hands a browser event to the NEXT page when this request * cannot print a footer. * * The live add-to-cart paths never reach wp_footer: the classic form POST * redirects, and the AJAX and Store API routes return JSON. A browser copy * queued in memory dies with those requests, and the server half then * goes out alone, so the platforms see the cart action once from the * server and, if a theme fires its own, once more from the browser with * no shared id. Instead the event is written into this short-lived * first-party cookie, which carries the SAME event_id the queue was * given. It is written by the browser-visible response itself, so a * full-page cache cannot serve one visitor another visitor's cart event. * * The value is `<64 hex HMAC-SHA256>.`, keyed by the * per-site AdvisorPPC_Options::handoff_secret(). PHP is the verifier: * claim_pending() runs on template_redirect, checks the signature before * anything is printed, moves the events into the footer queue and expires * the cookie, so on every request WordPress actually serves the browser * never reads the value at all. * * public/js/tags.js keeps a drain for the two cases PHP cannot cover -- a * WooCommerce AJAX add-to-cart with no page load after it, and a page a * full-page cache serves without running PHP -- and JS cannot check an * HMAC. That drain is therefore held to pending_js_events(), so a cookie * an attacker managed to set can never fire a purchase, a refund or a * lead through it. */ const PENDING_COOKIE = 'advisorppc_pending'; /** Seconds a pending browser event stays deliverable. */ const PENDING_TTL = 600; /** Most events the cookie carries; oldest are dropped first. */ const PENDING_MAX = 5; /** Cookie byte budget, under the 4 KB limit with room for the name. */ const PENDING_MAX_BYTES = 3500; /** * The only event names the JavaScript drain may fire out of the cookie. * * Exactly the cart-shaped events a footerless request stashes. See * pending_js_events() for why the list is closed. */ const PENDING_JS_EVENTS = array( 'add_to_cart', 'begin_checkout', 'view_item' ); /** @var array Events written to the pending cookie this request. */ private static $pending = array(); /** @var bool Whether the "headers were already gone" note has been logged. */ private static $pending_logged = false; /** @var array|null Sanitised tag IDs, cached per request. */ private static $ids_cache = null; /** * The eight networks, exactly as keyed in the `tag_ids` option. * * @return string[] */ public static function networks() { return array( 'ga4', 'google_ads', 'gtm', 'meta', 'tiktok', 'uet', 'linkedin', 'pinterest' ); } /** * Every key the settings screen renders a field for: the eight contract * networks plus `google_ads_label`, which is the conversion label half of * a Google Ads conversion action. Both halves are needed before the * conversion can fire, so the label gets validated like any other ID. * * @return string[] */ public static function field_keys() { return array_merge( self::networks(), array( 'google_ads_label' ) ); } /** * Validation pattern per network. An admin account is a plausible attacker * here, so an ID that does not match its platform's documented shape is * dropped rather than printed into a script tag. * * @param string $network Network key. * @return string Regular expression, or empty string for an unknown network. */ public static function tag_id_pattern( $network ) { $patterns = array( 'ga4' => '/^G-[A-Z0-9]{4,20}$/i', 'google_ads' => '/^AW-[0-9]{6,15}$/i', 'google_ads_label' => '/^[A-Za-z0-9_\-]{6,40}$/', 'gtm' => '/^GTM-[A-Z0-9]{4,12}$/i', 'meta' => '/^[0-9]{8,20}$/', 'tiktok' => '/^[A-Z0-9]{10,32}$/i', 'uet' => '/^[0-9]{5,15}$/', 'linkedin' => '/^[0-9]{4,12}$/', 'pinterest' => '/^[0-9]{9,20}$/', ); return isset( $patterns[ $network ] ) ? $patterns[ $network ] : ''; } /** * A human example of each ID, for lane A's settings screen placeholders. * * @param string $network Network key. * @return string */ public static function tag_id_example( $network ) { $examples = array( 'ga4' => 'G-XXXXXXXXXX', 'google_ads' => 'AW-123456789', 'google_ads_label' => 'AbC-D_efGhIjK', 'gtm' => 'GTM-XXXXXXX', 'meta' => '123456789012345', 'tiktok' => 'CXXXXXXXXXXXXXXXXXXX', 'uet' => '12345678', 'linkedin' => '1234567', 'pinterest' => '2612345678901', ); return isset( $examples[ $network ] ) ? $examples[ $network ] : ''; } /** * Does this value look like a real ID for this network? * * @param string $network Network key. * @param string $value Candidate ID. * @return bool */ public static function validate_tag_id( $network, $value ) { $pattern = self::tag_id_pattern( $network ); if ( '' === $pattern || ! is_string( $value ) || '' === $value ) { return false; } return (bool) preg_match( $pattern, $value ); } /** * Sanitising callback for the `tag_ids` sub-array. * * FOR LANE A: use this as the sanitiser for the tag ID fields. It strips * whitespace, drops unknown keys, drops anything that fails its platform * pattern, and never throws. * * @param mixed $raw Submitted value. * @return array */ public static function sanitize_tag_ids( $raw ) { $out = array(); if ( ! is_array( $raw ) ) { return $out; } foreach ( self::field_keys() as $network ) { if ( ! isset( $raw[ $network ] ) || ! is_scalar( $raw[ $network ] ) ) { continue; } $value = trim( sanitize_text_field( (string) $raw[ $network ] ) ); if ( '' === $value ) { continue; } if ( self::validate_tag_id( $network, $value ) ) { $out[ $network ] = $value; } } return $out; } /** * Register hooks. Idempotent. * * @return void */ public static function init() { if ( self::$booted ) { return; } self::$booted = true; // Before a byte of the page is sent, so the signature can be checked // and the cookie expired while the headers are still open. add_action( 'template_redirect', array( __CLASS__, 'claim_pending' ), 1 ); add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue' ), 6 ); add_action( 'wp_body_open', array( __CLASS__, 'print_body_noscript' ) ); add_action( 'wp_footer', array( __CLASS__, 'print_footer_noscript' ), 20 ); add_action( 'wp_footer', array( __CLASS__, 'print_events' ), 21 ); } /** * Read an option without hard-depending on the options class. * * @param string $key Option key. * @param mixed $default Fallback. * @return mixed */ private static function opt( $key, $default = null ) { if ( ! class_exists( 'AdvisorPPC_Options' ) ) { return $default; } return AdvisorPPC_Options::get( $key, $default ); } /** * Is module 1 switched on? Absent means on, so a fresh install where the * admin only pasted a GA4 ID actually tracks. * * @return bool */ public static function is_active() { if ( class_exists( 'AdvisorPPC_Options' ) && method_exists( 'AdvisorPPC_Options', 'module_enabled' ) ) { return (bool) AdvisorPPC_Options::module_enabled( 'tags' ); } $modules = self::opt( 'modules', array() ); if ( ! is_array( $modules ) || ! array_key_exists( 'tags', $modules ) ) { return true; } return ! empty( $modules['tags'] ); } /** * The configured IDs, validated. Anything malformed is simply absent. * * An ID that another plugin is already loading is absent too * (AdvisorPPC_Detect::drop_externally_owned()), so this plugin never adds * a second library, init, page_view or noscript for it. Every output path * in this class reads its IDs from here. * * @return array */ public static function tag_ids() { if ( null !== self::$ids_cache ) { return self::$ids_cache; } $ids = self::sanitize_tag_ids( self::opt( 'tag_ids', array() ) ); if ( class_exists( 'AdvisorPPC_Detect' ) ) { $ids = AdvisorPPC_Detect::drop_externally_owned( $ids ); } self::$ids_cache = $ids; return self::$ids_cache; } /** * Per-network configuration report, for lane A's settings screen. * * The `networks` map covers self::field_keys(), so it includes * `google_ads_label`. The `configured` list stays limited to the eight * contract networks. * * @return array{active:bool,configured:string[],invalid:string[],networks:array,ads_conversion_ready:bool} */ public static function status() { $raw = self::opt( 'tag_ids', array() ); $valid_ids = self::sanitize_tag_ids( $raw ); $clean = self::tag_ids(); $rows = array(); $bad = array(); foreach ( self::field_keys() as $network ) { $has_raw = is_array( $raw ) && ! empty( $raw[ $network ] ); $valid = isset( $clean[ $network ] ); if ( $has_raw && ! isset( $valid_ids[ $network ] ) ) { $bad[] = $network; } $rows[ $network ] = array( 'configured' => $valid, 'id' => $valid ? $clean[ $network ] : '', 'malformed' => ( $has_raw && ! isset( $valid_ids[ $network ] ) ), // Valid, but another plugin already loads it, so we do not. 'owned_elsewhere' => ( isset( $valid_ids[ $network ] ) && ! $valid ), 'example' => self::tag_id_example( $network ), ); } return array( 'active' => self::is_active(), 'configured' => array_values( array_intersect( self::networks(), array_keys( $clean ) ) ), 'invalid' => $bad, 'networks' => $rows, /* Remarketing only needs the AW- id. A conversion action needs the * label as well, so the settings screen can say which one the site * currently has. */ 'ads_conversion_ready' => ( isset( $clean['google_ads'] ) && isset( $clean['google_ads_label'] ) ), ); } /* ------------------------------------------------------------------ */ /* event_id - the single most important function in this lane */ /* ------------------------------------------------------------------ */ /** * The site identity used in every event_id hash. * * Delegates to lane C so the two halves of a conversion can never disagree * about what this site is called. The fallback reproduces lane C's * definition exactly, including the `advisorppc_site_url` filter. * * @return string */ public static function site_url() { if ( class_exists( 'AdvisorPPC_Events' ) && method_exists( 'AdvisorPPC_Events', 'site_url' ) ) { return AdvisorPPC_Events::site_url(); } return (string) apply_filters( 'advisorppc_site_url', untrailingslashit( home_url() ) ); } /** * Events the browser fires with NO server twin in v1. * * Lane C sends purchase, refund and lead server side. Everything in this * list exists only in the browser, so a deterministic id would collapse two * genuinely separate add-to-cart clicks into one. These get a * per-occurrence id instead, minted in the browser where the page cache * cannot freeze it. * * @return string[] */ public static function browser_only_events() { return array( 'page_view', 'view_item', 'add_to_cart', 'begin_checkout' ); } /** * The cart events module 3 sends a server twin for, keyed on the session. * * Their server id is wp_sha1( site_url | event_name | session_id ), with * no per-occurrence token, so the browser half can carry the identical * value: AdvisorPPC_Woo hands it to enqueue_browser_event(), and tags.js * re-derives it from the same session cookie for a theme-originated * advisorppc.track() call. * * @return string[] */ public static function server_twin_events() { return array( 'add_to_cart', 'begin_checkout' ); } /** * Whether module 3 will actually produce a server twin for a cart event. * * This has to match WooCommerce's own `ready()` gate exactly. Testing merely * that WooCommerce is installed is not the same thing: a store with the * plugin active but the WooCommerce MODULE switched off produces no server * event, and dropping the browser copy on that site would lose the cart * action entirely rather than deduplicate it. * * @return bool */ protected static function server_twin_will_be_produced() { if ( ! class_exists( 'AdvisorPPC_Woo' ) || ! AdvisorPPC_Woo::is_active() ) { return false; } if ( ! class_exists( 'AdvisorPPC_Events' ) || ! AdvisorPPC_Events::is_enabled() ) { return false; } if ( class_exists( 'AdvisorPPC_Options' ) && method_exists( 'AdvisorPPC_Options', 'module_enabled' ) ) { return (bool) AdvisorPPC_Options::module_enabled( 'woo' ); } return true; } /** * The canonical deterministic deduplication key, for the three events that * have a server twin. * * Contract rule: `wp_`. * When lane C's class is loaded this delegates to it, so the two lanes run * literally the same line of code. Worked reference, site * `https://example.com` and Woo order 1234: * * event_id( 'purchase', 'wc_1234' ) * === 'wp_bfa5e1b1ffdef15bfe745f32184f496c70207aaf' * * @param string $event_name purchase|refund|lead (or any contract event). * @param string $reference Order reference, e.g. 'wc_1234'. Falls back to * the visitor's session id when empty. * @return string */ public static function event_id( $event_name, $reference = '' ) { $name = strtolower( trim( (string) $event_name ) ); $ref = trim( (string) $reference ); if ( '' === $ref && class_exists( 'AdvisorPPC_ClickIDs' ) ) { $ref = (string) AdvisorPPC_ClickIDs::get_session_id(); } if ( class_exists( 'AdvisorPPC_Events' ) && method_exists( 'AdvisorPPC_Events', 'event_id' ) ) { return AdvisorPPC_Events::event_id( $name, $ref ); } return 'wp_' . sha1( self::site_url() . '|' . $name . '|' . $ref ); } /** * Queue a conversion to fire in the browser, deduplicated against the * server-side twin by `$event_id`. * * Canonical parameter keys, mapped per platform in public/js/tags.js: * value float, major units, e.g. 49.99 * currency ISO 4217, e.g. 'USD' * order_ref human order reference, e.g. 'wc_1234' * items list of { item_id, item_name, quantity, price } * content_type 'product' by default * * @param string $event_name Canonical event name. * @param array $params Canonical parameters. * @param string|null $event_id For purchase, refund and lead pass the * result of self::event_id() (or lane C's * AdvisorPPC_Events::event_id(), they are * the same function). For add_to_cart and * begin_checkout pass the id the server * event was queued with, as AdvisorPPC_Woo * does. Leave empty for the browser-only * events and the browser mints a * per-occurrence id. * @return void */ public static function enqueue_browser_event( $event_name, $params = array(), $event_id = '' ) { if ( ! self::is_active() ) { return; } // A request with no footer to print into: the AJAX and Store API // add-to-cart routes. The event is not dropped; it is handed to the // next page through the pending cookie (see PENDING_COOKIE). Everything // else that should_load() refuses (a wp-admin screen, cron, a feed, a // site that excluded this traffic through the filter) has no visitor // to fire for and is dropped as before. $footerless = self::is_footerless_request(); if ( $footerless ) { if ( ( defined( 'DOING_CRON' ) && DOING_CRON ) || ! apply_filters( 'advisorppc_should_load_scripts', true ) ) { return; } } elseif ( ! self::should_load() ) { return; } $name = strtolower( trim( (string) $event_name ) ); if ( '' === $name ) { return; } $has_id = ! ( null === $event_id || '' === $event_id ); // Module 3 sends server twins for add_to_cart and begin_checkout, and // those fan out to Meta, GA4, TikTok and Pinterest. A browser copy is // only allowed alongside them when it carries the SAME id, which is what // AdvisorPPC_Woo passes in: the value the queue was given. A copy with // no id would be minted per occurrence and could never collapse against // the server event, so on a store that produces the twin it is dropped. // The server hook is the more reliable of the two (a real WooCommerce // action, not a script an ad blocker can drop), so it wins. if ( ! $has_id && in_array( $name, self::server_twin_events(), true ) && self::server_twin_will_be_produced() ) { return; } if ( ! $has_id ) { if ( in_array( $name, self::browser_only_events(), true ) ) { // No server twin: let the browser mint a per-occurrence id, so // a cached page cannot serve the same id to every visitor. $event_id = null; } else { $reference = ''; if ( is_array( $params ) && ! empty( $params['order_ref'] ) ) { $reference = (string) $params['order_ref']; } $event_id = self::event_id( $name, $reference ); } } $entry = array( 'name' => $name, 'params' => is_array( $params ) ? $params : array(), 'event_id' => ( null === $event_id ) ? null : (string) $event_id, ); if ( $footerless ) { self::stash_pending( $entry ); return; } self::$events[] = $entry; // A form POST may render this page or may redirect away from it (the // classic add-to-cart with "redirect to cart" on, for one). An event // with a shared id is stashed as well, so it survives the redirect. // When the page does render, the footer and the cookie carry the same // id and tags.js fires it once. if ( null !== $entry['event_id'] && self::is_post_request() ) { self::stash_pending( $entry ); } } /** * A request that returns to the browser without running wp_footer. * * @return bool */ private static function is_footerless_request() { if ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) { return true; } return defined( 'REST_REQUEST' ) && REST_REQUEST; } /** * Whether this is a POST request. * * @return bool */ private static function is_post_request() { return isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ); } /** * Write a browser event into the pending cookie. * * Merges with whatever the cookie already holds (an earlier add-to-cart * the browser has not drained yet), deduplicates on event_id, keeps the * newest PENDING_MAX, and stays under PENDING_MAX_BYTES. * * @param array $entry { name, params, event_id }. * @return bool Whether the cookie header was sent. */ private static function stash_pending( $entry ) { $list = self::pending_events(); foreach ( $list as $index => $existing ) { if ( null !== $entry['event_id'] && isset( $existing['event_id'] ) && $existing['event_id'] === $entry['event_id'] ) { unset( $list[ $index ] ); } } $list = array_values( $list ); $list[] = $entry; while ( count( $list ) > self::PENDING_MAX ) { array_shift( $list ); } $value = self::encode_pending( $list ); while ( strlen( $value ) > self::PENDING_MAX_BYTES && count( $list ) > 1 ) { array_shift( $list ); $value = self::encode_pending( $list ); } self::$pending = $list; if ( '' === $value || headers_sent() ) { // No Set-Cookie is possible. A request that still prints a footer // can deliver the event from the in-memory queue instead, so put // it there rather than dropping it; a footerless request has no // footer to fall back on and the browser half is genuinely lost. if ( ! self::is_footerless_request() && ! in_array( $entry, self::$events, true ) ) { self::$events[] = $entry; } self::log_pending_failure( '' === $value ? 'no signing key' : 'headers already sent' ); return false; } // setrawcookie() so the value is exactly the signed payload that // pending_events() verifies. Not HttpOnly: tags.js still drains it on // the AJAX path, which is why the signature exists. return setrawcookie( self::PENDING_COOKIE, $value, array( 'expires' => time() + self::PENDING_TTL, 'path' => self::pending_cookie_path(), 'domain' => self::pending_cookie_domain(), 'secure' => function_exists( 'is_ssl' ) && is_ssl(), 'httponly' => false, 'samesite' => 'Lax', ) ); } /** * Note, once per request and only at debug level, that a browser event * could not be handed to the next page. * * @param string $reason Short reason. * @return void */ private static function log_pending_failure( $reason ) { if ( self::$pending_logged ) { return; } self::$pending_logged = true; if ( class_exists( 'AdvisorPPC_Logger' ) && method_exists( 'AdvisorPPC_Logger', 'debug' ) ) { AdvisorPPC_Logger::debug( 'The browser copy of an event could not be written to the handoff cookie.', array( 'reason' => $reason ) ); } } /** * Signed cookie value for a list of pending events. * * `<64 hex HMAC-SHA256 of the JSON>.`. PHP urldecodes * $_COOKIE for us, so the reader sees the signature, a dot, and the exact * JSON that was signed; document.cookie hands tags.js the encoded form, * which decodeURIComponent() turns back into the same string. * * @param array $list Events. * @return string Empty when the site has no signing key. */ private static function encode_pending( $list ) { $secret = self::pending_secret(); if ( '' === $secret ) { return ''; } $json = wp_json_encode( array_values( $list ) ); $json = is_string( $json ) ? $json : '[]'; return hash_hmac( 'sha256', $json, $secret ) . '.' . rawurlencode( $json ); } /** * The per-site key that signs the handoff cookie. * * @return string */ private static function pending_secret() { if ( class_exists( 'AdvisorPPC_Options' ) && method_exists( 'AdvisorPPC_Options', 'handoff_secret' ) ) { return (string) AdvisorPPC_Options::handoff_secret(); } return ''; } /** * The JSON inside a cookie value, once its signature checks out. * * Fails closed: a value in the old unsigned format, a value whose payload * was edited, and a site with no signing key all return ''. * * @param string $raw Cookie value, already urldecoded and unslashed. * @return string Verified JSON, or '' . */ private static function verified_pending_json( $raw ) { if ( strlen( $raw ) < 66 || '.' !== $raw[64] ) { return ''; } $signature = substr( $raw, 0, 64 ); if ( ! ctype_xdigit( $signature ) ) { return ''; } $secret = self::pending_secret(); if ( '' === $secret ) { return ''; } $json = substr( $raw, 65 ); if ( ! hash_equals( hash_hmac( 'sha256', $json, $secret ), $signature ) ) { return ''; } return $json; } /** * The event names tags.js is allowed to fire out of the handoff cookie. * * JS cannot check an HMAC, so the drain it keeps for the AJAX and * full-page-cache cases is held to the cart-shaped events a footerless * request can actually stash. A purchase, a refund or a lead is only ever * printed by claim_pending(), which verifies the signature first. * * @return string[] */ public static function pending_js_events() { /** * Filters the events tags.js may fire out of the handoff cookie. * * A filter can only narrow the list; a name that is not cart-shaped * is ignored, because widening it would hand back the injection * surface the signature exists to close. * * @param string[] $events Allowed event names. */ $offered = (array) apply_filters( 'advisorppc_pending_js_events', self::PENDING_JS_EVENTS ); $allowed = array(); foreach ( $offered as $name ) { if ( ! is_scalar( $name ) ) { continue; } $name = strtolower( trim( (string) $name ) ); if ( '' !== $name && in_array( $name, self::PENDING_JS_EVENTS, true ) ) { $allowed[] = $name; } } return array_values( array_unique( $allowed ) ); } /** * Take the handoff cookie into the footer queue and expire it, before a * byte of the page has been sent. * * This is what makes the cookie a handoff rather than a pixel injector: * on every request WordPress actually renders, the signature is checked * here and print_events() does the printing, so the browser never reads * the value. * * @return void */ public static function claim_pending() { if ( self::is_footerless_request() || ! self::is_active() || ! self::should_load() ) { return; } if ( empty( $_COOKIE[ self::PENDING_COOKIE ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only cookie inspection. return; } $claimed = self::pending_events(); // Read once. Whatever it held is this request's to print now, so it // must not be seen again by a later stash on the same request. unset( $_COOKIE[ self::PENDING_COOKIE ] ); self::$pending = array(); if ( ! headers_sent() ) { setrawcookie( self::PENDING_COOKIE, '', array( 'expires' => time() - 3600, 'path' => self::pending_cookie_path(), 'domain' => self::pending_cookie_domain(), 'secure' => function_exists( 'is_ssl' ) && is_ssl(), 'httponly' => false, 'samesite' => 'Lax', ) ); } foreach ( $claimed as $event ) { self::$events[] = $event; } } /** * The pending events: what this request has stashed, or failing that * what the visitor's cookie already carries. * * The cookie is signed with the per-site handoff key and the signature is * checked here before a single byte of it is decoded, so a value some * other script on the origin wrote is not an event: it is nothing. Every * field is still sanitised afterwards. * * @return array */ public static function pending_events() { if ( ! empty( self::$pending ) ) { return self::$pending; } if ( empty( $_COOKIE[ self::PENDING_COOKIE ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only cookie inspection. return array(); } $raw = wp_unslash( $_COOKIE[ self::PENDING_COOKIE ] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- signature checked, then sanitised entry by entry below. if ( ! is_string( $raw ) || strlen( $raw ) > 4096 ) { return array(); } $json = self::verified_pending_json( $raw ); if ( '' === $json ) { return array(); } $decoded = json_decode( $json, true ); if ( ! is_array( $decoded ) ) { return array(); } $out = array(); foreach ( array_slice( $decoded, -self::PENDING_MAX ) as $item ) { if ( ! is_array( $item ) || empty( $item['name'] ) || ! is_string( $item['name'] ) ) { continue; } $event_id = isset( $item['event_id'] ) && is_string( $item['event_id'] ) ? substr( $item['event_id'], 0, 64 ) : null; $out[] = array( 'name' => sanitize_key( $item['name'] ), 'params' => isset( $item['params'] ) && is_array( $item['params'] ) ? $item['params'] : array(), 'event_id' => ( null === $event_id || '' === $event_id ) ? null : $event_id, ); } return $out; } /** * Cookie path for the pending cookie; printed to tags.js so it deletes * the same cookie it read. * * @return string */ public static function pending_cookie_path() { $path = defined( 'COOKIEPATH' ) ? (string) COOKIEPATH : ''; return '' !== $path ? $path : '/'; } /** * Cookie domain for the pending cookie, empty for the current host. * * @return string */ public static function pending_cookie_domain() { return defined( 'COOKIE_DOMAIN' ) && is_string( COOKIE_DOMAIN ) ? COOKIE_DOMAIN : ''; } /** * Print the queued browser events. Runs late so the pixels are already up. * * @return void */ public static function print_events() { if ( empty( self::$events ) ) { return; } $events = self::$events; self::$events = array(); $js = 'window.advisorppcQueue = (window.advisorppcQueue || []).concat(' . wp_json_encode( $events ) . '); if (window.advisorppc && window.advisorppc.flushQueue) { window.advisorppc.flushQueue(); }'; wp_print_inline_script_tag( $js, array( 'id' => 'advisorppc-events-js' ) ); } /* ------------------------------------------------------------------ */ /* Enqueueing */ /* ------------------------------------------------------------------ */ /** * Front-end request test, shared with the click-ID module. * * @return bool */ public static function should_load() { return class_exists( 'AdvisorPPC_ClickIDs' ) ? AdvisorPPC_ClickIDs::should_load() : ( ! is_admin() ); } /** * Vendor library URL, filterable so a site can serve it from its own * first-party proxy. * * @param string $network Network key. * @param array $ids Validated IDs. * @return string */ private static function vendor_src( $network, $ids ) { $src = ''; switch ( $network ) { case 'gtag': $primary = isset( $ids['ga4'] ) ? $ids['ga4'] : ( isset( $ids['google_ads'] ) ? $ids['google_ads'] : '' ); $src = 'https://www.googletagmanager.com/gtag/js?id=' . rawurlencode( $primary ); break; case 'gtm': $src = 'https://www.googletagmanager.com/gtm.js?id=' . rawurlencode( $ids['gtm'] ); break; case 'meta': $src = 'https://connect.facebook.net/en_US/fbevents.js'; break; case 'tiktok': $src = 'https://analytics.tiktok.com/i18n/pixel/events.js?sdkid=' . rawurlencode( $ids['tiktok'] ) . '&lib=ttq'; break; case 'uet': $src = 'https://bat.bing.com/bat.js'; break; case 'linkedin': $src = 'https://snap.licdn.com/li.lms-analytics/insight.min.js'; break; case 'pinterest': $src = 'https://s.pinimg.com/ct/core.js'; break; } /** * Filters a vendor tag library URL. * * @since 0.1.0 * @param string $src Default vendor URL. * @param string $network Network key. * @param array $ids Validated tag IDs. */ return (string) apply_filters( 'advisorppc_tag_script_src', $src, $network, $ids ); } /** * Which consent signals must be granted before a vendor may execute. * * Google's own tags understand Consent Mode, so in Advanced mode we let * them load and send cookieless pings. Everyone else has no such mode, so * they stay held back until `ad_storage` is granted, in both modes. * * @param string $network Network key. * @return string Comma separated signals, empty when not gated. */ private static function gate_for( $network ) { $mode = AdvisorPPC_Consent::mode(); if ( AdvisorPPC_Consent::MODE_OFF === $mode ) { return ''; } if ( in_array( $network, array( 'gtag', 'gtm' ), true ) ) { return AdvisorPPC_Consent::MODE_BASIC === $mode ? 'analytics_storage' : ''; } return 'ad_storage'; } /** * Enqueue the bootstrap and every configured vendor library. * * @return void */ public static function enqueue() { if ( ! self::is_active() || ! self::should_load() ) { return; } $ids = self::tag_ids(); if ( empty( $ids ) ) { return; } wp_enqueue_script( self::HANDLE, AdvisorPPC_Consent::asset_url( 'public/js/tags.js' ), array( AdvisorPPC_Consent::HANDLE, AdvisorPPC_ClickIDs::HANDLE ), AdvisorPPC_Consent::asset_version(), false ); $config = array( 'ids' => $ids, /* Never derived from location.origin in the browser: the hash has * to agree with what PHP sends server side. */ 'siteUrl' => self::site_url(), 'browserOnly' => self::browser_only_events(), /* The cart events that will have a server twin on THIS store, so a * theme-originated advisorppc.track() reuses the server's * deterministic id instead of minting one of its own. Empty when * module 3 is not producing them. */ 'serverTwins' => self::server_twin_will_be_produced() ? self::server_twin_events() : array(), /* The cookie a footerless request (AJAX or Store API add-to-cart, * a redirecting form POST) hands its browser event through. The * path and domain are printed so the script deletes exactly the * cookie PHP set. On a request WordPress renders, claim_pending() * has already verified the signature and emptied this cookie, so * the drain below only ever sees the AJAX and cached-page cases, * and pendingEvents holds it to what those can legitimately * contain. */ 'pendingCookie' => self::PENDING_COOKIE, 'pendingCookiePath' => self::pending_cookie_path(), 'pendingCookieDomain' => self::pending_cookie_domain(), 'pendingCookieSecure' => is_ssl(), 'pendingEvents' => self::pending_js_events(), 'sendPageView' => (bool) apply_filters( 'advisorppc_send_page_view', true ), /** * Filters extra parameters merged into every gtag() config call, * for example array( 'anonymize_ip' => true ). * * @since 0.1.0 * @param array $params Default empty. */ 'gtagParams' => (array) apply_filters( 'advisorppc_gtag_params', array() ), ); wp_add_inline_script( self::HANDLE, 'window.advisorppcTags = ' . wp_json_encode( $config ) . ';', 'before' ); $wanted = array(); if ( isset( $ids['ga4'] ) || isset( $ids['google_ads'] ) ) { $wanted[] = 'gtag'; } foreach ( array( 'gtm', 'meta', 'tiktok', 'uet', 'linkedin', 'pinterest' ) as $network ) { if ( isset( $ids[ $network ] ) ) { $wanted[] = $network; } } foreach ( $wanted as $network ) { $src = self::vendor_src( $network, $ids ); if ( '' === $src ) { continue; } $handle = self::VENDOR_PREFIX . $network; // Microsoft's bat.js exposes a constructor rather than a queue, so // it must stay parser-blocking; everything else is queue-safe and // can load out of band. $args = ( 'uet' === $network ) ? array( 'in_footer' => false ) : array( 'in_footer' => false, 'strategy' => 'async', ); // Vendor-hosted library: the vendor versions it, and a ?ver would // only split the vendor's CDN cache. wp_enqueue_script( $handle, $src, array( self::HANDLE ), null, $args ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion $gate = self::gate_for( $network ); if ( '' !== $gate ) { AdvisorPPC_Consent::register_gated( $handle, $gate ); } } } /* ------------------------------------------------------------------ */ /* noscript fallbacks */ /* ------------------------------------------------------------------ */ /** * Without JavaScript there is no consent dialogue and no way to gate a * pixel, so when consent mode is on we print no noscript pixels at all. * * @return bool */ private static function noscript_allowed() { if ( ! self::is_active() || ! self::should_load() ) { return false; } if ( AdvisorPPC_Consent::is_enabled() ) { return false; } /** * Filters whether the noscript fallback pixels are printed. * * @since 0.1.0 * @param bool $allowed Default true when consent mode is off. */ return (bool) apply_filters( 'advisorppc_print_noscript_pixels', true ); } /** * The GTM noscript iframe, which must sit immediately after . * * @return void */ public static function print_body_noscript() { if ( ! self::noscript_allowed() ) { return; } $ids = self::tag_ids(); if ( empty( $ids['gtm'] ) ) { return; } $src = 'https://www.googletagmanager.com/ns.html?id=' . rawurlencode( $ids['gtm'] ); echo ''; } /** * The image-based noscript pixels. * * @return void */ public static function print_footer_noscript() { if ( ! self::noscript_allowed() ) { return; } $ids = self::tag_ids(); $pixels = array(); if ( ! empty( $ids['meta'] ) ) { $pixels[] = 'https://www.facebook.com/tr?id=' . rawurlencode( $ids['meta'] ) . '&ev=PageView&noscript=1'; } if ( ! empty( $ids['linkedin'] ) ) { $pixels[] = 'https://px.ads.linkedin.com/collect/?pid=' . rawurlencode( $ids['linkedin'] ) . '&fmt=gif'; } if ( ! empty( $ids['pinterest'] ) ) { $pixels[] = 'https://ct.pinterest.com/v3/?event=init&tid=' . rawurlencode( $ids['pinterest'] ) . '&noscript=1'; } if ( empty( $pixels ) ) { return; } echo ''; } } add_action( 'advisorppc_loaded', array( 'AdvisorPPC_Tags', 'init' ) );