is_tracking_enabled() ) { return; } // Client-side tracking. add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_pixel' ), 1 ); add_action( 'wp_enqueue_scripts', array( $this, 'inject_page_data' ), 10 ); add_action( 'wp_enqueue_scripts', array( $this, 'inject_cart_rebuild_script' ), 20 ); // Server-side tracking. add_action( 'woocommerce_add_to_cart', array( $this, 'track_add_to_cart' ), 25, 6 ); add_action( 'woocommerce_remove_cart_item', array( $this, 'track_remove_from_cart' ), 25, 2 ); add_action( 'woocommerce_thankyou', array( $this, 'track_order_completed' ), 10, 1 ); add_action( 'woocommerce_checkout_update_order_review', array( $this, 'capture_checkout_email' ) ); // Cart rebuild. add_action( 'init', array( $this, 'handle_cart_rebuild' ) ); } /** * Enqueue pixel script. * * Note: Cannot use 'defer' or 'async' strategy because this script uses * wp_add_inline_script() in the 'after' position, which prevents deferred loading. * See: https://make.wordpress.org/core/2023/07/14/registering-scripts-with-async-and-defer-attributes-in-wordpress-6-3/ */ public function enqueue_pixel() { $options = agentshop()->get_options(); wp_enqueue_script( 'agentshop-pixel', AGENTSHOP_PLUGIN_URL . 'assets/js/pixel.js', array( 'jquery' ), AGENTSHOP_VERSION, array( 'in_footer' => true, ) ); wp_localize_script( 'agentshop-pixel', 'agentshopConfig', array( 'pixelToken' => $options['pixel_token'] ?? '', 'storeId' => $options['store_id'] ?? '', 'storeUrl' => agentshop()->get_store_url(), 'apiEndpoint' => agentshop()->get_api_endpoint() . '/api/woocommerce/pixel/events', 'customerId' => agentshop()->get_customer_id(), 'sessionId' => agentshop()->get_session_id(), 'version' => AGENTSHOP_VERSION, 'currency' => get_woocommerce_currency(), 'currencySymbol' => get_woocommerce_currency_symbol(), ) ); } /** * Inject page data. */ public function inject_page_data() { $data = array( 'pageType' => $this->get_page_type(), ); if ( is_product() ) { $product = wc_get_product(); if ( $product ) { $data['product'] = $this->build_product_data( $product ); } } if ( is_cart() || is_checkout() ) { $data['cart'] = $this->build_cart_data(); } if ( is_search() ) { $data['search'] = array( 'query' => get_search_query(), 'results_count' => $GLOBALS['wp_query']->found_posts ?? 0, ); } wp_localize_script( 'agentshop-pixel', 'agentshopPageData', $data ); } /** * Get page type. * * @return string */ private function get_page_type() { if ( is_front_page() ) { return 'home'; } if ( is_product() ) { return 'product'; } if ( is_product_category() || is_product_tag() ) { return 'collection'; } if ( is_cart() ) { return 'cart'; } if ( is_checkout() ) { return is_wc_endpoint_url( 'order-received' ) ? 'thank_you' : 'checkout'; } if ( is_account_page() ) { return 'account'; } if ( is_search() ) { return 'search'; } if ( is_shop() ) { return 'shop'; } return 'page'; } /** * Build product data. * * @param \WC_Product $product Product. * @return array */ private function build_product_data( $product ) { $data = array( 'product_id' => $product->get_id(), 'title' => $product->get_name(), 'sku' => $product->get_sku(), 'price' => (float) $product->get_price(), 'regular_price' => (float) $product->get_regular_price(), 'sale_price' => $product->get_sale_price() ? (float) $product->get_sale_price() : null, 'url' => get_permalink( $product->get_id() ), 'image_url' => $this->get_product_image_url( $product ), 'categories' => $this->get_product_categories( $product ), 'in_stock' => $product->is_in_stock(), ); if ( $product->is_type( 'variation' ) ) { $data['variant_id'] = $product->get_id(); $data['product_id'] = $product->get_parent_id(); $data['attributes'] = $product->get_variation_attributes(); } return $data; } /** * Build cart data. * * @return array */ private function build_cart_data() { $cart = WC()->cart; if ( ! $cart || $cart->is_empty() ) { return array( 'cart_total' => 0, 'item_count' => 0, 'line_items' => array(), ); } $line_items = array(); foreach ( $cart->get_cart() as $cart_item ) { $product = $cart_item['data']; $line_items[] = array( 'product_id' => $cart_item['product_id'], 'variant_id' => $cart_item['variation_id'] ?: $cart_item['product_id'], 'title' => $product->get_name(), 'sku' => $product->get_sku(), 'quantity' => $cart_item['quantity'], 'price' => (float) $product->get_price(), 'line_total' => (float) $cart_item['line_total'], 'url' => get_permalink( $cart_item['product_id'] ), 'image_url' => $this->get_product_image_url( $product ), ); } return array( 'cart_total' => (float) $cart->get_cart_contents_total(), 'subtotal' => (float) $cart->get_subtotal(), 'shipping_total' => (float) $cart->get_shipping_total(), 'tax_total' => (float) $cart->get_cart_contents_tax(), 'discount_total' => (float) $cart->get_discount_total(), 'currency' => get_woocommerce_currency(), 'item_count' => $cart->get_cart_contents_count(), 'line_items' => $line_items, 'cart_rebuild_key' => $this->generate_cart_rebuild_key( $cart ), ); } /** * Generate cart rebuild key with HMAC signature. * * @param \WC_Cart $cart Cart. * @return string */ private function generate_cart_rebuild_key( $cart ) { $items = array(); foreach ( $cart->get_cart() as $cart_item ) { $items[] = array( 'id' => $cart_item['variation_id'] ?: $cart_item['product_id'], 'qty' => min( $cart_item['quantity'], self::MAX_CART_QUANTITY ), ); } $data = wp_json_encode( $items ); $encoded = base64_encode( $data ); $signature = $this->generate_signature( $encoded ); return $encoded . '.' . $signature; } /** * Generate HMAC signature. * * @param string $data Data to sign. * @return string */ private function generate_signature( $data ) { $options = agentshop()->get_options(); $secret = isset( $options['webhook_secret'] ) ? $options['webhook_secret'] : wp_salt( 'auth' ); $signature = hash_hmac( 'sha256', $data, $secret, true ); return rtrim( strtr( base64_encode( $signature ), '+/', '-_' ), '=' ); } /** * Verify signature. * * @param string $data Data. * @param string $signature Signature. * @return bool */ private function verify_signature( $data, $signature ) { $expected = $this->generate_signature( $data ); return hash_equals( $expected, $signature ); } /** * Track add to cart. * * @param string $cart_item_key Cart item key. * @param int $product_id Product ID. * @param int $quantity Quantity. * @param int $variation_id Variation ID. * @param array $variation Variation data. * @param array $cart_item_data Cart item data. */ public function track_add_to_cart( $cart_item_key, $product_id, $quantity, $variation_id, $variation, $cart_item_data ) { $product = $variation_id ? wc_get_product( $variation_id ) : wc_get_product( $product_id ); if ( ! $product ) { return; } $event_data = array( 'event_type' => 'added_to_cart', 'event_source' => 'server', 'event_data' => array_merge( $this->build_product_data( $product ), array( 'quantity' => $quantity, 'cart_total' => (float) WC()->cart->get_cart_contents_total(), ) ), ); if ( ! empty( $variation ) ) { $event_data['event_data']['attributes'] = $variation; } $this->send_event_async( $event_data ); } /** * Track remove from cart. * * @param string $cart_item_key Cart item key. * @param \WC_Cart $cart Cart. */ public function track_remove_from_cart( $cart_item_key, $cart ) { $cart_item = $cart->get_cart_item( $cart_item_key ); if ( ! $cart_item ) { return; } $product = $cart_item['data']; $this->send_event_async( array( 'event_type' => 'removed_from_cart', 'event_source' => 'server', 'event_data' => array( 'product_id' => $cart_item['product_id'], 'variant_id' => $cart_item['variation_id'] ?: $cart_item['product_id'], 'title' => $product->get_name(), 'quantity' => $cart_item['quantity'], ), ) ); } /** * Track order completed. * * @param int $order_id Order ID. */ public function track_order_completed( $order_id ) { $order = wc_get_order( $order_id ); if ( ! $order ) { return; } // Check if already tracked. if ( $order->get_meta( '_agentshop_tracked' ) ) { return; } $line_items = array(); foreach ( $order->get_items() as $item ) { $product = $item->get_product(); if ( ! $product ) { continue; } $line_items[] = array( 'product_id' => $item->get_product_id(), 'variant_id' => $item->get_variation_id() ?: $item->get_product_id(), 'title' => $item->get_name(), 'sku' => $product->get_sku(), 'quantity' => $item->get_quantity(), 'price' => (float) ( $item->get_total() / max( 1, $item->get_quantity() ) ), 'line_total' => (float) $item->get_total(), ); } $event_data = array( 'event_type' => 'checkout_completed', 'event_source' => 'server', 'customer_email' => $order->get_billing_email(), 'event_data' => array( 'order_id' => $order->get_id(), 'order_number' => $order->get_order_number(), 'total_price' => (float) $order->get_total(), 'subtotal' => (float) $order->get_subtotal(), 'shipping_total' => (float) $order->get_shipping_total(), 'tax_total' => (float) $order->get_total_tax(), 'discount_total' => (float) $order->get_discount_total(), 'currency' => $order->get_currency(), 'payment_method' => $order->get_payment_method_title(), 'customer_email' => $order->get_billing_email(), 'customer_name' => $order->get_formatted_billing_full_name(), 'line_items' => $line_items, 'billing_address' => array( 'city' => $order->get_billing_city(), 'state' => $order->get_billing_state(), 'country' => $order->get_billing_country(), ), ), ); // Mark as tracked. $order->update_meta_data( '_agentshop_tracked', time() ); $order->save(); agentshop()->api->send_pixel_event( $event_data ); } /** * Capture checkout email. * * @param string $post_data Posted data. */ public function capture_checkout_email( $post_data ) { parse_str( $post_data, $parsed ); if ( ! empty( $parsed['billing_email'] ) ) { $email = sanitize_email( $parsed['billing_email'] ); if ( is_email( $email ) ) { WC()->session->set( 'agentshop_customer_email', $email ); $this->send_event_async( array( 'event_type' => 'checkout_contact_submitted', 'event_source' => 'server', 'customer_email' => $email, 'event_data' => array_merge( $this->build_cart_data(), array( 'customer_email' => $email ) ), ) ); } } } /** * Handle cart rebuild. */ public function handle_cart_rebuild() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Uses signature verification below. if ( ! isset( $_GET['agentshop_rebuild'] ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Signature verified at line 447. $key = sanitize_text_field( wp_unslash( $_GET['agentshop_rebuild'] ) ); $parts = explode( '.', $key ); if ( count( $parts ) !== 2 ) { agentshop()->log( 'Cart rebuild: invalid key format', 'warning' ); return; } list( $encoded_data, $signature ) = $parts; // Verify signature. if ( ! $this->verify_signature( $encoded_data, $signature ) ) { agentshop()->log( 'Cart rebuild: invalid signature', 'error' ); return; } $decoded = base64_decode( $encoded_data ); if ( ! $decoded ) { return; } $items = json_decode( $decoded, true ); if ( ! is_array( $items ) ) { return; } WC()->cart->empty_cart(); foreach ( $items as $item ) { if ( isset( $item['id'] ) && isset( $item['qty'] ) ) { $product_id = absint( $item['id'] ); $quantity = min( absint( $item['qty'] ), self::MAX_CART_QUANTITY ); $product = wc_get_product( $product_id ); if ( $product && $product->is_purchasable() && $product->is_in_stock() ) { WC()->cart->add_to_cart( $product_id, $quantity ); } } } wp_safe_redirect( wc_get_checkout_url() ); exit; } /** * Inject cart rebuild script using wp_add_inline_script. */ public function inject_cart_rebuild_script() { if ( ! is_checkout() || is_wc_endpoint_url( 'order-received' ) ) { return; } $cart_data = $this->build_cart_data(); $cart_rebuild_url = esc_url( home_url( '/?agentshop_rebuild=' . $cart_data['cart_rebuild_key'] ) ); $inline_script = sprintf( '(function() { if (typeof agentshopConfig !== "undefined") { window.agentshopCartRebuildUrl = %s; } })();', wp_json_encode( $cart_rebuild_url ) ); wp_add_inline_script( 'agentshop-pixel', $inline_script, 'after' ); } /** * Send event asynchronously. * * @param array $event_data Event data. */ private function send_event_async( $event_data ) { $options = agentshop()->get_options(); $payload = array_merge( array( 'pixel_version' => AGENTSHOP_VERSION, 'store_url' => agentshop()->get_store_url(), 'pixel_token' => $options['pixel_token'] ?? '', 'session_id' => agentshop()->get_session_id(), 'customer_id' => agentshop()->get_customer_id(), 'llm_source' => $this->get_llm_source(), 'timestamp' => gmdate( 'c' ), 'url' => $this->get_current_url(), 'referrer' => wp_get_referer() ?: '', ), $event_data ); $url = agentshop()->get_api_endpoint() . '/api/woocommerce/pixel/events'; wp_remote_post( $url, array( 'method' => 'POST', 'timeout' => 0.01, 'blocking' => false, 'headers' => array( 'Content-Type' => 'application/json', 'X-AgentShop-Token' => $options['pixel_token'] ?? '', ), 'body' => wp_json_encode( $payload ), ) ); } /** * Get LLM source from cookie. * * @return string|null */ private function get_llm_source() { if ( isset( $_COOKIE['agentshop_llm'] ) ) { return sanitize_text_field( wp_unslash( $_COOKIE['agentshop_llm'] ) ); } $referrer = wp_get_referer(); if ( $referrer ) { return agentshop()->detect_llm_source( $referrer ); } return null; } /** * Get product image URL. * * @param \WC_Product $product Product. * @return string */ private function get_product_image_url( $product ) { $image_id = $product->get_image_id(); if ( $image_id ) { $url = wp_get_attachment_image_url( $image_id, 'large' ); if ( $url ) { return $url; } } if ( $product->is_type( 'variation' ) ) { $parent = wc_get_product( $product->get_parent_id() ); if ( $parent ) { return $this->get_product_image_url( $parent ); } } return wc_placeholder_img_src( 'large' ); } /** * Get product categories. * * @param \WC_Product $product Product. * @return array */ private function get_product_categories( $product ) { if ( ! $product ) { return array(); } $product_id = $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id(); $terms = get_the_terms( $product_id, 'product_cat' ); if ( ! $terms || is_wp_error( $terms ) ) { return array(); } return wp_list_pluck( $terms, 'name' ); } /** * Get current URL. * * @return string */ private function get_current_url() { global $wp; return home_url( add_query_arg( array(), $wp->request ) ); } }