diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 9d112a5c..0741f98c 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,14 +1,14 @@ # Contributing -Thanks for your interest in contributing to WooCommerce Google Analytics Integration! +Thanks for your interest in contributing to Google Analytics for WooCommerce! ## Guidelines Like the WooCommerce project, we want to ensure a welcoming environment for everyone. With that in mind, all contributors are expected to follow our [Code of Conduct](./CODE_OF_CONDUCT.md). -If you wish to contribute code, please read the information in the sections below. Then [fork](https://help.github.com/articles/fork-a-repo/) WooCommerce Google Analytics Integration, commit your changes, and [submit a pull request](https://docs.github.com/en/github/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests) 🎉 +If you wish to contribute code, please read the information in the sections below. Then [fork](https://help.github.com/articles/fork-a-repo/) Google Analytics for WooCommerce, commit your changes, and [submit a pull request](https://docs.github.com/en/github/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests) 🎉 -WooCommerce Google Analytics Integration is licensed under the GPLv3+, and all contributions to the project will be released under the same license. You maintain copyright over any contribution you make, and by submitting a pull request, you are agreeing to release that contribution under the GPLv3+ license. +Google Analytics for WooCommerce is licensed under the GPLv3+, and all contributions to the project will be released under the same license. You maintain copyright over any contribution you make, and by submitting a pull request, you are agreeing to release that contribution under the GPLv3+ license. ## Reporting Security Issues diff --git a/README.md b/README.md index f9831add..b91980cf 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# WooCommerce Google Analytics Integration +# Google Analytics for WooCommerce [![PHP Unit Tests](https://github.com/woocommerce/woocommerce-google-analytics-integration/actions/workflows/php-unit-tests.yml/badge.svg)](https://github.com/woocommerce/woocommerce-google-analytics-integration/actions/workflows/php-unit-tests.yml) [![JavaScript Linting](https://github.com/woocommerce/woocommerce-google-analytics-integration/actions/workflows/js-linting.yml/badge.svg)](https://github.com/woocommerce/woocommerce-google-analytics-integration/actions/workflows/js-linting.yml) @@ -14,7 +14,7 @@ Will be required for WooCommerce shops using the integration from WooCommerce 2. ## NPM Scripts -WooCommerce Google Analytics Integration utilizes npm scripts for task management utilities. +Google Analytics for WooCommerce utilizes npm scripts for task management utilities. `npm run build` - Runs the tasks necessary for a release. These may include building JavaScript, SASS, CSS minification, and language files. @@ -38,4 +38,27 @@ Alternatively, run `npm run lint:php:diff` to run coding standards checks agains ## Docs -- [Hooks defined or used in WooCommerce Google Analytics Integration](./docs/Hooks.md) +- [Hooks defined or used in Google Analytics for WooCommerce](./docs/Hooks.md) + +### Consent Mode + +The extension sets up [the default state of consent mode](https://developers.google.com/tag-platform/security/guides/consent?hl=en&consentmode=advanced#default-consent), denying all parameters for the EEA region. You can append or overwrite that configuration using the following snippet: + +```php +add_action( 'wp_enqueue_scripts', function () { + $customConsentConfig = " + gtag( 'consent', 'default', { + analytics_storage: 'granted', + ad_storage: 'granted', + ad_user_data: 'denied', + ad_personalization: 'denied', + region: 'ES', + } );"; + + wp_register_script( 'my-custom-consent-mode', '', array('woocommerce-google-analytics-integration'), null, false ); + wp_add_inline_script( 'my-custom-consent-mode', $customConsentConfig ); + wp_enqueue_script( 'my-custom-consent-mode' ); +} ); +``` + +After the page loads, the consent for particular parameters can be updated by other plugins or custom code, implementing UI for customer-facing configuration using [Google's consent API](https://developers.google.com/tag-platform/security/guides/consent?hl=en&consentmode=advanced#update-consent) (`gtag('consent', 'update', {…})`). diff --git a/assets/js/src/actions.js b/assets/js/src/actions.js deleted file mode 100644 index 502e4473..00000000 --- a/assets/js/src/actions.js +++ /dev/null @@ -1,198 +0,0 @@ -import { __ } from '@wordpress/i18n'; - -import { NAMESPACE, ACTION_PREFIX } from './constants'; -import { - trackListProducts, - trackAddToCart, - trackChangeCartItemQuantity, - trackRemoveCartItem, - trackCheckoutStep, - trackCheckoutOption, - trackEvent, - trackSelectContent, - trackSearch, - trackViewItem, - trackException, -} from './tracking'; -import { addUniqueAction } from './utils'; - -/** - * Track customer progress through steps of the checkout. Triggers the event when the step changes: - * 1 - Contact information - * 2 - Shipping address - * 3 - Billing address - * 4 - Shipping options - * 5 - Payment options - * - * @summary Track checkout progress with begin_checkout and checkout_progress - * @see https://developers.google.com/analytics/devguides/collection/gtagjs/enhanced-ecommerce#1_measure_checkout_steps - */ -addUniqueAction( - `${ ACTION_PREFIX }-checkout-render-checkout-form`, - NAMESPACE, - ( { ...storeCart } ) => trackCheckoutStep( 0 )( storeCart ) -); -addUniqueAction( - `${ ACTION_PREFIX }-checkout-set-email-address`, - NAMESPACE, - ( { ...storeCart } ) => trackCheckoutStep( 1 )( storeCart ) -); -addUniqueAction( - `${ ACTION_PREFIX }-checkout-set-shipping-address`, - NAMESPACE, - ( { ...storeCart } ) => trackCheckoutStep( 2 )( storeCart ) -); -addUniqueAction( - `${ ACTION_PREFIX }-checkout-set-billing-address`, - NAMESPACE, - ( { ...storeCart } ) => trackCheckoutStep( 3 )( storeCart ) -); -addUniqueAction( - `${ ACTION_PREFIX }-checkout-set-phone-number`, - NAMESPACE, - ( { step, ...storeCart } ) => { - trackCheckoutStep( step === 'shipping' ? 2 : 3 )( storeCart ); - } -); - -/** - * Choose a shipping rate - * - * @summary Track the shipping rate being set using set_checkout_option - * @see https://developers.google.com/analytics/devguides/collection/gtagjs/enhanced-ecommerce#2_measure_checkout_options - */ -addUniqueAction( - `${ ACTION_PREFIX }-checkout-set-selected-shipping-rate`, - NAMESPACE, - ( { shippingRateId } ) => { - trackCheckoutOption( { - step: 4, - option: __( 'Shipping Method', 'woo-gutenberg-products-block' ), - value: shippingRateId, - } )(); - } -); - -/** - * Choose a payment method - * - * @summary Track the payment method being set using set_checkout_option - * @see https://developers.google.com/analytics/devguides/collection/gtagjs/enhanced-ecommerce#2_measure_checkout_options - */ -addUniqueAction( - `${ ACTION_PREFIX }-checkout-set-active-payment-method`, - NAMESPACE, - ( { paymentMethodSlug } ) => { - trackCheckoutOption( { - step: 5, - option: __( 'Payment Method', 'woo-gutenberg-products-block' ), - value: paymentMethodSlug, - } )(); - } -); - -/** - * Product List View - * - * @summary Track the view_item_list event - * @see https://developers.google.com/gtagjs/reference/ga4-events#view_item_list - */ -addUniqueAction( - `${ ACTION_PREFIX }-product-list-render`, - NAMESPACE, - trackListProducts -); - -/** - * Add to cart. - * - * This event signifies that an item was added to a cart for purchase. - * - * @summary Track the add_to_cart event - * @see https://developers.google.com/gtagjs/reference/ga4-events#add_to_cart - */ -addUniqueAction( - `${ ACTION_PREFIX }-cart-add-item`, - NAMESPACE, - trackAddToCart -); - -/** - * Change cart item quantities - * - * @summary Custom change_cart_quantity event. - */ -addUniqueAction( - `${ ACTION_PREFIX }-cart-set-item-quantity`, - NAMESPACE, - trackChangeCartItemQuantity -); - -/** - * Remove item from the cart - * - * @summary Track the remove_from_cart event - * @see https://developers.google.com/gtagjs/reference/ga4-events#remove_from_cart - */ -addUniqueAction( - `${ ACTION_PREFIX }-cart-remove-item`, - NAMESPACE, - trackRemoveCartItem -); - -/** - * Add Payment Information - * - * This event signifies a user has submitted their payment information. Note, this is used to indicate checkout - * submission, not `purchase` which is triggered on the thanks page. - * - * @summary Track the add_payment_info event - * @see https://developers.google.com/gtagjs/reference/ga4-events#add_payment_info - */ -addUniqueAction( `${ ACTION_PREFIX }-checkout-submit`, NAMESPACE, () => { - trackEvent( 'add_payment_info' ); -} ); - -/** - * Product View Link Clicked - * - * @summary Track the select_content event - * @see https://developers.google.com/gtagjs/reference/ga4-events#select_content - */ -addUniqueAction( - `${ ACTION_PREFIX }-product-view-link`, - NAMESPACE, - trackSelectContent -); - -/** - * Product Search - * - * @summary Track the search event - * @see https://developers.google.com/gtagjs/reference/ga4-events#search - */ -addUniqueAction( `${ ACTION_PREFIX }-product-search`, NAMESPACE, trackSearch ); - -/** - * Single Product View - * - * @summary Track the view_item event - * @see https://developers.google.com/gtagjs/reference/ga4-events#view_item - */ -addUniqueAction( - `${ ACTION_PREFIX }-product-render`, - NAMESPACE, - trackViewItem -); - -/** - * Track notices as Exception events. - * - * @summary Track the exception event - * @see https://developers.google.com/analytics/devguides/collection/gtagjs/exceptions - */ -addUniqueAction( - `${ ACTION_PREFIX }-store-notice-create`, - NAMESPACE, - trackException -); diff --git a/assets/js/src/admin-ga-settings.js b/assets/js/src/admin-ga-settings.js deleted file mode 100644 index 41fc7d55..00000000 --- a/assets/js/src/admin-ga-settings.js +++ /dev/null @@ -1,44 +0,0 @@ -jQuery( document ).ready( function ( $ ) { - const ecCheckbox = $( - '#woocommerce_google_analytics_ga_enhanced_ecommerce_tracking_enabled' - ); - const uaCheckbox = $( - '#woocommerce_google_analytics_ga_use_universal_analytics' - ); - const gtagCheckbox = $( '#woocommerce_google_analytics_ga_gtag_enabled' ); - - updateToggles(); - - ecCheckbox.change( updateToggles ); - uaCheckbox.change( updateToggles ); - gtagCheckbox.change( updateToggles ); - - function updateToggles() { - const isEnhancedEcommerce = ecCheckbox.is( ':checked' ); - const isUniversalAnalytics = uaCheckbox.is( ':checked' ); - const isGtag = gtagCheckbox.is( ':checked' ); - - // Legacy: gtag NO - toggleCheckboxRow( $( '.legacy-setting' ), ! isGtag ); - - // Enhanced settings: Enhanced YES + universal YES or gtag YES - toggleCheckboxRow( - $( '.enhanced-setting' ), - isEnhancedEcommerce && ( isUniversalAnalytics || isGtag ) - ); - - // Enhanced toggle: universal YES or gtag YES - toggleCheckboxRow( ecCheckbox, isUniversalAnalytics || isGtag ); - - // Universal toggle: gtag NO - toggleCheckboxRow( uaCheckbox, ! isGtag ); - } - - function toggleCheckboxRow( checkbox, isVisible ) { - if ( isVisible ) { - checkbox.closest( 'tr' ).show(); - } else { - checkbox.closest( 'tr' ).hide(); - } - } -} ); diff --git a/assets/js/src/config.js b/assets/js/src/config.js new file mode 100644 index 00000000..7c072e42 --- /dev/null +++ b/assets/js/src/config.js @@ -0,0 +1,36 @@ +/* global wcgai */ +export const config = wcgai.config; +export const EEARegions = [ + 'AT', + 'BE', + 'BG', + 'HR', + 'CY', + 'CZ', + 'DK', + 'EE', + 'FI', + 'FR', + 'DE', + 'GR', + 'HU', + 'IS', + 'IE', + 'IT', + 'LV', + 'LI', + 'LT', + 'LU', + 'MT', + 'NL', + 'NO', + 'PL', + 'PT', + 'RO', + 'SK', + 'SI', + 'ES', + 'SE', + 'GB', + 'CH', +]; diff --git a/assets/js/src/ga-integration.js b/assets/js/src/ga-integration.js deleted file mode 100644 index 39078bef..00000000 --- a/assets/js/src/ga-integration.js +++ /dev/null @@ -1,14 +0,0 @@ -// eslint-disable-next-line camelcase -window.google_analytics_integration_product_data = {}; - -jQuery( document ).ready( function ( $ ) { - $( document ).on( - 'found_variation', - 'form.cart', - function ( e, variation ) { - window.google_analytics_integration_product_data[ - variation.variation_id - ] = variation.google_analytics_integration; - } - ); -} ); diff --git a/assets/js/src/index.js b/assets/js/src/index.js new file mode 100644 index 00000000..7f198bf2 --- /dev/null +++ b/assets/js/src/index.js @@ -0,0 +1,6 @@ +// Initialize tracking for classic WooCommerce pages +import { trackClassicPages } from './integrations/classic'; +window.wcgai.trackClassicPages = trackClassicPages; + +// Initialize tracking for Block based WooCommerce pages +import './integrations/blocks'; diff --git a/assets/js/src/integrations/blocks.js b/assets/js/src/integrations/blocks.js new file mode 100644 index 00000000..a816ea6c --- /dev/null +++ b/assets/js/src/integrations/blocks.js @@ -0,0 +1,53 @@ +import { removeAction } from '@wordpress/hooks'; +import { addUniqueAction } from '../utils'; +import { tracker } from '../tracker'; +import { ACTION_PREFIX, NAMESPACE } from '../constants'; + +addUniqueAction( + `${ ACTION_PREFIX }-product-list-render`, + NAMESPACE, + tracker.eventHandler( 'view_item_list' ) +); + +addUniqueAction( + `${ ACTION_PREFIX }-product-render`, + NAMESPACE, + tracker.eventHandler( 'view_item' ) +); + +addUniqueAction( + `${ ACTION_PREFIX }-cart-add-item`, + NAMESPACE, + tracker.eventHandler( 'add_to_cart' ) +); + +addUniqueAction( + `${ ACTION_PREFIX }-cart-remove-item`, + NAMESPACE, + tracker.eventHandler( 'remove_from_cart' ) +); + +addUniqueAction( + `${ ACTION_PREFIX }-checkout-render-checkout-form`, + NAMESPACE, + tracker.eventHandler( 'begin_checkout' ) +); + +addUniqueAction( + `${ ACTION_PREFIX }-product-view-link`, + NAMESPACE, + tracker.eventHandler( 'select_content' ) +); + +/** + * Remove additional actions added by WooCommerce Core which are either + * not supported by Google Analytics for WooCommerce or are redundant + * since Google retired Universal Analytics. + */ +removeAction( `${ ACTION_PREFIX }-checkout-submit`, NAMESPACE ); +removeAction( `${ ACTION_PREFIX }-checkout-set-email-address`, NAMESPACE ); +removeAction( `${ ACTION_PREFIX }-checkout-set-phone-number`, NAMESPACE ); +removeAction( `${ ACTION_PREFIX }-checkout-set-billing-address`, NAMESPACE ); +removeAction( `${ ACTION_PREFIX }-cart-set-item-quantity`, NAMESPACE ); +removeAction( `${ ACTION_PREFIX }-product-search`, NAMESPACE ); +removeAction( `${ ACTION_PREFIX }-store-notice-create`, NAMESPACE ); diff --git a/assets/js/src/integrations/classic.js b/assets/js/src/integrations/classic.js new file mode 100644 index 00000000..37df69fb --- /dev/null +++ b/assets/js/src/integrations/classic.js @@ -0,0 +1,159 @@ +import { tracker } from '../tracker'; +import { getProductFromID } from '../utils'; + +/** + * The Google Analytics integration for classic WooCommerce pages + * triggers events using three different methods. + * + * 1. Instantly handle events listed in the `events` object. + * 2. Listen for custom events from WooCommerce core. + * 3. Listen for various actions (i.e clicks) on specific elements. + * + * To be executed once data set is complete, and `document` is ready. + * + * @param {Object} data - The tracking data from the current page load, containing the following properties: + * @param {Object} data.events - An object containing the events to be instantly tracked. + * @param {Object} data.cart - The cart object. + * @param {Object[]} data.products - An array of all product from the current page. + * @param {Object} data.product - The single product object. + * @param {Object} data.added_to_cart - The product added to cart. + * @param {Object} data.order - The order object. + */ +export function trackClassicPages( { + events, + cart, + products, + product, + added_to_cart: addedToCart, + order, +} ) { + // Instantly track the events listed in the `events` object. + const eventData = { + storeCart: cart, + products, + product, + order, + }; + Object.values( events ?? {} ).forEach( ( eventName ) => { + if ( eventName === 'add_to_cart' ) { + tracker.eventHandler( eventName )( { product: addedToCart } ); + } else { + tracker.eventHandler( eventName )( eventData ); + } + } ); + + // Handle runtime cart events. + /** + * Track the custom add to cart event dispatched by WooCommerce Core + * + * @param {Event} e - The event object + * @param {Object} fragments - An object containing fragments of the updated cart. + * @param {string} cartHash - A string representing the hash of the cart after the update. + * @param {HTMLElement[]} button - An array of HTML elements representing the add to cart button. + */ + document.body.onadded_to_cart = ( e, fragments, cartHash, button ) => { + tracker.eventHandler( 'add_to_cart' )( { + product: getProductFromID( + parseInt( button[ 0 ].dataset.product_id ), + products, + cart + ), + } ); + }; + + /** + * Attaches click event listeners to all remove from cart links + */ + const removeFromCartListener = () => { + document + .querySelectorAll( + '.woocommerce-cart-form .woocommerce-cart-form__cart-item .remove[data-product_id]' + ) + .forEach( ( item ) => + item.addEventListener( 'click', removeFromCartHandler ) + ); + }; + + /** + * Handle remove from cart events + * + * @param {HTMLElement|Object} element - The HTML element clicked on to trigger this event + */ + function removeFromCartHandler( element ) { + tracker.eventHandler( 'remove_from_cart' )( { + product: getProductFromID( + parseInt( element.target.dataset.product_id ), + products, + cart + ), + } ); + } + + // Attach event listeners on initial page load and when the cart div is updated + removeFromCartListener(); + const oldOnupdatedWcDiv = document.body.onupdated_wc_div; + document.body.onupdated_wc_div = ( ...args ) => { + if ( typeof oldOnupdatedWcDiv === 'function' ) { + oldOnupdatedWcDiv( ...args ); + } + removeFromCartListener(); + }; + + // Trigger the handler when an item is removed from the mini-cart and WooCommerce dispatches the `removed_from_cart` event. + const oldOnRemovedFromCart = document.body.onremoved_from_cart; + document.body.onremoved_from_cart = ( ...args ) => { + if ( typeof oldOnRemovedFromCart === 'function' ) { + oldOnRemovedFromCart( ...args ); + } + removeFromCartHandler( { target: args[ 3 ][ 0 ] } ); + }; + + // Handle product selection events. + // Attach click event listeners to non-block product listings + // to send a `select_content` event if the target link takes the user to the product page. + document + .querySelectorAll( '.products .product:not(.wp-block-post)' ) + ?.forEach( ( item ) => { + // Get the Product ID from a child node containing the relevant attribute + const productId = item + .querySelector( 'a[data-product_id]' ) + ?.getAttribute( 'data-product_id' ); + + if ( ! productId ) { + return; + } + + item.addEventListener( 'click', ( event ) => { + // Return early if the user has clicked on an + // "Add to cart" button or anything other than a product link + const targetLink = event.target.closest( + '.woocommerce-loop-product__link' + ); + + const isProductButton = + event.target.classList.contains( 'button' ) && + event.target.hasAttribute( 'data-product_id' ); + + const isAddToCartButton = + event.target.classList.contains( 'add_to_cart_button' ) && + ! event.target.classList.contains( + 'product_type_variable' + ); + + if ( + ! targetLink && + ( ! isProductButton || isAddToCartButton ) + ) { + return; + } + + tracker.eventHandler( 'select_content' )( { + product: getProductFromID( + parseInt( productId ), + products, + cart + ), + } ); + } ); + } ); +} diff --git a/assets/js/src/tracker/data-formatting.js b/assets/js/src/tracker/data-formatting.js new file mode 100644 index 00000000..ab7d4283 --- /dev/null +++ b/assets/js/src/tracker/data-formatting.js @@ -0,0 +1,213 @@ +import { __ } from '@wordpress/i18n'; +import { + getProductFieldObject, + getProductImpressionObject, + getProductId, + formatPrice, + getCartCoupon, +} from '../utils'; + +/* eslint-disable camelcase */ + +/** + * Formats data for the view_item_list event + * + * @param {Object} params The function params + * @param {Array} params.products The products to track + * @param {string} [params.listName] The name of the list in which the item was presented to the user. + */ +export const view_item_list = ( { + products, + listName = __( 'Product List', 'woocommerce-google-analytics-integration' ), +} ) => { + if ( products.length === 0 ) { + return false; + } + + return { + item_list_id: 'engagement', + item_list_name: __( + 'Viewing products', + 'woocommerce-google-analytics-integration' + ), + items: products.map( ( product, index ) => ( { + ...getProductImpressionObject( product, listName ), + index: index + 1, + } ) ), + }; +}; + +/** + * Formats data for the add_to_cart event + * + * @param {Object} params The function params + * @param {Array} params.product The product to track + * @param {number} [params.quantity=1] The quantity of that product in the cart. + */ +export const add_to_cart = ( { product, quantity = 1 } ) => { + return { + items: [ getProductFieldObject( product, quantity ) ], + }; +}; + +/** + * Formats data for the remove_from_cart event + * + * @param {Object} params The function params + * @param {Array} params.product The product to track + * @param {number} [params.quantity=1] The quantity of that product in the cart. + */ +export const remove_from_cart = ( { product, quantity = 1 } ) => { + return { + items: [ getProductFieldObject( product, quantity ) ], + }; +}; + +/** + * Tracks change_cart_quantity event + * + * @param {Object} params The function params + * @param {Array} params.product The product to track + * @param {number} [params.quantity=1] The quantity of that product in the cart. + */ +export const trackChangeCartItemQuantity = ( { product, quantity = 1 } ) => { + trackEvent( 'change_cart_quantity', { + event_category: 'ecommerce', + event_label: __( + 'Change Cart Item Quantity', + 'woocommerce-google-analytics-integration' + ), + items: [ getProductFieldObject( product, quantity ) ], + } ); +}; + +/** + * Formats data for the begin_checkout event + * + * @param {Object} params The function params + * @param {Object} params.storeCart The cart object + */ +export const begin_checkout = ( { storeCart } ) => { + return { + currency: storeCart.totals.currency_code, + value: formatPrice( + storeCart.totals.total_price, + storeCart.totals.currency_minor_unit + ), + ...getCartCoupon( storeCart ), + items: storeCart.items.map( getProductFieldObject ), + }; +}; + +/** + * Formats data for the add_shipping_info event + * + * @param {Object} params The function params + * @param {Object} params.storeCart The cart object + */ +export const add_shipping_info = ( { storeCart } ) => { + return { + currency: storeCart.totals.currency_code, + value: formatPrice( + storeCart.totals.total_price, + storeCart.totals.currency_minor_unit + ), + shipping_tier: + storeCart.shippingRates[ 0 ]?.shipping_rates?.find( + ( rate ) => rate.selected + )?.name || '', + ...getCartCoupon( storeCart ), + items: storeCart.items.map( getProductFieldObject ), + }; +}; + +/** + * Formats data for the select_content event. + * + * @param {Object} params The function params + * @param {Object} params.product The product to track + */ +export const select_content = ( { product } ) => { + return { + content_type: 'product', + content_id: getProductId( product ), + }; +}; + +/** + * Formats data for the search event. + * + * @param {Object} params The function params + * @param {string} params.searchTerm The search term to track + */ +export const search = ( { searchTerm } ) => { + return { + search_term: searchTerm, + }; +}; + +/** + * Formats data for the view_item event + * + * @param {Object} params The function params + * @param {Object} params.product The product to track + * @param {string} [params.listName] The name of the list in which the item was presented to the user. + */ +export const view_item = ( { + product, + listName = __( 'Product List', 'woocommerce-google-analytics-integration' ), +} ) => { + if ( ! product ) { + return false; + } + + return { + items: [ getProductImpressionObject( product, listName ) ], + }; +}; + +/** + * Formats order data for the purchase event + * + * @param {Object} params The function params + * @param {Object} params.order The order object + */ +export const purchase = ( { order } ) => { + return { + currency: order.currency, + value: parseInt( order.value ), + items: order.items.map( getProductFieldObject ), + }; +}; + +/* eslint-enable camelcase */ + +/** + * Formats data for the exception event + * + * @param {Object} params The function params + * @param {string} params.status The status of the exception. It should be "error" for tracking it. + * @param {string} params.content The exception description + */ +export const trackException = ( { status, content } ) => { + if ( status === 'error' ) { + return { + description: content, + fatal: false, + }; + } +}; + +/** + * Track an event using the global gtag function. + * + * @param {string} eventName - Name of the event to track + * @param {Object} [eventParams] - Props to send within the event + */ +export const trackEvent = ( eventName, eventParams ) => { + if ( typeof gtag !== 'function' ) { + throw new Error( 'Function gtag not implemented.' ); + } + + window.gtag( 'event', eventName, eventParams ); +}; diff --git a/assets/js/src/tracker/index.js b/assets/js/src/tracker/index.js new file mode 100644 index 00000000..25b69071 --- /dev/null +++ b/assets/js/src/tracker/index.js @@ -0,0 +1,87 @@ +import { config, EEARegions } from '../config'; +import * as formatters from './data-formatting'; + +let instance; + +/** + * A tracking utility for initializing a GA4 and tracking accepted events. + * + * @class + */ +class Tracker { + /** + * Constructs a new instance of the Tracker class. + * + * @throws {Error} If an instance of the Tracker already exists. + */ + constructor() { + if ( instance ) { + throw new Error( 'Cannot instantiate more than one Tracker' ); + } + instance = this; + instance.init(); + } + + /** + * Initializes the tracker and dataLayer if not already done. + */ + init() { + if ( window[ config.tracker_function_name ] ) { + // Tracker already initialized. Do nothing. + return; + } + + window.dataLayer = window.dataLayer || []; + + function gtag() { + window.dataLayer.push( arguments ); + } + + window[ config.tracker_function_name ] = gtag; + + // Set up default consent state, denying all for EEA visitors. + gtag( 'consent', 'default', { + analytics_storage: 'denied', + ad_storage: 'denied', + ad_user_data: 'denied', + ad_personalization: 'denied', + region: EEARegions, + } ); + + gtag( 'js', new Date() ); + gtag( 'set', `developer_id.${ config.developer_id }`, true ); + gtag( 'config', config.gtag_id, { + allow_google_signals: config.allow_google_signals, + link_attribution: config.link_attribution, + anonymize_ip: config.anonymize_ip, + logged_in: config.logged_in, + linker: config.linker, + custom_map: config.custom_map, + } ); + } + + /** + * Creates and returns an event handler for a specified event name. + * + * @param {string} name The name of the event. + * @return {function(*): void} Function for processing and tracking the event. + * @throws {Error} If the event name is not supported. + */ + eventHandler( name ) { + /* eslint import/namespace: [ 'error', { allowComputed: true } ] */ + const formatter = formatters[ name ]; + if ( typeof formatter !== 'function' ) { + throw new Error( `Event ${ name } is not supported.` ); + } + + return function trackerEventHandler( data ) { + window[ config.tracker_function_name ]( + 'event', + name, + formatter( data ) + ); + }; + } +} + +export const tracker = Object.freeze( new Tracker() ); diff --git a/assets/js/src/tracking.js b/assets/js/src/tracking.js deleted file mode 100644 index 65960069..00000000 --- a/assets/js/src/tracking.js +++ /dev/null @@ -1,230 +0,0 @@ -import { __ } from '@wordpress/i18n'; -import { - getProductFieldObject, - getProductImpressionObject, - formatPrice, -} from './utils'; - -/** - * Variable holding the current checkout step. It will be modified by trackCheckoutOption and trackCheckoutStep methods. - * - * @type {number} - */ -let currentStep = -1; - -/** - * Tracks view_item_list event - * - * @param {Object} params The function params - * @param {Array} params.products The products to track - * @param {string} [params.listName] The name of the list in which the item was presented to the user. - */ -export const trackListProducts = ( { - products, - listName = __( 'Product List', 'woocommerce-google-analytics-integration' ), -} ) => { - trackEvent( 'view_item_list', { - event_category: 'engagement', - event_label: __( - 'Viewing products', - 'woocommerce-google-analytics-integration' - ), - items: products.map( ( product, index ) => ( { - ...getProductImpressionObject( product, listName ), - list_position: index + 1, - } ) ), - } ); -}; - -/** - * Tracks add_to_cart event - * - * @param {Object} params The function params - * @param {Array} params.product The product to track - * @param {number} [params.quantity=1] The quantity of that product in the cart. - */ -export const trackAddToCart = ( { product, quantity = 1 } ) => { - trackEvent( 'add_to_cart', { - event_category: 'ecommerce', - event_label: __( - 'Add to Cart', - 'woocommerce-google-analytics-integration' - ), - items: [ getProductFieldObject( product, quantity ) ], - } ); -}; - -/** - * Tracks remove_from_cart event - * - * @param {Object} params The function params - * @param {Array} params.product The product to track - * @param {number} [params.quantity=1] The quantity of that product in the cart. - */ -export const trackRemoveCartItem = ( { product, quantity = 1 } ) => { - trackEvent( 'remove_from_cart', { - event_category: 'ecommerce', - event_label: __( - 'Remove Cart Item', - 'woocommerce-google-analytics-integration' - ), - items: [ getProductFieldObject( product, quantity ) ], - } ); -}; - -/** - * Tracks change_cart_quantity event - * - * @param {Object} params The function params - * @param {Array} params.product The product to track - * @param {number} [params.quantity=1] The quantity of that product in the cart. - */ -export const trackChangeCartItemQuantity = ( { product, quantity = 1 } ) => { - trackEvent( 'change_cart_quantity', { - event_category: 'ecommerce', - event_label: __( - 'Change Cart Item Quantity', - 'woocommerce-google-analytics-integration' - ), - items: [ getProductFieldObject( product, quantity ) ], - } ); -}; - -/** - * Track a begin_checkout and checkout_progress event - * Notice calling this will set the current checkout step as the step provided in the parameter. - * - * @param {number} step The checkout step for to track - * @return {(function( { storeCart: Object } ): void)} A callable receiving the cart to track the checkout event. - */ -export const trackCheckoutStep = - ( step ) => - ( { storeCart } ) => { - if ( currentStep === step ) { - return; - } - - // compatibility-code "WC >= 8.1" -- The data structure of `storeCart` was (accidentally) changed. - if ( ! storeCart.hasOwnProperty( 'cartTotals' ) ) { - storeCart = { - cartCoupons: storeCart.coupons, - cartItems: storeCart.items, - cartTotals: storeCart.totals, - }; - } - - trackEvent( step === 0 ? 'begin_checkout' : 'checkout_progress', { - items: storeCart.cartItems.map( ( item ) => - getProductFieldObject( item, item.quantity ) - ), - coupon: storeCart.cartCoupons[ 0 ]?.code || '', - currency: storeCart.cartTotals.currency_code, - value: formatPrice( - storeCart.cartTotals.total_price, - storeCart.cartTotals.currency_minor_unit - ), - checkout_step: step, - } ); - - currentStep = step; - }; - -/** - * Track a set_checkout_option event - * Notice calling this will set the current checkout step as the step provided in the parameter. - * - * @param {Object} params The params from the option. - * @param {number} params.step The step to track - * @param {string} params.option The option to set in checkout - * @param {string} params.value The value for the option - * - * @return {(function() : void)} A callable to track the checkout event. - */ -export const trackCheckoutOption = - ( { step, option, value } ) => - () => { - trackEvent( 'set_checkout_option', { - checkout_step: step, - checkout_option: option, - value, - } ); - - currentStep = step; - }; - -/** - * Tracks select_content event. - * - * @param {Object} params The function params - * @param {Object} params.product The product to track - * @param {string} params.listName The name of the list in which the item was presented to the user. - */ -export const trackSelectContent = ( { - product, - listName = __( 'Product List', 'woocommerce-google-analytics-integration' ), -} ) => { - trackEvent( 'select_content', { - content_type: 'product', - items: [ getProductImpressionObject( product, listName ) ], - } ); -}; - -/** - * Tracks search event. - * - * @param {Object} params The function params - * @param {string} params.searchTerm The search term to track - */ -export const trackSearch = ( { searchTerm } ) => { - trackEvent( 'search', { - search_term: searchTerm, - } ); -}; - -/** - * Tracks view_item event - * - * @param {Object} params The function params - * @param {Object} params.product The product to track - * @param {string} [params.listName] The name of the list in which the item was presented to the user. - */ -export const trackViewItem = ( { - product, - listName = __( 'Product List', 'woocommerce-google-analytics-integration' ), -} ) => { - if ( product ) { - trackEvent( 'view_item', { - items: [ getProductImpressionObject( product, listName ) ], - } ); - } -}; - -/** - * Track exception event - * - * @param {Object} params The function params - * @param {string} params.status The status of the exception. It should be "error" for tracking it. - * @param {string} params.content The exception description - */ -export const trackException = ( { status, content } ) => { - if ( status === 'error' ) { - trackEvent( 'exception', { - description: content, - fatal: false, - } ); - } -}; - -/** - * Track an event using the global gtag function. - * - * @param {string} eventName - Name of the event to track - * @param {Object} [eventParams] - Props to send within the event - */ -export const trackEvent = ( eventName, eventParams ) => { - if ( typeof gtag !== 'function' ) { - throw new Error( 'Function gtag not implemented.' ); - } - - window.gtag( 'event', eventName, eventParams ); -}; diff --git a/assets/js/src/utils.js b/assets/js/src/utils.js deleted file mode 100644 index 4145b9d9..00000000 --- a/assets/js/src/utils.js +++ /dev/null @@ -1,93 +0,0 @@ -import { addAction, removeAction } from '@wordpress/hooks'; - -/** - * Formats data into the productFieldObject shape. - * - * @see https://developers.google.com/analytics/devguides/collection/gtagjs/enhanced-ecommerce#product-data - * @param {Object} product - The product data - * @param {number} quantity - The product quantity - * - * @return {Object} The product data - */ -export const getProductFieldObject = ( product, quantity ) => { - return { - id: getProductId( product ), - name: product.name, - quantity, - category: getProductCategory( product ), - price: formatPrice( - product.prices.price, - product.prices.currency_minor_unit - ), - }; -}; - -/** - * Formats data into the impressionFieldObject shape. - * - * @see https://developers.google.com/analytics/devguides/collection/gtagjs/enhanced-ecommerce#impression-data - * @param {Object} product - The product data - * @param {string} listName - The list for this product - * - * @return {Object} - The product impression data - */ -export const getProductImpressionObject = ( product, listName ) => { - return { - id: getProductId( product ), - name: product.name, - list_name: listName, - category: getProductCategory( product ), - price: formatPrice( - product.prices.price, - product.prices.currency_minor_unit - ), - }; -}; - -/** - * Returns the price of a product formatted as a string. - * - * @param {string} price - The price to parse - * @param {number} [currencyMinorUnit=2] - The number decimals to show in the currency - * - * @return {string} - The price of the product formatted - */ -export const formatPrice = ( price, currencyMinorUnit = 2 ) => { - return ( parseInt( price, 10 ) / 10 ** currencyMinorUnit ).toString(); -}; - -/** - * Removes previous actions with the same hookName and namespace and then adds the new action. - * - * @param {string} hookName The hook name for the action - * @param {string} namespace The unique namespace for the action - * @param {Function} callback The function to run when the action happens. - */ -export const addUniqueAction = ( hookName, namespace, callback ) => { - removeAction( hookName, namespace ); - addAction( hookName, namespace, callback ); -}; - -/** - * Returns the product ID by checking if the product has a SKU, if not, it returns '#' concatenated with the product ID. - * - * @param {Object} product - The product object - * - * @return {string} - The product ID - */ -const getProductId = ( product ) => { - return product.sku ? product.sku : '#' + product.id; -}; - -/** - * Returns the name of the first category of a product, or an empty string if the product has no categories. - * - * @param {Object} product - The product object - * - * @return {string} - The name of the first category of the product or an empty string if the product has no categories. - */ -const getProductCategory = ( product ) => { - return 'categories' in product && product.categories.length - ? product.categories[ 0 ].name - : ''; -}; diff --git a/assets/js/src/utils/index.js b/assets/js/src/utils/index.js new file mode 100644 index 00000000..7a3c0f22 --- /dev/null +++ b/assets/js/src/utils/index.js @@ -0,0 +1,170 @@ +import { addAction, removeAction } from '@wordpress/hooks'; +import { config } from '../config.js'; + +/** + * Formats data into the productFieldObject shape. + * + * @see https://developers.google.com/analytics/devguides/collection/gtagjs/enhanced-ecommerce#product-data + * @param {Object} product - The product data + * @param {number} quantity - The product quantity + * + * @return {Object} The product data + */ +export const getProductFieldObject = ( product, quantity ) => { + const variantData = {}; + if ( product.variation ) { + variantData.item_variant = product.variation; + } + + return { + item_id: getProductId( product ), + item_name: product.name, + ...getProductCategories( product ), + quantity: product.quantity ?? quantity, + price: formatPrice( + product.prices.price, + product.prices.currency_minor_unit + ), + ...variantData, + }; +}; + +/** + * Formats data into the impressionFieldObject shape. + * + * @see https://developers.google.com/analytics/devguides/collection/gtagjs/enhanced-ecommerce#impression-data + * @param {Object} product - The product data + * @param {string} listName - The list for this product + * + * @return {Object} - The product impression data + */ +export const getProductImpressionObject = ( product, listName ) => { + return { + item_id: getProductId( product ), + item_name: product.name, + item_list_name: listName, + ...getProductCategories( product ), + price: formatPrice( + product.prices.price, + product.prices.currency_minor_unit + ), + }; +}; + +/** + * Returns the price of a product formatted as a string. + * + * @param {string} price - The price to parse + * @param {number} [currencyMinorUnit=2] - The number decimals to show in the currency + * + * @return {number} - The price of the product formatted + */ +export const formatPrice = ( price, currencyMinorUnit = 2 ) => { + return parseInt( price, 10 ) / 10 ** currencyMinorUnit; +}; + +/** + * Removes previous actions with the same hookName and namespace and then adds the new action. + * + * @param {string} hookName The hook name for the action + * @param {string} namespace The unique namespace for the action + * @param {Function} callback The function to run when the action happens. + */ +export const addUniqueAction = ( hookName, namespace, callback ) => { + removeAction( hookName, namespace ); + addAction( hookName, namespace, callback ); +}; + +/** + * Returns the product ID by checking if the product data includes the formatted + * identifier. If the identifier is not present then it will return either the product + * SKU, the product ID prefixed with #, or the product ID depending on the site settings + * + * @param {Object} product - The product object + * + * @return {string} - The product ID + */ +export const getProductId = ( product ) => { + const identifier = + product.extensions?.woocommerce_google_analytics_integration + ?.identifier; + + if ( identifier !== undefined ) { + return identifier; + } + + if ( config.identifier === 'product_sku' ) { + return product.sku ? product.sku : '#' + product.id; + } + + return product.id; +}; + +/** + * Returns an Object containing the cart coupon if one has been applied + * + * @param {Object} storeCart - The cart to check for coupons + * + * @return {Object} - Either an empty Object or one containing the coupon + */ +export const getCartCoupon = ( storeCart ) => { + return storeCart.coupons[ 0 ]?.code + ? { + coupon: storeCart.coupons[ 0 ]?.code, + } + : {}; +}; + +/** + * Returns the name of the first category of a product, or an empty string if the product has no categories. + * + * @param {Object} product - The product object + * + * @return {string} - The name of the first category of the product or an empty string if the product has no categories. + */ +const getProductCategories = ( product ) => { + return 'categories' in product && product.categories.length + ? getCategoryObject( product.categories ) + : {}; +}; + +/** + * Returns an object containing up to 5 categories for the product. + * + * @param {Object} categories - An array of product categories + * + * @return {Object} - An categories object + */ +const getCategoryObject = ( categories ) => { + return Object.fromEntries( + categories.slice( 0, 5 ).map( ( category, index ) => { + return [ formatCategoryKey( index ), category.name ]; + } ) + ); +}; + +/** + * Returns the correctly formatted key for the category object. + * + * @param {number} index Index of the current category + * + * @return {string} - A formatted key for the category object + */ +const formatCategoryKey = ( index ) => { + return 'item_category' + ( index > 0 ? index + 1 : '' ); +}; + +/** + * Searches through the global wcgaiData.products object to find a single product by its ID + * + * @param {number} search The ID of the product to search for + * @param {Object[]} products The array of available products + * @param {Object} cart The cart object + * @return {Object|undefined} The product object or undefined if not found + */ +export const getProductFromID = ( search, products, cart ) => { + return ( + cart?.items?.find( ( { id } ) => id === search ) ?? + products?.find( ( { id } ) => id === search ) + ); +}; diff --git a/composer.json b/composer.json index 176bb54d..5e2a268d 100644 --- a/composer.json +++ b/composer.json @@ -24,4 +24,4 @@ "autoload": { "psr-4": { "GoogleAnalyticsIntegration\\Tests\\": "tests/" } } -} +} \ No newline at end of file diff --git a/composer.lock b/composer.lock index a9bf3f9d..ea67d554 100644 --- a/composer.lock +++ b/composer.lock @@ -2070,4 +2070,4 @@ "platform": [], "platform-dev": [], "plugin-api-version": "2.2.0" -} +} \ No newline at end of file diff --git a/includes/class-wc-abstract-google-analytics-js.php b/includes/class-wc-abstract-google-analytics-js.php index c78fe467..9e49138c 100644 --- a/includes/class-wc-abstract-google-analytics-js.php +++ b/includes/class-wc-abstract-google-analytics-js.php @@ -4,6 +4,9 @@ exit; } +use Automattic\WooCommerce\StoreApi\Schemas\V1\ProductSchema; +use Automattic\WooCommerce\StoreApi\Schemas\V1\CartItemSchema; + /** * WC_Abstract_Google_Analytics_JS class * @@ -14,41 +17,106 @@ abstract class WC_Abstract_Google_Analytics_JS { /** @var WC_Abstract_Google_Analytics_JS $instance Class Instance */ protected static $instance; - /** @var array $options Inherited Analytics options */ - protected static $options; + /** @var array $settings Inherited Analytics settings */ + protected static $settings; /** @var string Developer ID */ public const DEVELOPER_ID = 'dOGY3NW'; /** - * Get the class instance + * Constructor + * To be called from child classes to setup event data * - * @param array $options Options - * @return WC_Abstract_Google_Analytics_JS + * @return void */ - abstract public static function get_instance( $options = array() ); + public function __construct() { + $this->attach_event_data(); + + if ( did_action( 'woocommerce_blocks_loaded' ) ) { + woocommerce_store_api_register_endpoint_data( + array( + 'endpoint' => ProductSchema::IDENTIFIER, + 'namespace' => 'woocommerce_google_analytics_integration', + 'data_callback' => array( $this, 'data_callback' ), + 'schema_callback' => array( $this, 'schema_callback' ), + 'schema_type' => ARRAY_A, + ) + ); + + woocommerce_store_api_register_endpoint_data( + array( + 'endpoint' => CartItemSchema::IDENTIFIER, + 'namespace' => 'woocommerce_google_analytics_integration', + 'data_callback' => array( $this, 'data_callback' ), + 'schema_callback' => array( $this, 'schema_callback' ), + 'schema_type' => ARRAY_A, + ) + ); + } + } /** - * Return one of our options + * Hook into various parts of WooCommerce and set the relevant + * script data that the frontend tracking script will use. * - * @param string $option Key/name for the option - * @return string Value of the option + * @return void */ - protected static function get( $option ) { - return self::$options[ $option ]; + public function attach_event_data(): void { + add_action( + 'wp_head', + function () { + $this->set_script_data( 'cart', $this->get_formatted_cart() ); + } + ); + + add_action( + 'woocommerce_before_single_product', + function () { + global $product; + $this->set_script_data( 'product', $this->get_formatted_product( $product ) ); + } + ); + + add_action( + 'woocommerce_add_to_cart', + function ( $cart_item_key, $product_id, $quantity, $variation_id, $variation ) { + $this->set_script_data( 'added_to_cart', $this->get_formatted_product( wc_get_product( $product_id ), $variation ) ); + }, + 10, + 5 + ); + + add_action( + 'woocommerce_shop_loop_item_title', + function () { + global $product; + $this->append_script_data( 'products', $this->get_formatted_product( $product ) ); + } + ); + + add_action( + 'woocommerce_thankyou', + function ( $order_id ) { + $this->set_script_data( 'order', $this->get_formatted_order( $order_id ) ); + } + ); } /** - * Returns the tracker variable this integration should use + * Return one of our settings * - * @return string + * @param string $setting Key/name for the setting. + * + * @return string|null Value of the setting or null if not found */ - abstract public static function tracker_var(); + protected static function get( $setting ): ?string { + return self::$settings[ $setting ] ?? null; + } /** * Generic GA snippet for opt out */ - public static function load_opt_out() { + public static function load_opt_out(): void { $code = " var gaProperty = '" . esc_js( self::get( 'ga_id' ) ) . "'; var disableStr = 'ga-disable-' + gaProperty; @@ -66,176 +134,213 @@ function gaOptout() { } /** - * Enqueues JavaScript to build the addImpression object + * Get item identifier from product data * - * @param WC_Product $product - */ - abstract public static function listing_impression( $product ); - - /** - * Enqueues JavaScript to build an addProduct and click object + * @param WC_Product $product WC_Product Object. * - * @param WC_Product $product + * @return string */ - abstract public static function listing_click( $product ); + public static function get_product_identifier( WC_Product $product ): string { + $identifier = $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id(); + + if ( 'product_sku' === self::get( 'ga_product_identifier' ) ) { + if ( ! empty( $product->get_sku() ) ) { + $identifier = $product->get_sku(); + } else { + $identifier = '#' . ( $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id() ); + } + } - /** - * Loads the correct Google Gtag code (classic or universal) - * - * @param boolean|WC_Order $order Classic analytics needs order data to set the currency correctly - */ - abstract public static function load_analytics( $order = false ); + return apply_filters( 'woocommerce_ga_product_identifier', $identifier, $product ); + } /** - * Generate code used to pass transaction data to Google Analytics. + * Returns an array of cart data in the required format * - * @param WC_Order $order WC_Order Object + * @return array */ - public function add_transaction( $order ) { - if ( 'yes' === self::get( 'ga_enhanced_ecommerce_tracking_enabled' ) || 'yes' === self::get( 'ga_gtag_enabled' ) ) { - wc_enqueue_js( static::add_transaction_enhanced( $order ) ); - } else { - wc_enqueue_js( self::add_transaction_universal( $order ) ); - } + public function get_formatted_cart(): array { + return array( + 'items' => array_map( + function ( $item ) { + return array_merge( + $this->get_formatted_product( $item['data'] ), + array( + 'quantity' => $item['quantity'], + 'prices' => array( + 'price' => $this->get_formatted_price( $item['line_total'] ), + 'currency_minor_unit' => wc_get_price_decimals(), + ), + ) + ); + }, + array_values( WC()->cart->get_cart() ) + ), + 'coupons' => WC()->cart->get_coupons(), + 'totals' => array( + 'currency_code' => get_woocommerce_currency(), + 'total_price' => $this->get_formatted_price( WC()->cart->get_total( 'edit' ) ), + 'currency_minor_unit' => wc_get_price_decimals(), + ), + ); } /** - * Generate Enhanced eCommerce transaction tracking code + * Returns an array of product data in the required format * - * @param WC_Order $order WC_Order object - * @return string Add Transaction Code - */ - abstract protected function add_transaction_enhanced( $order ); - - /** - * Get item identifier from product data + * @param WC_Product $product The product to format. + * @param array|bool $variation An array containing product variation attributes to include in the product data. * - * @param WC_Product $product WC_Product Object - * @return string + * @return array */ - public static function get_product_identifier( $product ) { - if ( ! empty( $product->get_sku() ) ) { - return esc_js( $product->get_sku() ); - } else { - return esc_js( '#' . ( $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id() ) ); + public function get_formatted_product( WC_Product $product, $variation = false ): array { + $formatted = array( + 'id' => $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id(), + 'name' => $product->get_title(), + 'categories' => array_map( + fn( $category ) => array( 'name' => $category->name ), + wc_get_product_terms( $product->get_id(), 'product_cat', array( 'number' => 5 ) ) + ), + 'prices' => array( + 'price' => $this->get_formatted_price( $product->get_price() ), + 'currency_minor_unit' => wc_get_price_decimals(), + ), + 'extensions' => array( + 'woocommerce_google_analytics_integration' => array( + 'identifier' => $this->get_product_identifier( $product ), + ), + ), + ); + + if ( $product->is_type( 'variation' ) ) { + $variation = $product->get_attributes(); } + + if ( false !== $variation ) { + $formatted['variation'] = implode( + ', ', + array_map( + function ( $attribute, $value ) { + return sprintf( + '%s: %s', + str_replace( 'attribute_', '', $attribute ), + $value + ); + }, + array_keys( $variation ), + array_values( $variation ) + ) + ); + } + + return $formatted; } /** - * Generate Universal Analytics add item tracking code + * Returns an array of order data in the required format * - * @param WC_Order $order WC_Order Object - * @param WC_Order_Item $item The item to add to a transaction/order - * @return string + * @param int $order_id The ID of the order + * + * @return array */ - protected function add_item_universal( $order, $item ) { - $_product = version_compare( WC_VERSION, '3.0', '<' ) ? $order->get_product_from_item( $item ) : $item->get_product(); - - $code = "ga('ecommerce:addItem', {"; - $code .= "'id': '" . esc_js( $order->get_order_number() ) . "',"; - $code .= "'name': '" . esc_js( $item['name'] ) . "',"; - $code .= "'sku': '" . esc_js( $_product->get_sku() ? $_product->get_sku() : $_product->get_id() ) . "',"; - $code .= "'category': " . self::product_get_category_line( $_product ); - $code .= "'price': '" . esc_js( $order->get_item_total( $item ) ) . "',"; - $code .= "'quantity': '" . esc_js( $item['qty'] ) . "'"; - $code .= '});'; - - return $code; + public function get_formatted_order( int $order_id ): array { + $order = wc_get_order( $order_id ); + + return array( + 'currency' => $order->get_currency(), + 'value' => $this->get_formatted_price( $order->get_total() ), + 'items' => array_map( + function ( $item ) { + return array_merge( + $this->get_formatted_product( $item->get_product() ), + array( + 'quantity' => $item->get_quantity(), + ) + ); + }, + array_values( $order->get_items() ), + ), + ); } /** - * Generate Universal Analytics transaction tracking code + * Formats a price the same way WooCommerce Blocks does * - * @param WC_Order $order WC_Order object - * @return string Add Transaction tracking code + * @param mixed $value The price value for format + * + * @return int */ - protected function add_transaction_universal( $order ) { - $code = "ga('ecommerce:addTransaction', { - 'id': '" . esc_js( $order->get_order_number() ) . "', // Transaction ID. Required - 'affiliation': '" . esc_js( get_bloginfo( 'name' ) ) . "', // Affiliation or store name - 'revenue': '" . esc_js( $order->get_total() ) . "', // Grand Total - 'shipping': '" . esc_js( $order->get_total_shipping() ) . "', // Shipping - 'tax': '" . esc_js( $order->get_total_tax() ) . "', // Tax - 'currency': '" . esc_js( version_compare( WC_VERSION, '3.0', '<' ) ? $order->get_order_currency() : $order->get_currency() ) . "' // Currency - });"; - - // Order items - if ( $order->get_items() ) { - foreach ( $order->get_items() as $item ) { - $code .= self::add_item_universal( $order, $item ); - } - } - - $code .= "ga('ecommerce:send');"; - return $code; + public function get_formatted_price( $value ): int { + return intval( + round( + ( (float) wc_format_decimal( $value ) ) * ( 10 ** absint( wc_get_price_decimals() ) ), + 0 + ) + ); } /** - * Returns a 'category' JSON line based on $product + * Add product identifier to StoreAPI + * + * @param WC_Product|array $product Either an instance of WC_Product or a cart item array depending on the endpoint * - * @param WC_Product $_product Product to pull info for - * @return string Line of JSON + * @return array */ - public static function product_get_category_line( $_product ) { - $out = []; - $variation_data = $_product->is_type( 'variation' ) ? wc_get_product_variation_attributes( $_product->get_id() ) : false; - $categories = get_the_terms( $_product->get_id(), 'product_cat' ); - - if ( is_array( $variation_data ) && ! empty( $variation_data ) ) { - $parent_product = wc_get_product( $_product->get_parent_id() ); - $categories = get_the_terms( $parent_product->get_id(), 'product_cat' ); - } - - if ( $categories ) { - foreach ( $categories as $category ) { - $out[] = $category->name; - } - } + public function data_callback( $product ): array { + $product = is_a( $product, 'WC_Product' ) ? $product : $product['data']; - return "'" . esc_js( join( '/', $out ) ) . "',"; + return array( + 'identifier' => (string) $this->get_product_identifier( $product ), + ); } /** - * Returns a 'variant' JSON line based on $product + * Schema for the extended StoreAPI data * - * @param WC_Product $_product Product to pull info for - * @return string Line of JSON + * @return array */ - public static function product_get_variant_line( $_product ) { - $out = ''; - $variation_data = $_product->is_type( 'variation' ) ? wc_get_product_variation_attributes( $_product->get_id() ) : false; - - if ( is_array( $variation_data ) && ! empty( $variation_data ) ) { - $out = "'" . esc_js( wc_get_formatted_variation( $variation_data, true ) ) . "',"; - } - - return $out; + public function schema_callback(): array { + return array( + 'identifier' => array( + 'description' => __( 'The formatted product identifier to use in Google Analytics events.', 'woocommerce-google-analytics-integration' ), + 'type' => 'string', + 'readonly' => true, + ), + ); } /** - * Echo JavaScript to track an enhanced ecommerce remove from cart action + * Returns the tracker variable this integration should use + * + * @return string */ - abstract public function remove_from_cart(); + abstract public static function tracker_function_name(): string; /** - * Enqueue JavaScript to track a product detail view + * Add an event to the script data + * + * @param string $type The type of event this data is related to. + * @param string|array $data The event data to add. * - * @param WC_Product $product + * @return void */ - abstract public function product_detail( $product ); + abstract public function set_script_data( string $type, $data ): void; /** - * Enqueue JS to track when the checkout process is started + * Append data to an existing script data array * - * @param array $cart items/contents of the cart + * @param string $type The type of event this data is related to. + * @param string|array $data The event data to add. + * + * @return void */ - abstract public function checkout_process( $cart ); + abstract public function append_script_data( string $type, $data ): void; /** - * Enqueue JavaScript for Add to cart tracking + * Get the class instance * - * @param array $parameters associative array of _trackEvent parameters - * @param string $selector jQuery selector for binding click event + * @param array $settings Settings + * @return WC_Abstract_Google_Analytics_JS */ - abstract public function event_tracking_code( $parameters, $selector ); + abstract public static function get_instance( $settings = array() ): WC_Abstract_Google_Analytics_JS; } diff --git a/includes/class-wc-google-analytics-js.php b/includes/class-wc-google-analytics-js.php deleted file mode 100644 index acf80565..00000000 --- a/includes/class-wc-google-analytics-js.php +++ /dev/null @@ -1,542 +0,0 @@ -get_order_currency() : $order->get_currency() ) . "']"; - } - - $code .= ');'; - - self::load_analytics_code_in_header( apply_filters( 'woocommerce_ga_classic_snippet_output', $code ) ); - } - - /** - * Enqueues JavaScript to build the addImpression object - * - * @param WC_Product $product - */ - public static function listing_impression( $product ) { - if ( is_search() ) { - $list = 'Search Results'; - } else { - $list = 'Product List'; - } - - wc_enqueue_js( - self::tracker_var() . "( 'ec:addImpression', { - 'id': '" . esc_js( $product->get_id() ) . "', - 'name': '" . esc_js( $product->get_title() ) . "', - 'category': " . self::product_get_category_line( $product ) . " - 'list': '" . esc_js( $list ) . "' - } ); - " - ); - } - - /** - * Enqueues JavaScript to build an addProduct and click object - * - * @param WC_Product $product - */ - public static function listing_click( $product ) { - if ( is_search() ) { - $list = 'Search Results'; - } else { - $list = 'Product List'; - } - - wc_enqueue_js( - " - $( '.product.post-" . esc_js( $product->get_id() ) . ' a.button , .product.post-' . esc_js( $product->get_id() ) . " button' ).on('click', function() { - if ( false === $(this).hasClass( 'product_type_variable' ) && false === $(this).hasClass( 'product_type_grouped' ) ) { - " . self::tracker_var() . "( 'ec:addProduct', { - 'id': '" . esc_js( $product->get_id() ) . "', - 'name': '" . esc_js( $product->get_title() ) . "', - 'category': " . self::product_get_category_line( $product ) . ' - }); - } - ' . self::tracker_var() . "( 'ec:setAction', 'click', { list: '" . esc_js( $list ) . "' }); - " . self::tracker_var() . "( 'send', 'event', 'UX', 'click', ' " . esc_js( $list ) . "' ); - }); - " - ); - } - - /** - * Loads in the footer - * - * @see wp_footer - */ - public static function classic_analytics_footer() { - if ( 'yes' === self::get( 'ga_support_display_advertising' ) ) { - $ga_url = "('https:' == document.location.protocol ? 'https://' : 'http://') + 'stats.g.doubleclick.net/dc.js'"; - } else { - $ga_url = "('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js'"; - } - - $code = "(function() { - var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true; - ga.src = " . $ga_url . "; - var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s); - })();"; - - wc_enqueue_js( $code ); - } - - /** - * Enqueues JavaScript to send the pageview last thing (needed for things like addImpression) - */ - public static function load_page_view_footer() { - if ( apply_filters( 'wc_google_analytics_send_pageview', true ) ) { - wc_enqueue_js( self::tracker_var() . "( 'send', 'pageview' ); " ); - } - } - - /** - * This was created to fix public facing api typo in a filter name - * and inform about the deprecation. - * - * @param boolean $send_pageview - */ - public static function universal_analytics_footer_filter( $send_pageview ) { - return apply_filters_deprecated( 'wc_goole_analytics_send_pageview', array( $send_pageview ), '1.4.20', 'wc_google_analytics_send_pageview' ); - } - - /** - * Loads the universal analytics code - * - * @param string $logged_in 'yes' if the user is logged in, no if not (this is a string so we can pass it to GA) - */ - protected static function load_analytics_universal( $logged_in ) { - $domainname = self::get( 'ga_set_domain_name' ); - - if ( ! empty( $domainname ) ) { - $set_domain_name = esc_js( self::get( 'ga_set_domain_name' ) ); - } else { - $set_domain_name = 'auto'; - } - - $support_display_advertising = ''; - if ( 'yes' === self::get( 'ga_support_display_advertising' ) ) { - $support_display_advertising = self::tracker_var() . "( 'require', 'displayfeatures' );"; - } - - $support_enhanced_link_attribution = ''; - if ( 'yes' === self::get( 'ga_support_enhanced_link_attribution' ) ) { - $support_enhanced_link_attribution = self::tracker_var() . "( 'require', 'linkid' );"; - } - - $anonymize_enabled = ''; - if ( 'yes' === self::get( 'ga_anonymize_enabled' ) ) { - $anonymize_enabled = self::tracker_var() . "( 'set', 'anonymizeIp', true );"; - } - - $track_404_enabled = ''; - if ( 'yes' === self::get( 'ga_404_tracking_enabled' ) && is_404() ) { - // See https://developers.google.com/analytics/devguides/collection/analyticsjs/events for reference - $track_404_enabled = self::tracker_var() . "( 'send', 'event', 'Error', '404 Not Found', 'page: ' + document.location.pathname + document.location.search + ' referrer: ' + document.referrer );"; - } - - $src = apply_filters( 'woocommerce_google_analytics_script_src', '//www.google-analytics.com/analytics.js' ); - - $ga_snippet_head = "(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ - (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), - m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) - })(window,document,'script', '{$src}','" . self::tracker_var() . "');"; - - $ga_id = self::get( 'ga_id' ); - - if ( 'yes' === self::get( 'ga_linker_allow_incoming_enabled' ) ) { - $ga_snippet_create = self::tracker_var() . "( 'create', '" . esc_js( $ga_id ) . "', '" . $set_domain_name . "', { allowLinker: true });"; - } else { - $ga_snippet_create = self::tracker_var() . "( 'create', '" . esc_js( $ga_id ) . "', '" . $set_domain_name . "' );"; - } - - if ( ! empty( self::DEVELOPER_ID ) ) { - $ga_snippet_developer_id = "(window.gaDevIds=window.gaDevIds||[]).push('" . self::DEVELOPER_ID . "');"; - } else { - $ga_snippet_developer_id = ''; - } - - $ga_snippet_require = $support_display_advertising . - $support_enhanced_link_attribution . - $anonymize_enabled . - $track_404_enabled . ' - ' . self::tracker_var() . "( 'set', 'dimension1', '" . $logged_in . "' );\n"; - - if ( 'yes' === self::get( 'ga_enhanced_ecommerce_tracking_enabled' ) ) { - $ga_snippet_require .= self::tracker_var() . "( 'require', 'ec' );"; - } else { - $ga_snippet_require .= self::tracker_var() . "( 'require', 'ecommerce', 'ecommerce.js');"; - } - - $ga_cross_domains = ! empty( self::get( 'ga_linker_cross_domains' ) ) ? array_map( 'esc_js', explode( ',', self::get( 'ga_linker_cross_domains' ) ) ) : false; - - if ( $ga_cross_domains ) { - $ga_snippet_require .= self::tracker_var() . "( 'require', 'linker' );"; - $ga_snippet_require .= self::tracker_var() . "( 'linker:autoLink', " . wp_json_encode( $ga_cross_domains ) . ');'; - } - - $ga_snippet_head = apply_filters( 'woocommerce_ga_snippet_head', $ga_snippet_head ); - $ga_snippet_create = apply_filters( 'woocommerce_ga_snippet_create', $ga_snippet_create, $ga_id ); - $ga_snippet_developer_id = apply_filters( 'woocommerce_ga_snippet_developer_id', $ga_snippet_developer_id ); - $ga_snippet_require = apply_filters( 'woocommerce_ga_snippet_require', $ga_snippet_require ); - - $code = $ga_snippet_head . $ga_snippet_create . $ga_snippet_developer_id . $ga_snippet_require; - - self::load_analytics_code_in_header( apply_filters( 'woocommerce_ga_snippet_output', $code ) ); - } - - /** - * Generate code used to pass transaction data to Google Analytics. - * - * @param WC_Order $order WC_Order Object. - */ - public function add_transaction( $order ) { - if ( 'yes' === self::get( 'ga_use_universal_analytics' ) ) { - if ( 'yes' === self::get( 'ga_enhanced_ecommerce_tracking_enabled' ) ) { - $transaction_code = self::add_transaction_enhanced( $order ); - } else { - $transaction_code = self::add_transaction_universal( $order ); - } - } else { - $transaction_code = self::add_transaction_classic( $order ); - } - - // Check localStorage to avoid duplicate transactions if page is reloaded without hitting server. - $code = " - var ga_orders = []; - try { - ga_orders = localStorage.getItem( 'ga_orders' ); - ga_orders = ga_orders ? JSON.parse( ga_orders ) : []; - } catch {} - if ( -1 === ga_orders.indexOf( '" . esc_js( $order->get_order_number() ) . "' ) ) { - " . $transaction_code . " - try { - ga_orders.push( '" . esc_js( $order->get_order_number() ) . "' ); - localStorage.setItem( 'ga_orders', JSON.stringify( ga_orders ) ); - } catch {} - }"; - - wc_enqueue_js( $code ); - } - - /** - * Transaction tracking for ga.js (classic) - * - * @param WC_Order $order WC_Order Object - * @return string Add Transaction Code - */ - protected function add_transaction_classic( $order ) { - $code = "_gaq.push(['_addTrans', - '" . esc_js( $order->get_order_number() ) . "', // order ID - required - '" . esc_js( get_bloginfo( 'name' ) ) . "', // affiliation or store name - '" . esc_js( $order->get_total() ) . "', // total - required - '" . esc_js( $order->get_total_tax() ) . "', // tax - '" . esc_js( $order->get_total_shipping() ) . "', // shipping - '" . esc_js( version_compare( WC_VERSION, '3.0', '<' ) ? $order->billing_city : $order->get_billing_city() ) . "', // city - '" . esc_js( version_compare( WC_VERSION, '3.0', '<' ) ? $order->billing_state : $order->get_billing_state() ) . "', // state or province - '" . esc_js( version_compare( WC_VERSION, '3.0', '<' ) ? $order->billing_country : $order->get_billing_country() ) . "' // country - ]);"; - - // Order items - if ( $order->get_items() ) { - foreach ( $order->get_items() as $item ) { - $code .= self::add_item_classic( $order, $item ); - } - } - - $code .= "_gaq.push(['_trackTrans']);"; - return $code; - } - - /** - * Generate Universal Analytics Enhanced Ecommerce transaction tracking code - * - * @param WC_Order $order - * @return string - */ - protected function add_transaction_enhanced( $order ) { - $code = self::tracker_var() . "( 'set', '&cu', '" . esc_js( version_compare( WC_VERSION, '3.0', '<' ) ? $order->get_order_currency() : $order->get_currency() ) . "' );"; - - // Order items - if ( $order->get_items() ) { - foreach ( $order->get_items() as $item ) { - $code .= self::add_item_enhanced( $order, $item ); - } - } - - $code .= self::tracker_var() . "( 'ec:setAction', 'purchase', { - 'id': '" . esc_js( $order->get_order_number() ) . "', - 'affiliation': '" . esc_js( get_bloginfo( 'name' ) ) . "', - 'revenue': '" . esc_js( $order->get_total() ) . "', - 'tax': '" . esc_js( $order->get_total_tax() ) . "', - 'shipping': '" . esc_js( $order->get_total_shipping() ) . "' - } );"; - - return $code; - } - - /** - * Add Item (Classic) - * - * @param WC_Order $order WC_Order Object - * @param array $item The item to add to a transaction/order - * @return string - */ - protected function add_item_classic( $order, $item ) { - $_product = version_compare( WC_VERSION, '3.0', '<' ) ? $order->get_product_from_item( $item ) : $item->get_product(); - - $code = "_gaq.push(['_addItem',"; - $code .= "'" . esc_js( $order->get_order_number() ) . "',"; - $code .= "'" . esc_js( $_product->get_sku() ? $_product->get_sku() : $_product->get_id() ) . "',"; - $code .= "'" . esc_js( $item['name'] ) . "',"; - $code .= self::product_get_category_line( $_product ); - $code .= "'" . esc_js( $order->get_item_total( $item ) ) . "',"; - $code .= "'" . esc_js( $item['qty'] ) . "'"; - $code .= ']);'; - - return $code; - } - - /** - * Add Item (Enhanced, Universal) - * - * @param WC_Order $order WC_Order Object - * @param WC_Order_Item $item The item to add to a transaction/order - * @return string - */ - protected function add_item_enhanced( $order, $item ) { - $_product = version_compare( WC_VERSION, '3.0', '<' ) ? $order->get_product_from_item( $item ) : $item->get_product(); - $variant = self::product_get_variant_line( $_product ); - - $code = self::tracker_var() . "( 'ec:addProduct', {"; - $code .= "'id': '" . esc_js( $_product->get_sku() ? $_product->get_sku() : $_product->get_id() ) . "',"; - $code .= "'name': '" . esc_js( $item['name'] ) . "',"; - $code .= "'category': " . self::product_get_category_line( $_product ); - - if ( '' !== $variant ) { - $code .= "'variant': " . $variant; - } - - $code .= "'price': '" . esc_js( $order->get_item_total( $item ) ) . "',"; - $code .= "'quantity': '" . esc_js( $item['qty'] ) . "'"; - $code .= '});'; - - return $code; - } - - /** - * Output JavaScript to track an enhanced ecommerce remove from cart action - */ - public function remove_from_cart() { - echo( " - - " ); - } - - /** - * Enqueue JavaScript to track a product detail view - * - * @param WC_Product $product - */ - public function product_detail( $product ) { - if ( empty( $product ) ) { - return; - } - - wc_enqueue_js( - self::tracker_var() . "( 'ec:addProduct', { - 'id': '" . esc_js( $product->get_sku() ? $product->get_sku() : ( '#' . $product->get_id() ) ) . "', - 'name': '" . esc_js( $product->get_title() ) . "', - 'category': " . self::product_get_category_line( $product ) . " - 'price': '" . esc_js( $product->get_price() ) . "', - } ); - - " . self::tracker_var() . "( 'ec:setAction', 'detail' );" - ); - } - - /** - * Enqueue JS to track when the checkout process is started - * - * @param array $cart items/contents of the cart - */ - public function checkout_process( $cart ) { - $code = ''; - - foreach ( $cart as $cart_item_key => $cart_item ) { - $product = apply_filters( 'woocommerce_cart_item_product', $cart_item['data'], $cart_item, $cart_item_key ); - $variant = self::product_get_variant_line( $product ); - $code .= self::tracker_var() . "( 'ec:addProduct', { - 'id': '" . esc_js( $product->get_sku() ? $product->get_sku() : ( '#' . $product->get_id() ) ) . "', - 'name': '" . esc_js( $product->get_title() ) . "', - 'category': " . self::product_get_category_line( $product ); - - if ( '' !== $variant ) { - $code .= "'variant': " . $variant; - } - - $code .= "'price': '" . esc_js( $product->get_price() ) . "', - 'quantity': '" . esc_js( $cart_item['quantity'] ) . "' - } );"; - } - - $code .= self::tracker_var() . "( 'ec:setAction','checkout' );"; - wc_enqueue_js( $code ); - } - - /** - * Enqueue JavaScript for Add to cart tracking - * - * @param array $parameters associative array of _trackEvent parameters - * @param string $selector jQuery selector for binding click event - */ - public function event_tracking_code( $parameters, $selector ) { - $parameters = apply_filters( 'woocommerce_ga_event_tracking_parameters', $parameters ); - - if ( 'yes' === self::get( 'ga_use_universal_analytics' ) ) { - if ( 'yes' === self::get( 'ga_enhanced_ecommerce_tracking_enabled' ) ) { - wc_enqueue_js( - " - $( '" . $selector . "' ).on( 'click', function() { - " . $parameters['enhanced'] . ' - ' . self::tracker_var() . "( 'ec:setAction', 'add' ); - " . self::tracker_var() . "( 'send', 'event', 'UX', 'click', 'add to cart' ); - }); - " - ); - return; - } else { - $track_event = self::tracker_var() . "('send', 'event', %s, %s, %s);"; - } - } else { - $track_event = "_gaq.push(['_trackEvent', %s, %s, %s]);"; - } - - wc_enqueue_js( - " - $( '" . $selector . "' ).on( 'click', function() { - " . sprintf( $track_event, $parameters['category'], $parameters['action'], $parameters['label'] ) . ' - }); - ' - ); - } - - /** - * Loads a code using the google-analytics handler in the head. - * - * @param string $code The code to add attached to the google-analytics handler - */ - protected static function load_analytics_code_in_header( $code ) { - wp_register_script( 'google-analytics', '', array( 'google-analytics-opt-out' ), null, false ); - wp_add_inline_script( 'google-analytics', $code ); - wp_enqueue_script( 'google-analytics' ); - } -} diff --git a/includes/class-wc-google-analytics-task.php b/includes/class-wc-google-analytics-task.php index 97d76310..128430b0 100644 --- a/includes/class-wc-google-analytics-task.php +++ b/includes/class-wc-google-analytics-task.php @@ -1,8 +1,8 @@ ga_gtag_enabled ) { - return WC_Google_Gtag_JS::get_instance( $options ); - } - - return WC_Google_Analytics_JS::get_instance( $options ); + protected function get_tracking_instance() { + return WC_Google_Gtag_JS::get_instance( $this->settings ); } /** @@ -105,90 +42,77 @@ public function __construct() { // Load the settings $this->init_form_fields(); $this->init_settings(); - $constructor = $this->init_options(); + + add_action( 'admin_notices', array( $this, 'universal_analytics_upgrade_notice' ) ); // Contains snippets/JS tracking code include_once 'class-wc-abstract-google-analytics-js.php'; - include_once 'class-wc-google-analytics-js.php'; include_once 'class-wc-google-gtag-js.php'; - $this->get_tracking_instance( $constructor ); + $this->get_tracking_instance(); // Display a task on "Things to do next section" add_action( 'init', array( $this, 'add_wc_setup_task' ), 20 ); // Admin Options - add_filter( 'woocommerce_tracker_data', array( $this, 'track_options' ) ); + add_filter( 'woocommerce_tracker_data', array( $this, 'track_settings' ) ); add_action( 'woocommerce_update_options_integration_google_analytics', array( $this, 'process_admin_options' ) ); add_action( 'woocommerce_update_options_integration_google_analytics', array( $this, 'show_options_info' ) ); - add_action( 'admin_enqueue_scripts', array( $this, 'load_admin_assets' ) ); add_action( 'admin_init', array( $this, 'privacy_policy' ) ); // Tracking code add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_tracking_code' ), 9 ); add_filter( 'script_loader_tag', array( $this, 'async_script_loader_tags' ), 10, 3 ); - // Event tracking code - add_action( 'woocommerce_after_add_to_cart_button', array( $this, 'add_to_cart' ) ); - add_action( 'wp_footer', array( $this, 'loop_add_to_cart' ) ); - add_action( 'woocommerce_after_cart', array( $this, 'remove_from_cart' ) ); - add_action( 'woocommerce_after_mini_cart', array( $this, 'remove_from_cart' ) ); - add_filter( 'woocommerce_cart_item_remove_link', array( $this, 'remove_from_cart_attributes' ), 10, 2 ); - add_filter( 'woocommerce_loop_add_to_cart_link', array( $this, 'track_product' ), 10, 2 ); - add_action( 'woocommerce_after_single_product', array( $this, 'product_detail' ) ); - add_action( 'woocommerce_after_checkout_form', array( $this, 'checkout_process' ) ); - // utm_nooverride parameter for Google AdWords add_filter( 'woocommerce_get_return_url', array( $this, 'utm_nooverride' ) ); + + // Dequeue the WooCommerce Blocks Google Analytics integration, + // not to let it register its `gtag` function so that we could provide a more detailed configuration. + add_action( + 'wp_enqueue_scripts', + function () { + wp_dequeue_script( 'wc-blocks-google-analytics' ); + } + ); } /** - * Loads all of our options for this plugin (stored as properties as well) + * Conditionally display an error notice to the merchant if the stored property ID starts with "UA" * - * @return array An array of options that can be passed to other classes + * @return void */ - public function init_options() { - $options = array( - 'ga_id', - 'ga_set_domain_name', - 'ga_gtag_enabled', - 'ga_standard_tracking_enabled', - 'ga_support_display_advertising', - 'ga_support_enhanced_link_attribution', - 'ga_use_universal_analytics', - 'ga_anonymize_enabled', - 'ga_404_tracking_enabled', - 'ga_ecommerce_tracking_enabled', - 'ga_enhanced_ecommerce_tracking_enabled', - 'ga_enhanced_remove_from_cart_enabled', - 'ga_enhanced_product_impression_enabled', - 'ga_enhanced_product_click_enabled', - 'ga_enhanced_checkout_process_enabled', - 'ga_enhanced_product_detail_view_enabled', - 'ga_event_tracking_enabled', - 'ga_linker_cross_domains', - 'ga_linker_allow_incoming_enabled', - ); - - $constructor = array(); - foreach ( $options as $option ) { - $constructor[ $option ] = $this->$option = $this->get_option( $option ); + public function universal_analytics_upgrade_notice() { + if ( 'ua' === substr( strtolower( $this->get_option( 'ga_id' ) ), 0, 2 ) ) { + printf( + '

