WooCommerce Code Reference

class-wc-products-tracking.php

Source code

<?php
/**
 * WooCommerce Import Tracking
 *
 * @package WooCommerce\Tracks
 */

use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;

defined( 'ABSPATH' ) || exit;

require_once WC_ABSPATH . 'includes/admin/wc-admin-functions.php';

/**
 * This class adds actions to track usage of WooCommerce Products.
 */
class WC_Products_Tracking {

	/**
	 * Tracks source.
	 */
	const TRACKS_SOURCE = 'product-legacy-editor';

	/**
	 * Init tracking.
	 */
	public function init() {
		add_action( 'load-edit.php', array( $this, 'track_products_view' ), 10 );
		add_action( 'load-edit-tags.php', array( $this, 'track_categories_and_tags_view' ), 10, 2 );
		add_action( 'edit_post', array( $this, 'track_product_updated' ), 10, 2 );
		add_action( 'woocommerce_new_product', array( $this, 'track_product_published' ), 10, 3 );
		add_action( 'woocommerce_update_product', array( $this, 'track_product_published' ), 10, 3 );
		add_action( 'created_product_cat', array( $this, 'track_product_category_created' ) );
		add_action( 'edited_product_cat', array( $this, 'track_product_category_updated' ) );
		add_action( 'add_meta_boxes_product', array( $this, 'track_product_updated_client_side' ), 10 );
		add_action( 'admin_enqueue_scripts', array( $this, 'possibly_add_product_tracking_scripts' ) );
		add_action( 'admin_enqueue_scripts', array( $this, 'possibly_add_product_import_scripts' ) );
		add_action( 'admin_enqueue_scripts', array( $this, 'possibly_add_attribute_tracking_scripts' ) );
		add_action( 'admin_enqueue_scripts', array( $this, 'possibly_add_tag_tracking_scripts' ) );
	}

	/**
	 * Send a Tracks event when the Products page is viewed.
	 */
	public function track_products_view() {
		// We only record Tracks event when no `_wp_http_referer` query arg is set, since
		// when searching, the request gets sent from the browser twice,
		// once with the `_wp_http_referer` and once without it.
		//
		// Otherwise, we would double-record the view and search events.

		// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification
		if (
			isset( $_GET['post_type'] )
			&& 'product' === wp_unslash( $_GET['post_type'] )
			&& ! isset( $_GET['_wp_http_referer'] )
		) {
			// phpcs:enable

			WC_Tracks::record_event( 'products_view' );

			// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification
			if (
				isset( $_GET['s'] )
				&& 0 < strlen( sanitize_text_field( wp_unslash( $_GET['s'] ) ) )
			) {
				// phpcs:enable

				WC_Tracks::record_event( 'products_search' );
			}
		}
	}

	/**
	 * Send a Tracks event when the Products Categories and Tags page is viewed.
	 */
	public function track_categories_and_tags_view() {
		// We only record Tracks event when no `_wp_http_referer` query arg is set, since
		// when searching, the request gets sent from the browser twice,
		// once with the `_wp_http_referer` and once without it.
		//
		// Otherwise, we would double-record the view and search events.

		// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification
		if (
			isset( $_GET['post_type'] )
			&& 'product' === wp_unslash( $_GET['post_type'] )
			&& isset( $_GET['taxonomy'] )
			&& ! isset( $_GET['_wp_http_referer'] )
		) {
			$taxonomy = wp_unslash( $_GET['taxonomy'] );
			// phpcs:enable

			if ( 'product_cat' === $taxonomy ) {
				WC_Tracks::record_event( 'categories_view' );
			} elseif ( 'product_tag' === $taxonomy ) {
				WC_Tracks::record_event( 'tags_view' );
			}

			// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification
			if (
				isset( $_GET['s'] )
				&& 0 < strlen( sanitize_text_field( wp_unslash( $_GET['s'] ) ) )
			) {
				// phpcs:enable

				if ( 'product_cat' === $taxonomy ) {
					WC_Tracks::record_event( 'categories_search' );
				} elseif ( 'product_tag' === $taxonomy ) {
					WC_Tracks::record_event( 'tags_search' );
				}
			}
		}
	}

