Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 245
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Universal
0.00% covered (danger)
0.00%
0 / 245
0.00% covered (danger)
0.00%
0 / 14
3540
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 wp_head
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 maybe_anonymize_ip
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 maybe_track_purchases
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
156
 maybe_track_hpos_purchases
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
42
 add_to_cart
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 loop_add_to_cart
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 remove_from_cart
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 remove_from_cart_attributes
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 listing_impression
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 listing_click
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 product_detail
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 checkout_process
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 send_pageview_in_footer
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * Universal hooks and enqueues support for analytics.js
4 * https://developers.google.com/analytics/devguides/collection/analyticsjs/
5 * https://developers.google.com/analytics/devguides/collection/analyticsjs/enhanced-ecommerce
6 *
7 * @package automattic/jetpack-google-analytics
8 */
9
10namespace Automattic\Jetpack\Google_Analytics;
11
12/**
13 * Universal main class.
14 */
15class Universal {
16    /**
17     * Jetpack_Google_Analytics_Universal constructor.
18     */
19    public function __construct() {
20        add_filter( 'jetpack_wga_universal_commands', array( $this, 'maybe_anonymize_ip' ) );
21        add_filter( 'jetpack_wga_universal_commands', array( $this, 'maybe_track_purchases' ) );
22
23        add_action( 'wp_head', array( $this, 'wp_head' ), 999999 );
24
25        add_action( 'woocommerce_after_add_to_cart_button', array( $this, 'add_to_cart' ) );
26        add_action( 'wp_footer', array( $this, 'loop_add_to_cart' ) );
27        add_action( 'woocommerce_after_cart', array( $this, 'remove_from_cart' ) );
28        add_action( 'woocommerce_after_mini_cart', array( $this, 'remove_from_cart' ) );
29        add_filter( 'woocommerce_cart_item_remove_link', array( $this, 'remove_from_cart_attributes' ), 10, 2 );
30        add_action( 'woocommerce_after_shop_loop_item', array( $this, 'listing_impression' ) );
31        add_action( 'woocommerce_after_shop_loop_item', array( $this, 'listing_click' ) );
32        add_action( 'woocommerce_after_single_product', array( $this, 'product_detail' ) );
33        add_action( 'woocommerce_after_checkout_form', array( $this, 'checkout_process' ) );
34
35        // we need to send a pageview command last - so we use priority 24 to add
36        // this command's JavaScript just before wc_print_js is called (pri 25)
37        add_action( 'wp_footer', array( $this, 'send_pageview_in_footer' ), 24 );
38    }
39
40    /**
41     * Hook for the `wp_head` action to output the analytics code.
42     */
43    public function wp_head() {
44        $tracking_code = Options::get_tracking_code();
45        if ( empty( $tracking_code ) ) {
46            echo "<!-- No tracking ID configured for Jetpack Google Analytics -->\r\n";
47            return;
48        }
49
50        // If we're in the admin_area or DNT is honored and enabled, return without inserting code.
51        if (
52            is_admin()
53            || Utils::is_dnt_enabled()
54        ) {
55            return;
56        }
57
58        // TODO: Test the code for cases with existing and missing Jetpack_AMP_Support class.
59        // @phan-suppress-next-line PhanUndeclaredClassMethod
60        if ( class_exists( 'Jetpack_AMP_Support' ) && \Jetpack_AMP_Support::is_amp_request() ) {
61            // For Reader mode — legacy.
62            add_filter( 'amp_post_template_analytics', array( GA_Manager::class, 'amp_analytics_entries' ), 1000 );
63            // For Standard and Transitional modes.
64            add_filter( 'amp_analytics_entries', array( GA_Manager::class, 'amp_analytics_entries' ), 1000 );
65            return;
66        }
67
68        /**
69         * Allow for additional elements to be added to the universal Google Analytics queue (ga) array
70         *
71         * @since jetpack-5.6.0
72         *
73         * @param array $custom_vars Array of universal Google Analytics queue elements
74         */
75        $universal_commands = apply_filters( 'jetpack_wga_universal_commands', array() );
76
77        // phpcs:disable WordPress.WP.EnqueuedResources.NonEnqueuedScript -- Script is added to wp_head.
78        $async_code = "
79            <!-- Jetpack Google Analytics -->
80            <script>
81                window.ga = window.ga || function(){ ( ga.q = ga.q || [] ).push( arguments ) }; ga.l=+new Date;
82                ga( 'create', '%tracking_id%', 'auto' );
83                ga( 'require', 'ec' );
84                %universal_commands%
85            </script>
86            <script async src='https://www.google-analytics.com/analytics.js'></script>
87            <!-- End Jetpack Google Analytics -->
88        "; // phpcs:enable WordPress.WP.EnqueuedResources.NonEnqueuedScript
89        $async_code = str_replace( '%tracking_id%', $tracking_code, $async_code );
90
91        $universal_commands_string = implode( "\r\n", $universal_commands );
92        $async_code                = str_replace( '%universal_commands%', $universal_commands_string, $async_code );
93
94        echo "$async_code\r\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
95    }
96
97    /**
98     * Check if the 'anonymize_ip' option should be added to the universal Google Analytics queue (ga) commands.
99     *
100     * @param array $command_array Array of commands.
101     * @return array `$command_array` with the additional command conditionally added.
102     */
103    public function maybe_anonymize_ip( $command_array ) {
104        if ( Options::anonymize_ip_is_enabled() ) {
105            array_push( $command_array, "ga( 'set', 'anonymizeIp', true );" );
106        }
107
108        return $command_array;
109    }
110
111    /**
112     * Process purchase tracking options for the universal Google Analytics queue (ga) commands.
113     *
114     * May also update post meta to indicate the order has been tracked.
115     *
116     * @phan-suppress PhanUndeclaredClassMethod
117     *
118     * @param array $command_array Array of commands.
119     * @return array `$command_array` with additional commands conditionally added.
120     */
121    public function maybe_track_purchases( $command_array ) {
122        global $wp;
123
124        if ( ! Options::track_purchases_is_enabled() ) {
125            return $command_array;
126        }
127
128        if ( ! class_exists( 'WooCommerce' ) ) {
129            return $command_array;
130        }
131
132        // @phan-suppress-next-line PhanUndeclaredConstant
133        $minimum_woocommerce_active = class_exists( 'WooCommerce' ) && version_compare( WC_VERSION, '3.0', '>=' );
134        if ( ! $minimum_woocommerce_active ) {
135            return $command_array;
136        }
137
138        // @phan-suppress-next-line PhanUndeclaredFunction
139        if ( ! \is_order_received_page() ) {
140            return $command_array;
141        }
142
143        $order_id = $wp->query_vars['order-received'] ?? 0;
144        if ( 0 === (int) $order_id ) {
145            return $command_array;
146        }
147
148        $hpos_enabled =
149            class_exists( 'Automattic\WooCommerce\Utilities\OrderUtil' )
150            && \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled();
151        if ( $hpos_enabled ) {
152            return $this->maybe_track_hpos_purchases( $command_array );
153        }
154
155        // A 1 indicates we've already tracked this order - don't do it again
156        if ( 1 === (int) get_post_meta( $order_id, '_ga_tracked', true ) ) {
157            return $command_array;
158        }
159
160        $order          = new \WC_Order( $order_id );
161        $order_currency = $order->get_currency();
162        $command        = "ga( 'set', '&cu', '" . esc_js( $order_currency ) . "' );";
163        array_push( $command_array, $command );
164
165        // Order items
166        if ( $order->get_items() ) {
167            foreach ( $order->get_items() as $item ) {
168                $product           = $order->get_product_from_item( $item );
169                $product_sku_or_id = Utils::get_product_sku_or_id( $product );
170
171                $item_details = array(
172                    'id'       => $product_sku_or_id,
173                    'name'     => $item['name'],
174                    'category' => Utils::get_product_categories_concatenated( $product ),
175                    'price'    => $order->get_item_total( $item ),
176                    'quantity' => $item['qty'],
177                );
178                $command      = "ga( 'ec:addProduct', " . wp_json_encode( $item_details, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ' );';
179                array_push( $command_array, $command );
180            }
181        }
182
183        // Order summary
184        $summary = array(
185            'id'          => $order->get_order_number(),
186            'affiliation' => get_bloginfo( 'name' ),
187            'revenue'     => $order->get_total(),
188            'tax'         => $order->get_total_tax(),
189            'shipping'    => $order->get_total_shipping(),
190        );
191        $command = "ga( 'ec:setAction', 'purchase', " . wp_json_encode( $summary, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ' );';
192        array_push( $command_array, $command );
193
194        update_post_meta( $order_id, '_ga_tracked', 1 );
195
196        return $command_array;
197    }
198
199    /**
200     * Process purchase tracking options for the universal Google Analytics queue (ga) commands.
201     *
202     * May also update post meta to indicate the order has been tracked.
203     *
204     * This method is different from maybe_track_purchases in HPOS support.
205     *
206     * @see https://github.com/woocommerce/woocommerce/wiki/High-Performance-Order-Storage-Upgrade-Recipe-Book
207     *
208     * @since jetpack-13.1
209     * @param array $command_array Array of commands.
210     * @return array `$command_array` with additional commands conditionally added.
211     */
212    public function maybe_track_hpos_purchases( $command_array ) {
213        global $wp;
214
215        $order_id = $wp->query_vars['order-received'] ?? 0;
216        if ( 0 === (int) $order_id ) {
217            return $command_array;
218        }
219
220        // @phan-suppress-next-line PhanUndeclaredFunction
221        $order = \wc_get_order( $order_id );
222
223        if ( false === $order ) {
224            return $command_array;
225        }
226
227        // A 1 indicates we've already tracked this order - don't do it again
228        if ( 1 === (int) $order->get_meta( '_ga_tracked', true ) ) {
229            return $command_array;
230        }
231
232        $order_currency = $order->get_currency();
233        $command        = "ga( 'set', '&cu', '" . esc_js( $order_currency ) . "' );";
234        array_push( $command_array, $command );
235
236        // Order items
237        if ( $order->get_items() ) {
238            foreach ( $order->get_items() as $item ) {
239                $product           = $order->get_product_from_item( $item );
240                $product_sku_or_id = Utils::get_product_sku_or_id( $product );
241
242                $item_details = array(
243                    'id'       => $product_sku_or_id,
244                    'name'     => $item['name'],
245                    'category' => Utils::get_product_categories_concatenated( $product ),
246                    'price'    => $order->get_item_total( $item ),
247                    'quantity' => $item['qty'],
248                );
249                $command      = "ga( 'ec:addProduct', " . wp_json_encode( $item_details, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ' );';
250                array_push( $command_array, $command );
251            }
252        }
253
254        // Order summary
255        $summary = array(
256            'id'          => $order->get_order_number(),
257            'affiliation' => get_bloginfo( 'name' ),
258            'revenue'     => $order->get_total(),
259            'tax'         => $order->get_total_tax(),
260            'shipping'    => $order->get_shipping_total(),
261        );
262        $command = "ga( 'ec:setAction', 'purchase', " . wp_json_encode( $summary, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ' );';
263        array_push( $command_array, $command );
264
265        $order->update_meta_data( '_ga_tracked', 1 );
266        $order->save();
267
268        return $command_array;
269    }
270
271    /**
272     * Enqueue add-to-cart click tracking script, if enabled.
273     */
274    public function add_to_cart() {
275        if ( ! Options::track_add_to_cart_is_enabled() ) {
276            return;
277        }
278
279        if ( ! is_single() ) {
280            return;
281        }
282
283        global $product;
284
285        $product_sku_or_id = Utils::get_product_sku_or_id( $product );
286        $selector          = '.single_add_to_cart_button';
287
288        // @phan-suppress-next-line PhanUndeclaredFunction
289        \wc_enqueue_js(
290            '$( ' . wp_json_encode( $selector, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . " ).click( function() {
291                var productDetails = {
292                    'id': '" . esc_js( $product_sku_or_id ) . "',
293                    'name' : '" . esc_js( $product->get_title() ) . "',
294                    'quantity': $( 'input.qty' ).val() ? $( 'input.qty' ).val() : '1',
295                };
296                ga( 'ec:addProduct', productDetails );
297                ga( 'ec:setAction', 'add' );
298                ga( 'send', 'event', 'UX', 'click', 'add to cart' );
299            } );"
300        );
301    }
302
303    /**
304     * Enqueue add-to-cart click tracking script for looped product views, if enabled.
305     */
306    public function loop_add_to_cart() {
307        if ( ! Options::track_add_to_cart_is_enabled() ) {
308            return;
309        }
310
311        if ( ! class_exists( 'WooCommerce' ) ) {
312            return;
313        }
314
315        // @phan-suppress-next-line PhanUndeclaredConstant
316        $minimum_woocommerce_active = class_exists( 'WooCommerce' ) && version_compare( WC_VERSION, '3.0', '>=' );
317        if ( ! $minimum_woocommerce_active ) {
318            return;
319        }
320
321        $selector = '.add_to_cart_button:not(.product_type_variable, .product_type_grouped)';
322
323        // @phan-suppress-next-line PhanUndeclaredFunction
324        \wc_enqueue_js(
325            '$( ' . wp_json_encode( $selector, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . " ).click( function() {
326                var productSku = $( this ).data( 'product_sku' );
327                var productID = $( this ).data( 'product_id' );
328                var productDetails = {
329                    'id': productSku ? productSku : '#' + productID,
330                    'quantity': $( this ).data( 'quantity' ),
331                };
332                ga( 'ec:addProduct', productDetails );
333                ga( 'ec:setAction', 'add' );
334                ga( 'send', 'event', 'UX', 'click', 'add to cart' );
335            } );"
336        );
337    }
338
339    /**
340     * Enqueue remove-from-cart click tracking script, if enabled.
341     */
342    public function remove_from_cart() {
343        if ( ! Options::enhanced_ecommerce_tracking_is_enabled() ) {
344            return;
345        }
346
347        if ( ! Options::track_remove_from_cart_is_enabled() ) {
348            return;
349        }
350
351        // We listen at div.woocommerce because the cart 'form' contents get forcibly
352        // updated and subsequent removals from cart would then not have this click
353        // handler attached
354        // @phan-suppress-next-line PhanUndeclaredFunction
355        \wc_enqueue_js(
356            "$( 'div.woocommerce' ).on( 'click', 'a.remove', function() {
357                var productSku = $( this ).data( 'product_sku' );
358                var productID = $( this ).data( 'product_id' );
359                var quantity = $( this ).parent().parent().find( '.qty' ).val()
360                var productDetails = {
361                    'id': productSku ? productSku : '#' + productID,
362                    'quantity': quantity ? quantity : '1',
363                };
364                ga( 'ec:addProduct', productDetails );
365                ga( 'ec:setAction', 'remove' );
366                ga( 'send', 'event', 'UX', 'click', 'remove from cart' );
367            } );"
368        );
369    }
370
371    /**
372     * Adds the product ID and SKU to the remove product link (for use by remove_from_cart above) if not present
373     *
374     * @param string $url Full HTML a tag of the link to remove an item from the cart.
375     * @param string $key Unique Key ID for a cart item.
376     */
377    public function remove_from_cart_attributes( $url, $key ) {
378        if ( str_contains( $url, 'data-product_id' ) ) {
379            return $url;
380        }
381
382        // @phan-suppress-next-line PhanUndeclaredFunction
383        $item    = \WC()->cart->get_cart_item( $key );
384        $product = $item['data'];
385
386        $new_attributes = sprintf(
387            '" data-product_id="%1$s" data-product_sku="%2$s">',
388            esc_attr( $product->get_id() ),
389            esc_attr( $product->get_sku() )
390        );
391
392        $url = str_replace( '">', $new_attributes, $url );
393        return $url;
394    }
395
396    /**
397     * Enqueue listing impression tracking script, if enabled.
398     */
399    public function listing_impression() {
400        if ( ! Options::enhanced_ecommerce_tracking_is_enabled() ) {
401            return;
402        }
403
404        if ( ! Options::track_product_impressions_is_enabled() ) {
405            return;
406        }
407
408        if ( isset( $_GET['s'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- No site actions, just GA options being set.
409            $list = 'Search Results';
410        } else {
411            $list = 'Product List';
412        }
413
414        global $product, $woocommerce_loop;
415        $product_sku_or_id = Utils::get_product_sku_or_id( $product );
416
417        $item_details = array(
418            'id'       => $product_sku_or_id,
419            'name'     => $product->get_title(),
420            'category' => Utils::get_product_categories_concatenated( $product ),
421            'list'     => $list,
422            'position' => $woocommerce_loop['loop'] ?? null,
423        );
424        // @phan-suppress-next-line PhanUndeclaredFunction
425        \wc_enqueue_js( "ga( 'ec:addImpression', " . wp_json_encode( $item_details, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ' );' );
426    }
427
428    /**
429     * Enqueue listing click tracking script, if enabled.
430     */
431    public function listing_click() {
432        if ( ! Options::enhanced_ecommerce_tracking_is_enabled() ) {
433            return;
434        }
435
436        if ( ! Options::track_product_clicks_is_enabled() ) {
437            return;
438        }
439
440        if ( isset( $_GET['s'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- No site actions, just GA options being set.
441            $list = 'Search Results';
442        } else {
443            $list = 'Product List';
444        }
445
446        global $product, $woocommerce_loop;
447        $product_sku_or_id = Utils::get_product_sku_or_id( $product );
448
449        $selector = '.products .post-' . $product->get_id() . ' a';
450
451        $item_details = array(
452            'id'       => $product_sku_or_id,
453            'name'     => $product->get_title(),
454            'category' => Utils::get_product_categories_concatenated( $product ),
455            'position' => $woocommerce_loop['loop'] ?? null,
456        );
457
458        // @phan-suppress-next-line PhanUndeclaredFunction
459        \wc_enqueue_js(
460            '$( ' . wp_json_encode( $selector, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . " ).click( function() {
461                if ( true === $( this ).hasClass( 'add_to_cart_button' ) ) {
462                    return;
463                }
464
465                ga( 'ec:addProduct', " . wp_json_encode( $item_details, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . " );
466                ga( 'ec:setAction', 'click', { list: '" . esc_js( $list ) . "' } );
467                ga( 'send', 'event', 'UX', 'click', { list: '" . esc_js( $list ) . "' } );
468            } );"
469        );
470    }
471
472    /**
473     * Enqueue product detail view tracking script, if enabled.
474     */
475    public function product_detail() {
476        if ( ! Options::enhanced_ecommerce_tracking_is_enabled() ) {
477            return;
478        }
479
480        if ( ! Options::track_product_detail_view_is_enabled() ) {
481            return;
482        }
483
484        global $product;
485        $product_sku_or_id = Utils::get_product_sku_or_id( $product );
486
487        $item_details = array(
488            'id'       => $product_sku_or_id,
489            'name'     => $product->get_title(),
490            'category' => Utils::get_product_categories_concatenated( $product ),
491            'price'    => $product->get_price(),
492        );
493        // @phan-suppress-next-line PhanUndeclaredFunction
494        \wc_enqueue_js(
495            "ga( 'ec:addProduct', " . wp_json_encode( $item_details, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ' );' .
496            "ga( 'ec:setAction', 'detail' );"
497        );
498    }
499
500    /**
501     * Enqueue post-checkout tracking script, if enabled.
502     */
503    public function checkout_process() {
504        if ( ! Options::enhanced_ecommerce_tracking_is_enabled() ) {
505            return;
506        }
507
508        if ( ! Options::track_checkout_started_is_enabled() ) {
509            return;
510        }
511
512        $universal_commands = array();
513        // @phan-suppress-next-line PhanUndeclaredFunction
514        $cart = \WC()->cart->get_cart();
515
516        foreach ( $cart as $cart_item_key => $cart_item ) {
517            /**
518             * This filter is already documented in woocommerce/templates/cart/cart.php
519             */
520            $product           = apply_filters( 'woocommerce_cart_item_product', $cart_item['data'], $cart_item, $cart_item_key );
521            $product_sku_or_id = Utils::get_product_sku_or_id( $product );
522
523            $item_details = array(
524                'id'       => $product_sku_or_id,
525                'name'     => $product->get_title(),
526                'category' => Utils::get_product_categories_concatenated( $product ),
527                'price'    => $product->get_price(),
528                'quantity' => $cart_item['quantity'],
529            );
530
531            array_push( $universal_commands, "ga( 'ec:addProduct', " . wp_json_encode( $item_details, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ' );' );
532        }
533
534        array_push( $universal_commands, "ga( 'ec:setAction','checkout' );" );
535
536        // @phan-suppress-next-line PhanUndeclaredFunction
537        \wc_enqueue_js( implode( "\r\n", $universal_commands ) );
538    }
539
540    /**
541     * Enqueue pageview event in footer of all pages.
542     *
543     * Action hook added with later priority to come after all of the above tracking.
544     */
545    public function send_pageview_in_footer() {
546        if ( ! Options::has_tracking_code() ) {
547            return;
548        }
549
550        if ( is_admin() ) {
551            return;
552        }
553
554        if ( ! class_exists( 'WooCommerce' ) ) {
555            return;
556        }
557
558        // @phan-suppress-next-line PhanUndeclaredFunction
559        \wc_enqueue_js( "ga( 'send', 'pageview' );" );
560    }
561}