capi = new PXSYNC_CAPI(); $this->logger = new PXSYNC_Debug_Log(); add_action( 'wp_enqueue_scripts', [ $this, 'load_tracker_script' ] ); // AJAX helpers — always registered regardless of toggles add_action( 'wp_ajax_pxsync_hash_user_data', [ $this, 'ajax_hash_user_data' ] ); add_action( 'wp_ajax_nopriv_pxsync_hash_user_data', [ $this, 'ajax_hash_user_data' ] ); add_action( 'wp_ajax_pxsync_log_batch', [ $this, 'ajax_log_batch' ] ); add_action( 'wp_ajax_nopriv_pxsync_log_batch', [ $this, 'ajax_log_batch' ] ); add_action( 'wp_ajax_pxsync_poll_for_order', [ $this, 'ajax_poll_for_order' ] ); add_action( 'wp_ajax_nopriv_pxsync_poll_for_order', [ $this, 'ajax_poll_for_order' ] ); add_action( 'wp_ajax_pxsync_get_latest_order', [ $this, 'ajax_get_latest_order' ] ); add_action( 'wp_ajax_nopriv_pxsync_get_latest_order', [ $this, 'ajax_get_latest_order' ] ); // ViewContent CAPI (cache-proof AJAX handler) if ( get_option('pxsync_evt_viewcontent', 1) == 1 ) { add_action( 'wp_ajax_pxsync_fire_capi_vc', [ $this, 'handle_vc_capi' ] ); add_action( 'wp_ajax_nopriv_pxsync_fire_capi_vc', [ $this, 'handle_vc_capi' ] ); } // Search CAPI if ( get_option('pxsync_evt_search', 1) == 1 ) { add_action( 'wp_ajax_pxsync_fire_capi_search', [ $this, 'ajax_fire_capi_search' ] ); add_action( 'wp_ajax_nopriv_pxsync_fire_capi_search', [ $this, 'ajax_fire_capi_search' ] ); } // ViewCart CAPI if ( get_option('pxsync_evt_viewcart', 1) == 1 ) { add_action( 'wp_ajax_pxsync_fire_capi_vc_cart', [ $this, 'ajax_fire_capi_vc_cart' ] ); add_action( 'wp_ajax_nopriv_pxsync_fire_capi_vc_cart', [ $this, 'ajax_fire_capi_vc_cart' ] ); } // InitiateCheckout CAPI if ( get_option('pxsync_evt_initiatecheckout', 1) == 1 ) { add_action( 'wp_ajax_pxsync_fire_capi_checkout', [ $this, 'ajax_fire_capi_checkout' ] ); add_action( 'wp_ajax_nopriv_pxsync_fire_capi_checkout', [ $this, 'ajax_fire_capi_checkout' ] ); } // AddPaymentInfo CAPI if ( get_option('pxsync_evt_addpayment', 1) == 1 ) { add_action( 'woocommerce_checkout_update_order_review', [ $this, 'handle_api_capi' ] ); } // AddToCart CAPI if ( get_option('pxsync_evt_addtocart', 1) == 1 ) { add_action( 'woocommerce_add_to_cart', [ $this, 'handle_atc_capi' ], 10, 6); add_filter( 'woocommerce_add_to_cart_fragments', [ $this, 'add_eid_to_fragments' ] ); } // CompletePayment CAPI if ( get_option('pxsync_evt_purchase', 1) == 1 ) { add_action( 'woocommerce_checkout_order_processed', [ $this, 'handle_purchase_capi' ], 10, 1 ); add_action( 'woocommerce_thankyou', [ $this, 'handle_purchase_capi' ], 10, 1 ); add_action( 'woocommerce_payment_complete', [ $this, 'handle_purchase_capi' ], 10, 1 ); add_action( 'woocommerce_order_status_processing', [ $this, 'handle_purchase_capi' ], 10, 1 ); } // store_order_for_browser always runs so polling can work add_action( 'woocommerce_checkout_order_processed', [ $this, 'store_order_for_browser' ], 20, 1 ); add_action( 'wp_ajax_pxsync_confirm_browser_purchase', [ $this, 'ajax_confirm_browser_purchase' ] ); add_action( 'wp_ajax_nopriv_pxsync_confirm_browser_purchase', [ $this, 'ajax_confirm_browser_purchase' ] ); // CompleteRegistration CAPI if ( get_option('pxsync_evt_registration', 1) == 1 ) { add_action( 'user_register', [ $this, 'handle_registration_capi' ], 10, 1 ); } add_action( 'template_redirect', [ $this, 'capture_traffic_source' ] ); add_action( 'pxsync_retry_capi_event', [ $this, 'process_capi_retry' ], 10, 1 ); // Quality of Life: Admin Bar Indicator add_action( 'admin_bar_menu', [ $this, 'add_admin_bar_indicator' ], 999 ); } public function load_tracker_script() { // // QoL 1: Never fire tracking scripts inside the WP Admin dashboard // if ( is_admin() ) return; // // QoL 2: Do not track the Store Owner on the frontend (keeps analytics clean) // // Note: If you want to test the pixel yourself, just add ?pxsync_test=1 to your URL // if ( current_user_can( 'manage_options' ) && ! isset( $_GET['pxsync_test'] ) ) { // return; // } // Pull the Pixel ID from the 'options' table in the WordPress database. $pixel_id = get_option( 'pxsync_pixel_id' ); // If the user hasn't saved an ID in the settings, stop here to prevent errors. if ( empty( $pixel_id ) ) return; // Advanced Matching: Hashed Email if user is logged in // Get our advanced matching UI setting $user = wp_get_current_user(); $enable_am = (int) get_option( 'pxsync_enable_am', 0 ); // Never forget comments: Hashing logic is now centralized in PXSYNC_GDPR_Hash $hashed_email = ''; $hashed_phone = ''; $user_city = ''; if ( $enable_am && is_user_logged_in() ) { $hashed_email = PXSYNC_GDPR_Hash::hash_email( $user->user_email ); $raw_phone = get_user_meta( $user->ID, 'billing_phone', true ); $country = get_user_meta( $user->ID, 'billing_country', true ) ?: $this->tiktok_get_country_code(); $hashed_phone = PXSYNC_GDPR_Hash::format_and_hash_phone( $raw_phone, $country ); $raw_city = get_user_meta( $user->ID, 'billing_city', true ); $user_city = PXSYNC_GDPR_Hash::hash( str_replace(' ', '', $raw_city) ); } // Generates a stable External ID for deduplication and matching. $external_id = $this->get_external_id(); // Generate the ID // Generate IDs for all possible events on this page $event_ids = [ 'PageView' => $this->get_event_id('PageView'), ]; $auto_pageview = false; // 1. ViewContent only --- $product_data = null; $product_id = 0; if (is_product() && get_option('pxsync_evt_viewcontent', 1) == 1) { $product_id = get_the_ID(); $product_data = PXSYNC_Payload_Data::get_product_payload( $product_id ); //$event_ids['ViewContent'] = $this->get_event_id('ViewContent', $product_id); if (get_option('pxsync_evt_viewcontent', 1) == 1) { $auto_pageview = true; } } // 2. CompleteRegistration Detection --- $user_id = get_current_user_id(); // Even if not logged in yet, some setups pass the ID via session or cookie // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only lookup, no state change if ( ! $user_id && isset( $_GET['pxsync_reg_user'] ) ) { $user_id = absint( wp_unslash( $_GET['pxsync_reg_user'] ) ); } // phpcs:enable WordPress.Security.NonceVerification.Recommended if ( $user_id ) { $reg_event_id = get_transient( 'pxsync_reg_event_id_' . $user_id ); if ( $reg_event_id ) { $event_ids['CompleteRegistration'] = $reg_event_id; delete_transient( 'pxsync_reg_event_id_' . $user_id ); // Only fire once } } // 3. Search Logic --- $search_query = null; if ( is_search() && get_option('pxsync_evt_search', 1) == 1) { $search_query = get_search_query(); $event_ids['Search'] = $this->get_event_id('Search'); $auto_pageview = true; // CAPI moved to AJAX handler (pxsync_fire_capi_search) — cache-proof } // 4. ViewCart Logic --- $cart_data = null; if ( is_cart() && ! WC()->cart->is_empty() && get_option('pxsync_evt_viewcart', 1) == 1) { $event_ids['ViewCart'] = $this->get_event_id('ViewCart'); $cart_data = PXSYNC_Payload_Data::get_cart_payload(); $auto_pageview = true; // CAPI moved to AJAX handler (pxsync_fire_capi_vc_cart) — cache-proof } // 5. InitiateCheckout CAPI if ( is_checkout() && ! is_order_received_page() && get_option('pxsync_evt_initiatecheckout', 1) == 1) { $event_ids['InitiateCheckout'] = $this->get_event_id('InitiateCheckout', 'cart'); $event_ids['AddPaymentInfo'] = $this->get_event_id('AddPaymentInfo'); $cart_data = PXSYNC_Payload_Data::get_cart_payload(); $auto_pageview = true; // CAPI moved to AJAX handler (pxsync_fire_capi_checkout) — cache-proof } // 6. Order Data (CompletePayment) -- Standard thank-you page only $order_data = null; if ( is_order_received_page() ) { global $wp; $order_id = absint( $wp->query_vars['order-received'] ); if ( $order_id > 0 ) { // prevent the pixel from firing again if the user refreshes the page. $already_sent = get_post_meta( $order_id, '_pxsync_browser_sent', true ); if ( ! $already_sent ) { $event_ids["CompletePayment"] = $this->get_event_id('CompletePayment', $order_id); $order_data = PXSYNC_Payload_Data::get_order_payload( $order_id ); // Mark as sent to the browser so refresh doesn't trigger it again update_post_meta( $order_id, '_pxsync_browser_sent', 'yes' ); } } $auto_pageview = true; } // Register the JS file // Tell WordPress where the JavaScript file is and give it a version number (2.0.0) to clear old caches. wp_register_script( 'pxsync-external-tracker', PXSYNC_URL . 'assets/js/tracker.js', ['jquery'], PXSYNC_VERSION, true // Load in the footer ); // Page-reload add-to-cart: consume the session mailbox once (flash) and hand it to JS. $pending_atc = null; if ( get_option( 'pxsync_evt_addtocart', 1 ) == 1 && function_exists( 'WC' ) && WC()->session && ! $this->is_ajax_add_to_cart() ) { $p = WC()->session->get( 'pxsync_pending_atc' ); if ( ! empty( $p['event_id'] ) ) { $pending_atc = $p; WC()->session->__unset( 'pxsync_pending_atc' ); } } // 6. "Sticky Note" // This is the "Data Bridge." It takes PHP data and creates a JavaScript object named 'pxsync_vars'. // Pass both the ID and the hashed email to our JS file wp_localize_script( 'pxsync-external-tracker', 'pxsync_vars', [ 'pixel_id' => (string) $pixel_id, // Force String 'external_id' => $external_id, // <--- ADD THIS 'enable_am' => $enable_am, // Tell JS if it's allowed to send guest data 'event_ids' => $event_ids, 'user_email' => $hashed_email, 'user_phone' => $hashed_phone, 'user_city' => $user_city, 'ajax_url' => admin_url( 'admin-ajax.php' ) , // We add this URL so the JS knows where to send the data for hashing 'nonce' => wp_create_nonce( 'pxsync_security' ), 'product_data' => $product_data, // Pass product info if on a product page 'product_id' => $product_id, 'order_data' => $order_data, // data for purchases 'cart_data' => $cart_data, 'search_query' => $search_query, 'debug_mode' => (int) get_option('pxsync_debug_mode', 0), 'auto_pageview'=> $auto_pageview, 'raw_user_id' => is_user_logged_in() ? get_current_user_id() : 'Guest', // Event toggles — passed to JS so browser pixel also respects on/off state 'evt' => [ 'pageview' => get_option('pxsync_evt_pageview', 1), 'viewcontent' => get_option('pxsync_evt_viewcontent', 1), 'addtocart' => get_option('pxsync_evt_addtocart', 1), 'viewcart' => get_option('pxsync_evt_viewcart', 1), 'initiatecheckout' => get_option('pxsync_evt_initiatecheckout', 1), 'addpayment' => get_option('pxsync_evt_addpayment', 1), 'purchase' => get_option('pxsync_evt_purchase', 1), 'search' => get_option('pxsync_evt_search', 1), 'registration' => get_option('pxsync_evt_registration', 1), ], 'pending_atc' => $pending_atc, ]); // 7. Actually print the script tags onto the webpage. wp_enqueue_script( 'pxsync-external-tracker' ); } /** * The Secure Hasher for Guest Data * Takes raw data from JS (email and phone_number), hashes it, and sends it back. * Never forget comments: Formats phone numbers to E.164 before hashing. */ public function ajax_hash_user_data() { // 1. Security: Verify Nonce securely. // Passing 'false' prevents a hard die() and allows a graceful JSON error response. if ( ! check_ajax_referer( 'pxsync_security', 'nonce', false ) ) { wp_send_json_error( 'Invalid security token.', 403 ); } // 2. Extraction & Sanitization // Note: wp_unslash() is a WordPress standard requirement before sanitizing $_POST data. $raw_data = sanitize_text_field( wp_unslash( $_POST['data'] ?? '' ) ); $type = sanitize_text_field( wp_unslash( $_POST['type'] ?? 'email' ) ); // 3. Early Exit / Validation if ( empty( $raw_data ) ) { wp_send_json_error( 'Missing data payload.', 400 ); } $hashed_value = ''; // 4. Process Data by Type switch ( $type ) { case 'phone': $country_code = $this->tiktok_get_country_code(); // Format the raw input string directly (Fixed get_user_meta bug) $clean_phone = PXSYNC_GDPR_Hash::format_phone_e164( $raw_data, $country_code ); // Fallback to raw data if the formatter fails and only returns a '+' if ( empty( $clean_phone ) || $clean_phone === '+' ) { $clean_phone = $raw_data; } $hashed_value = PXSYNC_GDPR_Hash::hash( $clean_phone ); break; case 'email': default: // Email processing (hash function handles lowercase and trim) $hashed_value = PXSYNC_GDPR_Hash::hash( $raw_data ); break; } // 5. Output Response if ( empty( $hashed_value ) ) { wp_send_json_error( 'Hashing failed.', 500 ); } wp_send_json_success( [ 'hash' => $hashed_value, 'type' => $type // Returning the type is useful for frontend promise resolution ] ); } /** * Pro Event ID Generator: Stable, Deterministic, and Bulletproof. * Never forget comments: Deduplication fails if the ID changes on page refresh. * This logic ensures the ID stays the same for the user's current session/context. */ private static $runtime_cache = []; private function get_event_id($event_type, $context_id = '') { $key = $event_type . '_' . $context_id; // 1. Check Runtime Cache (Current Request) if (isset(self::$runtime_cache[$key])) { return self::$runtime_cache[$key]; } // 2. Generate ID based on Event Strategy switch ($event_type) { case 'ViewContent': // One unique ID per product view. The server creates it, fires CAPI with it and hands it to the // browser pixel, so the pair deduplicates, and every real view is counted (a refresh is a new view). $id = 'vc_' . wp_generate_uuid4(); break; case 'Search': // Stable ID based on the search query and the user's session $search_query = get_search_query(); $id = 'srch_' . md5($search_query . $this->get_user_session_hash()); break; case 'AddToCart': // Usually triggered via AJAX. A fresh UUID is fine here as it's a "Point in Time" action. $id = 'atc_' . wp_generate_uuid4(); break; case 'ViewCart': // Stable ID based on the current items in the cart $cart_hash = WC()->cart ? WC()->cart->get_cart_hash() : 'empty'; $id = 'cart_' . md5($cart_hash); break; case 'InitiateCheckout': /** * Stable Cart ID: Tie it to the WooCommerce Cart Hash. * As long as the cart items don't change, the ID is stable. */ // The 30-minute window makes each new checkout visit a new event. Without it the ID is the same // for every checkout with the same cart, so a second order with the same product would be // skipped by the "already sent" guard and the server event would silently stop firing. $cart_hash = WC()->cart ? WC()->cart->get_cart_hash() : uniqid(); $id = 'ic_' . md5( $cart_hash . '|' . floor( time() / 1800 ) ); break; case 'AddPaymentInfo': // Stable ID tied to the current cart state and user session $cart_hash = WC()->cart ? WC()->cart->get_cart_hash() : 'checkout'; $id = 'api_' . md5($cart_hash . $this->get_user_session_hash()); break; case 'CompletePayment': // The Gold Standard: Always tie to the Order Number. $id = 'order_' . $context_id; break; case 'CompleteRegistration': // Use the User ID as the unique anchor for registration events $id = 'reg_' . $context_id; break; default: // Standard PageView or unknown events $id = 'req_' . wp_generate_uuid4(); break; } return self::$runtime_cache[$key] = $id; } /** * Helper to get a stable hash for the current user/guest */ private function get_user_session_hash() { // 1. Check if the browser passed a specific Session ID via the AJAX request // The calling AJAX handlers verify the nonce before this helper runs. // phpcs:disable WordPress.Security.NonceVerification.Missing if ( isset($_POST['pxsync_sess_id']) && !empty($_POST['pxsync_sess_id']) ) { return sanitize_text_field(wp_unslash($_POST['pxsync_sess_id']) ); } // phpcs:enable WordPress.Security.NonceVerification.Missing // 2. Check standard cookie if ( isset($_COOKIE['pxsync_sess_id']) ) { return sanitize_text_field( wp_unslash( $_COOKIE['pxsync_sess_id'] ) ); } // 3. Fallback: Generate and set $inst_id = wp_generate_password(24, false); setcookie('pxsync_sess_id', $inst_id, [ 'expires' => time() + (86400 * 7), 'path' => COOKIEPATH ?: '/', 'secure' => is_ssl(), 'httponly' => false, // Set to false so JS can read it as a backup 'samesite' => 'Lax' ]); return $inst_id; } /** * Generates a stable External ID for deduplication and matching. * Never forget comments: Logged-in users use their WP ID; guests use their session/IP. */ private function get_external_id() { if ( is_user_logged_in() ) { // Use the permanent WordPress User ID for logged-in users return hash( 'sha256', (string) get_current_user_id() ); } // For guests, we use the WooCommerce session ID. // If no session exists yet, we fallback to the IP address. $session_id = ( WC()->session && WC()->session->get_customer_id() ) ? WC()->session->get_customer_id() : $this->capi->get_client_ip(); return hash( 'sha256', 'guest_' . $session_id ); } /** * Handle Purchase CAPI * Never forget comments: We use a meta flag '_pxsync_capi_sent' to ensure * the server only reports this conversion once, preventing duplicates on refresh. */ public function handle_purchase_capi( $order_id ) { if ( ! $order_id ) return; // Handle hooks that pass WC_Order object instead of integer ID if ( is_a( $order_id, 'WC_Order' ) ) { $order_id = $order_id->get_id(); } $order = wc_get_order( $order_id ); if ( ! $order ) return; // Drop order ID into WC session mailbox for browser polling if ( isset( WC()->session ) && WC()->session->has_session() ) { WC()->session->set( 'pxsync_pixel_order_id', $order_id ); } // 1. Prevent double-firing: Exit if CAPI token is missing or already attempted if ( empty( get_option( 'pxsync_capi_token' ) ) ) return; if ( 'yes' === get_post_meta( $order_id, '_pxsync_capi_sent', true ) ) return; // Lock IMMEDIATELY before the API call — prevents any race condition // regardless of API response time or secondary hook triggers update_post_meta( $order_id, '_pxsync_capi_sent', 'yes' ); // Prepare Data $event_id = $order_id ? $this->get_event_id( 'CompletePayment', $order_id ) : ''; $order_data = PXSYNC_Payload_Data::get_order_payload( $order_id ); $external_id = $this->get_external_id(); // AFTER — falls back to order billing email for guests: $email = $order ? $order->get_billing_email() : wp_get_current_user()->user_email; $user_context = [ 'email' => PXSYNC_GDPR_Hash::hash_email($email), 'phone' => PXSYNC_GDPR_Hash::format_and_hash_phone( $order->get_billing_phone(), $order->get_billing_country() ), 'url' => $order->get_checkout_order_received_url() ]; // Dispatch to TikTok CAPI $response = $this->capi->send( 'CompletePayment', $event_id, $external_id, $order_data, $user_context ); $status_code = 0; // Handle cURL / connection errors if ( is_wp_error( $response ) ) { $error_msg = $response->get_error_message(); update_post_meta( $order_id, '_pxsync_capi_status', 'failed' ); update_post_meta( $order_id, '_pxsync_capi_error', $error_msg ); $status_code = '500'; } else { // Handle TikTok API non-2xx responses $status_code = wp_remote_retrieve_response_code( $response ); if ( $status_code < 200 || $status_code >= 300 ) { update_post_meta( $order_id, '_pxsync_capi_status', 'failed_http_' . $status_code ); update_post_meta( $order_id, '_pxsync_capi_error', wp_remote_retrieve_body( $response ) ); } else { update_post_meta( $order_id, '_pxsync_capi_status', 'success' ); } } // Live Diagnostic Feed Logger (Logs success, HTTP errors, and cURL failures) if ( get_option( 'pxsync_debug_mode', 0 ) == 1 ) { $log_details = [ 'id' => isset( $order_data['contents'][0]['content_id'] ) ? $order_data['contents'][0]['content_id'] : 'N/A', 'value' => isset( $order_data['value'] ) ? $order_data['value'] : 0, 'currency' => isset( $order_data['currency'] ) ? $order_data['currency'] : 'USD', 'device' => $this->get_device_type(), 'user_id' => get_current_user_id() ?: 'Guest' ]; $this->logger->add_entry( 'CompletePayment', 'Server (CAPI)', $this->get_stored_platform(), $status_code, $log_details, $event_id ); } } /** True when the current request is WooCommerce's AJAX add-to-cart endpoint. */ private function is_ajax_add_to_cart() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return isset( $_GET['wc-ajax'] ) && 'add_to_cart' === sanitize_key( wp_unslash( $_GET['wc-ajax'] ) ); } /** * Sends AddToCart via CAPI and hands the SAME event_id to the browser through the WC session. * Hooked to woocommerce_add_to_cart, so it only runs when the item was really added. */ public function handle_atc_capi( $cart_id, $product_id, $quantity = 1, $variation_id = 0, $variation = [], $cart_item_data = [] ) { if ( ! $product_id ) return; $product_data = PXSYNC_Payload_Data::get_product_payload( $product_id ); if ( ! $product_data ) return; // Reflect the real quantity (payload is cached per product, so work on a copy). $quantity = max( 1, (int) $quantity ); if ( $quantity > 1 && isset( $product_data['contents'][0] ) ) { $product_data['contents'][0]['quantity'] = $quantity; $product_data['value'] = (float) $product_data['contents'][0]['price'] * $quantity; } $event_id = 'atc_' . wp_generate_uuid4(); // Mailbox for the browser. Consumed once (AJAX fragments or next full page load). if ( function_exists( 'WC' ) && WC()->session ) { WC()->session->set( 'pxsync_pending_atc', [ 'event_id' => $event_id, 'payload' => $product_data, ] ); } if ( ! empty( get_option( 'pxsync_capi_token' ) ) ) { $user = wp_get_current_user(); $user_context = [ 'email' => PXSYNC_GDPR_Hash::hash_email( $user->user_email ), 'phone' => PXSYNC_GDPR_Hash::format_and_hash_phone( get_user_meta( $user->ID, 'billing_phone', true ), $this->tiktok_get_country_code() ), ]; $response = $this->capi->send( 'AddToCart', $event_id, $this->get_external_id(), $product_data, $user_context ); if ( get_option( 'pxsync_debug_mode', 0 ) == 1 ) { $status_code = wp_remote_retrieve_response_code( $response ); $log_details = [ 'id' => isset( $product_data['contents'][0]['content_id'] ) ? $product_data['contents'][0]['content_id'] : 'N/A', 'value' => isset( $product_data['value'] ) ? $product_data['value'] : 0, 'currency' => isset( $product_data['currency'] ) ? $product_data['currency'] : 'USD', 'device' => $this->get_device_type(), 'user_id' => get_current_user_id() ?: 'Guest', ]; $this->logger->add_entry( 'AddToCart', 'Server (CAPI)', $this->get_stored_platform(), $status_code, $log_details, $event_id ); } } } /** * AJAX path: give the browser the pending ID inside the add_to_cart response (consumed once). * Only for the add_to_cart endpoint, so fragment refreshes on page load don't eat the mailbox. */ public function add_eid_to_fragments( $fragments ) { if ( ! $this->is_ajax_add_to_cart() || ! WC()->session ) return $fragments; $pending = WC()->session->get( 'pxsync_pending_atc' ); if ( ! empty( $pending['event_id'] ) ) { $fragments['pxsync_last_event_id'] = $pending['event_id']; $fragments['pxsync_last_product_data'] = $pending['payload']; WC()->session->__unset( 'pxsync_pending_atc' ); } return $fragments; } /** * Sends ViewContent event to TikTok via CAPI. */ function handle_vc_capi() { //ignore_user_abort(true); $nonce = isset($_POST['nonce']) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; $product_id = isset($_POST['product_id']) ? absint( wp_unslash( $_POST['product_id'] ) ) : 0; if ( ! wp_verify_nonce( $nonce, 'pxsync_security' ) || ! $product_id ) { $this->log_server_skip( 'ViewContent', 'product_' . $product_id, 'Invalid nonce or product' ); wp_send_json_error('Invalid request'); return; } // 1. SERVER INITIALIZES THE ID // We use your highly stable generator based on the user's session and product $event_id = $this->get_event_id('ViewContent', $product_id); $product_data = PXSYNC_Payload_Data::get_product_payload($product_id); // 2. FIRE CAPI (If token exists) if ( ! empty( get_option( 'pxsync_capi_token' ) ) ) { if ( $this->is_vc_rate_limited() ) { // Abuse guard only (a bot hammering product pages), never a limit for normal browsing. $this->log_server_skip( 'ViewContent', $event_id, 'Skipped: rate limit' ); } else { $external_id = $this->get_external_id(); $user = wp_get_current_user(); $user_context = [ 'email' => PXSYNC_GDPR_Hash::hash_email( $user->user_email ), 'phone' => PXSYNC_GDPR_Hash::format_and_hash_phone( get_user_meta($user->ID, 'billing_phone', true), $this->tiktok_get_country_code() ), ]; $response = $this->capi->send( 'ViewContent', $event_id, $external_id, $product_data, $user_context ); $status_code = wp_remote_retrieve_response_code( $response ); if ( get_option( 'pxsync_debug_mode', 0 ) == 1 ) { $log_details = [ 'id' => isset($product_data['contents'][0]['content_id']) ? $product_data['contents'][0]['content_id'] : 'N/A', 'value' => isset($product_data['value']) ? $product_data['value'] : 0, 'currency' => isset($product_data['currency']) ? $product_data['currency'] : 'USD', 'device' => $this->get_device_type(), 'user_id' => get_current_user_id() ?: 'Guest', ]; $this->logger->add_entry( 'ViewContent', 'Server (CAPI)', $this->get_stored_platform(), $status_code, $log_details, $event_id ); } } } // 3. RETURN THE SERVER-GENERATED ID TO THE BROWSER wp_send_json_success([ 'event_id' => $event_id ]); } /** * Sends AddPaymentInfo to TikTok via CAPI when checkout is updated. */ public function handle_api_capi( $post_data ) { if ( empty(get_option('pxsync_capi_token')) ) return; parse_str( $post_data, $params ); // Only fire if a payment method is selected if ( empty( $params['payment_method'] ) ) return; // Use a session flag to prevent duplicate CAPI hits for the same checkout session $cart_hash = WC()->cart->get_cart_hash(); if ( WC()->session->get( 'pxsync_api_sent' ) === $cart_hash ) return; $event_id = $this->get_event_id( 'AddPaymentInfo' ); $external_id = $this->get_external_id(); $cart_data = PXSYNC_Payload_Data::get_cart_payload(); $user = wp_get_current_user(); $user_context = [ 'email' => PXSYNC_GDPR_Hash::hash_email( $user->user_email ), 'phone' => PXSYNC_GDPR_Hash::format_and_hash_phone( get_user_meta($user->ID, 'billing_phone', true), $this->tiktok_get_country_code() ) ]; $response = $this->capi->send( 'AddPaymentInfo', $event_id, $external_id, $cart_data, $user_context ); if ( get_option( 'pxsync_debug_mode', 0 ) == 1 ) { $status_code = wp_remote_retrieve_response_code( $response ); $this->logger->add_entry( 'AddPaymentInfo', 'Server (CAPI)',$this->get_stored_platform(), $status_code, $cart_data, $event_id ); } WC()->session->set( 'pxsync_api_sent', $cart_hash ); } /** * Sends CompleteRegistration to TikTok via CAPI immediately upon account creation. */ public function handle_registration_capi( $user_id ) { if ( ! $user_id || empty(get_option('pxsync_capi_token')) ) return; $user = get_userdata( $user_id ); if ( ! $user ) return; $event_id = $this->get_event_id( 'CompleteRegistration', $user_id ); $external_id = hash( 'sha256', (string) $user_id ); // Stable ID for this new user // Pass this ID to the browser session so the Pixel can match it set_transient( 'pxsync_reg_event_id_' . $user_id, $event_id, 600 ); // Valid for 10 mins $user_context = [ 'email' => PXSYNC_GDPR_Hash::hash_email( $user->user_email ), // Phone might not be available yet depending on the form, but we attempt it 'phone' => PXSYNC_GDPR_Hash::format_and_hash_phone( get_user_meta($user_id, 'billing_phone', true), $this->tiktok_get_country_code() ) ]; $response = $this->capi->send( 'CompleteRegistration', $event_id, $external_id, [], $user_context ); if ( get_option( 'pxsync_debug_mode', 0 ) == 1 ) { $status_code = wp_remote_retrieve_response_code( $response ); $this->logger->add_entry( 'CompleteRegistration', 'Server (CAPI)',$this->get_stored_platform(), $status_code, 'New User: ' . $user->user_login, $event_id ); } } function tiktok_get_country_code() { $country = ''; // Initialize // 2. WooCommerce store base country if (empty($country)) { $country = WC()->countries->get_base_country(); } // 3. WordPress locale fallback if (empty($country)) { $locale = get_locale(); if (preg_match('/_([A-Z]{2})$/', $locale, $matches)) { $country = $matches[1]; } } // 4. Plugin default setting fallback if (empty($country)) { $country = get_option('pxsync_default_country', 'US'); } return $country; } function tiktok_get_dial_code( $iso_country ) { static $codes = null; // 1. If already loaded in this specific request, return immediately if ( $codes !== null ) { return $codes[ $iso_country ] ?? '1'; } // 2. Try to get from WordPress Object Cache $codes = wp_cache_get( 'pxsync_dial_codes' ); if ( false === $codes ) { // 3. Not in cache? Load from the file $file_path = plugin_dir_path( __FILE__ ) . 'data/country-dial-codes.php'; if ( file_exists( $file_path ) ) { $codes = include $file_path; // 4. Store in cache for 24 hours to prevent repeated file reading wp_cache_set( 'pxsync_dial_codes', $codes, '', DAY_IN_SECONDS ); } else { $codes = []; // Fallback to avoid repeated file_exists checks } } return $codes[ $iso_country ] ?? '1'; } /** * AJAX Batch Logger (Enterprise Version) * Never forget comments: Handles sendBeacon FormData and converts JS timestamps. */ /** * Whitelists and sanitizes the "details" a visitor's browser sends to the Live Log. * Only known scalar fields are kept, each with a length limit, so nothing nested or oversized is stored. */ private function sanitize_log_details( $details ) { $clean = []; if ( ! is_array( $details ) ) { return [ 'info' => substr( sanitize_text_field( is_scalar( $details ) ? (string) $details : '' ), 0, 100 ) ]; } foreach ( [ 'id', 'value', 'currency', 'device', 'user_id' ] as $key ) { if ( isset( $details[ $key ] ) && is_scalar( $details[ $key ] ) ) { $clean[ $key ] = substr( sanitize_text_field( (string) $details[ $key ] ), 0, 100 ); } } return $clean; } public function ajax_log_batch() { // Use wp_verify_nonce instead of check_ajax_referer // sendBeacon doesn't guarantee delivery of all FormData fields $nonce = isset($_POST['nonce']) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'pxsync_security' ) ) { wp_send_json_error('Invalid nonce'); // Returns JSON, doesn't die with HTML return; } // JSON blob: decoded below and every field is sanitized individually. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $events_json = isset($_POST['events']) ? wp_unslash( $_POST['events'] ) : ''; $events = json_decode($events_json, true); if ( ! is_array($events) ) { wp_send_json_error('Invalid Log Data'); } // Nothing to log when debug mode is off (the browser only sends batches in debug mode). if ( ! get_option( 'pxsync_debug_mode', 0 ) ) { wp_send_json_success( 'Debug off' ); } // This endpoint is public (visitors), so cap the batch size and throttle per IP. $events = array_slice( $events, 0, 20 ); $throttle = 'pxsync_lograte_' . md5( $this->get_rate_limit_ip() ); $hits = (int) get_transient( $throttle ); if ( $hits >= 60 ) { wp_send_json_error( 'Too many requests' ); } set_transient( $throttle, $hits + 1, MINUTE_IN_SECONDS ); $allowed_events = [ 'PageView', 'ViewContent', 'AddToCart', 'ViewCart', 'InitiateCheckout', 'AddPaymentInfo', 'CompletePayment', 'CompleteRegistration', 'Search' ]; foreach ( $events as $ev ) { if ( ! is_array( $ev ) ) continue; $name = isset( $ev['event'] ) ? sanitize_text_field( (string) $ev['event'] ) : ''; if ( ! in_array( $name, $allowed_events, true ) ) continue; $details = $this->sanitize_log_details( $ev['details'] ?? [] ); $event_id = substr( sanitize_text_field( (string) ( $ev['event_id'] ?? '' ) ), 0, 100 ); $source_platform = substr( sanitize_text_field( (string) ( $ev['source_platform'] ?? 'Direct' ) ), 0, 50 ); $this->logger->add_entry( $name, 'Browser', $source_platform, '200', $details, $event_id ); } wp_send_json_success('Batch Logged'); } /** * Capture and store the original traffic source in the session. * Run this early (e.g., in the __construct or on 'init' hook). */ public function capture_traffic_source() { if ( is_admin() || ! isset( WC()->session ) ) return; // FOR TESTING: Comment out the line below to allow overwriting // if ( WC()->session->get( 'pxsync_original_platform' ) ) return; $platform = 'Search / Web'; // 1. Check for Click IDs via $_GET (Most reliable) // Secure Superglobal sanitization // Click IDs arrive in ad-click URLs (no form, no nonce possible); they are only read and sanitized. // phpcs:disable WordPress.Security.NonceVerification.Recommended $ttclid = isset( $_GET['ttclid'] ) ? sanitize_text_field( wp_unslash( $_GET['ttclid'] ) ) : ''; $fbclid = isset( $_GET['fbclid'] ) ? sanitize_text_field( wp_unslash( $_GET['fbclid'] ) ) : ''; $gclid = isset( $_GET['gclid'] ) ? sanitize_text_field( wp_unslash( $_GET['gclid'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended if ( ! empty( $ttclid ) ) { $platform = 'TikTok'; } elseif ( ! empty( $fbclid ) ) { $platform = 'Facebook'; } elseif ( ! empty( $gclid ) ) { $platform = 'Google Ads'; } // 2. Fallback to Referrer elseif ( ! empty( $_SERVER['HTTP_REFERER'] ) ) { $referer = esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ); $host = (string) wp_parse_url( $referer, PHP_URL_HOST ); if ( strpos( $host, 'tiktok.com' ) !== false ) $platform = 'TikTok'; elseif ( strpos( $host, 'facebook.com' ) !== false ) $platform = 'Facebook'; elseif ( strpos( $host, 'google.' ) !== false ) $platform = 'Google'; else if (strpos( $host,'instagram')!== false) $platform = 'Instagram'; else if (strpos( $host,'bing')!== false) $platform = 'Bing'; else if (strpos( $host,'twitter') !== false || strpos( $host,'x.com')!== false) $platform = 'Twitter/X'; } WC()->session->set( 'pxsync_original_platform', $platform ); } /** * Retrieve the stored platform for CAPI events. */ public function get_stored_platform() { return ( isset( WC()->session ) ) ? WC()->session->get( 'pxsync_original_platform' ) : 'Direct'; } /** * Called by K1 browser pixel immediately after it fires on the thank-you page. * Clears the WC session mailbox so K2 polling finds nothing and cannot * double-fire. Also sets _pxsync_browser_sent to block any further browser attempts. * Never forget comments: This is the K1/K2 handshake that eliminates * the browser-side duplicate conversion. */ public function ajax_confirm_browser_purchase() { $nonce = isset($_POST['nonce']) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'pxsync_security' ) ) { wp_send_json_error('Invalid nonce'); return; } // Clear the WC session mailbox — K2 poll will now return nothing if ( isset( WC()->session ) && WC()->session->has_session() ) { WC()->session->__unset('pxsync_pixel_order_id'); } // Set browser sent flag — blocks ajax_poll_for_order from responding $order_id = isset($_POST['order_id']) ? absint( wp_unslash( $_POST['order_id'] ) ) : 0; if ( $order_id ) { update_post_meta( $order_id, '_pxsync_browser_sent', 'yes' ); } wp_send_json_success('Confirmed'); } /** * Returns order payload for browser pixel. * Accepts order_id directly from JS interceptor (K2) * OR falls back to session-based mailbox for COD/same-page checkouts (K3). */ public function ajax_get_latest_order() { $nonce = isset($_POST['nonce']) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'pxsync_security' ) ) { wp_send_json_error('Invalid nonce'); return; } $order_id = 0; // Strategy 1: Order ID passed directly by JS K2 interceptor if ( ! empty($_POST['order_id']) ) { $order_id = absint( wp_unslash( $_POST['order_id'] ) ); } // Strategy 2: Session mailbox fallback for K3 DOM polling if ( ! $order_id ) { $session_id = $this->get_user_session_hash(); if ( $session_id ) { $order_id = (int) get_transient( 'pxsync_pending_order_' . $session_id ); if ( $order_id ) { delete_transient( 'pxsync_pending_order_' . $session_id ); } } } if ( ! $order_id ) { wp_send_json_error('No order found'); return; } // Prevent browser double-firing across all three layers if ( get_post_meta( $order_id, '_pxsync_browser_sent', true ) ) { wp_send_json_error('Already sent'); return; } $order_data = PXSYNC_Payload_Data::get_order_payload( $order_id ); $event_id = $this->get_event_id( 'CompletePayment', $order_id ); update_post_meta( $order_id, '_pxsync_browser_sent', 'yes' ); wp_send_json_success([ 'order_data' => $order_data, 'event_id' => $event_id, ]); } /** * Returns order payload for browser pixel. * Never forget comments: Reads from WC session which is always available * during checkout regardless of custom plugins or same-page checkout. */ public function ajax_poll_for_order() { $nonce = isset($_POST['nonce']) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'pxsync_security' ) ) { wp_send_json_error('Invalid nonce'); return; } if ( ! WC()->session ) { wp_send_json_error('No WC session'); return; } $order_id = (int) WC()->session->get('pxsync_pixel_order_id'); if ( ! $order_id ) { wp_send_json_error('No order yet'); return; } // Prevent double-firing if ( get_post_meta( $order_id, '_pxsync_browser_sent', true ) ) { WC()->session->__unset('pxsync_pixel_order_id'); wp_send_json_error('Already sent'); return; } $order_data = PXSYNC_Payload_Data::get_order_payload( $order_id ); $event_id = $this->get_event_id( 'CompletePayment', $order_id ); update_post_meta( $order_id, '_pxsync_browser_sent', 'yes' ); WC()->session->__unset('pxsync_pixel_order_id'); wp_send_json_success([ 'order_data' => $order_data, 'event_id' => $event_id, ]); } /** * Stores the order ID in the WooCommerce session immediately after creation. * Never forget comments: We use the WC session (not a cookie) because it is * guaranteed to exist during any WooCommerce checkout request. The browser * reads it back via ajax_get_order_for_pixel on the next page load or poll. */ public function store_order_for_browser( $order_id ) { if ( ! $order_id || ! WC()->session ) return; WC()->session->set( 'pxsync_pixel_order_id', $order_id ); } /** * Cache-Proof Search CAPI Handler. * Never forget comments: Browser sends the event_id it received from * wp_localize_script. Server fires CAPI with that same ID. */ public function ajax_fire_capi_search() { $nonce = isset($_POST['nonce']) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; $event_id = isset($_POST['event_id']) ? sanitize_text_field( wp_unslash( $_POST['event_id'] ) ) : ''; $query = isset($_POST['query']) ? sanitize_text_field( wp_unslash( $_POST['query'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'pxsync_security' ) || ! $event_id ) { wp_send_json_error('Invalid request'); return; } if ( empty( get_option('pxsync_capi_token') ) ) { wp_send_json_success('CAPI disabled'); return; } $search_data = [ 'query' => $query ]; $external_id = $this->get_external_id(); $user = wp_get_current_user(); $user_context = [ 'email' => PXSYNC_GDPR_Hash::hash_email( $user->user_email ), 'phone' => PXSYNC_GDPR_Hash::format_and_hash_phone( get_user_meta( $user->ID, 'billing_phone', true ), $this->tiktok_get_country_code() ), ]; $response = $this->capi->send( 'Search', $event_id, $external_id, $search_data, $user_context ); if ( get_option( 'pxsync_debug_mode', 0 ) == 1 ) { $status_code = wp_remote_retrieve_response_code( $response ); $this->logger->add_entry( 'Search', 'Server (CAPI)', $this->get_stored_platform(), $status_code, $search_data, $event_id ); } wp_send_json_success(); } /** * Cache-Proof ViewCart CAPI Handler. */ public function ajax_fire_capi_vc_cart() { $nonce = isset($_POST['nonce']) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; $event_id = isset($_POST['event_id']) ? sanitize_text_field( wp_unslash( $_POST['event_id'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'pxsync_security' ) || ! $event_id ) { wp_send_json_error('Invalid request'); return; } if ( empty( get_option('pxsync_capi_token') ) ) { wp_send_json_success('CAPI disabled'); return; } $cart_data = PXSYNC_Payload_Data::get_cart_payload(); $external_id = $this->get_external_id(); $user = wp_get_current_user(); $user_context = [ 'email' => PXSYNC_GDPR_Hash::hash_email( $user->user_email ), 'phone' => PXSYNC_GDPR_Hash::format_and_hash_phone( get_user_meta( $user->ID, 'billing_phone', true ), $this->tiktok_get_country_code() ), ]; $response = $this->capi->send( 'ViewCart', $event_id, $external_id, $cart_data, $user_context ); if ( get_option( 'pxsync_debug_mode', 0 ) == 1 ) { $status_code = wp_remote_retrieve_response_code( $response ); $this->logger->add_entry( 'ViewCart', 'Server (CAPI)', $this->get_stored_platform(), $status_code, [ 'value' => $cart_data['value'] ?? 0, 'currency' => $cart_data['currency'] ?? 'DZD', ], $event_id ); } wp_send_json_success(); } /** * Cache-Proof InitiateCheckout CAPI Handler. * Never forget comments: AddPaymentInfo CAPI still fires via * handle_api_capi on woocommerce_checkout_update_order_review * because that hook is not affected by page caching. */ /** Shows a skipped/failed InitiateCheckout server call in the Live Log (debug mode only). */ private function log_ic_skip( $event_id, $status ) { if ( get_option( 'pxsync_debug_mode', 0 ) == 1 ) { $this->logger->add_entry( 'InitiateCheckout', 'Server (CAPI)', $this->get_stored_platform(), $status, [ 'info' => $status ], $event_id ); } } public function ajax_fire_capi_checkout() { $nonce = isset($_POST['nonce']) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; $event_id = isset($_POST['event_id']) ? sanitize_text_field( wp_unslash( $_POST['event_id'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'pxsync_security' ) || ! $event_id ) { $this->log_ic_skip( $event_id, 'Error: Invalid nonce' ); wp_send_json_error('Invalid request'); return; } if ( empty( get_option('pxsync_capi_token') ) ) { wp_send_json_success('CAPI disabled'); return; } // Same checkout visit reloaded: the ID is identical, so do not send it twice. if ( WC()->session && WC()->session->get('pxsync_ic_sent') === $event_id ) { $this->log_ic_skip( $event_id, 'Skipped: already sent' ); wp_send_json_success('Already sent'); return; } $cart_data = PXSYNC_Payload_Data::get_cart_payload(); $external_id = $this->get_external_id(); $user = wp_get_current_user(); $user_context = [ 'email' => PXSYNC_GDPR_Hash::hash_email( $user->user_email ), 'phone' => PXSYNC_GDPR_Hash::format_and_hash_phone( get_user_meta( $user->ID, 'billing_phone', true ), $this->tiktok_get_country_code() ), ]; $response = $this->capi->send( 'InitiateCheckout', $event_id, $external_id, $cart_data, $user_context ); if ( WC()->session ) { WC()->session->set( 'pxsync_ic_sent', $event_id ); } if ( get_option( 'pxsync_debug_mode', 0 ) == 1 ) { $status_code = wp_remote_retrieve_response_code( $response ); $this->logger->add_entry( 'InitiateCheckout', 'Server (CAPI)', $this->get_stored_platform(), $status_code, [ 'id' => $cart_data['contents'][0]['content_id'] ?? 'N/A', 'value' => $cart_data['value'] ?? 0, 'currency' => $cart_data['currency'] ?? 'DZD', ], $event_id ); } wp_send_json_success(); } public function get_device_type() { $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; $user_agent = strtolower( $user_agent ); if (preg_match('/(tablet|ipad|playbook)|(android(?!.*(mobi|opera mini)))/i', $user_agent)) return 'Tablet'; if (preg_match('/(up.browser|up.link|mmp|symbian|smartphone|midp|wap|phone|android|iemobile|mobile)/i', $user_agent)) return 'Mobile'; return 'Desktop'; } public function process_capi_retry( $args ) { if ( empty($args['event']) || empty($args['event_id']) ) return; $this->capi->send( $args['event'], $args['event_id'], $args['external_id'] ?? '', $args['properties'] ?? [], $args['user'] ?? [] ); } /** * Address used only for throttling. It is REMOTE_ADDR, the address that actually connected to the server, * because forwarding headers can be set by anyone and would let a bot dodge the limit. * (Behind a CDN many visitors can share one address, which is why the limits are generous.) */ private function get_rate_limit_ip() { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : 'unknown'; } /** * Abuse guard for the public ViewContent endpoint: at most 120 server sends per minute per connecting address. * Normal browsing never reaches this; it only stops a bot from flooding your TikTok token. */ private function is_vc_rate_limited() { $key = 'pxsync_vcrate_' . md5( $this->get_rate_limit_ip() ); $hits = (int) get_transient( $key ); if ( $hits >= 120 ) return true; set_transient( $key, $hits + 1, MINUTE_IN_SECONDS ); return false; } /** Shows a skipped server event in the Live Log (debug mode only). */ private function log_server_skip( $event_name, $event_id, $status ) { if ( get_option( 'pxsync_debug_mode', 0 ) == 1 ) { $this->logger->add_entry( $event_name, 'Server (CAPI)', $this->get_stored_platform(), $status, [ 'info' => $status ], $event_id ); } } /** * Quality of Life: Admin Bar Indicator * Displays a status badge in the top WP Admin bar for site owners. */ public function add_admin_bar_indicator( $wp_admin_bar ) { // Only show this to administrators if ( ! current_user_can( 'manage_options' ) ) return; $pixel_id = get_option( 'pxsync_pixel_id' ); $debug_mode = get_option( 'pxsync_debug_mode', 0 ); // Determine friendly Status & Color if ( empty( $pixel_id ) ) { $title = '🔴 TikTok: Action Needed'; $color = '#ff4d4d'; } elseif ( $debug_mode ) { $title = '🟠 TikTok: Testing Mode'; $color = '#ff9800'; } else { $title = '🟢 TikTok: Tracking Active'; $color = '#25F4EE'; // Kept your signature cyan! } // Add the node to the top black admin bar $wp_admin_bar->add_node( [ 'id' => 'pxsync-status', 'title' => '' . $title . '', 'href' => admin_url( 'admin.php?page=pxsync-bridge' ), 'meta' => [ 'title' => 'Click to manage TikTok tracking settings' ] ] ); } }