assets_handler = $assets_handler; $this->gtag_js = $gtag_js; $this->product_helper = $product_helper; $this->wc = $wc; $this->wp = $wp; } /** * Register the service. */ public function register(): void { $conversion_action = $this->options->get( OptionsInterface::ADS_CONVERSION_ACTION ); // No snippets without conversion action info. if ( ! $conversion_action ) { return; } $ads_conversion_id = $conversion_action['conversion_id']; $ads_conversion_label = $conversion_action['conversion_label']; add_action( 'wp_head', function () use ( $ads_conversion_id ) { $this->activate_global_site_tag( $ads_conversion_id ); }, 999999 ); add_action( 'woocommerce_before_thankyou', function ( $order_id ) use ( $ads_conversion_id, $ads_conversion_label ) { $this->maybe_display_purchase_event_snippet( $ads_conversion_id, $ads_conversion_label, $order_id ); }, ); add_action( 'woocommerce_after_single_product', function () { $this->display_view_item_event_snippet(); } ); add_action( 'wp_body_open', function () { $this->display_page_view_event_snippet(); } ); $this->product_data_hooks(); $this->register_assets(); } /** * Attach filters to add product data required for tracking events. */ protected function product_data_hooks() { // Add product data for any add_to_cart link. add_filter( 'woocommerce_loop_add_to_cart_link', function ( $link, $product ) { $this->add_product_data( $product ); return $link; }, 10, 2 ); // Add display name for an available variation. add_filter( 'woocommerce_available_variation', function ( $data, $instance, $variation ) { $data['display_name'] = $variation->get_name(); return $data; }, 10, 3 ); } /** * Register and enqueue assets for gtag events in blocks. */ protected function register_assets() { $gtag_events = new ScriptWithBuiltDependenciesAsset( 'gla-gtag-events', 'js/build/gtag-events', "{$this->get_root_dir()}/js/build/gtag-events.asset.php", new BuiltScriptDependencyArray( [ 'dependencies' => [], 'version' => $this->get_version(), ] ), function () { return is_page() || is_woocommerce() || is_cart(); } ); $this->assets_handler->register( $gtag_events ); $wp_consent_api = new ScriptWithBuiltDependenciesAsset( 'gla-wp-consent-api', 'js/build/wp-consent-api', "{$this->get_root_dir()}/js/build/wp-consent-api.asset.php", new BuiltScriptDependencyArray( [ 'dependencies' => [ 'wp-consent-api' ], 'version' => $this->get_version(), ] ) ); $this->assets_handler->register( $wp_consent_api ); add_action( 'wp_footer', function () use ( $gtag_events, $wp_consent_api ) { $gtag_events->add_localization( 'glaGtagData', [ 'currency_minor_unit' => wc_get_price_decimals(), 'products' => $this->products, ] ); $this->register_js_for_fast_refresh_dev(); $this->assets_handler->enqueue( $gtag_events ); if ( ! class_exists( '\WC_Google_Gtag_JS' ) && function_exists( 'wp_has_consent' ) ) { $this->assets_handler->enqueue( $wp_consent_api ); } } ); } /** * Activate the Global Site Tag framework: * - Insert GST code, or * - Include the Google Ads conversion ID in WooCommerce Google Analytics for WooCommerce output, if available * * @param string $ads_conversion_id Google Ads account conversion ID. */ public function activate_global_site_tag( string $ads_conversion_id ) { if ( $this->gtag_js->is_adding_framework() ) { if ( $this->gtag_js->ga4w_v2 ) { $inline_script = $this->get_gtag_config( $ads_conversion_id ); $inline_script .= "\n" . $this->get_enhanced_conversion_tag(); $this->wp->wp_add_inline_script( 'woocommerce-google-analytics-integration', $inline_script ); } else { // Legacy code to support Google Analytics for WooCommerce version < 2.0.0. add_filter( 'woocommerce_gtag_snippet', function ( $gtag_snippet ) use ( $ads_conversion_id ) { return preg_replace( '~(\s)~', "\tgtag('config', '" . $ads_conversion_id . "', { 'groups': 'GLA', 'send_page_view': false });\n$1", $gtag_snippet ); } ); } } else { $this->display_global_site_tag( $ads_conversion_id ); } } /** * Display the JavaScript code to load the Global Site Tag framework. * * @param string $ads_conversion_id Google Ads account conversion ID. */ protected function display_global_site_tag( string $ads_conversion_id ) { // phpcs:disable WordPress.WP.EnqueuedResources.NonEnqueuedScript ?> wp->wp_add_inline_script( 'woocommerce-google-analytics-integration', $inline_script ); } else { $this->wp->wp_print_inline_script_tag( $inline_script ); } } /** * Display the JavaScript code to track purchase on the order confirmation page. * * @param string $ads_conversion_id Google Ads account conversion ID. * @param string $ads_conversion_label Google Ads conversion label. * @param int $order_id The order id. */ public function maybe_display_purchase_event_snippet( string $ads_conversion_id, string $ads_conversion_label, int $order_id ): void { // Only display on the order confirmation page. if ( ! is_order_received_page() ) { return; } $order = wc_get_order( $order_id ); // Make sure there is a valid order object and it is not already marked as tracked if ( ! $order || 1 === (int) $order->get_meta( self::ORDER_CONVERSION_META_KEY, true ) ) { return; } // Mark the order as tracked, to avoid double-reporting if the confirmation page is reloaded. $order->update_meta_data( self::ORDER_CONVERSION_META_KEY, 1 ); $order->save_meta_data(); // Get the item info in the order $item_info = []; foreach ( $order->get_items() as $item_id => $item ) { $product_id = $item->get_variation_id() ?: $item->get_product_id(); $product_name = $item->get_name(); $quantity = $item->get_quantity(); $price = $order->get_item_total( $item ); $item_info [] = sprintf( '{ id: "gla_%s", price: %f, google_business_vertical: "retail", name: "%s", quantity: %d, }', esc_js( $product_id ), $price, esc_js( $product_name ), $quantity, ); } // Check if this is the first time customer $is_new_customer = $this->is_first_time_customer( $order->get_billing_email() ); // Track the purchase page $language = $this->wp->get_locale(); if ( 'en_US' === $language ) { $language = 'English'; } $purchase_page_gtag = sprintf( 'gtag("event", "purchase", { ecomm_pagetype: "purchase", send_to: "%s", transaction_id: "%s", currency: "%s", country: "%s", value: %f, new_customer: %s, tax: %f, shipping: %f, delivery_postal_code: "%s", aw_feed_country: "%s", aw_feed_language: "%s", items: [%s]});', esc_js( "{$ads_conversion_id}/{$ads_conversion_label}" ), esc_js( $order->get_id() ), esc_js( $order->get_currency() ), esc_js( $this->wc->get_base_country() ), $order->get_total(), $is_new_customer ? 'true' : 'false', esc_js( $order->get_cart_tax() ), $order->get_total_shipping(), esc_js( $order->get_billing_postcode() ), esc_js( $this->wc->get_base_country() ), esc_js( $language ), join( ',', $item_info ), ); $this->add_inline_event_script( $purchase_page_gtag ); } /** * Display the JavaScript code to track the product view page. */ private function display_view_item_event_snippet(): void { $product = wc_get_product( get_the_ID() ); if ( ! $product instanceof WC_Product ) { return; } $this->add_product_data( $product ); $view_item_gtag = sprintf( 'gtag("event", "view_item", { send_to: "GLA", ecomm_pagetype: "product", value: %f, items:[{ id: "gla_%s", price: %f, google_business_vertical: "retail", name: "%s", category: "%s", }]});', wc_get_price_to_display( $product ), esc_js( $product->get_id() ), wc_get_price_to_display( $product ), esc_js( $product->get_name() ), esc_js( join( ' & ', $this->product_helper->get_categories( $product ) ) ), ); $this->add_inline_event_script( $view_item_gtag ); } /** * Display the JavaScript code to track all pages. */ private function display_page_view_event_snippet(): void { if ( ! is_cart() ) { $this->add_inline_event_script( 'gtag("event", "page_view", {send_to: "GLA"});' ); return; } // display the JavaScript code to track the cart page $item_info = []; foreach ( WC()->cart->get_cart() as $cart_item ) { // gets the product id $id = ! empty( $cart_item['variation_id'] ) ? $cart_item['variation_id'] : $cart_item['product_id']; // gets the product object $product = $cart_item['data']; $name = $product->get_name(); $price = WC()->cart->display_prices_including_tax() ? wc_get_price_including_tax( $product ) : wc_get_price_excluding_tax( $product ); // gets the cart item quantity $quantity = $cart_item['quantity']; $item_info[] = sprintf( '{ id: "gla_%s", price: %f, google_business_vertical: "retail", name:"%s", quantity: %d, }', esc_js( $id ), $price, esc_js( $name ), $quantity, ); } $value = WC()->cart->total; $page_view_gtag = sprintf( 'gtag("event", "page_view", { send_to: "GLA", ecomm_pagetype: "cart", value: %f, items: [%s]});', $value, join( ',', $item_info ), ); $this->add_inline_event_script( $page_view_gtag ); } /** * Add product data to include in JS data. * * @since 2.0.3 * * @param WC_Product $product */ protected function add_product_data( $product ) { $this->products[ $product->get_id() ] = [ 'name' => $product->get_name(), 'price' => wc_get_price_to_display( $product ), ]; } /** * Controls whether the Google Ads tag and event snippets should be included on the page. * * @return bool True if the Global Site Tag framework should be included. */ public static function is_needed(): bool { if ( apply_filters( 'woocommerce_gla_disable_gtag_tracking', false ) ) { return false; } return true; } /** * Check if the customer has previous orders. * Called after order creation (check for older orders including the order which was just created). * * @param string $customer_email Customer email address. * @return bool True if this customer has previous orders. */ private static function is_first_time_customer( $customer_email ): bool { $query = new \WC_Order_Query( [ 'limit' => 2, 'return' => 'ids', ] ); $query->set( 'customer', $customer_email ); $orders = $query->get_orders(); return count( $orders ) === 1 ? true : false; } /** * This method ONLY works during development in the Fast Refresh mode. * * The runtime.js and react-refresh-runtime.js files are created when the front-end development is * running `npm run start:hot`, and they need to be loaded to make the gtag-events scrips work. */ private function register_js_for_fast_refresh_dev() { // This file exists only when running `npm run start:hot` $runtime_path = "{$this->get_root_dir()}/js/build/runtime.js"; if ( ! file_exists( $runtime_path ) ) { return; } $plugin_url = $this->get_plugin_url(); wp_enqueue_script( 'gla-webpack-runtime', "{$plugin_url}/js/build/runtime.js", [], (string) filemtime( $runtime_path ), false ); // This script is one of the gtag-events dependencies, and its handle is wp-react-refresh-runtime. // Ref: js/build/gtag-events.asset.php wp_register_script( 'wp-react-refresh-runtime', "{$plugin_url}/js/build-dev/react-refresh-runtime.js", [ 'gla-webpack-runtime' ], $this->get_version(), false ); } /** * Set user data config when Enhanced Conversions is enabled. * * @return string|null */ public function get_enhanced_conversion_tag() { $enhanced_conversions = $this->options->get( OptionsInterface::ADS_ENHANCED_CONVERSIONS_ENABLED ); if ( ! $enhanced_conversions ) { return; } // Retrieve user data from the current session, returns an empty array if not set. $customer = $this->wc->get_customer_details(); $ec_data = []; // Add email address to enhanced conversion data. if ( ! empty( $customer['email'] ) ) { $ec_data['sha256_email_address'] = $this->normalize_and_hash( $customer['email'] ); } // Add address details if available. if ( ! empty( $customer['first_name'] ) && ! empty( $customer['last_name'] ) && ! empty( $customer['postcode'] ) && ! empty( $customer['country'] ) ) { $ec_data['address'] = [ 'sha256_first_name' => $this->normalize_and_hash( $customer['first_name'] ), 'sha256_last_name' => $this->normalize_and_hash( $customer['last_name'] ), 'postal_code' => $customer['postcode'], 'country' => $customer['country'], ]; if ( ! empty( $customer['address'] ) ) { $ec_data['address']['street'] = $customer['address']; } if ( ! empty( $customer['city'] ) ) { $ec_data['address']['city'] = $customer['city']; } if ( ! empty( $customer['state'] ) ) { $ec_data['address']['region'] = $customer['state']; } } // Phone number can only be added when email and/or address is present. if ( empty( $ec_data ) ) { return; } // Add phone number if available, requires country code for correct format. if ( ! empty( $customer['phone'] ) && ! empty( $customer['country'] ) ) { $phone = $this->format_phone_to_international( $customer['phone'], $customer['country'] ); if ( ! empty( $phone ) ) { $ec_data['sha256_phone_number'] = $this->normalize_and_hash( $phone ); } } // Return the tag. return sprintf( 'gtag("set", "user_data", %s);', wp_json_encode( $ec_data ) ); } /** * Converts a customers phone number to E.164 format. * * @param string $phone The customer entered phone number. * @param string $country The customer country code. * @return string */ private function format_phone_to_international( $phone, $country ) { // Get the calling code for the customers country. $countries = new WC_Countries(); $calling_code = $countries->get_country_calling_code( $country ); // Cannot create a international number if there is no valid call code. if ( empty( $calling_code ) ) { return ''; } // Remove any non-digit characters and the leading 0 from the phone number. $phone = ltrim( preg_replace( '/[^0-9]/', '', $phone ), '0' ); // Prepend the calling code. $phone = $calling_code . $phone; // Validate the number is the correct length. if ( strlen( $phone ) < 11 || strlen( $phone ) > 15 ) { return ''; } return $phone; } /** * Normalize and hash enhanced conversion data. * * @param string $value The value to hash. * @param string $algo The hashing algorithm to use. * * @return string */ private function normalize_and_hash( $value, $algo = 'sha256' ): string { return hash( $algo, strtolower( trim( $value ) ) ); } }