	/**
	 * Send a Tracks event when a product is updated.
	 *
	 * @param int    $product_id Product id.
	 * @param object $post       WordPress post.
	 */
	public function track_product_updated( $product_id, $post ) {
		if ( 'product' !== $post->post_type ) {
			return;
		}

		/* phpcs:disable WooCommerce.Commenting.CommentHooks.MissingHookComment */
		$source     = apply_filters( 'woocommerce_product_source', self::is_importing() ? 'import' : self::TRACKS_SOURCE );
		$properties = array(
			'product_id' => $product_id,
			'source'     => $source,
		);
		/* phpcs: enable */

		WC_Tracks::record_event( 'product_edit', $properties );
	}

	/**
	 * Track the Update button being clicked on the client side.
	 * This is needed because `track_product_updated` (using the `edit_post`
	 * hook) is called in response to a number of other triggers.
	 *
	 * @param WP_Post $post The post, not used.
	 */
	public function track_product_updated_client_side( $post ) {
		wc_enqueue_js(
			"
			if ( $( 'h1.wp-heading-inline' ).text().trim() === '" . __( 'Edit product', 'woocommerce' ) . "') {
				var initialStockValue = $( '#_stock' ).val();
				var isBlockEditor = false;
				var child_element = '#publish';

				if ( $( '.block-editor' ).length !== 0 && $( '.block-editor' )[0] ) {
	    			isBlockEditor = true;
				}

				if ( isBlockEditor ) {
					child_element = '.editor-post-publish-button';
				}

				$( '#wpwrap' ).on( 'click', child_element, function() {
					var description_value  = '';
					var tagsText = '';
					var currentStockValue = $( '#_stock' ).val();

					function getProductTypeOptions() {
						const productTypeOptionsCheckboxes = $( 'input[type=\"checkbox\"][data-product-type-option-id]' );
						const productTypeOptions = productTypeOptionsCheckboxes.map( function() {
							return {
								id: $( this ).data( 'product-type-option-id' ),
								isEnabled: $( this ).is( ':checked' ),
							};
						} ).get();
						return productTypeOptions;
					}

					function getProductTypeOptionsString( productTypeOptions ) {
						return productTypeOptions
							.filter( productTypeOption => productTypeOption.isEnabled )
							.map( productTypeOption => productTypeOption.id )
							.join( ', ' );
					}

					const productTypeOptions = getProductTypeOptions();
					const productTypeOptionsString = getProductTypeOptionsString( productTypeOptions );

					if ( ! isBlockEditor ) {
						tagsText          = $( '[name=\"tax_input[product_tag]\"]' ).val();
						if ( $( '#content' ).is( ':visible' ) ) {
							description_value = $( '#content' ).val();
						} else if ( typeof tinymce === 'object' && tinymce.get( 'content' ) ) {
							description_value = tinymce.get( 'content' ).getContent();
						}
					} else {
						description_value  = $( '.block-editor-rich-text__editable' ).text();
					}

					// We can't just check the number of '.woocommerce_attribute' elements because
					// there might be empty ones, which get stripped out when saved. So, we'll check
					// whether the name and values have been filled out.
					var numberOfAttributes = $( '.woocommerce_attribute' ).filter( function () {
						var attributeElement = $( this );
						var attributeName = attributeElement.find( 'input.attribute_name' ).val();
						var attributeValues = attributeElement.find( 'textarea[name^=\"attribute_values\"]' ).val();

						return attributeName !== '' && attributeValues !== '';
					} ).length;

					var properties = {
						attributes:				     numberOfAttributes,
						categories:				     $( '[name=\"tax_input[product_cat][]\"]:checked' ).length,
						cross_sells:			     $( '#crosssell_ids option' ).length ? 'Yes' : 'No',
						description:			     description_value.trim() !== '' ? 'Yes' : 'No',
						enable_reviews:			     $( '#comment_status' ).is( ':checked' ) ? 'Yes' : 'No',
						is_virtual:				     $( '#_virtual' ).is( ':checked' ) ? 'Yes' : 'No',
						is_block_editor:		     isBlockEditor,
						is_downloadable:		     $( '#_downloadable' ).is( ':checked' ) ? 'Yes' : 'No',
						manage_stock:			     $( '#_manage_stock' ).is( ':checked' ) ? 'Yes' : 'No',
						menu_order:				     parseInt( $( '#menu_order' ).val(), 10 ) !== 0 ? 'Yes' : 'No',
						product_gallery:		     $( '#product_images_container .product_images > li' ).length,
						product_image:			     $( '#_thumbnail_id' ).val() > 0 ? 'Yes' : 'No',
						product_type:			     $( '#product-type' ).val(),
						product_type_options_string: productTypeOptionsString,
						purchase_note:			     $( '#_purchase_note' ).val().length ? 'yes' : 'no',
						sale_price:				     $( '#_sale_price' ).val() ? 'yes' : 'no',
						short_description:		     $( '#excerpt' ).val().length ? 'yes' : 'no',
						stock_quantity_update:	     ( initialStockValue != currentStockValue ) ? 'Yes' : 'No',
						tags:					     tagsText.length > 0 ? tagsText.split( ',' ).length : 0,
						upsells:				     $( '#upsell_ids option' ).length ? 'Yes' : 'No',
						weight:					     $( '#_weight' ).val() ? 'Yes' : 'No',
					};
					window.wcTracks.recordEvent( 'product_update', properties );
				} );
			}
			"
		);
	}

