From 8bf13a7f4f27f10eeaa14282fe7f80ece822183b Mon Sep 17 00:00:00 2001 From: Seghir Nadir Date: Mon, 23 Oct 2023 12:23:38 +0200 Subject: [PATCH] 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 --- .../woocommerce-analytics/constants.ts | 2 + .../jetpack/woocommerce-analytics/index.ts | 185 +++++++++ .../woocommerce-analytics/test/index.ts | 21 + .../assets/js/previews/cart.ts | 2 + .../assets/js/types/type-defs/cart.ts | 1 + .../woocommerce-blocks/bin/webpack-entries.js | 2 + .../src/Domain/Bootstrap.php | 11 + .../Services/JetpackWooCommerceAnalytics.php | 390 ++++++++++++++++++ .../StoreApi/Schemas/V1/CartItemSchema.php | 1 + .../src/StoreApi/Schemas/V1/ItemSchema.php | 6 + .../src/StoreApi/docs/cart-items.md | 14 +- .../tests/php/StoreApi/Routes/CartItems.php | 3 + 12 files changed, 632 insertions(+), 6 deletions(-) create mode 100644 plugins/woocommerce-blocks/assets/js/extensions/jetpack/woocommerce-analytics/constants.ts create mode 100644 plugins/woocommerce-blocks/assets/js/extensions/jetpack/woocommerce-analytics/index.ts create mode 100644 plugins/woocommerce-blocks/assets/js/extensions/jetpack/woocommerce-analytics/test/index.ts create mode 100644 plugins/woocommerce-blocks/src/Domain/Services/JetpackWooCommerceAnalytics.php diff --git a/plugins/woocommerce-blocks/assets/js/extensions/jetpack/woocommerce-analytics/constants.ts b/plugins/woocommerce-blocks/assets/js/extensions/jetpack/woocommerce-analytics/constants.ts new file mode 100644 index 00000000000..88fa51b8a6a --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/extensions/jetpack/woocommerce-analytics/constants.ts @@ -0,0 +1,2 @@ +export const namespace = 'jetpack-woocommerce-analytics'; +export const actionPrefix = 'experimental__woocommerce_blocks'; diff --git a/plugins/woocommerce-blocks/assets/js/extensions/jetpack/woocommerce-analytics/index.ts b/plugins/woocommerce-blocks/assets/js/extensions/jetpack/woocommerce-analytics/index.ts new file mode 100644 index 00000000000..db84f07861c --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/extensions/jetpack/woocommerce-analytics/index.ts @@ -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() ); + } +} ); diff --git a/plugins/woocommerce-blocks/assets/js/extensions/jetpack/woocommerce-analytics/test/index.ts b/plugins/woocommerce-blocks/assets/js/extensions/jetpack/woocommerce-analytics/test/index.ts new file mode 100644 index 00000000000..4f4048c95b6 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/extensions/jetpack/woocommerce-analytics/test/index.ts @@ -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/' + ); + } ); + } ); +} ); diff --git a/plugins/woocommerce-blocks/assets/js/previews/cart.ts b/plugins/woocommerce-blocks/assets/js/previews/cart.ts index d5dceee95ef..27de1187cfb 100644 --- a/plugins/woocommerce-blocks/assets/js/previews/cart.ts +++ b/plugins/woocommerce-blocks/assets/js/previews/cart.ts @@ -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' ), diff --git a/plugins/woocommerce-blocks/assets/js/types/type-defs/cart.ts b/plugins/woocommerce-blocks/assets/js/types/type-defs/cart.ts index d0ecf0095b8..8ee9703cae3 100644 --- a/plugins/woocommerce-blocks/assets/js/types/type-defs/cart.ts +++ b/plugins/woocommerce-blocks/assets/js/types/type-defs/cart.ts @@ -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: { diff --git a/plugins/woocommerce-blocks/bin/webpack-entries.js b/plugins/woocommerce-blocks/bin/webpack-entries.js index 8dcb49987a8..91542d7c89f 100644 --- a/plugins/woocommerce-blocks/bin/webpack-entries.js +++ b/plugins/woocommerce-blocks/bin/webpack-entries.js @@ -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': diff --git a/plugins/woocommerce-blocks/src/Domain/Bootstrap.php b/plugins/woocommerce-blocks/src/Domain/Bootstrap.php index 4b40fd32ee6..58b1f13ac0e 100644 --- a/plugins/woocommerce-blocks/src/Domain/Bootstrap.php +++ b/plugins/woocommerce-blocks/src/Domain/Bootstrap.php @@ -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 ) { diff --git a/plugins/woocommerce-blocks/src/Domain/Services/JetpackWooCommerceAnalytics.php b/plugins/woocommerce-blocks/src/Domain/Services/JetpackWooCommerceAnalytics.php new file mode 100644 index 00000000000..0908813b64e --- /dev/null +++ b/plugins/woocommerce-blocks/src/Domain/Services/JetpackWooCommerceAnalytics.php @@ -0,0 +1,390 @@ +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, ' -- [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, diff --git a/plugins/woocommerce-blocks/tests/php/StoreApi/Routes/CartItems.php b/plugins/woocommerce-blocks/tests/php/StoreApi/Routes/CartItems.php index ca7ba6ace93..b963472acd1 100644 --- a/plugins/woocommerce-blocks/tests/php/StoreApi/Routes/CartItems.php +++ b/plugins/woocommerce-blocks/tests/php/StoreApi/Routes/CartItems.php @@ -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 );