From 79add4b54941b922253701240c313797fd4653b7 Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:16:31 +0200 Subject: [PATCH 1/3] Added: WPML Multilingual & Multicurrency for WooCommerce (WooCommerce Multilingual) compatibility and fixed some bugs related to multilingual ecommerce. --- readme.txt | 7 + src/Admin/Provisioning.php | 2 + src/Admin/Provisioning/Integrations.php | 197 +++++++++++++++--- src/Admin/Provisioning/Integrations/EDD.php | 2 +- .../Provisioning/Integrations/WooCommerce.php | 2 +- src/Admin/Upgrades.php | 47 +++++ src/Helpers.php | 174 ++++++++++++++++ src/Integrations/EDD.php | 5 + src/Integrations/WooCommerce.php | 90 ++++++-- .../Admin/Provisioning/IntegrationsTest.php | 104 +++++++++ 10 files changed, 578 insertions(+), 52 deletions(-) diff --git a/readme.txt b/readme.txt index 435515cd..13d57114 100644 --- a/readme.txt +++ b/readme.txt @@ -280,6 +280,13 @@ Please make sure you make a backup of your database before updating any version = 2.6.2 = * Added: TranslatePress "different domain per language" (Multiple Domains) compatibility. Each language domain can be mapped to its own Plausible Analytics dashboard, just like WPML. +* Added: WPML Multilingual & Multicurrency for WooCommerce (WooCommerce Multilingual) compatibility. +* Fixed: the view product goal is now created for the URL each language is served under, e.g. /es/producto*, instead of for the default language's URL only. This covers both the language prefix (/es/) + and a translated product base (WooCommerce Multilingual's Store URLs), so product pageviews are no longer missed in any language but the default one. Because a funnel step holds a single goal, the default language's path remains the first step of the purchase funnel, and every other language gets a goal of its own. This applies to WooCommerce as well as Easy Digital Downloads, on WPML as well as TranslatePress. +* Improved: the WooCommerce add to cart, remove from cart, start checkout, and complete purchase events now contain a currency property. Multicurrency plugins convert all amounts to the currency the +visitor is shopping in, which Plausible Analytics would otherwise sum up as if they were all in the same currency. The complete purchase event uses the currency the order was placed in. +* Improved: on multilingual sites, those same WooCommerce events now contain a language property, because translated products are separate posts, i.e., each language has its own product ID and product + name. * Fixed: in "domain per language" mode, the proxy endpoint and the locally cached tracker script were loaded from the default domain, which could cause CORS errors on the other language domains. * Fixed: Plugin Tokens for language domains whose key contains an underscore (e.g., TranslatePress' nl_NL) were stored under a stripped key, which meant they were never read back. * Tested with WP 7.1. diff --git a/src/Admin/Provisioning.php b/src/Admin/Provisioning.php index bbfde3b2..e2f8967b 100644 --- a/src/Admin/Provisioning.php +++ b/src/Admin/Provisioning.php @@ -22,7 +22,9 @@ class Provisioning { const CUSTOM_PROPERTIES = [ 'cart_total', 'cart_total_items', + 'currency', 'id', + 'language', 'name', 'price', 'product_id', diff --git a/src/Admin/Provisioning/Integrations.php b/src/Admin/Provisioning/Integrations.php index b7723e25..c335d6e7 100644 --- a/src/Admin/Provisioning/Integrations.php +++ b/src/Admin/Provisioning/Integrations.php @@ -9,6 +9,7 @@ namespace Plausible\Analytics\WP\Admin\Provisioning; use Plausible\Analytics\WP\Admin\Provisioning; +use Plausible\Analytics\WP\Helpers; class Integrations { /** @@ -49,57 +50,169 @@ private function init() { } /** + * @since 2.6.2 Added $post_type, to allow translating the Pageview goal's path. + * * @param array $event_goals * @param string $funnel_name + * @param string $post_type The integration's product post type, e.g. 'product'. * * @return void * @codeCoverageIgnore We don't want to test the API. */ - public function create_integration_funnel( $event_goals, $funnel_name ) { - $goals = []; + public function create_integration_funnel( $event_goals, $funnel_name, $post_type = '' ) { $all_ids = $this->provisioning->normalize_option( get_option( 'plausible_analytics_enhanced_measurements_goal_ids', [] ) ); - foreach ( $event_goals as $event_key => $event_goal ) { - if ( $event_key === 'remove-from-cart' ) { - foreach ( $this->provisioning->get_clients() as $key => $client ) { - $all_ids = $this->provisioning->create_goals( - [ $this->provisioning->create_goal_request( $event_goal ) ], - $client, - $key, - $all_ids - ); + foreach ( $this->provisioning->get_clients() as $key => $client ) { + $goals = []; + /** + * Goals which shouldn't (or can't) be part of the funnel. + */ + $extra_goals = []; + + foreach ( $event_goals as $event_key => $event_goal ) { + if ( $event_key === 'remove-from-cart' ) { + $extra_goals[] = $this->provisioning->create_goal_request( $event_goal ); + + continue; } - continue; - } + if ( $event_key === 'purchase' ) { + $currency = \Plausible\Analytics\WP\Integrations::is_edd_active() ? edd_get_currency() : get_woocommerce_currency(); + $goals[] = $this->provisioning->create_goal_request( $event_goal, 'Revenue', $currency ); - if ( $event_key === 'purchase' ) { - $currency = \Plausible\Analytics\WP\Integrations::is_edd_active() ? edd_get_currency() : get_woocommerce_currency(); - $goals[] = $this->provisioning->create_goal_request( $event_goal, 'Revenue', $currency ); + continue; + } - continue; - } + if ( $event_key === 'view-product' ) { + $paths = $this->get_pageview_goal_paths( $this->get_goal_path( $event_goal ), $key, $post_type ); + + /** + * A funnel step holds one goal, so the default language's path is the one that ends up in the + * funnel. The other languages get a goal of their own. + */ + $goals[] = $this->provisioning->create_goal_request( $event_goal, 'Pageview', null, array_shift( $paths ) ); + + foreach ( $paths as $path ) { + $extra_goals[] = $this->provisioning->create_goal_request( $event_goal, 'Pageview', null, $path ); + } - if ( $event_key === 'view-product' ) { - $path = preg_replace( '/^.*?\//', '', $event_goal ); - $goals[] = $this->provisioning->create_goal_request( $event_goal, 'Pageview', null, '/' . $path ); + continue; + } - continue; + $goals[] = $this->provisioning->create_goal_request( $event_goal ); } - $goals[] = $this->provisioning->create_goal_request( $event_goal ); - } + if ( ! empty( $extra_goals ) ) { + $all_ids = $this->provisioning->create_goals( $extra_goals, $client, $key, $all_ids ); + } - foreach ( $this->provisioning->get_clients() as $key => $client ) { $all_ids = $this->provisioning->create_funnel( $funnel_name, $goals, $client, $key, $all_ids ); } } + /** + * Returns the path of a "Visit /some/path*" goal. + * + * @since 2.6.2 + * + * @param string $event_goal + * + * @return string + */ + private function get_goal_path( $event_goal ) { + return '/' . preg_replace( '/^.*?\//', '', $event_goal ); + } + + /** + * Returns the Pageview goal paths for $path: one for each language that's served on $domain_key's domain. + * + * Multilingual plugins serve translated content under a language prefix (/es/product/...) and, when the post type's + * base slug is translated (e.g. WooCommerce Multilingual's Store URLs), under a translated base (/producto/...). + * A goal for the default language's path would never match those pageviews. + * + * The default language's path is always the first element. + * + * @since 2.6.2 + * + * @param string $path E.g. /product* + * @param string $domain_key The Language Domain the goal is created for. + * @param string $post_type The post type $path's base slug belongs to. + * + * @return array + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + private function get_pageview_goal_paths( $path, $domain_key, $post_type ) { + $languages = Helpers::get_active_languages(); + + if ( empty( $languages ) ) { + return [ $path ]; + } + + $default = Helpers::get_default_language(); + + if ( Helpers::is_language_per_domain_mode() ) { + // Each domain serves exactly one language, from its own root. + $languages = [ $domain_key === 'default' ? $default : $domain_key ]; + } else { + $languages = array_merge( [ $default ], array_diff( $languages, [ $default ] ) ); + } + + $paths = []; + + foreach ( array_filter( $languages ) as $language ) { + $paths[] = $this->localize_goal_path( $path, $language, $post_type ); + } + + $paths = array_values( array_unique( array_filter( $paths ) ) ); + + return ! empty( $paths ) ? $paths : [ $path ]; + } + + /** + * Rewrites $path to the URL the given language is served under, e.g. /product* > /es/producto*. + * + * @since 2.6.2 + * + * @param string $path + * @param string $language_code + * @param string $post_type + * + * @return string + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + private function localize_goal_path( $path, $language_code, $post_type ) { + $home_path = trim( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ), '/' ); + $relative = ltrim( $path, '/' ); + + // On multisite subdirectory installs the site's path precedes the language prefix. + if ( $home_path !== '' && strpos( $relative, "$home_path/" ) === 0 ) { + $relative = substr( $relative, strlen( $home_path ) + 1 ); + } else { + $home_path = ''; + } + + $suffix = ''; + + if ( substr( $relative, -1 ) === '*' ) { + $suffix = '*'; + $relative = substr( $relative, 0, -1 ); + } + + $slug = Helpers::translate_url_slug( trim( $relative, '/' ), $language_code, $post_type ); + $parts = array_filter( [ $home_path, Helpers::get_language_url_prefix( $language_code ), $slug ] ); + + return '/' . implode( '/', $parts ) . $suffix; + } + /** * Deletes the integration-specific goals using the stored goal IDs. * + * @since 2.6.2 Also deletes the Pageview goals created for the other languages. + * * @param object $integration The integration object containing event goals to be deleted. * * @return void @@ -112,10 +225,11 @@ public function delete_integration_goals( $integration ) { ); foreach ( $this->provisioning->get_clients() as $domain_key => $client ) { - $goals = $all_ids[ $domain_key ] ?? []; + $goals = $all_ids[ $domain_key ] ?? []; + $event_goals = $this->add_localized_event_goals( (array) $integration->event_goals, $domain_key, $integration->post_type ?? '' ); foreach ( $goals as $id => $name ) { - $key = $this->provisioning->array_search_contains( $name, $integration->event_goals ); + $key = $this->provisioning->array_search_contains( $name, $event_goals ); if ( $key ) { $client->delete_goal( $id ); @@ -132,4 +246,33 @@ public function delete_integration_goals( $integration ) { update_option( 'plausible_analytics_enhanced_measurements_goal_ids', $all_ids ); } + + /** + * Adds the view-product goal of every other language to $event_goals, so the goals created for those languages + * are recognized (and deleted) too. + * + * @since 2.6.2 + * + * @param array $event_goals + * @param string $domain_key + * @param string $post_type + * + * @return array + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + private function add_localized_event_goals( $event_goals, $domain_key, $post_type ) { + if ( empty( $event_goals['view-product'] ) ) { + return $event_goals; + } + + $event_goal = $event_goals['view-product']; + $path = $this->get_goal_path( $event_goal ); + + foreach ( $this->get_pageview_goal_paths( $path, $domain_key, $post_type ) as $i => $localized_path ) { + $event_goals[ "view-product-$i" ] = str_replace( $path, $localized_path, $event_goal ); + } + + return $event_goals; + } } diff --git a/src/Admin/Provisioning/Integrations/EDD.php b/src/Admin/Provisioning/Integrations/EDD.php index d5b9bf3c..2ba35df1 100644 --- a/src/Admin/Provisioning/Integrations/EDD.php +++ b/src/Admin/Provisioning/Integrations/EDD.php @@ -58,7 +58,7 @@ public function maybe_create_edd_funnel( $old_settings, $settings ) { $edd = new Integrations\EDD( false ); - $this->integrations->create_integration_funnel( $edd->event_goals, __( 'EDD Purchase Funnel', 'plausible-analytics' ) ); + $this->integrations->create_integration_funnel( $edd->event_goals, __( 'EDD Purchase Funnel', 'plausible-analytics' ), $edd->post_type ); } /** diff --git a/src/Admin/Provisioning/Integrations/WooCommerce.php b/src/Admin/Provisioning/Integrations/WooCommerce.php index 55e70b40..55155af4 100644 --- a/src/Admin/Provisioning/Integrations/WooCommerce.php +++ b/src/Admin/Provisioning/Integrations/WooCommerce.php @@ -59,7 +59,7 @@ public function maybe_create_woocommerce_funnel( $old_settings, $settings ) { $woocommerce = new Integrations\WooCommerce( false ); - $this->integrations->create_integration_funnel( $woocommerce->event_goals, __( 'Woo Purchase Funnel', 'plausible-analytics' ) ); + $this->integrations->create_integration_funnel( $woocommerce->event_goals, __( 'Woo Purchase Funnel', 'plausible-analytics' ), $woocommerce->post_type ); } /** diff --git a/src/Admin/Upgrades.php b/src/Admin/Upgrades.php index 481bb6b3..d1b54059 100644 --- a/src/Admin/Upgrades.php +++ b/src/Admin/Upgrades.php @@ -106,6 +106,10 @@ public function run() { $this->upgrade_to_260(); } + if ( version_compare( $plausible_analytics_version, '2.6.2', '<' ) ) { + $this->upgrade_to_262(); + } + // Add required upgrade routines for future versions here. } @@ -427,6 +431,49 @@ private function upgrade_to_260() { update_option( 'plausible_analytics_version', '2.6.0' ); } + /** + * If a supported multilingual plugin (WPML, with or without WPML Multilingual & Multicurrency for WooCommerce, or + * TranslatePress) is active alongside WooCommerce or Easy Digital Downloads, and Ecommerce Revenue is enabled, + * recreate the goals and custom properties after updating the plugin. + * + * That way, existing installs get the Pageview goals for the languages they serve, e.g. /es/producto*, and the + * currency and language custom properties, without having to save their settings first. + * + * @since v2.6.2 + * + * @return void + * + * @codeCoverageIgnore because all we'd be doing is testing the Plugins API. + */ + public function upgrade_to_262() { + $is_ecommerce = \Plausible\Analytics\WP\Integrations::is_wc_active() || \Plausible\Analytics\WP\Integrations::is_edd_active(); + + if ( Helpers::get_multilang_plugin() && $is_ecommerce && + EnhancedMeasurements::is_enabled( EnhancedMeasurements::ECOMMERCE_REVENUE ) ) { + $provisioning = new Provisioning(); + $integrations = new Integrations( $provisioning ); + $settings = Helpers::get_settings(); + + /** + * These routines bail when no Plugin Token is entered yet, and each funnel is only created for the + * ecommerce plugin that's actually active. If no token is entered, the goals are created as soon as one + * is, so there's no need to run this routine again. + * + * @see Provisioning::maybe_provision_on_connect() Creates the goals and custom properties as soon as a + * Plugin Token is entered. + * @see Provisioning\Integrations\WooCommerce::init() Both funnels are (re)created on + * update_option_plausible_analytics_settings, i.e. when that token is saved. + * @see Provisioning\Integrations\EDD::init() + */ + $provisioning->maybe_create_custom_properties( [], $settings ); + + ( new Provisioning\Integrations\WooCommerce( $integrations ) )->maybe_create_woocommerce_funnel( [], $settings ); + ( new Provisioning\Integrations\EDD( $integrations ) )->maybe_create_edd_funnel( [], $settings ); + } + + update_option( 'plausible_analytics_version', '2.6.2' ); + } + /** * Display a notice to CE users that haven't entered an API token yet. * diff --git a/src/Helpers.php b/src/Helpers.php index 68a17572..99c6c870 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -22,6 +22,36 @@ class Helpers { const MULTILANG_PLUGIN_TRANSLATEPRESS = 'translatepress'; + /** + * Returns the language codes of all languages served by the active multilingual plugin. + * + * @since v2.6.2 + * + * @return array Empty when no supported multilingual plugin is active. + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + public static function get_active_languages() { + $languages = []; + + switch ( static::get_multilang_plugin() ) { + case static::MULTILANG_PLUGIN_WPML: + $active = apply_filters( 'wpml_active_languages', null, [ 'skip_missing' => 0 ] ); + + if ( is_array( $active ) ) { + $languages = array_keys( $active ); + } + break; + + case static::MULTILANG_PLUGIN_TRANSLATEPRESS: + $settings = get_option( 'trp_settings', [] ); + $languages = $settings['translation-languages'] ?? []; + break; + } + + return (array) apply_filters( 'plausible_analytics_active_languages', array_values( (array) $languages ) ); + } + /** * Returns the API token. * @@ -225,6 +255,31 @@ protected static function get_translatepress_current_language() { return ! empty( $TRP_LANGUAGE ) ? $TRP_LANGUAGE : null; } + /** + * Returns the language code that's currently being served. + * + * @since v2.6.2 + * + * @return string Empty when no supported multilingual plugin is active. + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + public static function get_current_language() { + $language = ''; + + switch ( static::get_multilang_plugin() ) { + case static::MULTILANG_PLUGIN_WPML: + $language = apply_filters( 'wpml_current_language', null ); + break; + + case static::MULTILANG_PLUGIN_TRANSLATEPRESS: + $language = static::get_translatepress_current_language(); + break; + } + + return (string) apply_filters( 'plausible_analytics_current_language', (string) $language ); + } + /** * Returns the name of the current Plausible domain. * @@ -570,6 +625,98 @@ public static function get_language_domains() { return apply_filters( 'plausible_analytics_language_domains', $domains ); } + /** + * Returns the URL prefix the given language is served under e.g., 'es' for https://example.com/es/. + * + * Returns an empty string when the language isn't served under a prefix i.e., in "domain per language" mode, in + * "language as parameter" mode, or for the default language. + * + * @since v2.6.2 + * + * @param string $language_code + * + * @return string + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + public static function get_language_url_prefix( $language_code ) { + if ( empty( $language_code ) || static::is_language_per_domain_mode() ) { + return ''; + } + + $prefix = ''; + + switch ( static::get_multilang_plugin() ) { + case static::MULTILANG_PLUGIN_WPML: + /** + * wpml_permalink converts a URL to the requested language, whichever negotiation type is in use, so + * whatever it adds in front of the home URL's path is the prefix we're after. + */ + $prefix = static::get_home_relative_path( apply_filters( 'wpml_permalink', home_url( '/' ), $language_code ) ); + break; + + case static::MULTILANG_PLUGIN_TRANSLATEPRESS: + $settings = get_option( 'trp_settings', [] ); + $slugs = $settings['url-slugs'] ?? []; + + if ( $language_code !== static::get_default_language() || + ( $settings['add-subdirectory-to-default-language'] ?? 'no' ) === 'yes' ) { + $prefix = $slugs[ $language_code ] ?? $language_code; + } + break; + } + + return (string) apply_filters( 'plausible_analytics_language_url_prefix', trim( (string) $prefix, '/' ), $language_code ); + } + + /** + * Returns $url's path, relative to the home URL's path, without leading/trailing slashes. + * + * @since v2.6.2 + * + * @param string $url + * + * @return string + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + protected static function get_home_relative_path( $url ) { + $path = trim( (string) wp_parse_url( $url, PHP_URL_PATH ), '/' ); + $home = trim( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ), '/' ); + + if ( $home !== '' && ( $path === $home || strpos( $path, "$home/" ) === 0 ) ) { + $path = trim( substr( $path, strlen( $home ) ), '/' ); + } + + return $path; + } + + /** + * Returns the default language code of the active multilingual plugin. + * + * @since v2.6.2 + * + * @return string + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + public static function get_default_language() { + $language = ''; + + switch ( static::get_multilang_plugin() ) { + case static::MULTILANG_PLUGIN_WPML: + $language = apply_filters( 'wpml_default_language', null ); + break; + + case static::MULTILANG_PLUGIN_TRANSLATEPRESS: + $settings = get_option( 'trp_settings', [] ); + $language = $settings['default-language'] ?? ''; + break; + } + + return (string) apply_filters( 'plausible_analytics_default_language', (string) $language ); + } + /** * Get the name of the active multilang plugin. * @@ -662,6 +809,33 @@ public static function main_script_is_registered() { return wp_script_is( 'plausible-analytics', 'registered' ); } + /** + * Translates a URL slug, e.g., WooCommerce's product base, to the given language. + * + * WPML's String Translation registers these bases as 'URL slug: {post type}' in the 'WordPress' domain, which is + * what WooCommerce Multilingual's Store URLs use too. TranslatePress doesn't translate post type bases. + * + * @since v2.6.2 + * + * @param string $slug The untranslated slug, e.g. 'product'. + * @param string $language_code + * @param string $post_type The post-type the slug belongs to, e.g. 'product'. + * + * @return string + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + public static function translate_url_slug( $slug, $language_code, $post_type = '' ) { + $translated = $slug; + + if ( ! empty( $slug ) && ! empty( $language_code ) && ! empty( $post_type ) && + static::get_multilang_plugin() === static::MULTILANG_PLUGIN_WPML ) { + $translated = apply_filters( 'wpml_translate_single_string', $slug, 'WordPress', "URL slug: $post_type", $language_code ); + } + + return (string) apply_filters( 'plausible_analytics_translated_url_slug', $translated, $slug, $language_code, $post_type ); + } + /** * @param string $option_name * @param array|string|int $option_value diff --git a/src/Integrations/EDD.php b/src/Integrations/EDD.php index 58fa1497..b20d3937 100644 --- a/src/Integrations/EDD.php +++ b/src/Integrations/EDD.php @@ -19,6 +19,11 @@ class EDD { public $event_goals = []; + /** + * @var string The post type the view-product goal's path is built from. + */ + public $post_type = 'download'; + /** * Build class. * diff --git a/src/Integrations/WooCommerce.php b/src/Integrations/WooCommerce.php index 58f3a023..8da9f5b1 100644 --- a/src/Integrations/WooCommerce.php +++ b/src/Integrations/WooCommerce.php @@ -11,6 +11,7 @@ use Plausible\Analytics\WP\Admin\Provisioning; use Plausible\Analytics\WP\EnhancedMeasurements; +use Plausible\Analytics\WP\Helpers; use Plausible\Analytics\WP\Integrations; use Plausible\Analytics\WP\Proxy; use WC_Cart; @@ -22,6 +23,11 @@ class WooCommerce { */ public $event_goals = []; + /** + * @var string The post type the view-product goal's path is built from. + */ + public $post_type = 'product'; + /** * Build class. * @@ -162,15 +168,17 @@ public function track_add_to_cart( $product, $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 ), - ] + $this->add_common_props( + [ + '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 ); @@ -207,6 +215,36 @@ protected function get_wc_cart() { return WC()->cart; } + /** + * Adds the properties that apply to all events to $props. + * + * The currency is added because multicurrency plugins e.g., WPML Multilingual & Multicurrency for WooCommerce, + * convert all amounts to the currency the visitor is shopping in. Without it, Plausible Analytics would sum up + * amounts in different currencies as if they were the same. + * + * The language is only added on multilingual sites because translated products are separate posts, i.e. they have + * their own product ID and name. + * + * @since 2.6.2 + * + * @param array $props + * @param string $currency Defaults to the currency the visitor is shopping in. + * + * @return array + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + private function add_common_props( $props, $currency = '' ) { + $props['currency'] = $currency ?: get_woocommerce_currency(); + $language = Helpers::get_current_language(); + + if ( ! empty( $language ) ) { + $props['language'] = $language; + } + + return $props; + } + /** * Track add to cart actions by direct link, e.g. ?product_type=download&add-to-cart=1&quantity=1 * @@ -244,12 +282,14 @@ public function track_entered_checkout() { $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' => $this->add_common_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 ); @@ -279,6 +319,8 @@ public function track_purchase( $order_id ) { 'amount' => (string) $order->get_total(), 'currency' => $order->get_currency(), ], + // The order's currency can differ from the currency the visitor is browsing in right now. + 'props' => $this->add_common_props( [], $order->get_currency() ), ] ); $label = $this->event_goals['purchase']; @@ -314,14 +356,16 @@ public function track_remove_cart_item( $cart_item_key, $cart ) { $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 ), - ] + $this->add_common_props( + [ + '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 ); diff --git a/tests/integration/Admin/Provisioning/IntegrationsTest.php b/tests/integration/Admin/Provisioning/IntegrationsTest.php index 94ffdce9..a58aa6ba 100644 --- a/tests/integration/Admin/Provisioning/IntegrationsTest.php +++ b/tests/integration/Admin/Provisioning/IntegrationsTest.php @@ -44,4 +44,108 @@ public function testDeleteIntegrationGoals() { $this->removeAction( 'update_option_plausible_analytics_settings', 'maybe_delete_', 11 ); } } + + /** + * Every language should get a Pageview goal for the URL it's served under, with the default language first, + * because that's the one that ends up in the funnel. + * + * @see Integrations::get_pageview_goal_paths() + * @return void + * @throws \ReflectionException + */ + public function testGetPageviewGoalPaths() { + $this->withLanguages( + function ( $paths ) { + $this->assertEquals( [ '/product*', '/es/producto*', '/nl/product*' ], $paths( '/product*', 'default' ) ); + } + ); + } + + /** + * In "domain per language" mode each domain serves exactly one language, from its own root, so no prefix should be + * added and only the goal for that domain's language should be created. + * + * @see Integrations::get_pageview_goal_paths() + * @return void + * @throws \ReflectionException + */ + public function testGetPageviewGoalPathsInLanguagePerDomainMode() { + $per_domain = '__return_true'; + + add_filter( 'plausible_analytics_language_per_domain_mode', $per_domain ); + + try { + $this->withLanguages( + function ( $paths ) { + $this->assertEquals( [ '/producto*' ], $paths( '/product*', 'es' ) ); + $this->assertEquals( [ '/product*' ], $paths( '/product*', 'default' ) ); + } + ); + } finally { + remove_filter( 'plausible_analytics_language_per_domain_mode', $per_domain ); + } + } + + /** + * Without a multilingual plugin, the path should be left alone. + * + * @see Integrations::get_pageview_goal_paths() + * @return void + * @throws \ReflectionException + */ + public function testGetPageviewGoalPathsWithoutLanguages() { + $this->assertEquals( [ '/product*' ], $this->getPageviewGoalPaths()( '/product*', 'default' ) ); + } + + /** + * Runs $test with a site that serves Spanish and Dutch besides its default language, of which Spanish has a + * translated product base. + * + * @param callable $test Receives a callable which returns the Pageview goal paths for a path/domain key. + * + * @return void + * @throws \ReflectionException + */ + private function withLanguages( $test ) { + $languages = function () { + return [ 'en', 'es', 'nl' ]; + }; + $default = function () { + return 'en'; + }; + $prefix = function ( $prefix, $language ) { + return $language === 'en' ? '' : $language; + }; + $slug = function ( $translated, $slug, $language ) { + return $language === 'es' && $slug === 'product' ? 'producto' : $translated; + }; + + add_filter( 'plausible_analytics_active_languages', $languages ); + add_filter( 'plausible_analytics_default_language', $default ); + add_filter( 'plausible_analytics_language_url_prefix', $prefix, 10, 2 ); + add_filter( 'plausible_analytics_translated_url_slug', $slug, 10, 3 ); + + try { + $test( $this->getPageviewGoalPaths() ); + } finally { + remove_filter( 'plausible_analytics_active_languages', $languages ); + remove_filter( 'plausible_analytics_default_language', $default ); + remove_filter( 'plausible_analytics_language_url_prefix', $prefix, 10 ); + remove_filter( 'plausible_analytics_translated_url_slug', $slug, 10 ); + } + } + + /** + * @return callable + * @throws \ReflectionException + */ + private function getPageviewGoalPaths() { + $integrations = ( new \ReflectionClass( Integrations::class ) )->newInstanceWithoutConstructor(); + $method = new \ReflectionMethod( Integrations::class, 'get_pageview_goal_paths' ); + $method->setAccessible( true ); + + return function ( $path, $domain_key ) use ( $method, $integrations ) { + return $method->invoke( $integrations, $path, $domain_key, 'product' ); + }; + } } From 1c2c5461d4034f5716fc1b7b240e6df5c7fd2d4b Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:26:11 +0200 Subject: [PATCH 2/3] Fixed tests. --- .../Integrations/WooCommerceTest.php | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/tests/integration/Integrations/WooCommerceTest.php b/tests/integration/Integrations/WooCommerceTest.php index 8ba6830c..e982e9ae 100644 --- a/tests/integration/Integrations/WooCommerceTest.php +++ b/tests/integration/Integrations/WooCommerceTest.php @@ -20,6 +20,7 @@ public function testTrackEnteredCheckout() { when( 'is_checkout' )->justReturn( true ); when( 'is_wc_endpoint_url' )->justReturn( false ); when( 'wc_get_permalink_structure' )->justReturn( [ 'product_base' => 'product' ] ); + when( 'get_woocommerce_currency' )->justReturn( 'EUR' ); $cart_mock = $this->getMockBuilder( 'WC_Cart' )->setMethods( [ @@ -41,7 +42,7 @@ public function testTrackEnteredCheckout() { ->getMock(); $class->method( 'get_wc_cart' )->willReturn( $cart_mock ); - $this->expectOutputContains( '{"props":{"subtotal":10,"shipping":5,"tax":1,"total":"16.00"}}' ); + $this->expectOutputContains( '{"props":{"subtotal":10,"shipping":5,"tax":1,"total":"16.00","currency":"EUR"}}' ); $class->track_entered_checkout(); } @@ -69,8 +70,49 @@ public function testTrackPurchase() { when( 'wc_get_order' )->justReturn( $mock ); - $this->expectOutputContains( '{"revenue":{"amount":"10","currency":"EUR"}}' ); + $this->expectOutputContains( '{"revenue":{"amount":"10","currency":"EUR"},"props":{"currency":"EUR"}}' ); $class->track_purchase( 1 ); } + + /** + * On multilingual sites, the language the visitor is shopping in should be added to the event's properties, because + * translated products are separate posts, i.e., each language has its own product ID and product name. + * + * @see WooCommerce::track_purchase() + * @return void + */ + public function testTrackPurchaseAddsLanguage() { + when( 'wc_get_permalink_structure' )->justReturn( [ 'product_base' => 'product' ] ); + + $language = function () { + return 'nl'; + }; + + add_filter( 'plausible_analytics_current_language', $language ); + + try { + $class = new WooCommerce( false ); + $mock = $this->getMockBuilder( 'WC_Order' )->setMethods( + [ + 'get_meta', + 'get_total', + 'get_currency', + 'add_meta_data', + 'save', + ] + )->getMock(); + $mock->method( 'get_meta' )->willReturn( false ); + $mock->method( 'get_total' )->willReturn( 10 ); + $mock->method( 'get_currency' )->willReturn( 'EUR' ); + + when( 'wc_get_order' )->justReturn( $mock ); + + $this->expectOutputContains( '"props":{"currency":"EUR","language":"nl"}' ); + + $class->track_purchase( 1 ); + } finally { + remove_filter( 'plausible_analytics_current_language', $language ); + } + } } From 03b1da03f30a2e30568bc9edb7e1c6895783f46e Mon Sep 17 00:00:00 2001 From: Daan van den Bergh <18595395+Dan0sz@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:30:46 +0200 Subject: [PATCH 3/3] PHPDoc. --- src/Admin/Upgrades.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Admin/Upgrades.php b/src/Admin/Upgrades.php index d1b54059..86b56321 100644 --- a/src/Admin/Upgrades.php +++ b/src/Admin/Upgrades.php @@ -439,6 +439,9 @@ private function upgrade_to_260() { * That way, existing installs get the Pageview goals for the languages they serve, e.g. /es/producto*, and the * currency and language custom properties, without having to save their settings first. * + * This runs on init (@see Upgrades::__construct()), i.e. after WPML registered its language API on plugin load and + * booted on plugins_loaded, so the languages the Pageview goal paths are built from are available. + * * @since v2.6.2 * * @return void