diff --git a/readme.txt b/readme.txt index 435515cd..44231e01 100644 --- a/readme.txt +++ b/readme.txt @@ -279,7 +279,11 @@ Please make sure you make a backup of your database before updating any version == Changelog == = 2.6.2 = +* *Important!* This release rebuilds the WooCommerce (and EDD) purchase funnel for sites using WPML with a translated product base. If you built your own funnels using the same goals, please rebuild them after installing this update. * 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 now targets the URL each language is served under (e.g. /es/producto*), including a translated product base, so product pageviews are no longer missed in non-default languages. Existing purchase funnels whose first step targets a path that's no longer served are recreated. Works for WooCommerce and Easy Digital Downloads, on WPML and TranslatePress. +* Improved: the WooCommerce add-to-cart, remove-from-cart, checkout and purchase events now include a currency property, so they can be broken down by the currency they were placed in. The purchase event uses the currency the order was placed in. * 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..412dfd39 100644 --- a/src/Admin/Provisioning.php +++ b/src/Admin/Provisioning.php @@ -22,6 +22,7 @@ class Provisioning { const CUSTOM_PROPERTIES = [ 'cart_total', 'cart_total_items', + 'currency', 'id', 'name', 'price', diff --git a/src/Admin/Provisioning/Integrations.php b/src/Admin/Provisioning/Integrations.php index b7723e25..d2d58c0a 100644 --- a/src/Admin/Provisioning/Integrations.php +++ b/src/Admin/Provisioning/Integrations.php @@ -9,6 +9,8 @@ namespace Plausible\Analytics\WP\Admin\Provisioning; use Plausible\Analytics\WP\Admin\Provisioning; +use Plausible\Analytics\WP\Client; +use Plausible\Analytics\WP\Helpers; class Integrations { /** @@ -16,6 +18,12 @@ class Integrations { */ private $provisioning; + /** + * @var array The goals of each Language Domain's site, keyed by domain key. Retrieved once per request and shared by + * the WooCommerce and EDD funnels. @see self::get_existing_goals() + */ + private $existing_goals = []; + /** * Build class. * @@ -49,57 +57,353 @@ 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 ) { + $currency = ! empty( $event_goals['purchase'] ) ? $this->get_purchase_goal_currency( $event_goals['purchase'], $key, $client ) : ''; + // The existing goals couldn't be read, so the purchase goal's currency is unknown. Skip this domain rather + // than risk a rejected funnel; it's provisioned on the next settings save. + if ( $currency === null ) { 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 ); + $goals = []; + /** + * Goals which shouldn't (or can't) be part of the funnel. + */ + $extra_goals = []; + $view_product_paths = []; - continue; + foreach ( $event_goals as $event_key => $event_goal ) { + if ( $event_key === 'remove-from-cart' ) { + $extra_goals[] = $this->provisioning->create_goal_request( $event_goal ); + + continue; + } + + if ( $event_key === 'purchase' ) { + $goals[] = $this->provisioning->create_goal_request( $event_goal, 'Revenue', $currency ); + + continue; + } + + if ( $event_key === 'view-product' ) { + $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, $view_product_paths[0] ); + + foreach ( array_slice( $view_product_paths, 1 ) as $path ) { + $extra_goals[] = $this->provisioning->create_goal_request( $event_goal, 'Pageview', null, $path ); + } + + continue; + } + + $goals[] = $this->provisioning->create_goal_request( $event_goal ); } - if ( $event_key === 'view-product' ) { - $path = preg_replace( '/^.*?\//', '', $event_goal ); - $goals[] = $this->provisioning->create_goal_request( $event_goal, 'Pageview', null, '/' . $path ); + if ( ! empty( $extra_goals ) ) { + $all_ids = $this->provisioning->create_goals( $extra_goals, $client, $key, $all_ids ); + // Persist immediately so the localized Pageview goals stay tracked (and thus cleanable) even if the + // funnel creation below fails and never gets to save them. + update_option( 'plausible_analytics_enhanced_measurements_goal_ids', $all_ids ); + } + + if ( ! empty( $view_product_paths ) ) { + $all_ids = $this->maybe_dismantle_outdated_funnel( $funnel_name, $view_product_paths[0], $key, $client, $all_ids ); + } + + $all_ids = $this->provisioning->create_funnel( $funnel_name, $goals, $client, $key, $all_ids ); + + $all_ids = $this->reconcile_view_product_goals( $view_product_paths, $key, $client, $all_ids ); + } + } + + /** + * Makes Plausible remove $key's funnel when its view-product step targets a path that's no longer served there, e.g. + * "Visit /product*" on a domain that serves its products under /producto/, so it's recreated with the current steps. + * + * Funnels are sequential, so such a funnel never gets past its first step. The API can't update or delete a funnel, + * and creating it returns an existing funnel of the same name unchanged. But Plausible removes a funnel once fewer + * than two of its steps remain, which happens when their goals are deleted. So every step's goal is deleted except + * the last one, the purchase (Revenue) goal, whose currency can't be changed. create_funnel() then recreates the + * funnel and its goals. The goals' history is kept, as Plausible computes conversions from the events themselves. + * + * Deleted goals can't be restored, so there's nothing to roll back if recreating the funnel fails: it no longer + * exists then, and the next settings save creates it. A goal that couldn't be deleted keeps its stored ID. + * + * Note: a user-made funnel that shares one of these goals loses that step. + * + * @since 2.6.2 + * + * @param string $funnel_name + * @param string $view_product_path The path the funnel's view-product step should target, e.g. /producto*. + * @param string $key + * @param Client $client + * @param array $all_ids + * + * @return array The (possibly pruned) goal-ID map. + * + * @codeCoverageIgnore We don't want to test the API. + */ + private function maybe_dismantle_outdated_funnel( $funnel_name, $view_product_path, $key, $client, $all_ids ) { + foreach ( (array) $client->get_funnels() as $funnel ) { + $steps = $funnel['funnel']['steps'] ?? []; + + if ( ( $funnel['funnel']['name'] ?? '' ) !== $funnel_name || count( $steps ) < 2 ) { continue; } - $goals[] = $this->provisioning->create_goal_request( $event_goal ); + $first_step = (string) ( $steps[0]['goal']['display_name'] ?? '' ); + + // Only a view-product step is checked: other steps are named after the (translatable) event goals. + if ( strpos( $first_step, 'Visit ' ) !== 0 || $first_step === sprintf( 'Visit %s', $view_product_path ) ) { + return $all_ids; + } + + foreach ( array_slice( $steps, 0, -1 ) as $step ) { + $id = $step['goal']['id'] ?? null; + + if ( $id && $client->delete_goal( $id ) ) { + unset( $all_ids[ $key ][ $id ] ); + } + } + + update_option( 'plausible_analytics_enhanced_measurements_goal_ids', $all_ids ); + + break; } - foreach ( $this->provisioning->get_clients() as $key => $client ) { - $all_ids = $this->provisioning->create_funnel( $funnel_name, $goals, $client, $key, $all_ids ); + return $all_ids; + } + + /** + * Returns the currency to create $key's purchase (Revenue) goal in. + * + * A Revenue goal's name is unique per site and its currency can't be changed. So, when $key's dashboard already + * has this goal (e.g. created in the store's base currency before 2.6.2), its currency is kept: requesting another + * currency is rejected (422), which would abort the funnel. Deleting and recreating the goal isn't an option either: + * the existing funnel would lose its purchase step, and funnels can't be updated through the API. + * + * @since 2.6.2 + * + * @param string $event_goal The purchase goal's name. + * @param string $key The Language Domain the goal is created for. + * @param Client $client + * + * @return string|null ISO 4217 currency code, or null when the existing goals couldn't be retrieved. + * + * @codeCoverageIgnore We don't want to test the API. + */ + private function get_purchase_goal_currency( $event_goal, $key, $client ) { + $goals = $this->get_existing_goals( $key, $client ); + + if ( $goals === false ) { + return null; } + + foreach ( $goals as $goal ) { + if ( ( $goal['goal_type'] ?? '' ) === 'Goal.Revenue' && + ( $goal['goal']['event_name'] ?? '' ) === $event_goal && + ! empty( $goal['goal']['currency'] ) ) { + return $goal['goal']['currency']; + } + } + + return Helpers::get_currency_for_language( $key ); + } + + /** + * Returns the goals of $key's site, retrieving them only once per request. + * + * @since 2.6.2 + * + * @param string $key + * @param Client $client + * + * @return array|false @see Client::get_goals() + * + * @codeCoverageIgnore We don't want to test the API. + */ + private function get_existing_goals( $key, $client ) { + if ( ! isset( $this->existing_goals[ $key ] ) ) { + $this->existing_goals[ $key ] = $client->get_goals(); + } + + return $this->existing_goals[ $key ]; + } + + /** + * Removes stale localized view-product goals for $key's domain: a non-localized "Visit /product*" left by a + * pre-2.6.2 install, or a goal for a path no longer served. Provisioning is otherwise create-only, so those would + * linger alongside the current localized goals. + * + * This runs only after the current goals have been (re)created, and never when the current paths can't be trusted: + * it deletes nothing while a multilingual plugin is active but its language list is empty (the paths would fall + * back to the unlocalized "/product*" and the localized goals would be wrongly deleted), nor unless every current + * goal is already present (so a failed (re)create can't leave the domain without a view-product goal). View-product + * goals are the only Pageview ("Visit ") goals the plugin creates. + * + * @since 2.6.2 + * + * @param array $view_product_paths The current view-product goal paths, @see self::get_pageview_goal_paths(). + * Empty when the integration has no view-product goal. + * @param string $key + * @param Client $client + * @param array $all_ids + * + * @return array The (possibly pruned) goal-ID map. + * + * @codeCoverageIgnore Because it depends on 3rd party plugins. + */ + private function reconcile_view_product_goals( $view_product_paths, $key, $client, $all_ids ) { + if ( empty( $view_product_paths ) || + ( Helpers::get_multilang_plugin() && empty( Helpers::get_active_languages() ) ) ) { + return $all_ids; + } + + $current_view_product = array_map( + static function ( $path ) { + return sprintf( 'Visit %s', $path ); + }, + $view_product_paths + ); + + // Only prune once every current goal is present, so a failed (re)create can't leave the domain goalless. + if ( array_diff( $current_view_product, (array) ( $all_ids[ $key ] ?? [] ) ) ) { + return $all_ids; + } + + $deleted_stale = false; + + foreach ( $all_ids[ $key ] ?? [] as $id => $name ) { + if ( strpos( (string) $name, 'Visit ' ) === 0 && ! in_array( $name, $current_view_product, true ) && + $client->delete_goal( $id ) ) { + unset( $all_ids[ $key ][ $id ] ); + $deleted_stale = true; + } + } + + if ( $deleted_stale ) { + update_option( 'plausible_analytics_enhanced_measurements_goal_ids', $all_ids ); + } + + return $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 ) { + $relative = Helpers::get_home_relative_path( $path ); + // On multisite subdirectory installs the site's path precedes the language prefix. + $home_path = $relative !== trim( $path, '/' ) ? Helpers::get_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, including languages that have since + * been removed (whose localized goal name can no longer be regenerated). + * * @param object $integration The integration object containing event goals to be deleted. * * @return void @@ -111,14 +415,24 @@ public function delete_integration_goals( $integration ) { get_option( 'plausible_analytics_enhanced_measurements_goal_ids', [] ) ); + /** + * A view-product goal is a Pageview goal named "Visit *", whose localized path depends on the languages + * that were active when it was created. Those may since have changed (a language removed), so regenerating the + * name from the current languages no longer matches it, and it would be orphaned. The plugin only ever stores + * its own goals, and the view-product goals are the only Pageview goals it creates, so when this integration + * has a view-product goal every stored "Visit " goal is one of ours and can be removed regardless of the + * current languages. + */ + $delete_view_product = ! empty( $integration->event_goals['view-product'] ); + foreach ( $this->provisioning->get_clients() as $domain_key => $client ) { $goals = $all_ids[ $domain_key ] ?? []; foreach ( $goals as $id => $name ) { - $key = $this->provisioning->array_search_contains( $name, $integration->event_goals ); + $is_view_product = $delete_view_product && strpos( (string) $name, 'Visit ' ) === 0; - if ( $key ) { - $client->delete_goal( $id ); + if ( ( $is_view_product || $this->provisioning->array_search_contains( $name, $integration->event_goals ) ) && + $client->delete_goal( $id ) ) { unset( $goals[ $id ] ); } } 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..70e95145 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,64 @@ private function upgrade_to_260() { update_option( 'plausible_analytics_version', '2.6.0' ); } + /** + * After updating to 2.6.2, (re)create the Custom Properties and — for multilingual ecommerce installs — the + * integration goals, so existing installs pick up the currency Custom Property and the per-language + * Pageview goals (e.g. /es/producto*) without having to save their settings first. + * + * The Custom Properties apply to every WooCommerce/EDD install with Ecommerce Revenue enabled. The localized + * Pageview goals only apply when a multilingual plugin (WPML, with or without WooCommerce Multilingual & + * Multicurrency, or TranslatePress) is active. + * + * This runs on init (@see Upgrades::__construct()), so the multilingual plugin's languages are available: WPML loads + * them on plugins_loaded and TranslatePress stores them in an option. When there are none (e.g. WPML's setup hasn't + * been completed yet), the funnels are skipped — they're recreated on the next settings save anyway + * (@see Provisioning\Integrations\WooCommerce::init()) — so the upgrade always completes in a single pass. + * + * @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 ( $is_ecommerce && EnhancedMeasurements::is_enabled( EnhancedMeasurements::ECOMMERCE_REVENUE ) ) { + $provisioning = new Provisioning(); + $settings = Helpers::get_settings(); + + /** + * The currency Custom Property (@see Provisioning::CUSTOM_PROPERTIES) applies to every ecommerce install, + * so (re)create the Custom Properties whether or not a multilingual plugin is active. Bails when no Plugin + * Token is entered yet; the properties are created as soon as one is. + * + * @see Provisioning::maybe_provision_on_connect() Creates the goals and custom properties as soon as a + * Plugin Token is entered. + */ + $provisioning->maybe_create_custom_properties( [], $settings ); + + /** + * The localized Pageview goals only matter when a multilingual plugin is active and serves languages. When + * it doesn't serve any yet (e.g. WPML's setup hasn't been completed), skip the funnels rather than + * provisioning the default language's path only; they're (re)created with the right paths on the next + * settings save. + * + * @see Provisioning\Integrations\WooCommerce::init() Both funnels are (re)created on + * update_option_plausible_analytics_settings, i.e. when the Plugin Token is saved. + * @see Provisioning\Integrations\EDD::init() + */ + if ( Helpers::get_multilang_plugin() && ! empty( Helpers::get_active_languages() ) ) { + $integrations = new Integrations( $provisioning ); + + ( 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/Client.php b/src/Client.php index fef556e8..97d78589 100644 --- a/src/Client.php +++ b/src/Client.php @@ -30,6 +30,11 @@ class Client { */ private $api_instance; + /** + * @var GuzzleClient $http_client + */ + private $http_client; + /** * @var string $domain_key */ @@ -49,7 +54,8 @@ public function __construct( $token = '', $domain_key = 'default' ) { $config->setUsername( 'WordPress' ) ->setPassword( $token ) ->setHost( Helpers::get_hosted_domain_url() ); - $this->api_instance = new DefaultApi( new GuzzleClient( [ 'timeout' => $timeout, 'connect_timeout' => $connect_timeout ] ), $config ); + $this->http_client = new GuzzleClient( [ 'timeout' => $timeout, 'connect_timeout' => $connect_timeout ] ); + $this->api_instance = new DefaultApi( $this->http_client, $config ); } /** @@ -222,6 +228,78 @@ public function create_goals( $goals ) { } } + /** + * Retrieves all Goals of this Client's site. + * + * @since 2.6.2 + * + * @return array|false Each goal as ['goal_type' => 'Goal.Revenue', 'goal' => ['id' => 1, 'currency' => 'EUR', ...]], + * or false when the Goals couldn't be retrieved. + * + * @codeCoverageIgnore + */ + public function get_goals() { + return $this->get_all( 'plausibleWebPluginsAPIControllersGoalsIndexRequest', 'goals' ); + } + + /** + * Retrieves all Funnels of this Client's site. + * + * @since 2.6.2 + * + * @return array|false Each funnel as ['funnel' => ['id' => 1, 'name' => '...', 'steps' => [['goal' => [...]], ...]]], + * or false when the Funnels couldn't be retrieved. + * + * @codeCoverageIgnore + */ + public function get_funnels() { + return $this->get_all( 'plausibleWebPluginsAPIControllersFunnelsIndexRequest', 'funnels' ); + } + + /** + * Retrieves all pages of a paginated index endpoint. + * + * The responses are decoded as-is, because the generated models deserialize every goal as a Pageview goal, + * dropping e.g. a Revenue goal's event_name and currency. + * + * @since 2.6.2 + * + * @param string $request_method The DefaultApi method that builds the index request. + * @param string $key The response's key holding the items, e.g. 'goals'. + * + * @return array|false False when any of the pages couldn't be retrieved. + * + * @codeCoverageIgnore + */ + private function get_all( $request_method, $key ) { + $items = []; + $after = null; + + try { + do { + $request = $this->api_instance->$request_method( 100, $after ); + $response = json_decode( (string) $this->http_client->send( $request )->getBody(), true ); + + if ( ! isset( $response[ $key ] ) || ! is_array( $response[ $key ] ) ) { + return false; + } + + $items = array_merge( $items, $response[ $key ] ); + $after = null; + + if ( ! empty( $response['meta']['pagination']['has_next_page'] ) ) { + wp_parse_str( (string) wp_parse_url( $response['meta']['pagination']['links']['next']['url'] ?? '', PHP_URL_QUERY ), $query ); + + $after = $query['after'] ?? null; + } + } while ( $after ); + } catch ( \Throwable $e ) { + return false; + } + + return $items; + } + /** * Create Shared Link in Plausible Dashboard. * @@ -264,8 +342,12 @@ public function bulk_create_shared_links() { /** * Delete a Custom Event Goal by ID. * + * @since 2.6.2 Returns whether the goal is gone. Deleting a goal that doesn't exist (anymore) succeeds. + * * @param int $id * + * @return bool + * * @codeCoverageIgnore */ public function delete_goal( $id ) { @@ -280,7 +362,11 @@ public function delete_goal( $id ) { 'plausible-analytics' ) ); + + return false; } + + return true; } /** diff --git a/src/Helpers.php b/src/Helpers.php index 68a17572..483516db 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -22,6 +22,84 @@ class Helpers { const MULTILANG_PLUGIN_TRANSLATEPRESS = 'translatepress'; + /** + * Returns the language codes of all languages the active multilingual plugin serves to the public. + * + * Languages that aren't public yet (WPML's hidden languages, TranslatePress' unpublished languages) are left out, + * regardless of who's making the request: WPML only lists hidden languages to users who opted to see them, which + * would make the result depend on whether provisioning runs in an admin's or a visitor's request. + * + * @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 ) ) { + $hidden = (array) apply_filters( 'wpml_setting', [], 'hidden_languages' ); + $languages = array_diff( array_keys( $active ), $hidden ); + } + break; + + case static::MULTILANG_PLUGIN_TRANSLATEPRESS: + $settings = get_option( 'trp_settings', [] ); + $languages = $settings['publish-languages'] ?? []; + break; + } + + return (array) apply_filters( 'plausible_analytics_active_languages', array_values( (array) $languages ) ); + } + + /** + * Returns the currency a Revenue goal should be created in for a given language-domain. + * + * WooCommerce Multilingual & Multicurrency (WCML) lets a store pin a default currency per language + * ("WooCommerce → Multilingual → Currencies"). When one is set, the matching per-language dashboard's + * purchase goal is created in that storeview's own currency—mirroring how its view-product goals are + * already localized. Without WCML (or when multi-currency is off, or no default is set for this + * language) the store's base currency is used, and Plausible converts foreign-currency purchases into + * it automatically. + * + * @since 2.6.2 + * + * @param string $domain_key 'default' or a WPML language code (e.g. 'es'). + * + * @return string ISO 4217 currency code. + */ + public static function get_currency_for_language( $domain_key = 'default' ) { + $base = Integrations::is_edd_active() ? edd_get_currency() : get_woocommerce_currency(); + + // A default currency per language is a WCML (WooCommerce + WPML) concept only. + if ( Integrations::is_edd_active() || static::get_multilang_plugin() !== static::MULTILANG_PLUGIN_WPML ) { + return $base; + } + + $wcml = get_option( '_wcml_settings', [] ); + + if ( empty( $wcml['enable_multi_currency'] ) || empty( $wcml['default_currencies'] ) || ! is_array( $wcml['default_currencies'] ) ) { + return $base; // @codeCoverageIgnore + } + + // In "by location" mode the currency follows the visitor's country, so the per-language defaults don't apply. + if ( ( $wcml['currency_mode'] ?? '' ) === 'by_location' ) { + return $base; + } + + // The 'default' dashboard tracks WPML's default language. + $language = 'default' === $domain_key ? (string) apply_filters( 'wpml_default_language', null ) : $domain_key; + $currency = $wcml['default_currencies'][ $language ] ?? false; + + // Languages without a pinned default currency ("Keep") are stored as false, 0 or '0'. + return is_string( $currency ) && preg_match( '/^[A-Z]{3}$/', $currency ) ? $currency : $base; + } + /** * Returns the API token. * @@ -570,6 +648,107 @@ 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 the home URL's path without leading/trailing slashes, e.g. 'site' for https://example.com/site/. + * + * @since v2.6.2 + * + * @return string Empty when the site is served from the domain's root. + */ + public static function get_home_path() { + return trim( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ), '/' ); + } + + /** + * Returns $url's path, relative to the home URL's path, without leading/trailing slashes. + * + * @since v2.6.2 + * + * @param string $url A URL or a path. + * + * @return string + */ + public static function get_home_relative_path( $url ) { + $path = trim( (string) wp_parse_url( $url, PHP_URL_PATH ), '/' ); + $home = static::get_home_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 +841,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..4297ceb3 100644 --- a/src/Integrations/WooCommerce.php +++ b/src/Integrations/WooCommerce.php @@ -22,6 +22,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 +167,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 +214,28 @@ 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. It allows breaking down events (e.g. their price + * and cart total) by the currency they were placed in. + * + * @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(); + + return $props; + } + /** * Track add to cart actions by direct link, e.g. ?product_type=download&add-to-cart=1&quantity=1 * @@ -244,12 +273,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 +310,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 +347,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..e9958a22 100644 --- a/tests/integration/Admin/Provisioning/IntegrationsTest.php +++ b/tests/integration/Admin/Provisioning/IntegrationsTest.php @@ -44,4 +44,192 @@ public function testDeleteIntegrationGoals() { $this->removeAction( 'update_option_plausible_analytics_settings', 'maybe_delete_', 11 ); } } + + /** + * A goal that couldn't be deleted should keep its stored ID, so it can still be cleaned up later. + * + * @see Integrations::delete_integration_goals() + */ + public function testDeleteIntegrationGoalsKeepsFailedDeletions() { + $client = $this->getMockBuilder( Client::class ) + ->onlyMethods( [ 'delete_goal' ] ) + ->getMock(); + + $client->method( 'delete_goal' )->willReturnCallback( + function ( $id ) { + return $id !== 2; + } + ); + + $provisioning = $this->getMockBuilder( Provisioning::class ) + ->setConstructorArgs( [ $client ] ) + ->onlyMethods( [ 'array_search_contains' ] ) + ->getMock(); + + $provisioning->method( 'array_search_contains' ) + ->willReturn( 1 ); + + try { + update_option( 'plausible_analytics_enhanced_measurements_goal_ids', [ 'default' => [ 1 => 'a', 2 => 'b', 3 => 'c' ] ] ); + + $integration = new Integrations( $provisioning ); + $integration->delete_integration_goals( (object) [ 'event_goals' => [ '' ] ] ); + + $this->assertEquals( [ 'default' => [ 2 => 'b' ] ], get_option( 'plausible_analytics_enhanced_measurements_goal_ids' ) ); + } finally { + delete_option( 'plausible_analytics_enhanced_measurements_goal_ids' ); + $this->removeAction( 'update_option_plausible_analytics_settings', 'maybe_create_' ); + $this->removeAction( 'update_option_plausible_analytics_settings', 'maybe_delete_', 11 ); + } + } + + /** + * The goal path is the event goal's URI, i.e. everything after the "Visit " prefix, with a single leading slash. + * + * @see Integrations::get_goal_path() + * @return void + * @throws \ReflectionException + */ + public function testGetGoalPath() { + $integrations = ( new \ReflectionClass( Integrations::class ) )->newInstanceWithoutConstructor(); + $method = new \ReflectionMethod( Integrations::class, 'get_goal_path' ); + $method->setAccessible( true ); + + $this->assertEquals( '/product*', $method->invoke( $integrations, 'Visit /product*' ) ); + // A longer base (e.g. a multisite subdirectory install) is kept intact. + $this->assertEquals( '/shop/product*', $method->invoke( $integrations, 'Visit /shop/product*' ) ); + } + + /** + * 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 ); + } + } + + /** + * On (multisite) subdirectory installs the site's path should precede the language prefix. + * + * @see Integrations::get_pageview_goal_paths() + * @see Integrations::localize_goal_path() + * @return void + * @throws \ReflectionException + */ + public function testGetPageviewGoalPathsInSubdirectory() { + $home_url = function ( $url, $path ) { + return 'https://example.com/site/' . ltrim( $path, '/' ); + }; + + add_filter( 'home_url', $home_url, 10, 2 ); + + try { + $this->withLanguages( + function ( $paths ) { + $this->assertEquals( + [ '/site/product*', '/site/es/producto*', '/site/nl/product*' ], + $paths( '/site/product*', 'default' ) + ); + } + ); + } finally { + remove_filter( 'home_url', $home_url ); + } + } + + /** + * 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' ); + }; + } } diff --git a/tests/integration/HelpersTest.php b/tests/integration/HelpersTest.php index 62550e27..10de9191 100644 --- a/tests/integration/HelpersTest.php +++ b/tests/integration/HelpersTest.php @@ -9,6 +9,7 @@ use Plausible\Analytics\Tests\TestableHelpers; use Plausible\Analytics\Tests\TestCase; use Plausible\Analytics\WP\Helpers; +use function Brain\Monkey\Functions\when; class HelpersTest extends TestCase { /** @@ -400,4 +401,140 @@ public function testGetTranslatePressLanguageDomainsEmpty() { $this->assertEquals( [], $method->invoke( null ) ); } + + /** + * WPML's hidden languages should be left out, whether or not WPML lists them for the current user. + * + * @see Helpers::get_active_languages() + * @return void + */ + public function testGetActiveLanguagesWpmlSkipsHiddenLanguages() { + $plugin = function () { + return Helpers::MULTILANG_PLUGIN_WPML; + }; + $active = function () { + return [ 'en' => [], 'es' => [], 'de' => [] ]; + }; + $hidden = function ( $value, $setting ) { + return $setting === 'hidden_languages' ? [ 'de' ] : $value; + }; + + add_filter( 'plausible_analytics_multilang_plugin', $plugin ); + add_filter( 'wpml_active_languages', $active ); + add_filter( 'wpml_setting', $hidden, 10, 2 ); + + try { + $this->assertEquals( [ 'en', 'es' ], Helpers::get_active_languages() ); + } finally { + remove_filter( 'plausible_analytics_multilang_plugin', $plugin ); + remove_filter( 'wpml_active_languages', $active ); + remove_filter( 'wpml_setting', $hidden ); + } + } + + /** + * TranslatePress' unpublished languages should be left out. + * + * @see Helpers::get_active_languages() + * @return void + */ + public function testGetActiveLanguagesTranslatePressSkipsUnpublishedLanguages() { + $plugin = function () { + return Helpers::MULTILANG_PLUGIN_TRANSLATEPRESS; + }; + + add_filter( 'plausible_analytics_multilang_plugin', $plugin ); + update_option( + 'trp_settings', + [ + 'translation-languages' => [ 'en_US', 'es_ES', 'nl_NL' ], + 'publish-languages' => [ 'en_US', 'es_ES' ], + ] + ); + + try { + $this->assertEquals( [ 'en_US', 'es_ES' ], Helpers::get_active_languages() ); + } finally { + remove_filter( 'plausible_analytics_multilang_plugin', $plugin ); + delete_option( 'trp_settings' ); + } + } + + /** + * A language's pinned WCML default currency should be used; anything else ("Keep", stored as false, 0 or '0') + * should fall back to the store's base currency. + * + * @see Helpers::get_currency_for_language() + * @return void + */ + public function testGetCurrencyForLanguage() { + when( 'get_woocommerce_currency' )->justReturn( 'USD' ); + + $plugin = function () { + return Helpers::MULTILANG_PLUGIN_WPML; + }; + $default = function () { + return 'en'; + }; + + add_filter( 'plausible_analytics_integrations_edd', '__return_false' ); + add_filter( 'plausible_analytics_multilang_plugin', $plugin ); + add_filter( 'wpml_default_language', $default ); + update_option( + '_wcml_settings', + [ + 'enable_multi_currency' => 2, + 'default_currencies' => [ 'en' => 'GBP', 'es' => 'EUR', 'nl' => '0', 'de' => false, 'fr' => 0 ], + ] + ); + + try { + $this->assertEquals( 'GBP', Helpers::get_currency_for_language() ); + $this->assertEquals( 'EUR', Helpers::get_currency_for_language( 'es' ) ); + $this->assertEquals( 'USD', Helpers::get_currency_for_language( 'nl' ) ); + $this->assertEquals( 'USD', Helpers::get_currency_for_language( 'de' ) ); + $this->assertEquals( 'USD', Helpers::get_currency_for_language( 'fr' ) ); + $this->assertEquals( 'USD', Helpers::get_currency_for_language( 'it' ) ); + + // The per-language defaults don't apply when the currency follows the visitor's location. + update_option( '_wcml_settings', array_merge( get_option( '_wcml_settings' ), [ 'currency_mode' => 'by_location' ] ) ); + + $this->assertEquals( 'USD', Helpers::get_currency_for_language( 'es' ) ); + + // A default currency per language is a WCML (i.e. WPML) concept only. + remove_filter( 'plausible_analytics_multilang_plugin', $plugin ); + update_option( '_wcml_settings', array_merge( get_option( '_wcml_settings' ), [ 'currency_mode' => 'by_language' ] ) ); + + $this->assertEquals( 'USD', Helpers::get_currency_for_language( 'es' ) ); + } finally { + remove_filter( 'plausible_analytics_integrations_edd', '__return_false' ); + remove_filter( 'plausible_analytics_multilang_plugin', $plugin ); + remove_filter( 'wpml_default_language', $default ); + delete_option( '_wcml_settings' ); + } + } + + /** + * @see Helpers::get_home_relative_path() + * @return void + */ + public function testGetHomeRelativePath() { + $home_url = function ( $url, $path ) { + return 'https://example.com/site/' . ltrim( $path, '/' ); + }; + + add_filter( 'home_url', $home_url, 10, 2 ); + + try { + $this->assertEquals( 'site', Helpers::get_home_path() ); + $this->assertEquals( 'product*', Helpers::get_home_relative_path( '/site/product*' ) ); + $this->assertEquals( 'es', Helpers::get_home_relative_path( 'https://example.com/site/es/' ) ); + $this->assertEquals( '', Helpers::get_home_relative_path( 'https://example.com/site/' ) ); + // A path outside the site's path is returned as-is. + $this->assertEquals( 'product*', Helpers::get_home_relative_path( '/product*' ) ); + $this->assertEquals( 'sites/product*', Helpers::get_home_relative_path( '/sites/product*' ) ); + } finally { + remove_filter( 'home_url', $home_url ); + } + } } diff --git a/tests/integration/Integrations/WooCommerceTest.php b/tests/integration/Integrations/WooCommerceTest.php index 8ba6830c..00604fda 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,7 +70,7 @@ 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 ); }