	/**
	 * Get the IDs of the possible product type options.
	 *
	 * @return array
	 */
	private static function get_possible_product_type_options_ids() {
		$product_type_options_ids =
			array_values(
				array_map(
					function ( $product_type_option ) {
						return $product_type_option['id'];
					},
					/* phpcs:disable WooCommerce.Commenting.CommentHooks.MissingHookComment */
					apply_filters(
						'product_type_options',
						wc_get_default_product_type_options(),
					)
					/* phpcs: enable */
				)
			);

		return $product_type_options_ids;
	}

	/**
	 * Get the product type options for a product.
	 *
	 * @param int $post_id The ID of the product.
	 *
	 * @return array
	 */
	private static function get_product_type_options( $post_id ) {
		$possible_product_type_options_ids = self::get_possible_product_type_options_ids();
		$post_meta                         = get_post_meta( $post_id );
		$product_type_options              = array();

		foreach ( $possible_product_type_options_ids as $product_type_option_id ) {
			$product_type_options[ $product_type_option_id ] = isset( $post_meta[ $product_type_option_id ] ) ? $post_meta[ $product_type_option_id ][0] : 'no';
		}

		return $product_type_options;
	}

	/**
	 * Get a comma-separated string of the product type options that are enabled.
	 *
	 * @param array $product_type_options The product type options.
	 *
	 * @return string
	 */
	private static function get_product_type_options_string( $product_type_options ) {
		return implode(
			', ',
			array_keys(
				array_filter(
					$product_type_options,
					function ( $is_enabled ) {
						return 'yes' === $is_enabled;
					}
				)
			)
		);
	}