%2$s

', + 'notice notice-error', + sprintf( + /* translators: 1) URL for Google documentation on upgrading from UA to GA4 2) URL to WooCommerce Google Analytics settings page */ + esc_html__( 'Your website is configured to use Universal Analytics which Google retired in July of 2023. Update your account using the %1$ssetup assistant%2$s and then update your %3$sWooCommerce settings%4$s.', 'woocommerce-google-analytics-integration' ), + '', + '', + '', + '' + ) + ); } - - return $constructor; } /** * Tells WooCommerce which settings to display under the "integration" tab */ public function init_form_fields() { - // backwards_compatibility - if ( get_option( 'woocommerce_ga_use_universal_analytics' ) ) { - $ua_default_value = get_option( 'woocommerce_ga_use_universal_analytics' ); - } else { - // don't enable for extension updates, only default to enabled on new installs - $ua_default_value = get_option( $this->get_option_key() ) ? 'no' : 'yes'; - } - $this->form_fields = array( + 'ga_product_identifier' => array( + 'title' => __( 'Product Identification', 'woocommerce-google-analytics-integration' ), + 'description' => __( 'Specify how your products will be identified to Google Analytics. Changing this setting may cause issues with historical data if a product was previously identified using a different structure.', 'woocommerce-google-analytics-integration' ), + 'type' => 'select', + 'options' => array( + 'product_id' => __( 'Product ID', 'woocommerce-google-analytics-integration' ), + 'product_sku' => __( 'Product SKU with prefixed (#) ID as fallback', 'woocommerce-google-analytics-integration' ), + ), + // If the option is not set then the product SKU is used as default for existing installations + 'default' => 'product_sku', + ), 'ga_id' => array( 'title' => __( 'Google Analytics Tracking ID', 'woocommerce-google-analytics-integration' ), 'description' => __( 'Log into your Google Analytics account to find your ID. e.g. GT-XXXXX or G-XXXXX', 'woocommerce-google-analytics-integration' ), @@ -196,34 +120,6 @@ public function init_form_fields() { 'placeholder' => 'GT-XXXXX', 'default' => get_option( 'woocommerce_ga_id' ), // Backwards compat ), - 'ga_set_domain_name' => array( - 'title' => __( 'Set Domain Name', 'woocommerce-google-analytics-integration' ), - /* translators: Read more link */ - 'description' => sprintf( __( '(Optional) Sets the _setDomainName variable. %1$sSee here for more information%2$s.', 'woocommerce-google-analytics-integration' ), '', '' ), - 'type' => 'text', - 'default' => '', - 'class' => 'legacy-setting', - ), - - 'ga_gtag_enabled' => array( - 'title' => __( 'Tracking Options', 'woocommerce-google-analytics-integration' ), - 'label' => __( 'Use Global Site Tag', 'woocommerce-google-analytics-integration' ), - /* translators: Read more link */ - 'description' => sprintf( __( 'The Global Site Tag provides streamlined tagging across Google’s site measurement, conversion tracking, and remarketing products. This must be enabled to use a Google Analytics 4 Measurement ID (e.g., G-XXXXX or GT-XXXXX). %1$sSee here for more information%2$s.', 'woocommerce-google-analytics-integration' ), '', '' ), - 'type' => 'checkbox', - 'checkboxgroup' => '', - 'default' => get_option( $this->get_option_key() ) ? 'no' : 'yes', // don't enable on updates, only default on new installs - ), - - 'ga_use_universal_analytics' => array( - 'label' => __( 'Enable Universal Analytics', 'woocommerce-google-analytics-integration' ), - /* translators: Read more start and end links */ - 'description' => sprintf( __( 'Uses Universal Analytics instead of Classic Google Analytics. If you have not previously used Google Analytics on this site, check this box. Otherwise, %1$sfollow step 1 of the Universal Analytics upgrade guide.%2$s Enabling this setting will take care of step 2. %3$sRead more about Universal Analytics%4$s. Universal Analytics or Global Site Tag must be enabled to enable enhanced eCommerce.', 'woocommerce-google-analytics-integration' ), '', '', '', '' ), - 'type' => 'checkbox', - 'checkboxgroup' => '', - 'default' => $ua_default_value, - 'class' => 'legacy-setting', - ), 'ga_standard_tracking_enabled' => array( 'label' => __( 'Enable Standard Tracking', 'woocommerce-google-analytics-integration' ), 'description' => __( 'This tracks session data such as demographics, system, etc. You don\'t need to enable this if you are using a 3rd party Google analytics plugin.', 'woocommerce-google-analytics-integration' ), @@ -263,7 +159,15 @@ public function init_form_fields() { 'checkboxgroup' => '', 'default' => 'yes', ), + 'ga_linker_allow_incoming_enabled' => array( + 'label' => __( 'Accept Incoming Linker Parameters', 'woocommerce-google-analytics-integration' ), + 'description' => __( 'Enabling this option will allow incoming linker parameters from other websites.', 'woocommerce-google-analytics-integration' ), + 'type' => 'checkbox', + 'checkboxgroup' => '', + 'default' => 'no', + ), 'ga_ecommerce_tracking_enabled' => array( + 'title' => __( 'Event Tracking', 'woocommerce-google-analytics-integration' ), 'label' => __( 'Purchase Transactions', 'woocommerce-google-analytics-integration' ), 'description' => __( 'This requires a payment gateway that redirects to the thank you/order received page after payment. Orders paid with gateways which do not do this will not be tracked.', 'woocommerce-google-analytics-integration' ), 'type' => 'checkbox', @@ -276,40 +180,11 @@ public function init_form_fields() { 'checkboxgroup' => '', 'default' => 'yes', ), - 'ga_linker_cross_domains' => array( - 'title' => __( 'Cross Domain Tracking', 'woocommerce-google-analytics-integration' ), - /* translators: Read more link */ - 'description' => sprintf( __( 'Add a comma separated list of domains for automatic linking. %1$sRead more about Cross Domain Measurement%2$s', 'woocommerce-google-analytics-integration' ), '', '' ), - 'type' => 'text', - 'placeholder' => 'example.com, example.net', - 'default' => '', - ), - 'ga_linker_allow_incoming_enabled' => array( - 'label' => __( 'Accept Incoming Linker Parameters', 'woocommerce-google-analytics-integration' ), - 'description' => __( 'Enabling this option will allow incoming linker parameters from other websites.', 'woocommerce-google-analytics-integration' ), - 'type' => 'checkbox', - 'checkboxgroup' => '', - 'default' => 'no', - ), - 'ga_enhanced_ecommerce_tracking_enabled' => array( - 'title' => __( 'Enhanced eCommerce', 'woocommerce-google-analytics-integration' ), - 'label' => __( 'Enable Enhanced eCommerce ', 'woocommerce-google-analytics-integration' ), - /* translators: Read more link */ - 'description' => sprintf( __( 'Enhanced eCommerce allows you to measure more user interactions with your store, including: product impressions, product detail views, starting the checkout process, adding cart items, and removing cart items. Universal Analytics or Global Site Tag must be enabled for Enhanced eCommerce to work. If using Universal Analytics, turn on Enhanced eCommerce in your Google Analytics dashboard before enabling this setting. %1$sSee here for more information%2$s.', 'woocommerce-google-analytics-integration' ), '', '' ), - 'type' => 'checkbox', - 'checkboxgroup' => '', - 'default' => 'no', - 'class' => 'legacy-setting', - ), - - // Enhanced eCommerce Sub-Settings - 'ga_enhanced_remove_from_cart_enabled' => array( 'label' => __( 'Remove from Cart Events', 'woocommerce-google-analytics-integration' ), 'type' => 'checkbox', 'checkboxgroup' => '', 'default' => 'yes', - 'class' => 'enhanced-setting', ), 'ga_enhanced_product_impression_enabled' => array( @@ -317,7 +192,6 @@ public function init_form_fields() { 'type' => 'checkbox', 'checkboxgroup' => '', 'default' => 'yes', - 'class' => 'enhanced-setting', ), 'ga_enhanced_product_click_enabled' => array( @@ -325,7 +199,6 @@ public function init_form_fields() { 'type' => 'checkbox', 'checkboxgroup' => '', 'default' => 'yes', - 'class' => 'enhanced-setting', ), 'ga_enhanced_product_detail_view_enabled' => array( @@ -333,7 +206,6 @@ public function init_form_fields() { 'type' => 'checkbox', 'checkboxgroup' => '', 'default' => 'yes', - 'class' => 'enhanced-setting', ), 'ga_enhanced_checkout_process_enabled' => array( @@ -341,7 +213,14 @@ public function init_form_fields() { 'type' => 'checkbox', 'checkboxgroup' => '', 'default' => 'yes', - 'class' => 'enhanced-setting', + ), + 'ga_linker_cross_domains' => array( + 'title' => __( 'Cross Domain Tracking', 'woocommerce-google-analytics-integration' ), + /* translators: Read more link */ + 'description' => sprintf( __( 'Add a comma separated list of domains for automatic linking. %1$sRead more about Cross Domain Measurement%2$s', 'woocommerce-google-analytics-integration' ), '', '' ), + 'type' => 'text', + 'placeholder' => 'example.com, example.net', + 'default' => '', ), ); } @@ -366,26 +245,23 @@ public function show_options_info() { * @param array $data Current WC tracker data. * @return array Updated WC Tracker data. */ - public function track_options( $data ) { + public function track_settings( $data ) { + $settings = $this->settings; $data['wc-google-analytics'] = array( - 'standard_tracking_enabled' => $this->ga_standard_tracking_enabled, - 'support_display_advertising' => $this->ga_support_display_advertising, - 'support_enhanced_link_attribution' => $this->ga_support_enhanced_link_attribution, - 'use_universal_analytics' => $this->ga_use_universal_analytics, - 'anonymize_enabled' => $this->ga_anonymize_enabled, - 'ga_404_tracking_enabled' => $this->ga_404_tracking_enabled, - 'ecommerce_tracking_enabled' => $this->ga_ecommerce_tracking_enabled, - 'event_tracking_enabled' => $this->ga_event_tracking_enabled, - 'gtag_enabled' => $this->ga_gtag_enabled, - 'set_domain_name' => empty( $this->ga_set_domain_name ) ? 'no' : 'yes', - 'plugin_version' => WC_GOOGLE_ANALYTICS_INTEGRATION_VERSION, - 'enhanced_ecommerce_tracking_enabled' => $this->ga_enhanced_ecommerce_tracking_enabled, - 'linker_allow_incoming_enabled' => empty( $this->ga_linker_allow_incoming_enabled ) ? 'no' : 'yes', - 'linker_cross_domains' => $this->ga_linker_cross_domains, + 'standard_tracking_enabled' => $settings['ga_standard_tracking_enabled'], + 'support_display_advertising' => $settings['ga_support_display_advertising'], + 'support_enhanced_link_attribution' => $settings['ga_support_enhanced_link_attribution'], + 'anonymize_enabled' => $settings['ga_anonymize_enabled'], + 'ga_404_tracking_enabled' => $settings['ga_404_tracking_enabled'], + 'ecommerce_tracking_enabled' => $settings['ga_ecommerce_tracking_enabled'], + 'event_tracking_enabled' => $settings['ga_event_tracking_enabled'], + 'plugin_version' => WC_GOOGLE_ANALYTICS_INTEGRATION_VERSION, + 'linker_allow_incoming_enabled' => empty( $settings['ga_linker_allow_incoming_enabled'] ) ? 'no' : 'yes', + 'linker_cross_domains' => $settings['ga_linker_cross_domains'], ); // ID prefix, blank, or X for unknown - $prefix = strstr( strtoupper( $this->ga_id ), '-', true ); + $prefix = strstr( strtoupper( $settings['ga_id'] ), '-', true ); if ( in_array( $prefix, array( 'UA', 'G', 'GT' ), true ) || empty( $prefix ) ) { $data['wc-google-analytics']['ga_id'] = $prefix; } else { @@ -395,34 +271,6 @@ public function track_options( $data ) { return $data; } - /** - * Enqueue the admin JavaScript - */ - public function load_admin_assets() { - $screen = get_current_screen(); - if ( 'woocommerce_page_wc-settings' !== $screen->id ) { - return; - } - - // phpcs:disable WordPress.Security.NonceVerification.Recommended - if ( empty( $_GET['tab'] ) ) { - return; - } - - // phpcs:disable WordPress.Security.NonceVerification.Recommended - if ( 'integration' !== $_GET['tab'] ) { - return; - } - - wp_enqueue_script( - 'wc-google-analytics-admin-enhanced-settings', - Plugin::get_instance()->get_js_asset_url( 'admin-ga-settings.js' ), - Plugin::get_instance()->get_js_asset_dependencies( 'admin-ga-settings', [ 'jquery' ] ), - Plugin::get_instance()->get_js_asset_version( 'admin-ga-settings' ), - true - ); - } - /** * Add suggested privacy policy content * @@ -441,7 +289,7 @@ public function privacy_policy() {

' . $policy_text . '

'; - wp_add_privacy_policy_content( 'WooCommerce Google Analytics Integration', wpautop( $content, false ) ); + wp_add_privacy_policy_content( 'Google Analytics for WooCommerce', wpautop( $content, false ) ); } /** @@ -452,12 +300,14 @@ public function enqueue_tracking_code() { global $wp; $display_ecommerce_tracking = false; + $this->get_tracking_instance()->load_opt_out(); + if ( $this->disable_tracking( 'all' ) ) { return; } // Check if is order received page and stop when the products and not tracked - if ( is_order_received_page() && 'yes' === $this->ga_ecommerce_tracking_enabled ) { + if ( is_order_received_page() ) { $order_id = isset( $wp->query_vars['order-received'] ) ? $wp->query_vars['order-received'] : 0; $order = wc_get_order( $order_id ); if ( $order && ! (bool) $order->get_meta( '_ga_tracked' ) ) { @@ -465,23 +315,6 @@ public function enqueue_tracking_code() { $this->enqueue_ecommerce_tracking_code( $order_id ); } } - - if ( is_woocommerce() || is_cart() || ( is_checkout() && ! $display_ecommerce_tracking ) ) { - $display_ecommerce_tracking = true; - $this->enqueue_standard_tracking_code(); - } - - if ( ! $display_ecommerce_tracking && 'yes' === $this->ga_standard_tracking_enabled ) { - $this->enqueue_standard_tracking_code(); - } - } - - /** - * Generate Standard Google Analytics tracking - */ - protected function enqueue_standard_tracking_code() { - $this->get_tracking_instance()->load_opt_out(); - $this->get_tracking_instance()->load_analytics(); } /** @@ -505,10 +338,6 @@ protected function enqueue_ecommerce_tracking_code( $order_id ) { return; } - $this->get_tracking_instance()->add_transaction( $order ); - $this->get_tracking_instance()->load_opt_out(); - $this->get_tracking_instance()->load_analytics(); - // Mark the order as tracked. $order->update_meta_data( '_ga_tracked', 1 ); $order->save(); @@ -521,194 +350,7 @@ protected function enqueue_ecommerce_tracking_code( $order_id ) { * @return bool True if tracking for a certain setting is disabled */ private function disable_tracking( $type ) { - return is_admin() || current_user_can( 'manage_options' ) || ( ! $this->ga_id ) || 'no' === $type || apply_filters( 'woocommerce_ga_disable_tracking', false, $type ); - } - - /** - * Google Analytics event tracking for single product add to cart - */ - public function add_to_cart() { - if ( $this->disable_tracking( $this->ga_event_tracking_enabled ) ) { - return; - } - if ( ! is_single() ) { - return; - } - - global $product; - - if ( 'yes' === $this->ga_gtag_enabled ) { - $this->get_tracking_instance()->add_to_cart( $product ); - return; - } - - // Add single quotes to allow jQuery to be substituted into _trackEvent parameters - $parameters = array(); - $parameters['category'] = "'" . __( 'Products', 'woocommerce-google-analytics-integration' ) . "'"; - $parameters['action'] = "'" . __( 'Add to Cart', 'woocommerce-google-analytics-integration' ) . "'"; - $parameters['label'] = "'" . esc_js( $product->get_sku() ? __( 'ID:', 'woocommerce-google-analytics-integration' ) . ' ' . $product->get_sku() : '#' . $product->get_id() ) . "'"; - - if ( ! $this->disable_tracking( $this->ga_enhanced_ecommerce_tracking_enabled ) ) { - - $item = '{'; - - if ( $product->is_type( 'variable' ) ) { - $item .= "'id': google_analytics_integration_product_data[ $('input[name=\"variation_id\"]').val() ] !== undefined ? google_analytics_integration_product_data[ $('input[name=\"variation_id\"]').val() ].id : false,"; - $item .= "'variant': google_analytics_integration_product_data[ $('input[name=\"variation_id\"]').val() ] !== undefined ? google_analytics_integration_product_data[ $('input[name=\"variation_id\"]').val() ].variant : false,"; - } else { - $item .= "'id': '" . $this->get_tracking_instance()->get_product_identifier( $product ) . "',"; - } - - $item .= "'name': '" . esc_js( $product->get_title() ) . "',"; - $item .= "'category': " . $this->get_tracking_instance()->product_get_category_line( $product ); - $item .= "'quantity': $( 'input.qty' ).val() ? $( 'input.qty' ).val() : '1'"; - $item .= '}'; - - $parameters['item'] = $item; - - $code = $this->get_tracking_instance()->tracker_var() . "( 'ec:addProduct', {$item} );"; - $parameters['enhanced'] = $code; - } - - $this->get_tracking_instance()->event_tracking_code( $parameters, '.single_add_to_cart_button' ); - } - - /** - * Enhanced Analytics event tracking for removing a product from the cart - */ - public function remove_from_cart() { - if ( ! $this->enhanced_ecommerce_enabled( $this->ga_enhanced_remove_from_cart_enabled ) ) { - return; - } - - $this->get_tracking_instance()->remove_from_cart(); - } - - /** - * Adds the product ID and SKU to the remove product link if not present - * - * @param string $url - * @param string $key - * @return string - */ - public function remove_from_cart_attributes( $url, $key ) { - if ( strpos( $url, 'data-product_id' ) !== false ) { - return $url; - } - - if ( ! is_object( WC()->cart ) ) { - return $url; - } - - $item = WC()->cart->get_cart_item( $key ); - $product = $item['data']; - - if ( ! is_object( $product ) ) { - return $url; - } - - $url = str_replace( 'href=', 'data-product_id="' . esc_attr( $product->get_id() ) . '" data-product_sku="' . esc_attr( $product->get_sku() ) . '" href=', $url ); - return $url; - } - - /** - * Google Analytics event tracking for loop add to cart - */ - public function loop_add_to_cart() { - if ( $this->disable_tracking( $this->ga_event_tracking_enabled ) || 'yes' === $this->ga_gtag_enabled ) { - return; - } - - // Add single quotes to allow jQuery to be substituted into _trackEvent parameters - $parameters = array(); - $parameters['category'] = "'" . __( 'Products', 'woocommerce-google-analytics-integration' ) . "'"; - $parameters['action'] = "'" . __( 'Add to Cart', 'woocommerce-google-analytics-integration' ) . "'"; - $parameters['label'] = "($(this).data('product_sku')) ? ($(this).data('product_sku')) : ('#' + $(this).data('product_id'))"; // Product SKU or ID - - if ( ! $this->disable_tracking( $this->ga_enhanced_ecommerce_tracking_enabled ) ) { - $item = '{'; - $item .= "'id': ($(this).data('product_sku')) ? ($(this).data('product_sku')) : ('#' + $(this).data('product_id')),"; - $item .= "'quantity': $(this).data('quantity')"; - $item .= '}'; - $parameters['item'] = $item; - - $code = $this->get_tracking_instance()->tracker_var() . "( 'ec:addProduct', " . $item . ' );'; - $parameters['enhanced'] = $code; - } - - $this->get_tracking_instance()->event_tracking_code( $parameters, '.add_to_cart_button:not(.product_type_variable, .product_type_grouped)' ); - } - - /** - * Determine if the conditions are met for enhanced ecommerce interactions to be displayed. - * Currently checks if Global Tags OR Universal Analytics are enabled, plus Enhanced eCommerce. - * - * @param array $extra_checks Any extra option values that should be 'yes' to proceed - * @return bool Whether enhanced ecommerce transactions can be displayed. - */ - protected function enhanced_ecommerce_enabled( $extra_checks = [] ) { - if ( ! is_array( $extra_checks ) ) { - $extra_checks = [ $extra_checks ]; - } - - // False if gtag and UA are disabled. - if ( $this->disable_tracking( $this->ga_use_universal_analytics ) && $this->disable_tracking( $this->ga_gtag_enabled ) ) { - return false; - } - - // False if gtag or UA is enabled, but enhanced ecommerce is disabled. - if ( $this->disable_tracking( $this->ga_enhanced_ecommerce_tracking_enabled ) ) { - return false; - } - - // False if any specified interaction-level checks are disabled. - foreach ( $extra_checks as $option_value ) { - if ( $this->disable_tracking( $option_value ) ) { - return false; - } - } - - return true; - } - - /** - * Measure a product click and impression from a Product list - * - * @param string $link The Add To Cart Link - * @param WC_Product $product The Product - */ - public function track_product( $link, $product ) { - if ( $this->enhanced_ecommerce_enabled( $this->ga_enhanced_product_click_enabled ) ) { - $this->get_tracking_instance()->listing_impression( $product ); - $this->get_tracking_instance()->listing_click( $product ); - } - - return $link; - } - - /** - * Measure a product detail view - */ - public function product_detail() { - if ( ! $this->enhanced_ecommerce_enabled( $this->ga_enhanced_product_detail_view_enabled ) ) { - return; - } - - global $product; - $this->get_tracking_instance()->product_detail( $product ); - } - - /** - * Tracks when the checkout form is loaded - * - * @param mixed $checkout (unused) - */ - public function checkout_process( $checkout ) { - if ( ! $this->enhanced_ecommerce_enabled( $this->ga_enhanced_checkout_process_enabled ) ) { - return; - } - - $this->get_tracking_instance()->checkout_process( WC()->cart->get_cart() ); + return is_admin() || current_user_can( 'manage_options' ) || ( ! $this->settings['ga_id'] ) || 'no' === $type || apply_filters( 'woocommerce_ga_disable_tracking', false, $type ); } /** diff --git a/includes/class-wc-google-gtag-js.php b/includes/class-wc-google-gtag-js.php index 44733e3b..34464266 100644 --- a/includes/class-wc-google-gtag-js.php +++ b/includes/class-wc-google-gtag-js.php @@ -16,496 +16,234 @@ class WC_Google_Gtag_JS extends WC_Abstract_Google_Analytics_JS { /** @var string $script_handle Handle for the front end JavaScript file */ public $script_handle = 'woocommerce-google-analytics-integration'; - /** - * Get the class instance - * - * @param array $options Options - * @return WC_Abstract_Google_Analytics_JS - */ - public static function get_instance( $options = array() ) { - if ( null === self::$instance ) { - self::$instance = new self( $options ); - } + /** @var string $script_handle Handle for the event data inline script */ + public $data_script_handle = 'woocommerce-google-analytics-integration-data'; - return self::$instance; - } + /** @var string $script_data Data required for frontend event tracking */ + private $script_data = array(); + + /** @var array $mappings A map of the GA4 events and the classic WooCommerce hooks that trigger them */ + private $mappings = array( + 'begin_checkout' => 'woocommerce_before_checkout_form', + 'purchase' => 'woocommerce_thankyou', + 'view_item_list' => 'woocommerce_before_shop_loop_item', + 'add_to_cart' => 'woocommerce_add_to_cart', + 'remove_from_cart' => 'woocommerce_cart_item_removed', + 'view_item' => 'woocommerce_after_single_product', + ); /** * Constructor - * Takes our options from the parent class so we can later use them in the JS snippets + * Takes our settings from the parent class so we can later use them in the JS snippets * - * @param array $options Options + * @param array $settings Settings */ - public function __construct( $options = array() ) { - self::$options = $options; + public function __construct( $settings = array() ) { + parent::__construct(); + self::$settings = $settings; + + $this->map_actions(); + // Setup frontend scripts - add_action( 'wp_enqueue_scripts', array( $this, 'register_scripts' ) ); - add_action( 'woocommerce_before_single_product', array( $this, 'setup_frontend_scripts' ) ); + add_action( 'wp_enqueue_scripts', array( $this, 'enquque_tracker' ), 5 ); + add_action( 'wp_footer', array( $this, 'inline_script_data' ) ); } /** - * Enqueue the frontend scripts and make formatted variant data available via filter + * Register tracker scripts and its inline config. + * We need to execute tracker.js w/ `gtag` configuration before any trackable action may happen. * * @return void */ - public function setup_frontend_scripts() { - global $product; - - if ( $product instanceof WC_Product_Variable ) { - // Filter variation data to include formatted strings required for add_to_cart event - add_filter( 'woocommerce_available_variation', array( $this, 'variant_data' ), 10, 3 ); - // Add default inline product data for add to cart tracking - wp_enqueue_script( $this->script_handle . '-ga-integration' ); - } - } - - /** - * Register front end JavaScript - */ - public function register_scripts() { - wp_register_script( - $this->script_handle . '-ga-integration', - Plugin::get_instance()->get_js_asset_url( 'ga-integration.js' ), - Plugin::get_instance()->get_js_asset_dependencies( 'ga-integration', [ 'jquery' ] ), - Plugin::get_instance()->get_js_asset_version( 'ga-integration' ), - true + public function enquque_tracker(): void { + wp_enqueue_script( + 'google-tag-manager', + 'https://www.googletagmanager.com/gtag/js?id=' . self::get( 'ga_id' ), + array(), + null, + false ); + // tracker.js needs to be executed ASAP, the remaining bits for main.js could be deffered, + // but to reduce the traffic, we ship it all together. wp_enqueue_script( - $this->script_handle . '-actions', - Plugin::get_instance()->get_js_asset_url( 'actions.js' ), - Plugin::get_instance()->get_js_asset_dependencies( 'actions' ), - Plugin::get_instance()->get_js_asset_version( 'actions' ), + $this->script_handle, + Plugin::get_instance()->get_js_asset_url( 'main.js' ), + array( + ...Plugin::get_instance()->get_js_asset_dependencies( 'main' ), + 'google-tag-manager', + ), + Plugin::get_instance()->get_js_asset_version( 'main' ), true ); - } - - /** - * Returns the tracker variable this integration should use - * - * @return string - */ - public static function tracker_var() { - return apply_filters( 'woocommerce_gtag_tracker_variable', 'gtag' ); - } - - /** - * Add formatted id and variant to variable product data - * - * @param array $data Data accessible via `found_variation` trigger - * @param WC_Product_Variable $product - * @param WC_Product_Variation $variation - * @return array - */ - public function variant_data( $data, $product, $variation ) { - $data['google_analytics_integration'] = array( - 'id' => self::get_product_identifier( $variation ), - 'variant' => substr( self::product_get_variant_line( $variation ), 1, -2 ), - ); - - return $data; - } - - /** - * Returns Javascript string for Google Analytics events - * - * @param string $event The type of event - * @param array|string $data Event data to be sent. If $data is an array then it will be filtered, escaped, and encoded - * @return string - */ - public static function get_event_code( string $event, $data ): string { - return sprintf( "%s('event', '%s', %s);", self::tracker_var(), esc_js( $event ), ( is_array( $data ) ? self::format_event_data( $data ) : $data ) ); - } - - /** - * Escape and encode event data - * - * @param array $data Event data to processed and formatted - * @return string - */ - public static function format_event_data( array $data ): string { - $data = apply_filters( 'woocommerce_gtag_event_data', $data ); - - // Recursively walk through $data array and escape all values that will be used in JS. - array_walk_recursive( - $data, - function ( &$value, $key ) { - $value = esc_js( $value ); - } - ); - - return wp_json_encode( $data ); - } - - /** - * Returns a list of category names the product is atttributed to - * - * @param WC_Product $product Product to generate category line for - * @return string - */ - public static function product_get_category_line( $product ) { - $category_names = array(); - $categories = get_the_terms( $product->get_id(), 'product_cat' ); - - $variation_data = $product->is_type( 'variation' ) ? wc_get_product_variation_attributes( $product->get_id() ) : false; - if ( is_array( $variation_data ) && ! empty( $variation_data ) ) { - $categories = get_the_terms( $product->get_parent_id(), 'product_cat' ); - } - - if ( false !== $categories && ! is_wp_error( $categories ) ) { - foreach ( $categories as $category ) { - $category_names[] = $category->name; - } - } - - return join( '/', $category_names ); - } - - /** - * Return list name for event - * - * @return string - */ - public static function get_list_name(): string { - // phpcs:ignore WordPress.Security.NonceVerification.Recommended - return isset( $_GET['s'] ) ? __( 'Search Results', 'woocommerce-google-analytics-integration' ) : __( 'Product List', 'woocommerce-google-analytics-integration' ); - } - - /** - * Enqueues JavaScript to build the view_item_list event - * - * @param WC_Product $product - */ - public static function listing_impression( $product ) { - $event_code = self::get_event_code( - 'view_item_list', - array( - 'items' => array( - array( - 'id' => self::get_product_identifier( $product ), - 'name' => $product->get_title(), - 'category' => self::product_get_category_line( $product ), - 'list' => self::get_list_name(), - ), - ), - ) + // Provide tracker's configuration. + wp_add_inline_script( + $this->script_handle, + sprintf( + 'var wcgai = {config: %s};', + wp_json_encode( $this->get_analytics_config() ) + ), + 'before' ); - - wc_enqueue_js( $event_code ); } /** - * Enqueues JavaScript for select_content and add_to_cart events for the product archive + * Feed classic tracking with event data via inline script. + * Make sure it's added at the bottom of the page, so all the data is collected. * - * @param WC_Product $product + * @return void */ - public static function listing_click( $product ) { - $item = array( - 'id' => self::get_product_identifier( $product ), - 'name' => $product->get_title(), - 'category' => self::product_get_category_line( $product ), - 'quantity' => 1, - ); - - $select_content_event_code = self::get_event_code( - 'select_content', + public function inline_script_data(): void { + wp_register_script( + $this->data_script_handle, + '', + array( $this->script_handle ), + null, array( - 'items' => array( $item ), + 'in_footer' => true, ) ); - $add_to_cart_event_code = self::get_event_code( - 'add_to_cart', - array( - 'items' => array( $item ), + wp_add_inline_script( + $this->data_script_handle, + sprintf( + 'wcgai.trackClassicPages( %s );', + $this->get_script_data() ) ); - wc_enqueue_js( - " - $( '.product.post-" . esc_js( $product->get_id() ) . ' a.button , .product.post-' . esc_js( $product->get_id() ) . " button' ).on('click', function() { - if ( false === $(this).hasClass( 'product_type_variable' ) && false === $(this).hasClass( 'product_type_grouped' ) ) { - $add_to_cart_event_code - } else { - $select_content_event_code - } - });" - ); + wp_enqueue_script( $this->data_script_handle ); } /** - * Output Javascript to track add_to_cart event on single product page + * Hook into WooCommerce and add corresponding Blocks Actions to our event data * - * @param WC_Product $product The product currently being viewed + * @return void */ - public static function add_to_cart( WC_Product $product ) { - $items = array( - 'id' => self::get_product_identifier( $product ), - 'name' => $product->get_title(), - 'category' => self::product_get_category_line( $product ), - 'quantity' => 1, - ); - - // Set item data as Javascript variable so that quantity, variant, and ID can be updated before sending the event - $event_code = ' - const item_data = ' . self::format_event_data( $items ) . '; - item_data.quantity = $("input.qty").val() ? $("input.qty").val() : 1;'; - - if ( $product->is_type( 'variable' ) ) { - // Check the global google_analytics_integration_product_data Javascript variable contains data - // for the current variation selection and if it does update the item_data to be sent for this event - $event_code .= " - const selected_variation = google_analytics_integration_product_data[ $('input[name=\"variation_id\"]').val() ]; - if ( selected_variation !== undefined ) { - item_data.id = selected_variation.id; - item_data.variant = selected_variation.variant; + public function map_actions(): void { + array_walk( + $this->mappings, + function ( $hook, $gtag_event ) { + add_action( + $hook, + function () use ( $gtag_event ) { + if ( ! in_array( $gtag_event, $this->script_data['events'] ?? [], true ) ) { + $this->append_script_data( 'events', $gtag_event ); + } + } + ); } - "; - } - - $event_code .= self::get_event_code( - 'add_to_cart', - '{"items": [item_data]}', - false - ); - - wc_enqueue_js( - "$( '.single_add_to_cart_button' ).on('click', function() { - $event_code - });" ); } /** - * Loads the standard Gtag code + * Set script data for a specific event + * + * @param string $type The type of event this data is related to. + * @param string|array $data The event data to add. * - * @param WC_Order $order WC_Order Object (not used in this implementation, but mandatory in the abstract class) + * @return void */ - public static function load_analytics( $order = false ) { - $logged_in = is_user_logged_in() ? 'yes' : 'no'; - - $track_404_enabled = ''; - if ( 'yes' === self::get( 'ga_404_tracking_enabled' ) && is_404() ) { - // See https://developers.google.com/analytics/devguides/collection/gtagjs/events for reference - $track_404_enabled = self::tracker_var() . "( 'event', '404_not_found', { 'event_category':'error', 'event_label':'page: ' + document.location.pathname + document.location.search + ' referrer: ' + document.referrer });"; - } - - $gtag_developer_id = ''; - if ( ! empty( self::DEVELOPER_ID ) ) { - $gtag_developer_id = self::tracker_var() . "('set', 'developer_id." . self::DEVELOPER_ID . "', true);"; - } - - $gtag_id = self::get( 'ga_id' ); - $gtag_cross_domains = ! empty( self::get( 'ga_linker_cross_domains' ) ) ? array_map( 'esc_js', explode( ',', self::get( 'ga_linker_cross_domains' ) ) ) : array(); - $gtag_snippet = ' - window.dataLayer = window.dataLayer || []; - function ' . self::tracker_var() . '(){dataLayer.push(arguments);} - ' . self::tracker_var() . "('js', new Date()); - $gtag_developer_id - - " . self::tracker_var() . "('config', '" . esc_js( $gtag_id ) . "', { - 'allow_google_signals': " . ( 'yes' === self::get( 'ga_support_display_advertising' ) ? 'true' : 'false' ) . ", - 'link_attribution': " . ( 'yes' === self::get( 'ga_support_enhanced_link_attribution' ) ? 'true' : 'false' ) . ", - 'anonymize_ip': " . ( 'yes' === self::get( 'ga_anonymize_enabled' ) ? 'true' : 'false' ) . ", - 'linker':{ - 'domains': " . wp_json_encode( $gtag_cross_domains ) . ", - 'allow_incoming': " . ( 'yes' === self::get( 'ga_linker_allow_incoming_enabled' ) ? 'true' : 'false' ) . ", - }, - 'custom_map': { - 'dimension1': 'logged_in' - }, - 'logged_in': '$logged_in' - } ); - - $track_404_enabled - "; - - wp_register_script( 'google-tag-manager', 'https://www.googletagmanager.com/gtag/js?id=' . esc_js( $gtag_id ), array( 'google-analytics-opt-out' ), null, false ); - wp_add_inline_script( 'google-tag-manager', apply_filters( 'woocommerce_gtag_snippet', $gtag_snippet ) ); - wp_enqueue_script( 'google-tag-manager' ); + public function set_script_data( string $type, $data ): void { + $this->script_data[ $type ] = $data; } /** - * Generate Gtag transaction tracking code + * Append data to an existing script data array * - * @param WC_Order $order - * @return string + * @param string $type The type of event this data is related to. + * @param string|array $data The event data to add. + * + * @return void */ - public function add_transaction_enhanced( $order ) { - $event_items = array(); - $order_items = $order->get_items(); - if ( ! empty( $order_items ) ) { - foreach ( $order_items as $item ) { - $event_items[] = self::add_item( $order, $item ); - } + public function append_script_data( string $type, $data ): void { + if ( ! isset( $this->script_data[ $type ] ) ) { + $this->script_data[ $type ] = array(); } - - return self::get_event_code( - 'purchase', - array( - 'transaction_id' => $order->get_order_number(), - 'affiliation' => get_bloginfo( 'name' ), - 'value' => $order->get_total(), - 'tax' => $order->get_total_tax(), - 'shipping' => $order->get_total_shipping(), - 'currency' => $order->get_currency(), - 'items' => $event_items, - ) - ); + $this->script_data[ $type ][] = $data; } /** - * Add Item + * Return a JSON encoded string of all script data for the current page load * - * @param WC_Order $order WC_Order Object - * @param WC_Order_Item $item The item to add to a transaction/order + * @return string */ - public function add_item( $order, $item ) { - $product = $item->get_product(); - $variant = self::product_get_variant_line( $product ); - - $event_item = array( - 'id' => self::get_product_identifier( $product ), - 'name' => $item['name'], - 'category' => self::product_get_category_line( $product ), - 'price' => $order->get_item_total( $item ), - 'quantity' => $item['qty'], - ); - - if ( '' !== $variant ) { - $event_item['variant'] = $variant; - } - - return $event_item; + public function get_script_data(): string { + return wp_json_encode( $this->script_data ); } /** - * Output JavaScript to track an enhanced ecommerce remove from cart action + * Returns the tracker variable this integration should use + * + * @return string */ - public function remove_from_cart() { - $event_code = self::get_event_code( - 'remove_from_cart', - '{"items": [{ - "id": $(this).data("product_sku") ? $(this).data("product_sku") : "#" + $(this).data("product_id"), - "quantity": $(this).parent().parent().find(".qty").val() ? $(this).parent().parent().find(".qty").val() : "1" - }]}' - ); - - // To track all the consecutive removals, - // we listen for clicks on `.woocommerce` container(s), - // as `.woocommerce-cart-form` and its items are re-rendered on each removal. - wc_enqueue_js( - "(function(){ - const selector = '.woocommerce-cart-form__cart-item .remove'; - $( '.woocommerce' ).off('click', selector).on( 'click', selector, function() { - $event_code - }); - })();" - ); + public static function tracker_function_name(): string { + return apply_filters( 'woocommerce_gtag_tracker_variable', 'gtag' ); } /** - * Enqueue JavaScript to track a product detail view + * Return Google Analytics configuration, for JS to read. * - * @param WC_Product $product + * @return array */ - public function product_detail( $product ) { - if ( empty( $product ) ) { - return; - } - - $event_code = self::get_event_code( - 'view_item', - array( - 'items' => array( - array( - 'id' => self::get_product_identifier( $product ), - 'name' => $product->get_title(), - 'category' => self::product_get_category_line( $product ), - 'price' => $product->get_price(), - ), - ), - ) + public function get_analytics_config(): array { + return array( + 'developer_id' => self::DEVELOPER_ID, + 'gtag_id' => self::get( 'ga_id' ), + 'tracker_function_name' => self::tracker_function_name(), + 'track_404' => 'yes' === self::get( 'ga_404_tracking_enabled' ), + 'allow_google_signals' => 'yes' === self::get( 'ga_support_display_advertising' ), + 'link_attribution' => 'yes' === self::get( 'ga_support_enhanced_link_attribution' ), + 'anonymize_ip' => 'yes' === self::get( 'ga_anonymize_enabled' ), + 'logged_in' => is_user_logged_in(), + 'linker' => array( + 'domains' => ! empty( self::get( 'ga_linker_cross_domains' ) ) ? array_map( 'esc_js', explode( ',', self::get( 'ga_linker_cross_domains' ) ) ) : array(), + 'allow_incoming' => 'yes' === self::get( 'ga_linker_allow_incoming_enabled' ), + ), + 'custom_map' => array( + 'dimension1' => 'logged_in', + ), + 'events' => self::get_enabled_events(), + 'identifier' => self::get( 'ga_product_identifier' ), ); - - wc_enqueue_js( $event_code ); } /** - * Enqueue JS to track when the checkout process is started + * Get an array containing the names of all enabled events * - * @param array $cart items/contents of the cart + * @return array */ - public function checkout_process( $cart ) { - $items = array(); - foreach ( $cart as $cart_item_key => $cart_item ) { - $product = apply_filters( 'woocommerce_cart_item_product', $cart_item['data'], $cart_item, $cart_item_key ); - - $item_data = array( - 'id' => self::get_product_identifier( $product ), - 'name' => $product->get_title(), - 'category' => self::product_get_category_line( $product ), - 'price' => $product->get_price(), - 'quantity' => $cart_item['quantity'], - ); - - $variant = self::product_get_variant_line( $product ); - if ( '' !== $variant ) { - $item_data['variant'] = $variant; + public static function get_enabled_events(): array { + $events = array(); + $settings = array( + 'purchase' => 'ga_ecommerce_tracking_enabled', + 'add_to_cart' => 'ga_event_tracking_enabled', + 'remove_from_cart' => 'ga_enhanced_remove_from_cart_enabled', + 'view_item_list' => 'ga_enhanced_product_impression_enabled', + 'select_content' => 'ga_enhanced_product_click_enabled', + 'view_item' => 'ga_enhanced_product_detail_view_enabled', + 'begin_checkout' => 'ga_enhanced_checkout_process_enabled', + ); + + foreach ( $settings as $event => $setting_name ) { + if ( 'yes' === self::get( $setting_name ) ) { + $events[] = $event; } - - $items[] = $item_data; } - $event_code = self::get_event_code( - 'begin_checkout', - array( - 'items' => $items, - ) - ); - - wc_enqueue_js( $event_code ); + return $events; } /** - * @deprecated 1.6.0 - * - * Enqueue JavaScript for Add to cart tracking + * Get the class instance * - * @param array $parameters Associative array of _trackEvent parameters - * @param string $selector jQuery selector for binding click event + * @param array $settings Settings + * @return WC_Abstract_Google_Analytics_JS */ - public function event_tracking_code( $parameters, $selector ) { - wc_deprecated_function( 'event_tracking_code', '1.6.0', 'get_event_code' ); - - // Called with invalid 'Add to Cart' action, update to sync with Default Google Analytics Event 'add_to_cart' - $parameters['action'] = '\'add_to_cart\''; - $parameters['category'] = '\'ecommerce\''; - - $parameters = apply_filters( 'woocommerce_gtag_event_tracking_parameters', $parameters ); - - if ( 'yes' === self::get( 'ga_enhanced_ecommerce_tracking_enabled' ) ) { - $track_event = sprintf( - self::tracker_var() . "( 'event', %s, { 'event_category': %s, 'event_label': %s, 'items': [ %s ] } );", - $parameters['action'], - $parameters['category'], - $parameters['label'], - $parameters['item'] - ); - } else { - $track_event = sprintf( - self::tracker_var() . "( 'event', %s, { 'event_category': %s, 'event_label': %s } );", - $parameters['action'], - $parameters['category'], - $parameters['label'] - ); + public static function get_instance( $settings = array() ): WC_Abstract_Google_Analytics_JS { + if ( null === self::$instance ) { + self::$instance = new self( $settings ); } - wc_enqueue_js( - " - $( '" . $selector . "' ).on( 'click', function() { - " . $track_event . ' - }); - ' - ); + return self::$instance; } } diff --git a/package.json b/package.json index a4714714..2044679b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "woocommerce-google-analytics-integration", - "title": "WooCommerce Google Analytics Integration", + "title": "Google Analytics for WooCommerce", "version": "1.8.14", "license": "GPL-2.0", "homepage": "https://wordpress.org/plugins/woocommerce-google-analytics-integration/", diff --git a/readme.txt b/readme.txt index 31639880..fd5e370f 100644 --- a/readme.txt +++ b/readme.txt @@ -1,4 +1,4 @@ -=== WooCommerce Google Analytics Integration === +=== Google Analytics for WooCommerce === Contributors: woocommerce, automattic, claudiosanches, bor0, royho, laurendavissmith001, c-shultz Tags: woocommerce, google analytics Requires at least: 3.9 diff --git a/tests/unit-tests/AddTransactionEnhanced.php b/tests/unit-tests/AddTransactionEnhanced.php deleted file mode 100644 index 06f8a60a..00000000 --- a/tests/unit-tests/AddTransactionEnhanced.php +++ /dev/null @@ -1,53 +0,0 @@ -get_order(); - - $gtag = new WC_Google_Gtag_JS(); - $gtag->add_transaction_enhanced( $order ); - - // Confirm woocommerce_gtag_event_data is called by add_transaction_enhanced(). - $this->assertEquals( 1, $this->get_event_data_filter_call_count(), 'woocommerce_gtag_event_data filter was not called for purchase (add_transaction_enhanced()) event' ); - - $order_items = $order->get_items(); - $event_items = array(); - - if ( ! empty( $order_items ) ) { - foreach ( $order_items as $item ) { - $event_items[] = $gtag->add_item( $order, $item ); - } - } - - // The expected data structure for this event. - $expected_data = array( - 'transaction_id' => $order->get_order_number(), - 'affiliation' => get_bloginfo( 'name' ), - 'value' => $order->get_total(), - 'tax' => $order->get_total_tax(), - 'shipping' => $order->get_total_shipping(), - 'currency' => $order->get_currency(), - 'items' => $event_items, - ); - - // Confirm data structure matches what's expected. - $this->assertEquals( $expected_data, $this->get_event_data(), 'Event data does not match expected data structure for purchase (add_transaction_enhanced()) event' ); - } -} diff --git a/tests/unit-tests/CheckoutProcess.php b/tests/unit-tests/CheckoutProcess.php deleted file mode 100644 index b53ce376..00000000 --- a/tests/unit-tests/CheckoutProcess.php +++ /dev/null @@ -1,59 +0,0 @@ -get_customer()->get_id() ); - - $product = $this->get_product(); - $cart = WC()->cart; - - $add_to = $cart->add_to_cart( $product->get_id() ); - ( new WC_Google_Gtag_JS() )->checkout_process( $cart->get_cart() ); - - // Confirm woocommerce_gtag_event_data is called by checkout_process(). - $this->assertEquals( 1, $this->get_event_data_filter_call_count(), 'woocommerce_gtag_event_data filter was not called for begin_checkout (checkout_process()) event' ); - - $expected_data = array( - 'items' => array(), - ); - - foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) { - $product = apply_filters( 'woocommerce_cart_item_product', $cart_item['data'], $cart_item, $cart_item_key ); - - $item_data = array( - 'id' => WC_Google_Gtag_JS::get_product_identifier( $product ), - 'name' => $product->get_title(), - 'category' => WC_Google_Gtag_JS::product_get_category_line( $product ), - 'price' => $product->get_price(), - 'quantity' => $cart_item['quantity'], - ); - - $variant = WC_Google_Gtag_JS::product_get_variant_line( $product ); - if ( '' !== $variant ) { - $item_data['variant'] = $variant; - } - - $expected_data['items'][] = $item_data; - } - - // Confirm data structure matches what's expected. - $this->assertEquals( $expected_data, $this->get_event_data(), 'Event data does not match expected data structure for begin_checkout (checkout_process()) event' ); - } -} diff --git a/tests/unit-tests/ListingClick.php b/tests/unit-tests/ListingClick.php deleted file mode 100644 index 000ad1b7..00000000 --- a/tests/unit-tests/ListingClick.php +++ /dev/null @@ -1,44 +0,0 @@ -get_product(); - - ( new WC_Google_Gtag_JS() )->listing_click( $product ); - - // Code is generated for two events in listing_click() so we would expect the filter is called twice. - $this->assertEquals( 2, $this->get_event_data_filter_call_count(), 'woocommerce_gtag_event_data filter was not called for select_content and add_to_cart (listing_click()) events' ); - - // The expected data structure for this event. - $expected_data = array( - 'items' => array( - array( - 'id' => WC_Google_Gtag_JS::get_product_identifier( $product ), - 'name' => $product->get_title(), - 'category' => WC_Google_Gtag_JS::product_get_category_line( $product ), - 'quantity' => 1, - ), - ), - ); - - // Confirm data structure matches what's expected. - $this->assertEquals( $expected_data, $this->get_event_data(), 'Event data does not match expected data structure for select_content and add_to_cart (listing_click()) events' ); - } -} diff --git a/tests/unit-tests/ListingImpression.php b/tests/unit-tests/ListingImpression.php deleted file mode 100644 index 808ddead..00000000 --- a/tests/unit-tests/ListingImpression.php +++ /dev/null @@ -1,44 +0,0 @@ -get_product(); - - ( new WC_Google_Gtag_JS() )->listing_impression( $product ); - - // Confirm woocommerce_gtag_event_data is called by listing_impression(). - $this->assertEquals( 1, $this->get_event_data_filter_call_count(), 'woocommerce_gtag_event_data filter was not called for view_item_list (listing_impression()) event' ); - - // The expected data structure for this event. - $expected_data = array( - 'items' => array( - array( - 'id' => WC_Google_Gtag_JS::get_product_identifier( $product ), - 'name' => $product->get_title(), - 'category' => WC_Google_Gtag_JS::product_get_category_line( $product ), - 'list' => WC_Google_Gtag_JS::get_list_name(), - ), - ), - ); - - // Confirm data structure matches what's expected. - $this->assertEquals( $expected_data, $this->get_event_data(), 'Event data does not match expected data structure for view_item_list (listing_impression()) event' ); - } -} diff --git a/tests/unit-tests/ProductDetail.php b/tests/unit-tests/ProductDetail.php deleted file mode 100644 index f73723aa..00000000 --- a/tests/unit-tests/ProductDetail.php +++ /dev/null @@ -1,44 +0,0 @@ -get_product(); - - ( new WC_Google_Gtag_JS() )->product_detail( $product ); - - // Confirm woocommerce_gtag_event_data is called by product_detail(). - $this->assertEquals( 1, $this->get_event_data_filter_call_count(), 'woocommerce_gtag_event_data filter was not called for view_item (product_detail()) event' ); - - // The expected data structure for this event. - $expected_data = array( - 'items' => array( - array( - 'id' => WC_Google_Gtag_JS::get_product_identifier( $product ), - 'name' => $product->get_title(), - 'category' => WC_Google_Gtag_JS::product_get_category_line( $product ), - 'price' => $product->get_price(), - ), - ), - ); - - // Confirm data structure matches what's expected. - $this->assertEquals( $expected_data, $this->get_event_data(), 'Event data does not match expected data structure for view_item (product_detail()) event' ); - } -} diff --git a/tests/unit-tests/WCGoogleGtagJS.php b/tests/unit-tests/WCGoogleGtagJS.php index 5365da19..fd42f9cb 100644 --- a/tests/unit-tests/WCGoogleGtagJS.php +++ b/tests/unit-tests/WCGoogleGtagJS.php @@ -15,12 +15,7 @@ class WCGoogleGtagJS extends EventsDataTest { /** - * Check that `WC_Google_Gtag_JS` registers - * the `assets/js/ga-integration.js` script - * as `woocommerce-google-analytics-integration-ga-integration` (`->script_handle . '-ga-integration'`) - * and enqueues the `assets/js/actions.js` script - * as `woocommerce-google-analytics-integration--actions` (`->script_handle . '--actions'`) - * on `wp_enqueue_scripts` action. + * Check that `WC_Google_Gtag_JS` registers and enqueues the `assets/js/build/main.js` script * * @return void */ @@ -33,86 +28,153 @@ public function test_scripts_are_registered() { // Assert the handle property. $this->assertEquals( 'woocommerce-google-analytics-integration', $gtag->script_handle, '`WC_Google_Gtag_JS->script_handle` is not equal `woocommerce-google-analytics-integration`' ); - // Assert assert `-ga-intregration` is registered with the correct name, but not yet enqueued. - $integration_handle = $gtag->script_handle . '-ga-integration'; - $this->assertEquals( true, wp_script_is( $integration_handle, 'registered' ), '`…-ga-integration` script was not registered' ); - $this->assertEquals( false, wp_script_is( $integration_handle, 'enqueued' ), 'the script is enqueued too early' ); - $registered_url = wp_scripts()->registered[ $integration_handle ]->src; - $this->assertStringContainsString( 'assets/js/build/ga-integration.js', $registered_url, 'The script does not point to the correct URL' ); - - // Assert assert `-actions` is enqueued with the correct name. - $actions_handle = $gtag->script_handle . '-actions'; - $this->assertEquals( true, wp_script_is( $actions_handle, 'enqueued' ), '`…-actions` script was not enqueued' ); - $registered_url = wp_scripts()->registered[ $actions_handle ]->src; - $this->assertStringContainsString( 'assets/js/build/actions.js', $registered_url, 'The script does not point to the correct URL' ); + $this->assertEquals( true, wp_script_is( $gtag->script_handle, 'enqueued' ), '`…-main` script was not enqueued' ); + $registered_url = wp_scripts()->registered[ $gtag->script_handle ]->src; + $this->assertStringContainsString( 'assets/js/build/main.js', $registered_url, 'The script does not point to the correct URL' ); } /** - * Check that `WC_Google_Gtag_JS` does not enqueue - * the `…-ga-integration` script - * on `woocommerce_before_single_product` action, for a simple product. + * Test the get_product_identifier method to verify: + * + * 1. Product SKU is returned if the `ga_product_identifier` option is set to `product_sku`. + * 2. Prefixed (#) product ID is returned if the `ga_product_identifier` option is set to `product_sku` and the product SKU is empty. + * 3. Product ID is returned if the `ga_product_identifier` option is set to `product_id`. + * 4. The filter `woocommerce_ga_product_identifier` can be used to modify the value. * * @return void */ - public function test_integration_script_is_not_enqueued_for_simple() { - global $product; - $product = WC_Helper_Product::create_simple_product(); + public function test_get_product_identifier() { + $mock_sku = $this->getMockBuilder( WC_Google_Gtag_JS::class ) + ->setMethods( array( '__construct' ) ) + ->setConstructorArgs( array( array( 'ga_product_identifier' => 'product_sku' ) ) ) + ->getMock(); - $gtag = new WC_Google_Gtag_JS(); + $this->assertEquals( $this->get_product()->get_sku(), $mock_sku::get_product_identifier( $this->get_product() ) ); - // Mimic WC action. - do_action( 'wp_enqueue_scripts' ); - ob_start(); // Silence output. - do_action( 'woocommerce_before_single_product' ); - ob_get_clean(); + $this->get_product()->set_sku( '' ); + $this->assertEquals( '#' . $this->get_product()->get_id(), $mock_sku::get_product_identifier( $this->get_product() ) ); + + $mock_id = $this->getMockBuilder( WC_Google_Gtag_JS::class ) + ->setMethods( array( '__construct' ) ) + ->setConstructorArgs( array( array( 'ga_product_identifier' => 'product_id' ) ) ) + ->getMock(); - // Assert the handle is not enqueued. - $this->assertEquals( false, wp_script_is( $gtag->script_handle . '-ga-integration', 'enqueued' ), 'the script is enqueued' ); + $this->assertEquals( $this->get_product()->get_id(), $mock_id::get_product_identifier( $this->get_product() ) ); + + add_filter( + 'woocommerce_ga_product_identifier', + function ( $product ) { + return 'filtered'; + } + ); + + $this->assertEquals( 'filtered', $mock_id::get_product_identifier( $this->get_product() ) ); } /** - * Check that `WC_Google_Gtag_JS` does not enqueue - * the `…-ga-integration` script - * on `woocommerce_before_single_product` action, for a bool as a `global $product`. + * Test that events are correctly mapped to WooCommerce hooks and + * are added to the script data array when the action happens. * * @return void */ - public function test_integration_script_is_not_enqueued_for_bool() { - global $product; - $product = false; + public function test_map_actions(): void { + $gtag = new WC_Google_Gtag_JS(); + $mappings = array( + 'begin_checkout' => 'woocommerce_before_checkout_form', + 'purchase' => 'woocommerce_thankyou', + 'view_item_list' => 'woocommerce_before_shop_loop_item', + 'add_to_cart' => 'woocommerce_add_to_cart', + 'remove_from_cart' => 'woocommerce_cart_item_removed', + 'view_item' => 'woocommerce_after_single_product', + ); - $gtag = new WC_Google_Gtag_JS(); + array_map( 'remove_all_actions', $mappings ); - // Mimic WC action. - do_action( 'wp_enqueue_scripts' ); - ob_start(); // Silence output. - do_action( 'woocommerce_before_single_product' ); - ob_get_clean(); + $gtag->map_actions(); + + foreach ( $mappings as $event => $hook ) { + do_action( $hook ); + + $script_data = json_decode( $gtag->get_script_data(), true ); + + $this->assertTrue( in_array( $event, $script_data['events'], true ) ); + + // Reset event data + $gtag->set_script_data( 'events', array() ); + } + } + + /** + * Test that script data is correctly set + * + * @return void + */ + public function test_set_script_data(): void { + $gtag = new WC_Google_Gtag_JS(); + $example_data = array( + 'key' => 'value', + ); + + $gtag->set_script_data( 'test', $example_data ); - // Assert the handle is not enqueued. - $this->assertEquals( false, wp_script_is( $gtag->script_handle . '-ga-integration', 'enqueued' ), 'the script is enqueued' ); + $script_data = json_decode( $gtag->get_script_data(), true ); + $this->assertEquals( $script_data['test'], $example_data ); } /** - * Check that `WC_Google_Gtag_JS` enqueue - * the `…-ga-integration` script - * on `woocommerce_before_single_product` action, for a variable product. + * Test script data can be appended * * @return void */ - public function test_integration_script_is_enqueued_for_variation() { - global $product; - $product = WC_Helper_Product::create_variation_product(); + public function test_append_script_data(): void { + $gtag = new WC_Google_Gtag_JS(); + + $gtag->append_script_data( 'test', 'first' ); + $gtag->append_script_data( 'test', 'second' ); + + $script_data = json_decode( $gtag->get_script_data(), true ); + $this->assertEquals( $script_data['test'], array( 'first', 'second' ) ); + } + + /** + * Test the tracker_var filter `woocommerce_gtag_tracker_variable` + * + * @return void + */ + public function test_tracker_var(): void { $gtag = new WC_Google_Gtag_JS(); - // Mimic WC action. - do_action( 'wp_enqueue_scripts' ); - ob_start(); // Silence output. - do_action( 'woocommerce_before_single_product' ); - ob_get_clean(); + $this->assertEquals( $gtag->tracker_function_name(), 'gtag' ); + + add_filter( + 'woocommerce_gtag_tracker_variable', + function ( $variable ) { + return 'filtered'; + } + ); + $this->assertEquals( $gtag->tracker_function_name(), 'filtered' ); + } - // Assert the handle is enqueued. - $this->assertEquals( true, wp_script_is( $gtag->script_handle . '-ga-integration', 'enqueued' ), 'the script is enqueued' ); + /** + * Test only events enabled in settings will be returned for config + * + * @return void + */ + public function test_get_enabled_events(): void { + $settings = array( + 'purchase' => 'ga_ecommerce_tracking_enabled', + 'add_to_cart' => 'ga_event_tracking_enabled', + 'remove_from_cart' => 'ga_enhanced_remove_from_cart_enabled', + 'view_item_list' => 'ga_enhanced_product_impression_enabled', + 'select_content' => 'ga_enhanced_product_click_enabled', + 'view_item' => 'ga_enhanced_product_detail_view_enabled', + 'begin_checkout' => 'ga_enhanced_checkout_process_enabled', + ); + + foreach ( $settings as $event => $option_name ) { + $gtag = new WC_Google_Gtag_JS( array( $option_name => 'yes' ) ); + $this->assertEquals( $gtag->get_enabled_events(), array( $event ) ); + } } } diff --git a/webpack.config.js b/webpack.config.js index 33baae69..5dde7f32 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -4,17 +4,7 @@ const path = require( 'path' ); const webpackConfig = { ...defaultConfig, entry: { - actions: path.resolve( process.cwd(), 'assets/js/src', 'actions.js' ), - 'admin-ga-settings': path.resolve( - process.cwd(), - 'assets/js/src', - 'admin-ga-settings.js' - ), - 'ga-integration': path.resolve( - process.cwd(), - 'assets/js/src', - 'ga-integration.js' - ), + main: path.resolve( process.cwd(), 'assets/js/src', 'index.js' ), }, output: { ...defaultConfig.output, diff --git a/woocommerce-google-analytics-integration.php b/woocommerce-google-analytics-integration.php index 14969c51..f6bc084c 100644 --- a/woocommerce-google-analytics-integration.php +++ b/woocommerce-google-analytics-integration.php @@ -1,6 +1,6 @@ maybe_show_ga_pro_notices(); + WC_Google_Analytics_Integration::get_instance()->maybe_set_defaults(); } ); @@ -46,7 +47,7 @@ function () { ); /** - * WooCommerce Google Analytics Integration main class. + * Google Analytics for WooCommerce main class. */ class WC_Google_Analytics_Integration { @@ -149,10 +150,10 @@ public function load_plugin_textdomain() { public function woocommerce_missing_notice() { if ( defined( 'WOOCOMMERCE_VERSION' ) ) { /* translators: 1 is the required component, 2 the Woocommerce version */ - $error = sprintf( __( 'WooCommerce Google Analytics requires WooCommerce version %1$s or higher. You are using version %2$s', 'woocommerce-google-analytics-integration' ), WC_GOOGLE_ANALYTICS_INTEGRATION_MIN_WC_VER, WOOCOMMERCE_VERSION ); + $error = sprintf( __( 'Google Analytics for WooCommerce requires WooCommerce version %1$s or higher. You are using version %2$s', 'woocommerce-google-analytics-integration' ), WC_GOOGLE_ANALYTICS_INTEGRATION_MIN_WC_VER, WOOCOMMERCE_VERSION ); } else { /* translators: 1 is the required component */ - $error = sprintf( __( 'WooCommerce Google Analytics requires WooCommerce version %1$s or higher.', 'woocommerce-google-analytics-integration' ), WC_GOOGLE_ANALYTICS_INTEGRATION_MIN_WC_VER ); + $error = sprintf( __( 'Google Analytics for WooCommerce requires WooCommerce version %1$s or higher.', 'woocommerce-google-analytics-integration' ), WC_GOOGLE_ANALYTICS_INTEGRATION_MIN_WC_VER ); } echo '

' . wp_kses_post( $error ) . '

'; @@ -162,7 +163,7 @@ public function woocommerce_missing_notice() { * Add a new integration to WooCommerce. * * @param array $integrations WooCommerce integrations. - * @return array Google Analytics integration added. + * @return array Google Analytics for WooCommerce added. */ public function add_integration( $integrations ) { $integrations[] = 'WC_Google_Analytics'; @@ -209,6 +210,26 @@ public function maybe_show_ga_pro_notices() { update_option( 'woocommerce_google_analytics_pro_notice_shown', true ); } + /** + * Set default options during activation if no settings exist + * + * @since x.x.x + * + * @return void + */ + public function maybe_set_defaults() { + $settings_key = 'woocommerce_google_analytics_settings'; + + if ( false === get_option( $settings_key, false ) ) { + update_option( + $settings_key, + array( + 'ga_product_identifier' => 'product_id', + ) + ); + } + } + /** * Get the path to something in the plugin dir. *