Add Google Analytics Product Block and Checkout Events (https://github.com/woocommerce/woocommerce-blocks/pull/3967)

* Move PHP GA code to service class

* product search tracking

* Improve inline script handling

* trackViewProduct

* Product link events

* Rename events

* Add checkout specific event hook

* Prevent useStoreCart response changing on rerender

* Move address step into form so progress can be more easily tracked

* Checkout progress events

* Checkout events

* Tidy up tracking code

* Track error exceptions

* add_payment_info event

* remove console log

* Track product grid block views Closes woocommerce/woocommerce-blocks#3959

* Checkout context was unused

* Add comments to checkout events

* correct step number

* Standardize prefixes for events

* Tracking requires GA extension

* Fix select content events

* adjust product list render tracking so it's inline

* Inline search event handling

* remove render callback hook

* revert render callback changes

* Update GA bootstrap code
This commit is contained in:
Mike Jolley 2021-03-31 15:22:27 +01:00 committed by GitHub
parent 40c569d9ef
commit 86e54c19a0
24 changed files with 805 additions and 329 deletions

View File

@ -6,7 +6,8 @@ import Button from '@woocommerce/base-components/button';
import { Icon, done as doneIcon } from '@woocommerce/icons';
import { useState, useEffect } from '@wordpress/element';
import { useAddToCartFormContext } from '@woocommerce/base-context';
import { useStoreAddToCart } from '@woocommerce/base-hooks';
import { useStoreAddToCart, useStoreEvents } from '@woocommerce/base-hooks';
import { useInnerBlockLayoutContext } from '@woocommerce/shared-context';
/**
* Add to Cart Form Button Component.
@ -24,6 +25,8 @@ const AddToCartButton = () => {
hasError,
dispatchActions,
} = useAddToCartFormContext();
const { parentName } = useInnerBlockLayoutContext();
const { dispatchStoreEvent } = useStoreEvents();
const { cartQuantity } = useStoreAddToCart( product.id || 0 );
const [ addedToCart, setAddedToCart ] = useState( false );
const addToCartButtonData = product.add_to_cart || {
@ -69,7 +72,13 @@ const AddToCartButton = () => {
isDisabled={ isDisabled }
isProcessing={ isProcessing }
isDone={ addedToCart }
onClick={ () => dispatchActions.submitForm() }
onClick={ () => {
dispatchActions.submitForm();
dispatchStoreEvent( 'cart-add-item', {
product,
listName: parentName,
} );
} }
/>
) : (
<LinkComponent
@ -79,6 +88,12 @@ const AddToCartButton = () => {
addToCartButtonData.text ||
__( 'View Product', 'woo-gutenberg-products-block' )
}
onClick={ () => {
dispatchStoreEvent( 'product-view-link', {
product,
listName: parentName,
} );
} }
/>
);
};
@ -90,10 +105,16 @@ const AddToCartButton = () => {
* @param {string} props.className Css classnames.
* @param {string} props.href Link for button.
* @param {string} props.text Text content for button.
* @param {function():any} props.onClick Callback to execute when button is clicked.
*/
const LinkComponent = ( { className, href, text } ) => {
const LinkComponent = ( { className, href, text, onClick } ) => {
return (
<Button className={ className } href={ href } rel="nofollow">
<Button
className={ className }
href={ href }
onClick={ onClick }
rel="nofollow"
>
{ text }
</Button>
);

View File

@ -99,10 +99,15 @@ const AddToCartButton = ( { product } ) => {
if ( ! allowAddToCart ) {
buttonProps.href = permalink;
buttonProps.rel = 'nofollow';
buttonProps.onClick = () => {
dispatchStoreEvent( 'product-view-link', {
product,
} );
};
} else {
buttonProps.onClick = () => {
addToCart();
dispatchStoreEvent( 'add-cart-item', {
dispatchStoreEvent( 'cart-add-item', {
product,
} );
};

View File

@ -11,6 +11,7 @@ import {
useProductDataContext,
} from '@woocommerce/shared-context';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import { useStoreEvents } from '@woocommerce/base-hooks';
/**
* Internal dependencies
@ -39,6 +40,7 @@ export const Block = ( {
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const [ imageLoaded, setImageLoaded ] = useState( false );
const { dispatchStoreEvent } = useStoreEvents();
if ( ! product.id ) {
return (
@ -68,6 +70,11 @@ export const Block = ( {
href: product.permalink,
rel: 'nofollow',
...( ! hasProductImages && { 'aria-label': anchorLabel } ),
onClick: () => {
dispatchStoreEvent( 'product-view-link', {
product,
} );
},
};
return (

View File

@ -12,6 +12,7 @@ import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import { gatedStyledText } from '@woocommerce/atomic-utils';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import ProductName from '@woocommerce/base-components/product-name';
import { useStoreEvents } from '@woocommerce/base-hooks';
/**
* Internal dependencies
@ -45,6 +46,7 @@ export const Block = ( {
} ) => {
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const { dispatchStoreEvent } = useStoreEvents();
const TagName = `h${ headingLevel }`;
const colorClass = getColorClassName( 'color', color );
@ -104,6 +106,11 @@ export const Block = ( {
color: customColor,
fontSize: customFontSize,
} ) }
onClick={ () => {
dispatchStoreEvent( 'product-view-link', {
product,
} );
} }
/>
</TagName>
);

View File

@ -147,12 +147,10 @@ const ProductList = ( {
// If the product list changes, trigger an event.
useEffect( () => {
if ( products.length > 0 ) {
dispatchStoreEvent( 'list-products', {
products,
listName: parentName,
} );
}
dispatchStoreEvent( 'product-list-render', {
products,
listName: parentName,
} );
}, [ products, parentName, dispatchStoreEvent ] );
// If query state (excluding pagination/sorting attributes) changed, reset pagination to the first page.

View File

@ -16,6 +16,7 @@ import {
useStoreNotices,
useEmitResponse,
usePrevious,
useStoreEvents,
} from '@woocommerce/base-hooks';
/**
@ -101,6 +102,7 @@ export const CheckoutStateProvider = ( {
const currentObservers = useRef( observers );
const { setValidationErrors } = useValidationContext();
const { addErrorNotice, removeNotices } = useStoreNotices();
const { dispatchCheckoutEvent } = useStoreEvents();
const isCalculating = checkoutState.calculatingCount > 0;
const {
isSuccessResponse,
@ -350,8 +352,9 @@ export const CheckoutStateProvider = ( {
] );
const onSubmit = useCallback( () => {
dispatchCheckoutEvent( 'submit' );
dispatch( actions.setBeforeProcessing() );
}, [] );
}, [ dispatchCheckoutEvent ] );
/**
* @type {CheckoutDataContext}

View File

@ -11,7 +11,11 @@ import {
useRef,
useMemo,
} from '@wordpress/element';
import { useStoreNotices, useEmitResponse } from '@woocommerce/base-hooks';
import {
useStoreNotices,
useEmitResponse,
useStoreEvents,
} from '@woocommerce/base-hooks';
/**
* Internal dependencies
@ -102,6 +106,7 @@ export const PaymentMethodDataProvider = ( {
isFailResponse,
noticeContexts,
} = useEmitResponse();
const { dispatchCheckoutEvent } = useStoreEvents();
const [ activePaymentMethod, setActive ] = useState( '' ); // The active payment method - e.g. Stripe CC or BACS.
const [ activeSavedToken, setActiveSavedToken ] = useState( '' ); // If a previously saved payment method is active, the token for that method. For example, a for a Stripe CC card saved to user account.
@ -122,8 +127,11 @@ export const PaymentMethodDataProvider = ( {
( paymentMethodSlug ) => {
setActive( paymentMethodSlug );
dispatch( statusOnly( PRISTINE ) );
dispatchCheckoutEvent( 'set-active-payment-method', {
paymentMethodSlug,
} );
},
[ setActive, dispatch ]
[ setActive, dispatch, dispatchCheckoutEvent ]
);
const paymentMethodsDispatcher = useCallback<

View File

@ -14,6 +14,11 @@ import {
SnackbarNoticesContainer,
} from '@woocommerce/base-components/store-notices-container';
/**
* Internal dependencies
*/
import { useStoreEvents } from '../hooks/use-store-events';
/**
* @typedef {import('@woocommerce/type-defs/contexts').NoticeContext} NoticeContext
* @typedef {import('react')} React
@ -61,6 +66,7 @@ export const StoreNoticesProvider = ( {
} ) => {
const { createNotice, removeNotice } = useDispatch( 'core/notices' );
const [ isSuppressed, setIsSuppressed ] = useState( false );
const { dispatchStoreEvent } = useStoreEvents();
const createNoticeWithContext = useCallback(
( status = 'default', content = '', options = {} ) => {
@ -68,8 +74,13 @@ export const StoreNoticesProvider = ( {
...options,
context: options.context || context,
} );
dispatchStoreEvent( 'store-notice-create', {
status,
content,
options,
} );
},
[ createNotice, context ]
[ createNotice, dispatchStoreEvent, context ]
);
const removeNoticeWithContext = useCallback(

View File

@ -3,6 +3,8 @@
/**
* External dependencies
*/
import { isEqual } from 'lodash';
import { useRef } from '@wordpress/element';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { useSelect } from '@wordpress/data';
import { useEditorContext } from '@woocommerce/base-context';
@ -113,6 +115,7 @@ export const useStoreCart = (
const { isEditor, previewData } = useEditorContext();
const previewCart = previewData?.previewCart || {};
const { shouldSelect } = options;
const currentResults = useRef();
const results: StoreCart = useSelect(
( select, { dispatch } ) => {
@ -188,5 +191,13 @@ export const useStoreCart = (
},
[ shouldSelect ]
);
return results;
if (
! currentResults.current ||
! isEqual( currentResults.current, results )
) {
currentResults.current = results;
}
return currentResults.current;
};

View File

@ -9,6 +9,7 @@ import { Rate } from '@woocommerce/type-defs/shipping';
* Internal dependencies
*/
import { useSelectShippingRates } from './use-select-shipping-rates';
import { useStoreEvents } from '../use-store-events';
/**
* Selected rates are derived by looping over the shipping rates.
@ -39,6 +40,8 @@ export const useSelectShippingRate = (
selectedShippingRate: string | undefined;
isSelectingRate: boolean;
} => {
const { dispatchCheckoutEvent } = useStoreEvents();
// Rates are selected via the shipping data context provider.
const { selectShippingRate, isSelectingRate } = useSelectShippingRates();
@ -62,8 +65,11 @@ export const useSelectShippingRate = (
( newShippingRateId ) => {
setSelectedShippingRate( newShippingRateId );
selectShippingRate( newShippingRateId, packageId );
dispatchCheckoutEvent( 'set-selected-shipping-rate', {
shippingRateId: newShippingRateId,
} );
},
[ packageId, selectShippingRate ]
[ packageId, selectShippingRate, dispatchCheckoutEvent ]
);
return {

View File

@ -4,40 +4,56 @@
import { doAction } from '@wordpress/hooks';
import { useCallback } from '@wordpress/element';
/**
* Internal dependencies
*/
import { useStoreCart } from './cart/use-store-cart';
type StoreEvent = (
eventName: string,
eventParams?: Partial< Record< string, unknown > >
) => void;
/**
* Abstraction on top of @wordpress/hooks for dispatching events via doAction for 3rd parties to hook into.
*/
export const useStoreEvents = (
namespace = 'experimental__woocommerce_blocks'
): {
dispatchStoreEvent: (
eventName: string,
eventParams: Partial< Record< string, unknown > >
) => void;
export const useStoreEvents = (): {
dispatchStoreEvent: StoreEvent;
dispatchCheckoutEvent: StoreEvent;
} => {
const dispatchStoreEvent = useCallback(
(
eventName: string,
eventParams: Partial< Record< string, unknown > >
) => {
// eslint-disable-next-line no-console
console.log( {
event: `${ namespace }-${ eventName }`,
eventParams,
} );
const storeCart = useStoreCart();
const dispatchStoreEvent = useCallback( ( eventName, eventParams = {} ) => {
try {
doAction(
`experimental__woocommerce_blocks-${ eventName }`,
eventParams
);
} catch ( e ) {
// We don't handle thrown errors but just console.log for troubleshooting.
// eslint-disable-next-line no-console
console.error( e );
}
}, [] );
const dispatchCheckoutEvent = useCallback(
( eventName, eventParams = {} ) => {
try {
doAction( `${ namespace }-${ eventName }`, eventParams );
doAction(
`experimental__woocommerce_blocks-checkout-${ eventName }`,
{
...eventParams,
storeCart,
}
);
} catch ( e ) {
// We don't handle thrown errors but just console.log for troubleshooting.
// eslint-disable-next-line no-console
console.error( e );
}
},
[ namespace ]
[ storeCart ]
);
return {
dispatchStoreEvent,
};
return { dispatchStoreEvent, dispatchCheckoutEvent };
};

View File

@ -249,7 +249,7 @@ const CartLineItemRow = ( { lineItem = {} } ) => {
maximum={ quantityLimit }
onChange={ ( newQuantity ) => {
setItemQuantity( newQuantity );
dispatchStoreEvent( 'set-cart-item-quantity', {
dispatchStoreEvent( 'cart-set-item-quantity', {
product: lineItem,
quantity: newQuantity,
} );
@ -260,7 +260,7 @@ const CartLineItemRow = ( { lineItem = {} } ) => {
className="wc-block-cart-item__remove-link"
onClick={ () => {
removeItem();
dispatchStoreEvent( 'remove-cart-item', {
dispatchStoreEvent( 'cart-remove-item', {
product: lineItem,
quantity,
} );

View File

@ -1,114 +0,0 @@
/**
* External dependencies
*/
import { useMemo } from '@wordpress/element';
import PropTypes from 'prop-types';
import { useCheckoutAddress } from '@woocommerce/base-hooks';
import { useShippingDataContext } from '@woocommerce/base-context';
import { AddressForm } from '@woocommerce/base-components/cart-checkout';
/**
* Internal dependencies
*/
import BillingFieldsStep from './billing-fields-step';
import ContactFieldsStep from './contact-fields-step';
import ShippingFieldsStep from './shipping-fields-step';
import PhoneNumber from './phone-number';
import './style.scss';
const AddressStep = ( {
requireCompanyField,
requirePhoneField,
showApartmentField,
showCompanyField,
showPhoneField,
allowCreateAccount,
} ) => {
const {
defaultAddressFields,
billingFields,
setBillingFields,
setEmail,
setPhone,
setShippingAsBilling,
setShippingFields,
shippingAsBilling,
shippingFields,
showBillingFields,
} = useCheckoutAddress();
const { needsShipping } = useShippingDataContext();
const addressFieldsConfig = useMemo( () => {
return {
company: {
hidden: ! showCompanyField,
required: requireCompanyField,
},
address_2: {
hidden: ! showApartmentField,
},
};
}, [ showCompanyField, requireCompanyField, showApartmentField ] );
return (
<>
<ContactFieldsStep
emailValue={ billingFields.email }
onChangeEmail={ setEmail }
allowCreateAccount={ allowCreateAccount }
/>
{ needsShipping && (
<ShippingFieldsStep
shippingAsBilling={ shippingAsBilling }
setShippingAsBilling={ setShippingAsBilling }
>
<AddressForm
id="shipping"
type="shipping"
onChange={ setShippingFields }
values={ shippingFields }
fields={ Object.keys( defaultAddressFields ) }
fieldConfig={ addressFieldsConfig }
/>
{ showPhoneField && (
<PhoneNumber
isRequired={ requirePhoneField }
value={ billingFields.phone }
onChange={ setPhone }
/>
) }
</ShippingFieldsStep>
) }
{ showBillingFields && (
<BillingFieldsStep>
<AddressForm
id="billing"
type="billing"
onChange={ setBillingFields }
values={ billingFields }
fields={ Object.keys( defaultAddressFields ) }
fieldConfig={ addressFieldsConfig }
/>
{ showPhoneField && ! needsShipping && (
<PhoneNumber
isRequired={ requirePhoneField }
value={ billingFields.phone }
onChange={ setPhone }
/>
) }
</BillingFieldsStep>
) }
</>
);
};
AddressStep.propTypes = {
requireCompanyField: PropTypes.bool.isRequired,
requirePhoneField: PropTypes.bool.isRequired,
showApartmentField: PropTypes.bool.isRequired,
showCompanyField: PropTypes.bool.isRequired,
showPhoneField: PropTypes.bool.isRequired,
allowCreateAccount: PropTypes.bool.isRequired,
};
export default AddressStep;

View File

@ -1,14 +1,23 @@
/**
* External dependencies
*/
import Form from '@woocommerce/base-components/form';
import { useCheckoutContext } from '@woocommerce/base-context';
import PropTypes from 'prop-types';
import { useEffect, useMemo } from '@wordpress/element';
import {
useCheckoutContext,
useShippingDataContext,
} from '@woocommerce/base-context';
import { useCheckoutAddress, useStoreEvents } from '@woocommerce/base-hooks';
import { AddressForm } from '@woocommerce/base-components/cart-checkout';
import Form from '@woocommerce/base-components/form';
/**
* Internal dependencies
*/
import AddressStep from './address-step';
import BillingFieldsStep from './billing-fields-step';
import ContactFieldsStep from './contact-fields-step';
import ShippingFieldsStep from './shipping-fields-step';
import PhoneNumber from './phone-number';
import OrderNotesStep from './order-notes-step';
import PaymentMethodStep from './payment-method-step';
import ShippingOptionsStep from './shipping-options-step';
@ -24,17 +33,106 @@ const CheckoutForm = ( {
allowCreateAccount,
} ) => {
const { onSubmit } = useCheckoutContext();
const {
defaultAddressFields,
billingFields,
setBillingFields,
setEmail,
setPhone,
setShippingAsBilling,
setShippingFields,
shippingAsBilling,
shippingFields,
showBillingFields,
} = useCheckoutAddress();
const { needsShipping } = useShippingDataContext();
const { dispatchCheckoutEvent } = useStoreEvents();
const addressFieldsConfig = useMemo( () => {
return {
company: {
hidden: ! showCompanyField,
required: requireCompanyField,
},
address_2: {
hidden: ! showApartmentField,
},
};
}, [ showCompanyField, requireCompanyField, showApartmentField ] );
// Ignore changes to dispatchCheckoutEvent callback so this is ran on first mount only.
useEffect( () => {
dispatchCheckoutEvent( 'render-checkout-form' );
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [] );
return (
<Form className="wc-block-checkout__form" onSubmit={ onSubmit }>
<AddressStep
requireCompanyField={ requireCompanyField }
requirePhoneField={ requirePhoneField }
showApartmentField={ showApartmentField }
showCompanyField={ showCompanyField }
showPhoneField={ showPhoneField }
<ContactFieldsStep
emailValue={ billingFields.email }
onChangeEmail={ ( value ) => {
setEmail( value );
dispatchCheckoutEvent( 'set-email-address' );
} }
allowCreateAccount={ allowCreateAccount }
/>
{ needsShipping && (
<ShippingFieldsStep
shippingAsBilling={ shippingAsBilling }
setShippingAsBilling={ setShippingAsBilling }
>
<AddressForm
id="shipping"
type="shipping"
onChange={ ( values ) => {
setShippingFields( values );
dispatchCheckoutEvent( 'set-shipping-address' );
} }
values={ shippingFields }
fields={ Object.keys( defaultAddressFields ) }
fieldConfig={ addressFieldsConfig }
/>
{ showPhoneField && (
<PhoneNumber
isRequired={ requirePhoneField }
value={ billingFields.phone }
onChange={ ( value ) => {
setPhone( value );
dispatchCheckoutEvent( 'set-phone-number', {
step: 'shipping',
} );
} }
/>
) }
</ShippingFieldsStep>
) }
{ showBillingFields && (
<BillingFieldsStep>
<AddressForm
id="billing"
type="billing"
onChange={ ( values ) => {
setBillingFields( values );
dispatchCheckoutEvent( 'set-billing-address' );
} }
values={ billingFields }
fields={ Object.keys( defaultAddressFields ) }
fieldConfig={ addressFieldsConfig }
/>
{ showPhoneField && ! needsShipping && (
<PhoneNumber
isRequired={ requirePhoneField }
value={ billingFields.phone }
onChange={ ( value ) => {
setPhone( value );
dispatchCheckoutEvent( 'set-phone-number', {
step: 'billing',
} );
} }
/>
) }
</BillingFieldsStep>
) }
<ShippingOptionsStep />
<PaymentMethodStep />
{ showOrderNotes && <OrderNotesStep /> }

View File

@ -1,12 +1,14 @@
/**
* External dependencies
*/
import { useEffect } from '@wordpress/element';
import { withProduct } from '@woocommerce/block-hocs';
import {
InnerBlockLayoutContextProvider,
ProductDataContextProvider,
} from '@woocommerce/shared-context';
import { StoreNoticesProvider } from '@woocommerce/base-context';
import { useStoreEvents } from '@woocommerce/base-hooks';
/**
* Internal dependencies
@ -24,9 +26,17 @@ import { BLOCK_NAME } from './constants';
* @param {React.ReactChildren} props.children
*/
const Block = ( { isLoading, product, children } ) => {
const { dispatchStoreEvent } = useStoreEvents();
const className = 'wc-block-single-product wc-block-layout';
const noticeContext = `woocommerce/single-product/${ product?.id || 0 }`;
useEffect( () => {
dispatchStoreEvent( 'product-render', {
product,
listName: BLOCK_NAME,
} );
}, [ product, dispatchStoreEvent ] );
return (
<InnerBlockLayoutContextProvider
parentName={ BLOCK_NAME }

View File

@ -3,7 +3,11 @@
*/
import { __ } from '@wordpress/i18n';
import { addAction } from '@wordpress/hooks';
import type { ProductResponseItem, CartResponseItem } from '@woocommerce/types';
import type {
ProductResponseItem,
CartResponseItem,
StoreCart,
} from '@woocommerce/types';
/**
* Internal dependencies
@ -13,89 +17,288 @@ import {
getProductFieldObject,
getProductImpressionObject,
trackEvent,
trackCheckoutStep,
trackCheckoutOption,
} from './utils';
const trackListProducts = ( {
products,
listName = __( 'Product List', 'woo-gutenberg-products-block' ),
}: {
products: Array< ProductResponseItem >;
listName: string;
} ): void => {
trackEvent( 'view_item_list', {
event_category: 'engagement',
event_label: __( 'Viewing products', 'woo-gutenberg-products-block' ),
items: products.map( ( product, index ) => ( {
...getProductImpressionObject( product, listName ),
list_position: index + 1,
} ) ),
} );
};
/**
* 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
*/
addAction(
`${ actionPrefix }-checkout-render-checkout-form`,
namespace,
trackCheckoutStep( 0 )
);
addAction(
`${ actionPrefix }-checkout-set-email-address`,
namespace,
trackCheckoutStep( 1 )
);
addAction(
`${ actionPrefix }-checkout-set-shipping-address`,
namespace,
trackCheckoutStep( 2 )
);
addAction(
`${ actionPrefix }-checkout-set-billing-address`,
namespace,
trackCheckoutStep( 3 )
);
addAction(
`${ actionPrefix }-checkout-set-phone-number`,
namespace,
( { step, ...rest }: { step: string; storeCart: StoreCart } ): void => {
trackCheckoutStep( step === 'shipping' ? 2 : 3 )( rest );
}
);
const trackAddToCart = ( {
product,
quantity = 1,
}: {
product: ProductResponseItem;
quantity: number;
} ): void => {
trackEvent( 'add_to_cart', {
event_category: 'ecommerce',
event_label: __( 'Add to Cart', 'woo-gutenberg-products-block' ),
items: [ getProductFieldObject( product, quantity ) ],
} );
};
/**
* 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
*/
addAction(
`${ actionPrefix }-checkout-set-selected-shipping-rate`,
namespace,
( { shippingRateId }: { shippingRateId: string } ): void => {
trackCheckoutOption( {
step: 4,
option: __( 'Shipping Method', 'woo-gutenberg-products-block' ),
value: shippingRateId,
} )();
}
);
const trackRemoveCartItem = ( {
product,
quantity = 1,
}: {
product: CartResponseItem;
quantity: number;
} ): void => {
trackEvent( 'remove_from_cart', {
event_category: 'ecommerce',
event_label: __( 'Remove Cart Item', 'woo-gutenberg-products-block' ),
items: [ getProductFieldObject( product, quantity ) ],
} );
};
/**
* 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
*/
addAction(
`${ actionPrefix }-checkout-set-active-payment-method`,
namespace,
( { paymentMethodSlug }: { paymentMethodSlug: string } ): void => {
trackCheckoutOption( {
step: 5,
option: __( 'Payment Method', 'woo-gutenberg-products-block' ),
value: paymentMethodSlug,
} )();
}
);
const trackChangeCartItemQuantity = ( {
product,
quantity = 1,
}: {
product: CartResponseItem;
quantity: number;
} ): void => {
trackEvent( 'change_cart_quantity', {
event_category: 'ecommerce',
event_label: __(
'Change Cart Item Quantity',
'woo-gutenberg-products-block'
),
items: [ getProductFieldObject( product, quantity ) ],
} );
};
/**
* 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
*/
addAction( `${ actionPrefix }-checkout-submit`, namespace, (): void => {
trackEvent( 'add_payment_info' );
} );
function initialize() {
// eslint-disable-next-line no-console
console.log( `Google Analytics Tracking initialized` );
addAction(
`${ actionPrefix }-list-products`,
namespace,
trackListProducts
);
addAction( `${ actionPrefix }-add-cart-item`, namespace, trackAddToCart );
addAction(
`${ actionPrefix }-set-cart-item-quantity`,
namespace,
trackChangeCartItemQuantity
);
addAction(
`${ actionPrefix }-remove-cart-item`,
namespace,
trackRemoveCartItem
);
}
/**
* 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
*/
addAction(
`${ actionPrefix }-cart-add-item`,
namespace,
( {
product,
quantity = 1,
}: {
product: ProductResponseItem;
quantity: number;
} ): void => {
trackEvent( 'add_to_cart', {
event_category: 'ecommerce',
event_label: __( 'Add to Cart', 'woo-gutenberg-products-block' ),
items: [ getProductFieldObject( product, quantity ) ],
} );
}
);
initialize();
/**
* Remove item from the cart
*
* @summary Track the remove_from_cart event
* @see https://developers.google.com/gtagjs/reference/ga4-events#remove_from_cart
*/
addAction(
`${ actionPrefix }-cart-remove-item`,
namespace,
( {
product,
quantity = 1,
}: {
product: CartResponseItem;
quantity: number;
} ): void => {
trackEvent( 'remove_from_cart', {
event_category: 'ecommerce',
event_label: __(
'Remove Cart Item',
'woo-gutenberg-products-block'
),
items: [ getProductFieldObject( product, quantity ) ],
} );
}
);
/**
* Change cart item quantities
*
* @summary Custom change_cart_quantity event.
*/
addAction(
`${ actionPrefix }-cart-set-item-quantity`,
namespace,
( {
product,
quantity = 1,
}: {
product: CartResponseItem;
quantity: number;
} ): void => {
trackEvent( 'change_cart_quantity', {
event_category: 'ecommerce',
event_label: __(
'Change Cart Item Quantity',
'woo-gutenberg-products-block'
),
items: [ getProductFieldObject( product, quantity ) ],
} );
}
);
/**
* Product List View
*
* @summary Track the view_item_list event
* @see https://developers.google.com/gtagjs/reference/ga4-events#view_item_list
*/
addAction(
`${ actionPrefix }-product-list-render`,
namespace,
( {
products,
listName = __( 'Product List', 'woo-gutenberg-products-block' ),
}: {
products: Array< ProductResponseItem >;
listName: string;
} ): void => {
if ( products.length === 0 ) {
return;
}
trackEvent( 'view_item_list', {
event_category: 'engagement',
event_label: __(
'Viewing products',
'woo-gutenberg-products-block'
),
items: products.map( ( product, index ) => ( {
...getProductImpressionObject( product, listName ),
list_position: index + 1,
} ) ),
} );
}
);
/**
* Product View Link Clicked
*
* @summary Track the select_content event
* @see https://developers.google.com/gtagjs/reference/ga4-events#select_content
*/
addAction(
`${ actionPrefix }-product-view-link`,
namespace,
( {
product,
listName,
}: {
product: ProductResponseItem;
listName: string;
} ): void => {
trackEvent( 'select_content', {
content_type: 'product',
items: [ getProductImpressionObject( product, listName ) ],
} );
}
);
/**
* Product Search
*
* @summary Track the search event
* @see https://developers.google.com/gtagjs/reference/ga4-events#search
*/
addAction(
`${ actionPrefix }-product-search`,
namespace,
( { searchTerm }: { searchTerm: string } ): void => {
trackEvent( 'search', {
search_term: searchTerm,
} );
}
);
/**
* Single Product View
*
* @summary Track the view_item event
* @see https://developers.google.com/gtagjs/reference/ga4-events#view_item
*/
addAction(
`${ actionPrefix }-product-render`,
namespace,
( {
product,
listName,
}: {
product: ProductResponseItem;
listName: string;
} ): void => {
if ( product ) {
trackEvent( 'view_item', {
items: [ getProductImpressionObject( product, listName ) ],
} );
}
}
);
/**
* Track notices as Exception events.
*
* @summary Track the exception event
* @see https://developers.google.com/analytics/devguides/collection/gtagjs/exceptions
*/
addAction(
`${ actionPrefix }-store-notice-create`,
namespace,
( { status, content }: { status: string; content: string } ): void => {
if ( status === 'error' ) {
trackEvent( 'exception', {
description: content,
fatal: false,
} );
}
}
);

View File

@ -3,5 +3,5 @@
"compilerOptions": {
"types": [ "gtag.js" ]
},
"include": [ ".", "../../type-defs/**.ts", "../../mapped-types.ts" ]
"include": [ ".", "../../type-defs", "../../mapped-types.ts" ]
}

View File

@ -1,7 +1,11 @@
/**
* External dependencies
*/
import type { ProductResponseItem, CartResponseItem } from '@woocommerce/types';
import type {
ProductResponseItem,
CartResponseItem,
StoreCart,
} from '@woocommerce/types';
interface ImpressionItem extends Gtag.Item {
// eslint-disable-next-line camelcase
@ -73,3 +77,43 @@ export const trackEvent = (
console.log( `Tracking event ${ eventName }` );
window.gtag( 'event', eventName, eventParams );
};
let currentStep = -1;
export const trackCheckoutStep = ( step: number ) => ( {
storeCart,
}: {
storeCart: StoreCart;
} ): void => {
if ( currentStep === step ) {
return;
}
trackEvent( step === 0 ? 'begin_checkout' : 'checkout_progress', {
items: storeCart.cartItems.map( getProductFieldObject ),
coupon: storeCart.cartCoupons[ 0 ]?.code || '',
currency: storeCart.cartTotals.currency_code,
value: (
parseInt( storeCart.cartTotals.total_price, 10 ) /
10 ** storeCart.cartTotals.currency_minor_unit
).toString(),
checkout_step: step,
} );
currentStep = step;
};
export const trackCheckoutOption = ( {
step,
option,
value,
}: {
step: number;
option: string;
value: string;
} ) => (): void => {
trackEvent( 'set_checkout_option', {
checkout_step: step,
checkout_option: option,
value,
} );
currentStep = step;
};

View File

@ -27,7 +27,6 @@ class Assets {
add_action( 'woocommerce_login_form_end', array( __CLASS__, 'redirect_to_field' ) );
add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_scripts' ) );
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_scripts' ) );
add_filter( 'script_loader_tag', array( __CLASS__, 'async_script_loader_tags' ), 10, 3 );
}
/**
@ -55,10 +54,6 @@ class Assets {
$asset_api->register_script( 'wc-shared-hocs', 'build/wc-shared-hocs.js', [], false );
$asset_api->register_script( 'wc-price-format', 'build/price-format.js', [], false );
if ( Package::feature()->is_experimental_build() ) {
$asset_api->register_script( 'wc-blocks-google-analytics', 'build/wc-blocks-google-analytics.js', [ 'google-tag-manager' ] );
}
if ( Package::feature()->is_feature_plugin_build() ) {
$asset_api->register_script( 'wc-blocks-checkout', 'build/blocks-checkout.js', [], false );
}
@ -73,49 +68,6 @@ class Assets {
);
}
/**
* Get settings from the GA integration extension.
*
* @return array
*/
private static function get_google_analytics_settings() {
return wp_parse_args(
get_option( 'woocommerce_google_analytics_settings' ),
[
'ga_id' => '',
'ga_event_tracking_enabled' => 'no',
]
);
}
/**
* Enqueue the Google Tag Manager script if prerequisites are met.
*
* @param AssetApi $asset_api Asset API class Instance.
*/
private static function maybe_enqueue_google_analytics( $asset_api ) {
$settings = self::get_google_analytics_settings();
if ( is_admin() || ! stristr( $settings['ga_id'], 'G-' ) || apply_filters( 'woocommerce_ga_disable_tracking', ! wc_string_to_bool( $settings['ga_event_tracking_enabled'] ) ) ) {
return;
}
if ( ! wp_script_is( 'google-tag-manager', 'registered' ) ) {
// phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion
wp_register_script( 'google-tag-manager', 'https://www.googletagmanager.com/gtag/js?id=' . $settings['ga_id'], [], null, false );
wp_add_inline_script(
'google-tag-manager',
"
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '" . esc_js( $settings['ga_id'] ) . "', { 'send_page_view': false });"
);
}
wp_enqueue_script( 'wc-blocks-google-analytics' );
}
/**
* Register the vendors style file. We need to do it after the other files
* because we need to check if `wp-edit-post` has been enqueued.
@ -132,10 +84,6 @@ class Assets {
$block_style_dependencies = array();
}
self::register_style( 'wc-block-vendors-style', plugins_url( $asset_api->get_block_asset_build_path( 'vendors-style', 'css' ), __DIR__ ), $block_style_dependencies );
if ( Package::feature()->is_experimental_build() ) {
self::maybe_enqueue_google_analytics( $asset_api );
}
}
/**
@ -334,23 +282,4 @@ class Assets {
$ver = self::get_file_version( $filename );
wp_register_style( $handle, $src, $deps, $ver, $media );
}
/**
* Add async to script tags with defined handles.
*
* @param string $tag HTML for the script tag.
* @param string $handle Handle of script.
* @param string $src Src of script.
* @return string
*/
public static function async_script_loader_tags( $tag, $handle, $src ) {
if ( ! in_array( $handle, array( 'google-tag-manager' ), true ) ) {
return $tag;
}
// If script was output manually in wp_head, abort.
if ( did_action( 'woocommerce_gtag_snippet' ) ) {
return '';
}
return str_replace( '<script src', '<script async src', $tag );
}
}

View File

@ -11,6 +11,12 @@ use Exception;
* @since 2.5.0
*/
class Api {
/**
* Stores inline scripts already enqueued.
*
* @var array
*/
private $inline_scripts = [];
/**
* Reference to the Package instance
@ -168,4 +174,24 @@ class Api {
: '-legacy';
return "build/$filename$suffix.$type";
}
/**
* Adds an inline script, once.
*
* @param string $handle Script handle.
* @param string $script Script contents.
*/
public function add_inline_script( $handle, $script ) {
if ( ! empty( $this->inline_scripts[ $handle ] ) && in_array( $script, $this->inline_scripts[ $handle ], true ) ) {
return;
}
wp_add_inline_script( $handle, $script );
if ( isset( $this->inline_scripts[ $handle ] ) ) {
$this->inline_scripts[ $handle ][] = $script;
} else {
$this->inline_scripts[ $handle ] = array( $script );
}
}
}

View File

@ -2,6 +2,8 @@
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\Utils\BlocksWpQuery;
use Automattic\WooCommerce\Blocks\StoreApi\SchemaController;
use Automattic\WooCommerce\Blocks\Package;
/**
* AbstractProductGrid class.
@ -62,16 +64,51 @@ abstract class AbstractProductGrid extends AbstractDynamicBlock {
$this->attributes = $this->parse_attributes( $attributes );
$this->content = $content;
$this->query_args = $this->parse_query_args();
$products = $this->get_products();
$products = array_filter( array_map( 'wc_get_product', $this->get_products() ) );
if ( ! $products ) {
return '';
}
$classes = $this->get_container_classes();
$output = implode( '', array_map( array( $this, 'render_product' ), $products ) );
/**
* Product List Render event.
*
* Fires a WP Hook named `experimental__woocommerce_blocks-product-list-render` on render so that the client
* can add event handling when certain products are displayed. This can be used by tracking extensions such
* as Google Analytics to track impressions.
*
* Provides the list of product data (shaped like the Store API responses) and the block name.
*/
$this->asset_api->add_inline_script(
'wp-hooks',
'
window.addEventListener( "DOMContentLoaded", () => {
wp.hooks.doAction(
"experimental__woocommerce_blocks-product-list-render",
{
products: JSON.parse( decodeURIComponent( "' . esc_js(
rawurlencode(
wp_json_encode(
array_map(
[ Package::container()->get( SchemaController::class )->get( 'product' ), 'get_item_response' ],
$products
)
)
)
) . '" ) ),
listName: "' . esc_js( $this->block_name ) . '"
}
);
} );
',
'after'
);
return sprintf( '<div class="%s"><ul class="wc-block-grid__products">%s</ul></div>', esc_attr( $classes ), $output );
return sprintf(
'<div class="%s"><ul class="wc-block-grid__products">%s</ul></div>',
esc_attr( $this->get_container_classes() ),
implode( '', array_map( array( $this, 'render_product' ), $products ) )
);
}
/**
@ -305,16 +342,10 @@ abstract class AbstractProductGrid extends AbstractDynamicBlock {
/**
* Render a single products.
*
* @param int $id Product ID.
* @param \WC_Product $product Product object.
* @return string Rendered product output.
*/
protected function render_product( $id ) {
$product = wc_get_product( $id );
if ( ! $product ) {
return '';
}
protected function render_product( $product ) {
$data = (object) array(
'permalink' => esc_url( $product->get_permalink() ),
'image' => $this->get_image_html( $product ),

View File

@ -22,4 +22,41 @@ class ProductSearch extends AbstractBlock {
protected function get_block_type_script( $key = null ) {
return null;
}
/**
* Render the block.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content ) {
/**
* Product Search event.
*
* Listens for product search form submission, and on submission fires a WP Hook named
* `experimental__woocommerce_blocks-product-search`. This can be used by tracking extensions such as Google
* Analytics to track searches.
*/
$this->asset_api->add_inline_script(
'wp-hooks',
"
window.addEventListener( 'DOMContentLoaded', () => {
const forms = document.querySelectorAll( '.wc-block-product-search form' );
for ( const form of forms ) {
form.addEventListener( 'submit', ( event ) => {
const field = form.querySelector( '.wc-block-product-search__field' );
if ( field && field.value ) {
wp.hooks.doAction( 'experimental__woocommerce_blocks-product-search', { event: event, searchTerm: field.value } );
}
} );
}
} );
",
'after'
);
return $content;
}
}

View File

@ -27,6 +27,7 @@ use Automattic\WooCommerce\Blocks\StoreApi\Formatters\HtmlFormatter;
use Automattic\WooCommerce\Blocks\StoreApi\Formatters\CurrencyFormatter;
use Automattic\WooCommerce\Blocks\StoreApi\RoutesController;
use Automattic\WooCommerce\Blocks\StoreApi\SchemaController;
use Automattic\WooCommerce\Blocks\Domain\Services\GoogleAnalytics;
/**
* Takes care of bootstrapping the plugin.
@ -88,6 +89,7 @@ class Bootstrap {
$this->container->get( ExtendRestApi::class );
$this->container->get( PaymentsApi::class );
$this->container->get( RestApi::class );
$this->container->get( GoogleAnalytics::class );
Library::init();
}
@ -227,6 +229,17 @@ class Bootstrap {
return new ExtendRestApi( $container->get( Package::class ), $container->get( Formatters::class ) );
}
);
$this->container->register(
GoogleAnalytics::class,
function( Container $container ) {
// Require Google Analytics Integration to be activated.
if ( ! class_exists( 'WC_Google_Analytics_Integration' ) ) {
return;
}
$asset_api = $container->get( AssetApi::class );
return new GoogleAnalytics( $asset_api );
}
);
}
/**

View File

@ -0,0 +1,106 @@
<?php
namespace Automattic\WooCommerce\Blocks\Domain\Services;
use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Assets\Api as AssetApi;
/**
* Service class to integrate Blocks with the Google Analytics extension,
*/
class GoogleAnalytics {
/**
* Instance of the asset API.
*
* @var AssetApi
*/
protected $asset_api;
/**
* Constructor.
*
* @param AssetApi $asset_api Instance of the asset API.
*/
public function __construct( AssetApi $asset_api ) {
if ( ! Package::feature()->is_experimental_build() ) {
return;
}
$this->asset_api = $asset_api;
$this->init();
}
/**
* Hook into WP.
*/
protected function init() {
add_action( 'init', array( $this, 'register_assets' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
add_filter( 'script_loader_tag', array( $this, 'async_script_loader_tags' ), 10, 3 );
}
/**
* Register scripts.
*/
public function register_assets() {
$this->asset_api->register_script( 'wc-blocks-google-analytics', 'build/wc-blocks-google-analytics.js', [ 'google-tag-manager' ] );
}
/**
* Enqueue the Google Tag Manager script if prerequisites are met.
*/
public function enqueue_scripts() {
$settings = $this->get_google_analytics_settings();
// Require tracking to be enabled with a valid GA ID.
if ( ! stristr( $settings['ga_id'], 'G-' ) || apply_filters( 'woocommerce_ga_disable_tracking', ! wc_string_to_bool( $settings['ga_event_tracking_enabled'] ) ) ) {
return;
}
if ( ! wp_script_is( 'google-tag-manager', 'registered' ) ) {
// phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion
wp_register_script( 'google-tag-manager', 'https://www.googletagmanager.com/gtag/js?id=' . $settings['ga_id'], [], null, false );
wp_add_inline_script(
'google-tag-manager',
"
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '" . esc_js( $settings['ga_id'] ) . "', { 'send_page_view': false });"
);
}
wp_enqueue_script( 'wc-blocks-google-analytics' );
}
/**
* Get settings from the GA integration extension.
*
* @return array
*/
private function get_google_analytics_settings() {
return wp_parse_args(
get_option( 'woocommerce_google_analytics_settings' ),
[
'ga_id' => '',
'ga_event_tracking_enabled' => 'no',
]
);
}
/**
* Add async to script tags with defined handles.
*
* @param string $tag HTML for the script tag.
* @param string $handle Handle of script.
* @param string $src Src of script.
* @return string
*/
public function async_script_loader_tags( $tag, $handle, $src ) {
if ( ! in_array( $handle, array( 'google-tag-manager' ), true ) ) {
return $tag;
}
// If script was output manually in wp_head, abort.
if ( did_action( 'woocommerce_gtag_snippet' ) ) {
return '';
}
return str_replace( '<script src', '<script async src', $tag );
}
}