Add Local Pickup event and Cart/Checkout page views events (https://github.com/woocommerce/woocommerce-blocks/pull/11225)

Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>
Co-authored-by: Seghir Nadir <nadir.seghir@gmail.com>
This commit is contained in:
Seghir Nadir 2023-10-23 12:23:38 +02:00 committed by GitHub
parent 70e727e1af
commit 8bf13a7f4f
12 changed files with 632 additions and 6 deletions

View File

@ -0,0 +1,2 @@
export const namespace = 'jetpack-woocommerce-analytics';
export const actionPrefix = 'experimental__woocommerce_blocks';

View File

@ -0,0 +1,185 @@
/**
* External dependencies
*/
import { Cart, isObject, objectHasProp } from '@woocommerce/types';
import { select } from '@wordpress/data';
import { getSetting } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import { STORE_KEY as CART_STORE_KEY } from '../../../data/cart/constants';
declare global {
interface Window {
// eslint-disable-next-line @typescript-eslint/naming-convention
_wca: {
// eslint-disable-next-line @typescript-eslint/ban-types
push: ( properties: Record< string, unknown > ) => void;
};
}
}
interface StorePageDetails {
id: number;
title: string;
permalink: string;
}
interface StorePages {
checkout: StorePageDetails;
cart: StorePageDetails;
myaccount: StorePageDetails;
privacy: StorePageDetails;
shop: StorePageDetails;
terms: StorePageDetails;
}
/**
* Check if the _wca object is valid and has a push property that is a function.
*
* @param wca {unknown} Object that might be a Jetpack WooCommerce Analytics object.
*/
// eslint-disable-next-line @typescript-eslint/ban-types
const isValidWCA = (
wca: unknown
): wca is { push: ( properties: Record< string, unknown > ) => void } => {
if ( ! isObject( wca ) || ! objectHasProp( wca, 'push' ) ) {
return false;
}
return typeof wca.push === 'function';
};
const registerActions = (): void => {
if ( ! isValidWCA( window._wca ) ) {
// eslint-disable-next-line no-useless-return
return;
}
// We will register actions here in a later PR.
};
document.addEventListener( 'DOMContentLoaded', () => {
registerActions();
} );
export const cleanUrl = ( link: string ) => {
const url = link.split( '?' )[ 0 ];
if ( url.charAt( url.length - 1 ) !== '/' ) {
return url + '/';
}
return url;
};
const maybeTrackCheckoutPageView = ( cart: Cart ) => {
const storePages = getSetting< StorePages >( 'storePages', {} );
if ( ! objectHasProp( storePages, 'checkout' ) ) {
return;
}
if (
cleanUrl( storePages?.checkout?.permalink ) !==
cleanUrl( window.location.href )
) {
return;
}
if ( ! isValidWCA( window._wca ) ) {
return;
}
const checkoutData = getSetting< Record< string, unknown > >(
'wc-blocks-jetpack-woocommerce-analytics_cart_checkout_info',
{}
);
window._wca.push( {
_en: 'woocommerceanalytics_checkout_view',
products_count: cart.items.length,
order_value: cart.totals.total_price,
products: JSON.stringify(
cart.items.map( ( item ) => {
return {
pp: item.totals.line_total,
pq: item.quantity,
pi: item.id,
pn: item.name,
};
} )
),
...checkoutData,
} );
};
const maybeTrackCartPageView = ( cart: Cart ) => {
const storePages = getSetting< StorePages >( 'storePages', {} );
if ( ! objectHasProp( storePages, 'cart' ) ) {
return;
}
if (
cleanUrl( storePages?.cart?.permalink ) !==
cleanUrl( window.location.href )
) {
return;
}
if ( ! isValidWCA( window._wca ) ) {
return;
}
const checkoutData = getSetting< Record< string, unknown > >(
'wc-blocks-jetpack-woocommerce-analytics_cart_checkout_info',
{}
);
window._wca.push( {
_en: 'woocommerceanalytics_cart_view',
products_count: cart.items.length,
order_value: cart.totals.total_price,
products: JSON.stringify(
cart.items.map( ( item ) => {
return {
pp: item.totals.line_total,
pq: item.quantity,
pi: item.id,
pn: item.name,
pt: item.type,
};
} )
),
...checkoutData,
} );
};
const maybeTrackOrderReceivedPageView = () => {
const orderReceivedProps = getSetting(
'wc-blocks-jetpack-woocommerce-analytics_order_received_properties',
false
);
if ( ! orderReceivedProps || ! isValidWCA( window._wca ) ) {
return;
}
window._wca.push( {
_en: 'woocommerceanalytics_order_confirmation_view',
...orderReceivedProps,
} );
};
document.addEventListener( 'DOMContentLoaded', () => {
const store = select( CART_STORE_KEY );
// If the store doesn't load, we aren't on a cart/checkout block page, so maybe it's order received page.
if ( ! store ) {
maybeTrackOrderReceivedPageView();
return;
}
const hasCartLoaded = store.hasFinishedResolution( 'getCartTotals' );
if ( hasCartLoaded ) {
maybeTrackCartPageView( store.getCartData() );
maybeTrackCheckoutPageView( store.getCartData() );
}
} );

View File

@ -0,0 +1,21 @@
/**
* Internal dependencies
*/
import { cleanUrl } from '../index';
describe( 'WooCommerce Analytics', () => {
describe( 'cleanUrl', () => {
it( 'returns a clean URL with a trailing slash', () => {
expect( cleanUrl( 'https://test.com?test=1' ) ).toEqual(
'https://test.com/'
);
expect( cleanUrl( '' ) ).toEqual( '/' );
expect( cleanUrl( 'https://test.com/' ) ).toEqual(
'https://test.com/'
);
expect( cleanUrl( 'https://test.com' ) ).toEqual(
'https://test.com/'
);
} );
} );
} );

View File

@ -39,6 +39,7 @@ export const previewCart: CartResponse = {
{
key: '1',
id: 1,
type: 'simple',
quantity: 2,
catalog_visibility: 'visible',
name: __( 'Beanie', 'woo-gutenberg-products-block' ),
@ -120,6 +121,7 @@ export const previewCart: CartResponse = {
{
key: '2',
id: 2,
type: 'simple',
quantity: 1,
catalog_visibility: 'visible',
name: __( 'Cap', 'woo-gutenberg-products-block' ),

View File

@ -123,6 +123,7 @@ export type CatalogVisibility = 'catalog' | 'hidden' | 'search' | 'visible';
export interface CartItem {
key: string;
id: number;
type: string;
quantity: number;
catalog_visibility: CatalogVisibility;
quantity_limits: {

View File

@ -227,6 +227,8 @@ const entries = {
'./assets/js/extensions/google-analytics/index.ts',
'wc-shipping-method-pickup-location':
'./assets/js/extensions/shipping-methods/pickup-location/index.js',
'wc-blocks-jetpack-woocommerce-analytics':
'./assets/js/extensions/jetpack/woocommerce-analytics/index.ts',
},
editor: {
'wc-blocks-classic-template-revert-button':

View File

@ -8,6 +8,7 @@ use Automattic\WooCommerce\Blocks\BlockPatterns;
use Automattic\WooCommerce\Blocks\BlockTemplatesController;
use Automattic\WooCommerce\Blocks\BlockTypesController;
use Automattic\WooCommerce\Blocks\Domain\Services\CreateAccount;
use Automattic\WooCommerce\Blocks\Domain\Services\JetpackWooCommerceAnalytics;
use Automattic\WooCommerce\Blocks\Domain\Services\Notices;
use Automattic\WooCommerce\Blocks\Domain\Services\DraftOrders;
use Automattic\WooCommerce\Blocks\Domain\Services\FeatureGating;
@ -131,6 +132,7 @@ class Bootstrap {
$this->container->get( CreateAccount::class )->init();
$this->container->get( ShippingController::class )->init();
$this->container->get( TasksController::class )->init();
$this->container->get( JetpackWooCommerceAnalytics::class )->init();
// Load assets in admin and on the frontend.
if ( ! $is_rest ) {
@ -364,6 +366,15 @@ class Bootstrap {
return new GoogleAnalytics( $asset_api );
}
);
$this->container->register(
JetpackWooCommerceAnalytics::class,
function( Container $container ) {
$asset_api = $container->get( AssetApi::class );
$asset_data_registry = $container->get( AssetDataRegistry::class );
$block_templates_controller = $container->get( BlockTemplatesController::class );
return new JetpackWooCommerceAnalytics( $asset_api, $asset_data_registry, $block_templates_controller );
}
);
$this->container->register(
Notices::class,
function( Container $container ) {

View File

@ -0,0 +1,390 @@
<?php
namespace Automattic\WooCommerce\Blocks\Domain\Services;
use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry;
use Automattic\WooCommerce\Blocks\Assets\Api as AssetApi;
use Automattic\WooCommerce\Blocks\BlockTemplatesController;
use Automattic\WooCommerce\Blocks\Package;
use WC_Tracks;
/**
* Service class to integrate Blocks with the Jetpack WooCommerce Analytics extension,
*/
class JetpackWooCommerceAnalytics {
/**
* Instance of the asset API.
*
* @var AssetApi
*/
protected $asset_api;
/**
* Instance of the asset data registry.
*
* @var AssetDataRegistry
*/
protected $asset_data_registry;
/**
* Instance of the block templates controller.
*
* @var BlockTemplatesController
*/
protected $block_templates_controller;
/**
* Whether the required Jetpack WooCommerce Analytics classes are available.
*
* @var bool
*/
protected $is_compatible;
/**
* Constructor.
*
* @param AssetApi $asset_api Instance of the asset API.
* @param AssetDataRegistry $asset_data_registry Instance of the asset data registry.
* @param BlockTemplatesController $block_templates_controller Instance of the block templates controller.
*/
public function __construct( AssetApi $asset_api, AssetDataRegistry $asset_data_registry, BlockTemplatesController $block_templates_controller ) {
$this->asset_api = $asset_api;
$this->asset_data_registry = $asset_data_registry;
$this->block_templates_controller = $block_templates_controller;
}
/**
* Hook into WP.
*/
public function init() {
add_action( 'init', array( $this, 'check_compatibility' ) );
add_action( 'rest_pre_serve_request', array( $this, 'track_local_pickup' ), 10, 4 );
$is_rest = wc()->is_rest_api_request();
if ( ! $is_rest ) {
add_action( 'init', array( $this, 'init_if_compatible' ), 20 );
}
}
/**
* Gets product categories or varation attributes as a formatted concatenated string
*
* @param object $product WC_Product.
* @return string
*/
public function get_product_categories_concatenated( $product ) {
if ( ! $product instanceof WC_Product ) {
return '';
}
$variation_data = $product->is_type( 'variation' ) ? wc_get_product_variation_attributes( $product->get_id() ) : '';
if ( is_array( $variation_data ) && ! empty( $variation_data ) ) {
$line = wc_get_formatted_variation( $variation_data, true );
} else {
$out = array();
$categories = get_the_terms( $product->get_id(), 'product_cat' );
if ( $categories ) {
foreach ( $categories as $category ) {
$out[] = $category->name;
}
}
$line = implode( '/', $out );
}
return $line;
}
/**
* Gather relevant product information. Taken from Jetpack WooCommerce Analytics Module.
*
* @param \WC_Product $product product.
* @return array
*/
public function get_product_details( $product ) {
return array(
'id' => $product->get_id(),
'name' => $product->get_title(),
'category' => $this->get_product_categories_concatenated( $product ),
'price' => $product->get_price(),
'type' => $product->get_type(),
);
}
/**
* Save the order received page view event properties to the asset data registry. The front end will consume these
* later.
*
* @param int $order_id The order ID.
*
* @return void
*/
public function output_order_received_page_view_properties( $order_id ) {
$order = wc_get_order( $order_id );
$product_data = wp_json_encode(
array_map(
function( $item ) {
$product = wc_get_product( $item->get_product_id() );
$product_details = $this->get_product_details( $product );
return array(
'pi' => $product_details['id'],
'pq' => $item->get_quantity(),
'pt' => $product_details['type'],
'pn' => $product_details['name'],
'pc' => $product_details['category'],
'pp' => $product_details['price'],
);
},
$order->get_items()
)
);
$properties = $this->get_cart_checkout_info();
$properties['products'] = $product_data;
$this->asset_data_registry->add( 'wc-blocks-jetpack-woocommerce-analytics_order_received_properties', $properties );
}
/**
* Check compatibility with Jetpack WooCommerce Analytics.
*
* @return void
*/
public function check_compatibility() {
// Require Jetpack WooCommerce Analytics to be available.
$this->is_compatible = class_exists( 'Jetpack_WooCommerce_Analytics_Universal', false ) &&
class_exists( 'Jetpack_WooCommerce_Analytics', false ) &&
\Jetpack_WooCommerce_Analytics::should_track_store();
}
/**
* Initialize if compatible.
*/
public function init_if_compatible() {
if ( ! $this->is_compatible ) {
return;
}
$this->register_assets();
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'register_script_data' ) );
add_action( 'woocommerce_thankyou', array( $this, 'output_order_received_page_view_properties' ) );
}
/**
* Register scripts.
*/
public function register_assets() {
if ( ! $this->is_compatible ) {
return;
}
$asset_file = include Package::get_path() . 'build/wc-blocks-jetpack-woocommerce-analytics.asset.php';
if ( is_array( $asset_file['dependencies'] ) ) {
$this->asset_api->register_script( 'wc-blocks-jetpack-woocommerce-analytics', 'build/wc-blocks-jetpack-woocommerce-analytics.js', array_merge( array( 'wc-blocks' ), $asset_file['dependencies'] ) );
}
}
/**
* Enqueue the Google Tag Manager script if prerequisites are met.
*/
public function enqueue_scripts() {
// Additional check here before finally enqueueing the scripts. Done late here because checking these earlier fails.
if ( ! is_cart() && ! is_checkout() ) {
return;
}
wp_enqueue_script( 'wc-blocks-jetpack-woocommerce-analytics' );
}
/**
* Enqueue the Google Tag Manager script if prerequisites are met.
*/
public function register_script_data() {
$this->asset_data_registry->add( 'wc-blocks-jetpack-woocommerce-analytics_cart_checkout_info', $this->get_cart_checkout_info() );
}
/**
* Get the current user id
*
* @return int
*/
private function get_user_id() {
if ( is_user_logged_in() ) {
$blogid = \Jetpack::get_option( 'id' );
$userid = get_current_user_id();
return $blogid . ':' . $userid;
}
return 'null';
}
/**
* Default event properties which should be included with all events.
*
* @return array Array of standard event props.
*/
public function get_common_properties() {
if ( ! class_exists( 'Jetpack' ) || ! is_callable( array( 'Jetpack', 'get_option' ) ) ) {
return array();
}
return array(
'blog_id' => \Jetpack::get_option( 'id' ),
'ui' => $this->get_user_id(),
'url' => home_url(),
'woo_version' => WC()->version,
);
}
/**
* Get info about the cart & checkout pages, in particular whether the store is using shortcodes or Gutenberg blocks.
* This info is cached in a transient.
*
* @return array
*/
public function get_cart_checkout_info() {
$transient_name = 'woocommerce_blocks_jetpack_woocommerce_analytics_cart_checkout_info_cache';
$info = get_transient( $transient_name );
// Return cached data early to prevent additional processing, the transient lasts for 1 day.
if ( false !== $info ) {
return $info;
}
$cart_template = null;
$checkout_template = null;
$cart_template_id = null;
$checkout_template_id = null;
$templates = $this->block_templates_controller->get_block_templates( array( 'cart', 'checkout' ) );
$guest_checkout = ucfirst( get_option( 'woocommerce_enable_guest_checkout', 'No' ) );
$create_account = ucfirst( get_option( 'woocommerce_enable_signup_and_login_from_checkout', 'No' ) );
foreach ( $templates as $template ) {
if ( 'cart' === $template->slug ) {
$cart_template_id = ( $template->id );
continue;
}
if ( 'checkout' === $template->slug ) {
$checkout_template_id = ( $template->id );
}
}
// Get the template and its contents from the IDs we found above.
if ( function_exists( 'get_block_template' ) ) {
$cart_template = get_block_template( $cart_template_id );
$checkout_template = get_block_template( $checkout_template_id );
}
if ( function_exists( 'gutenberg_get_block_template' ) ) {
$cart_template = get_block_template( $cart_template_id );
$checkout_template = get_block_template( $checkout_template_id );
}
// Update the info transient with data we got from the templates, if the site isn't using WC Blocks we
// won't be doing this so no concern about overwriting.
// Sites that load this code will be loading it on a page using the relevant block, but we still need to check
// the other page to see if it's using the block or shortcode.
$info = array(
'cart_page_contains_cart_block' => str_contains( $cart_template->content, '<!-- wp:woocommerce/cart' ),
'cart_page_contains_cart_shortcode' => str_contains( $cart_template->content, '[woocommerce_cart]' ),
'checkout_page_contains_checkout_block' => str_contains( $checkout_template->content, '<!-- wp:woocommerce/checkout' ),
'checkout_page_contains_checkout_shortcode' => str_contains( $checkout_template->content, '[woocommerce_checkout]' ),
'additional_blocks_on_cart_page' => $this->get_additional_blocks(
$cart_template->content,
array( 'woocommerce/cart' )
),
'additional_blocks_on_checkout_page' => $this->get_additional_blocks(
$checkout_template->content,
array( 'woocommerce/checkout' )
),
'device' => wp_is_mobile() ? 'mobile' : 'desktop',
'guest_checkout' => 'Yes' === $guest_checkout ? 'Yes' : 'No',
'create_account' => 'Yes' === $create_account ? 'Yes' : 'No',
'store_currency' => get_woocommerce_currency(),
);
set_transient( $transient_name, $info, DAY_IN_SECONDS );
return array_merge( $this->get_common_properties(), $info );
}
/**
* Get the additional blocks used in a post or template.
*
* @param string $content The post content.
* @param array $exclude The blocks to exclude.
*
* @return array The additional blocks.
*/
private function get_additional_blocks( $content, $exclude = array() ) {
$parsed_blocks = parse_blocks( $content );
return $this->get_nested_blocks( $parsed_blocks, $exclude );
}
/**
* Get the nested blocks from a block array.
*
* @param array $blocks The blocks array to find nested blocks inside.
* @param string[] $exclude Blocks to exclude, won't find nested blocks within any of the supplied blocks.
*
* @return array
*/
private function get_nested_blocks( $blocks, $exclude = array() ) {
if ( ! is_array( $blocks ) ) {
return array();
}
$additional_blocks = array();
foreach ( $blocks as $block ) {
if ( ! isset( $block['blockName'] ) ) {
continue;
}
if ( in_array( $block['blockName'], $exclude, true ) ) {
continue;
}
if ( is_array( $block['innerBlocks'] ) ) {
$additional_blocks = array_merge( $additional_blocks, self::get_nested_blocks( $block['innerBlocks'], $exclude ) );
}
$additional_blocks[] = $block['blockName'];
}
return $additional_blocks;
}
/**
* Track local pickup settings changes via Store API
*
* @param bool $served Whether the request has already been served.
* @param \WP_REST_Response $result The response object.
* @param \WP_REST_Request $request The request object.
* @return bool
*/
public function track_local_pickup( $served, $result, $request ) {
if ( '/wp/v2/settings' !== $request->get_route() ) {
return $served;
}
// Param name here comes from the show_in_rest['name'] value when registering the setting.
if ( ! $request->get_param( 'pickup_location_settings' ) && ! $request->get_param( 'pickup_locations' ) ) {
return $served;
}
if ( ! $this->is_compatible ) {
return $served;
}
$event_name = 'local_pickup_save_changes';
$settings = $request->get_param( 'pickup_location_settings' );
$locations = $request->get_param( 'pickup_locations' );
$data = array(
'local_pickup_enabled' => 'yes' === $settings['enabled'] ? true : false,
'title' => __( 'Local Pickup', 'woo-gutenberg-products-block' ) === $settings['title'],
'price' => '' === $settings['cost'] ? true : false,
'cost' => '' === $settings['cost'] ? 0 : $settings['cost'],
'taxes' => $settings['tax_status'],
'total_pickup_locations' => count( $locations ),
'pickup_locations_enabled' => count(
array_filter(
$locations,
function( $location ) {
return $location['enabled']; }
)
),
);
WC_Tracks::record_event( $event_name, $data );
return $served;
}
}

View File

@ -50,6 +50,7 @@ class CartItemSchema extends ItemSchema {
return [
'key' => $cart_item['key'],
'id' => $product->get_id(),
'type' => $product->get_type(),
'quantity' => wc_stock_amount( $cart_item['quantity'] ),
'quantity_limits' => (object) ( new QuantityLimits() )->get_cart_item_quantity_limits( $cart_item ),
'name' => $this->prepare_html_response( $product->get_title() ),

View File

@ -19,6 +19,12 @@ abstract class ItemSchema extends ProductSchema {
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'type' => [
'description' => __( 'The item type.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'id' => [
'description' => __( 'The item product or variation ID.', 'woo-gutenberg-products-block' ),
'type' => 'integer',

View File

@ -2,12 +2,12 @@
## Table of Contents <!-- omit in toc -->
- [List Cart Items](#list-cart-items)
- [Single Cart Item](#single-cart-item)
- [Add Cart Item](#add-cart-item)
- [Edit Single Cart Item](#edit-single-cart-item)
- [Delete Single Cart Item](#delete-single-cart-item)
- [Delete All Cart Items](#delete-all-cart-items)
- [List Cart Items](#list-cart-items)
- [Single Cart Item](#single-cart-item)
- [Add Cart Item](#add-cart-item)
- [Edit Single Cart Item](#edit-single-cart-item)
- [Delete Single Cart Item](#delete-single-cart-item)
- [Delete All Cart Items](#delete-all-cart-items)
## List Cart Items
@ -29,6 +29,7 @@ curl "https://example-store.com/wp-json/wc/store/v1/cart/items"
"key": "c74d97b01eae257e44aa9d5bade97baf",
"id": 16,
"quantity": 1,
"type": "simple",
"quantity_limits": {
"minimum": 1,
"maximum": 1,
@ -108,6 +109,7 @@ curl "https://example-store.com/wp-json/wc/store/v1/cart/items"
"key": "e03e407f41901484125496b5ec69a76f",
"id": 29,
"quantity": 1,
"type": "variation",
"quantity_limits": {
"minimum": 1,
"maximum": 9999,

View File

@ -82,6 +82,7 @@ class CartItems extends ControllerTestCase {
0 => array(
'key' => $this->keys[0],
'id' => $this->products[0]->get_id(),
'type' => $this->products[0]->get_type(),
'name' => $this->products[0]->get_name(),
'sku' => $this->products[0]->get_sku(),
'permalink' => $this->products[0]->get_permalink(),
@ -113,6 +114,7 @@ class CartItems extends ControllerTestCase {
array(
'key' => $this->keys[0],
'id' => $this->products[0]->get_id(),
'type' => $this->products[0]->get_type(),
'name' => $this->products[0]->get_name(),
'sku' => $this->products[0]->get_sku(),
'permalink' => $this->products[0]->get_permalink(),
@ -256,6 +258,7 @@ class CartItems extends ControllerTestCase {
$this->assertArrayHasKey( 'key', $data );
$this->assertArrayHasKey( 'id', $data );
$this->assertArrayHasKey( 'type', $data );
$this->assertArrayHasKey( 'quantity', $data );
$this->assertArrayHasKey( 'name', $data );
$this->assertArrayHasKey( 'sku', $data );