path . $uri; } else { $uri = '/' . $uri; } $this->event_goals = [ // translators: %s: Product page URI pattern. 'view-product' => sprintf( __( 'Visit %s*', 'plausible-analytics' ), $uri ), 'add-to-cart' => __( 'Woo Add to Cart', 'plausible-analytics' ), 'remove-from-cart' => __( 'Woo Remove from Cart', 'plausible-analytics' ), 'checkout' => __( 'Woo Start Checkout', 'plausible-analytics' ), 'purchase' => __( 'Woo Complete Purchase', 'plausible-analytics' ), ]; $this->init( $init ); } /** * Filter and action hooks. * * @return void * * @codeCoverageIgnore */ private function init( $init ) { if ( ! $init ) { return; } /** * Adds required JS and classes. */ add_action( 'wp_enqueue_scripts', [ $this, 'add_js' ], 1 ); add_filter( 'woocommerce_store_api_add_to_cart_data', [ $this, 'add_http_referer' ], 10, 2 ); /** * Trigger tracking events. */ add_action( 'woocommerce_store_api_validate_add_to_cart', [ $this, 'track_add_to_cart' ], 10, 2 ); add_action( 'woocommerce_ajax_added_to_cart', [ $this, 'track_ajax_add_to_cart' ] ); /** @see \WC_Form_Handler::add_to_cart_action() runs on priority 20. We need to run before that, in case redirect is enabled. */ add_action( 'wp_loaded', [ $this, 'track_direct_add_to_cart' ], 19 ); add_action( 'woocommerce_remove_cart_item', [ $this, 'track_remove_cart_item' ], 10, 2 ); add_action( 'wp_head', [ $this, 'track_entered_checkout' ] ); add_action( 'woocommerce_thankyou', [ $this, 'track_purchase' ] ); } /** * A bit of a hacky approach to ensure the _wp_http_referer header is available to us when hitting the Proxy in @see self::track_remove_cart_item(). * * @see self::track_add_to_cart() * * @param $add_to_cart_data * * @param $request * * @return mixed * * @codeCoverageIgnore Because there's nothing to test here. */ public function add_http_referer( $add_to_cart_data, $request ) { $http_referer = $request->get_param( '_wp_http_referer' ); if ( ! empty( $http_referer ) ) { $_REQUEST['_wp_http_referer'] = sanitize_url( $http_referer ); } return $add_to_cart_data; } /** * Enqueue required JS in frontend. * * @return void * * @codeCoverageIgnore Because there's nothing to test here. */ public function add_js() { // Causes errors in checkout and isn't needed either way. if ( is_checkout() ) { return; // @codeCoverageIgnore } wp_enqueue_script( 'plausible-woocommerce-integration', PLAUSIBLE_ANALYTICS_PLUGIN_URL . 'assets/dist/js/plausible-woocommerce-integration.js', [], filemtime( PLAUSIBLE_ANALYTICS_PLUGIN_DIR . 'assets/dist/js/plausible-woocommerce-integration.js' ), [ 'in_footer' => true ], ); } /** * Track (non-Interactivity API i.e., AJAX) add to cart events. * * @param string|int $product_id ID of the product added to the cart. * * @return void * * @codeCoverageIgnore Because we can't test XHR requests here. */ public function track_ajax_add_to_cart( $product_id ) { $product = wc_get_product( $product_id ); $add_to_cart_data = [ 'id' => $product_id, 'quantity' => $_POST['quantity'] ?? 1, ]; $this->track_add_to_cart( $product, $add_to_cart_data ); } /** * Track regular (i.e., interactivity API) add to cart events. * * @param WC_Product $product General information about the product added to cart. * @param array $add_to_cart_data Cart data for the product added to the cart, e.g. quantity, variation ID, etc. * * @return void * * @codeCoverageIgnore Because we can't test XHR requests here. */ public function track_add_to_cart( $product, $add_to_cart_data ) { if ( ! $product ) { return; } $product_data = $this->clean_data( $product->get_data() ); $added_to_cart = $this->clean_data( $add_to_cart_data ); $cart = $this->get_wc_cart(); $props = apply_filters( 'plausible_analytics_woocommerce_add_to_cart_custom_properties', [ 'product_name' => $product_data['name'], 'product_id' => $added_to_cart['id'], 'quantity' => $added_to_cart['quantity'], 'price' => $product_data['price'], 'tax_class' => $product_data['tax_class'], 'cart_total_items' => count( $cart->get_cart_contents() ), 'cart_total' => $cart->get_total( null ), ] ); $proxy = new Proxy( false ); $proxy->do_request( $this->event_goals['add-to-cart'], null, null, $props ); } /** * Removes unneeded elements from the array. * * @param array $product Product Data. * * @return mixed * * @codeCoverageIgnore Because it can't be tested. */ private function clean_data( $product ) { foreach ( $product as $key => $value ) { if ( ! in_array( $key, Provisioning::CUSTOM_PROPERTIES ) ) { unset( $product[ $key ] ); } } return $product; } /** * A wrapper to keep our code testable. * * @return WC_Cart|null * * @codeCoverageIgnore */ protected function get_wc_cart() { return WC()->cart; } /** * Track add to cart actions by direct link, e.g. ?product_type=download&add-to-cart=1&quantity=1 * * @return void * * @codeCoverageIgnore Because we can't test XHR here. */ public function track_direct_add_to_cart() { if ( ! isset( $_REQUEST['add-to-cart'] ) || ! is_numeric( wp_unslash( $_REQUEST['add-to-cart'] ) ) ) { return; } $product_id = absint( wp_unslash( $_REQUEST['add-to-cart'] ) ); $product = wc_get_product( $product_id ); $quantity = isset( $_REQUEST['quantity'] ) ? absint( wp_unslash( $_REQUEST['quantity'] ) ) : 1; $this->track_add_to_cart( $product, [ 'id' => $product_id, 'quantity' => $quantity ] ); } /** * Tracks when a user enters the checkout process and sends event data to Plausible Analytics. * * This method checks if the current page is the checkout page. If it is, it collects relevant * cart information like subtotal, shipping, tax, and total, applies filters to allow custom properties, * encodes the data as JSON, and generates a JavaScript snippet to send the data to Plausible Analytics. * * @return void */ public function track_entered_checkout() { if ( ! is_checkout() || is_wc_endpoint_url( 'order-received' ) ) { return; // @codeCoverageIgnore } $cart = $this->get_wc_cart(); $props = apply_filters( 'plausible_analytics_woocommerce_entered_checkout_custom_properties', [ 'props' => [ 'subtotal' => $cart->get_subtotal(), 'shipping' => $cart->get_shipping_total(), 'tax' => $cart->get_total_tax(), 'total' => $cart->get_total( null ), ], ] ); $props = wp_json_encode( $props ); $label = $this->event_goals['checkout']; echo sprintf( Integrations::SCRIPT_WRAPPER, "window.plausible( '$label', $props )" ); } /** * Track WooCommerce purchase on thank you page. * * @param $order_id * * @return void */ public function track_purchase( $order_id ) { $order = wc_get_order( $order_id ); $is_tracked = $order->get_meta( Integrations::PURCHASE_TRACKED_META_KEY ); if ( $is_tracked ) { return; // @codeCoverageIgnore } $props = wp_json_encode( [ EnhancedMeasurements::ECOMMERCE_REVENUE => [ 'amount' => (string) $order->get_total(), 'currency' => $order->get_currency(), ], ] ); $label = $this->event_goals['purchase']; echo sprintf( Integrations::SCRIPT_WRAPPER, "window.plausible( '$label', $props )" ); $order->add_meta_data( Integrations::PURCHASE_TRACKED_META_KEY, true ); $order->save(); } /** * Track Remove from cart events. * * @param string $cart_item_key Key of item being removed from cart. * @param WC_Cart $cart Instance of the current cart. * * @return void * * @codeCoverageIgnore because we can't test XHR requests here. */ public function track_remove_cart_item( $cart_item_key, $cart ) { $cart_contents = $cart->get_cart_contents(); $item_removed_from_cart = $this->clean_data( $cart_contents[ $cart_item_key ] ?? [] ); $product = null; if ( isset( $item_removed_from_cart['product_id'] ) ) { $product = wc_get_product( $item_removed_from_cart['product_id'] ); } if ( ! $product ) { return; } $props = apply_filters( 'plausible_analytics_woocommerce_remove_cart_item_custom_properties', [ 'product_name' => $product->get_name(), 'product_id' => $item_removed_from_cart['product_id'], 'variation_id' => $item_removed_from_cart['variation_id'], 'quantity' => $item_removed_from_cart['quantity'], 'cart_total_items' => count( $cart_contents ), 'cart_total' => $cart->get_total( null ), ] ); $proxy = new Proxy( false ); $proxy->do_request( $this->event_goals['remove-from-cart'], null, null, $props ); } }