	/**
	 * Send a Tracks event when a product is published.
	 *
	 * @param int        $product_id  Product ID.
	 * @param WC_Product $product     Product object.
	 * @param array      $changes     Product changes.
	 */
	public function track_product_published( $product_id, $product, $changes = null ) {
		if (
			! isset( $product ) ||
			'product' !== $product->post_type ||
			'publish' !== $product->get_status( 'edit' ) ||
			( $changes && ! isset( $changes['status'] ) )
		) {
			return;
		}

		$product_type_options        = self::get_product_type_options( $product_id );
		$product_type_options_string = self::get_product_type_options_string( $product_type_options );

		$properties = array(
			'attributes'           => count( $product->get_attributes() ),
			'categories'           => count( $product->get_category_ids() ),
			'cross_sells'          => ! empty( $product->get_cross_sell_ids() ) ? 'yes' : 'no',
			'description'          => $product->get_description() ? 'yes' : 'no',
			'dimensions'           => wc_format_dimensions( $product->get_dimensions( false ) ) !== 'N/A' ? 'yes' : 'no',
			'enable_reviews'       => $product->get_reviews_allowed() ? 'yes' : 'no',
			'is_downloadable'      => $product->is_downloadable() ? 'yes' : 'no',
			'is_virtual'           => $product->is_virtual() ? 'yes' : 'no',
			'manage_stock'         => $product->get_manage_stock() ? 'yes' : 'no',
			'menu_order'           => $product->get_menu_order() ? 'yes' : 'no',
			'product_id'           => $product_id,
			'product_gallery'      => count( $product->get_gallery_image_ids() ),
			'product_image'        => $product->get_image_id() ? 'yes' : 'no',
			'product_type'         => $product->get_type(),
			'product_type_options' => $product_type_options_string,
			'purchase_note'        => $product->get_purchase_note() ? 'yes' : 'no',
			'sale_price'           => $product->get_sale_price() ? 'yes' : 'no',
			'source'               => apply_filters( 'woocommerce_product_source', self::is_importing() ? 'import' : self::TRACKS_SOURCE ),
			'short_description'    => $product->get_short_description() ? 'yes' : 'no',
			'tags'                 => count( $product->get_tag_ids() ),
			'upsells'              => ! empty( $product->get_upsell_ids() ) ? 'yes' : 'no',
			'weight'               => $product->get_weight() ? 'yes' : 'no',
		);

		WC_Tracks::record_event( 'product_add_publish', $properties );
	}

	/**
	 * Send a Tracks event when a product category is created.
	 *
	 * @param int $category_id Category ID.
	 */
	public function track_product_category_created( $category_id ) {
		// phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		// Only track category creation from the edit product screen or the
		// category management screen (which both occur via AJAX).
		if (
			! Constants::is_defined( 'DOING_AJAX' ) ||
			empty( $_POST['action'] ) ||
			(
				// Product Categories screen.
				'add-tag' !== $_POST['action'] &&
				// Edit Product screen.
				'add-product_cat' !== $_POST['action']
			)
		) {
			return;
		}

		$category        = get_term( $category_id, 'product_cat' );
		$parent_category = $category->parent > 0 ? 'Other' : 'None';
		if ( $category->parent > 0 ) {
			$parent = get_term( $category_id, 'product_cat' );
			if ( 'uncategorized' === $parent->name ) {
				$parent_category = 'Uncategorized';
			}
		}
		$properties = array(
			'category_id'     => $category_id,
			'parent_id'       => $category->parent,
			'parent_category' => $parent_category,
			'page'            => ( 'add-tag' === $_POST['action'] ) ? 'categories' : 'product',
			'display_type'    => isset( $_POST['display_type'] ) ? wp_unslash( $_POST['display_type'] ) : '',
			'image'           => isset( $_POST['product_cat_thumbnail_id'] ) && '' !== $_POST['product_cat_thumbnail_id'] ? 'Yes' : 'No',
		);
		// phpcs:enable

		WC_Tracks::record_event( 'product_category_add', $properties );
	}

	/**
	 * Send a Tracks event when a product category is updated.
	 *
	 * @param int $category_id Category ID.
	 */
	public function track_product_category_updated( $category_id ) {
		// phpcs:disable WordPress.Security.NonceVerification.Missing
		// Only track category creation from the edit product screen or the
		// category management screen (which both occur via AJAX).
		if (
			empty( $_POST['action'] ) ||
			( 'editedtag' !== $_POST['action'] && 'inline-save-tax' !== $_POST['action'] )
		) {
			return;
		}
		// phpcs:enable

		WC_Tracks::record_event( 'product_category_update' );
	}

	/**
	 * Adds the tracking scripts for product filtering actions.
	 *
	 * @param string $hook Hook of the current page.
	 * @return string|boolean
	 */
	protected function get_product_screen( $hook ) {
		// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification
		if (
			'edit.php' === $hook &&
			isset( $_GET['post_type'] ) &&
			'product' === wp_unslash( $_GET['post_type'] )
		) {
			return 'list';
		}

		if (
			'post-new.php' === $hook &&
			isset( $_GET['post_type'] ) &&
			'product' === wp_unslash( $_GET['post_type'] )
		) {
			return 'new';
		}

		if (
			'post.php' === $hook &&
			isset( $_GET['post'] ) &&
			'product' === get_post_type( intval( $_GET['post'] ) )
		) {
			return 'edit';
		}

		if ( 'product_page_product_importer' === $hook ) {
			return 'import';
		}

		// phpcs:enable

		return false;
	}

	/**
	 * Adds the tracking scripts for product filtering actions.
	 *
	 * @param string $hook Page hook.
	 */
	public function possibly_add_product_tracking_scripts( $hook ) {
		$product_screen = $this->get_product_screen( $hook );
		if ( ! $product_screen ) {
			return;
		}

		WCAdminAssets::register_script( 'wp-admin-scripts', 'product-tracking', false );
		wp_localize_script(
			'wc-admin-product-tracking',
			'productScreen',
			array(
				'name' => $product_screen,
			)
		);
	}

	/**
	 * Adds the tracking scripts for product setting pages.
	 *
	 * @param string $hook Page hook.
	 */
	public function possibly_add_product_import_scripts( $hook ) {
		$product_screen = $this->get_product_screen( $hook );

		if ( 'import' !== $product_screen ) {
			return;
		}

		WCAdminAssets::register_script( 'wp-admin-scripts', 'product-import-tracking', false );

	}

	/**
	 * Adds the tracking scripts for product attributes filtering actions.
	 *
	 * @param string $hook Page hook.
	 */
	public function possibly_add_attribute_tracking_scripts( $hook ) {
		// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification
		if (
			'product_page_product_attributes' !== $hook ||
			! isset( $_GET['page'] ) ||
			'product_attributes' !== wp_unslash( $_GET['page'] )
		) {
			return;
		}
		// phpcs:enable

		WCAdminAssets::register_script( 'wp-admin-scripts', 'attributes-tracking', false );
	}

	/**
	 * Adds the tracking scripts for tags and categories filtering actions.
	 *
	 * @param string $hook Page hook.
	 */
	public function possibly_add_tag_tracking_scripts( $hook ) {
		// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification
		if (
			'edit-tags.php' !== $hook ||
			! isset( $_GET['post_type'] ) ||
			'product' !== wp_unslash( $_GET['post_type'] )
		) {
			return;
		}
		// phpcs:enable

		// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		if (
			isset( $_GET['taxonomy'] ) &&
			'product_tag' === wp_unslash( $_GET['taxonomy'] )
		) {
			WCAdminAssets::register_script( 'wp-admin-scripts', 'tags-tracking', false );
			return;
		}

		// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		if (
			isset( $_GET['taxonomy'] ) &&
			'product_cat' === wp_unslash( $_GET['taxonomy'] )
		) {
			WCAdminAssets::register_script( 'wp-admin-scripts', 'category-tracking', false );
			return;
		}
		WCAdminAssets::register_script( 'wp-admin-scripts', 'add-term-tracking', false );
	}

	/**
	 * Check if the current process is importing products.
	 *
	 * @return bool True if importing, false otherwise.
	 */
	private function is_importing() {
		// phpcs:disable WordPress.Security.NonceVerification.Missing
		// Check if the current request is a product import.
		if ( isset( $_POST['action'] ) && 'woocommerce_do_ajax_product_import' === $_POST['action'] ) {
			return true;
		}
		return false;
		// phpcs:enable
	}
}