*/ private const STAGES = [ 'product' => 1, 'cart' => 2, 'checkout' => 3, 'order_received' => 3, ]; /** * Print the tracker. * * @internal Hooked to `wp_footer`. */ public function footer(): void { $site = Config::site(); if ( null === $site || self::excluded() ) { return; } $type = self::pageType(); $commands = []; $view = [ 'trackPageView' ]; $user = get_current_user_id(); if ( $user > 0 ) { $commands[] = [ 'setUserId', (string) $user ]; } $commands[] = [ 'setCustomDimension', 1, $type ]; $commands[] = [ 'setCustomDimension', 2, self::visitorType( $user ) ]; if ( 'product' === $type ) { $commands = array_merge( $commands, $this->productView() ); } elseif ( 'category' === $type && ( is_product_category() || is_product_tag() ) ) { $commands[] = [ 'setEcommerceView', false, false, single_term_title( '', false ) ]; } elseif ( 'search' === $type ) { [ $extra, $view ] = $this->search(); $commands = array_merge( $commands, $extra ); } elseif ( 'order_received' === $type ) { $commands = array_merge( $commands, $this->order() ); } $config = [ 'a' => \WC_AJAX::get_endpoint( self::CART_AJAX ), 'u' => $site['url'], 's' => $site['site'], 'p' => $type, 'g' => self::STAGES[ $type ] ?? 0, 'c' => $commands, 'v' => $view, ]; echo self::cartTag(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built by wp_get_inline_script_tag() from JSON with HTML characters escaped. wp_print_inline_script_tag( '(' . self::SCRIPT . ')(' . self::json( $config ) . ');' ); } /** * Add the cart JSON to WooCommerce's cart fragments. * * @param mixed $fragments Fragments by selector. * * @return array * * @internal Hooked to `woocommerce_add_to_cart_fragments`. */ public static function fragments( $fragments ): array { $fragments = is_array( $fragments ) ? $fragments : []; $fragments[ 'script#' . self::CART_ID ] = self::cartTag(); return $fragments; } /** * Answer the tracker with the cart, after the cart hash cookie changed. * * @internal Hooked to `wc_ajax_rahkar_matomo_cart`. */ public static function ajaxCart(): void { nocache_headers(); wp_send_json( self::cart() ); } /** * Requests Matomo should not count: staff, previews and the like. */ private static function excluded(): bool { if ( is_preview() || is_customize_preview() || is_feed() || is_embed() || is_robots() ) { return true; } return is_user_logged_in() && ( current_user_can( 'manage_woocommerce' ) || current_user_can( 'edit_posts' ) ); // phpcs:ignore WordPress.WP.Capabilities.Unknown -- WooCommerce's shop manager capability. } /** * Page type (dimension 1). These values never change once released. */ private static function pageType(): string { if ( ! function_exists( 'is_woocommerce' ) ) { return is_front_page() ? 'home' : ( is_search() ? 'search' : 'other' ); } return match ( true ) { is_order_received_page() => 'order_received', is_checkout() => 'checkout', is_cart() => 'cart', is_product() => 'product', is_search() => 'search', is_shop() || is_product_category() || is_product_tag() => 'category', is_account_page() => 'account', is_front_page() => 'home', default => 'other', }; } /** * Visitor type (dimension 2): `guest`, `member` or `customer`. * * @param int $user Current user id, 0 for a guest. */ private static function visitorType( int $user ): string { if ( 0 === $user ) { return 'guest'; } return get_user_meta( $user, 'paying_customer', true ) ? 'customer' : 'member'; } /** * A product page as an ecommerce view. * * @return list> */ private function productView(): array { $product = wc_get_product( get_queried_object_id() ); if ( ! $product instanceof \WC_Product ) { return []; } $id = $product->get_parent_id() > 0 ? $product->get_parent_id() : $product->get_id(); $terms = get_the_terms( $id, 'product_cat' ); $category = is_array( $terms ) && isset( $terms[0] ) ? $terms[0]->name : ''; return [ [ 'setEcommerceView', (string) $id, $product->get_name(), $category, Parse::money( wc_get_price_to_display( $product ), get_woocommerce_currency() ), ], ]; } /** * A search result page: Matomo's site search instead of a page view, unless * the term looks like a mobile number or an email. Then a plain page view * goes, with the term taken out of its address and out of its title, which * themes write as "results for …". * * @return array{0: list>, 1: list} */ private function search(): array { $term = trim( get_search_query( false ) ); if ( '' === $term || Parse::looksPersonal( $term ) ) { return [ [ [ 'setCustomUrl', remove_query_arg( 's' ) ], [ 'setDocumentTitle', __( 'نتایج جست‌وجو', 'rahkar-toolkit' ) ], ], [ 'trackPageView' ], ]; } $category = 'product' === get_query_var( 'post_type' ) ? 'product' : false; $found = (int) ( $GLOBALS['wp_query']->found_posts ?? 0 ); return [ [], [ 'trackSiteSearch', $term, $category, $found ] ]; } /** * The thank-you page: the order, once it is a sale. * * Matomo keeps an order id once, so a reload or the server-side send that * follows cannot count it twice. * * @return list> */ private function order(): array { $id = absint( get_query_var( 'order-received' ) ); $key = isset( $_GET['key'] ) ? sanitize_text_field( wp_unslash( $_GET['key'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Compared with the order's own key, as WooCommerce does on this page. $order = $id > 0 ? wc_get_order( $id ) : null; if ( ! $order instanceof WC_Order || ! $order->key_is_valid( $key ) || ! Orders::isSale( $order ) ) { return []; } $data = Orders::ecommerce( $order ); $commands = []; foreach ( $data['items'] as $item ) { $commands[] = array_merge( [ 'addEcommerceItem' ], $item ); } $commands[] = [ 'trackEcommerceOrder', $data['id'], $data['total'], $data['subtotal'], $data['tax'], $data['shipping'], $data['discount'] ]; return $commands; } /** * The cart as JSON in a script tag, by id, for the tracker to read. */ private static function cartTag(): string { return wp_get_inline_script_tag( self::json( self::cart() ), [ 'type' => 'application/json', 'id' => self::CART_ID, ] ); } /** * The cart: WooCommerce's cart hash, the items and the total. * * `h` is the hash WooCommerce puts in its `woocommerce_cart_hash` cookie, empty * for an empty cart, so the tracker can tell whether a copy is current. Items * go under their parent product id, as on the product page, so a variation in * the cart is the same product Matomo saw viewed. * * @return array{h: string, i: list, t: float} */ private static function cart(): array { $cart = function_exists( 'WC' ) ? WC()->cart : null; if ( null === $cart || $cart->is_empty() ) { return [ 'h' => '', 'i' => [], 't' => 0.0, ]; } $currency = get_woocommerce_currency(); $items = []; foreach ( $cart->get_cart() as $line ) { $product = $line['data'] ?? null; $quantity = max( 1, (int) ( $line['quantity'] ?? 1 ) ); if ( ! $product instanceof \WC_Product ) { continue; } $items[] = [ (string) ( (int) ( $line['product_id'] ?? 0 ) ), $product->get_title(), '', Parse::money( (float) ( $line['line_subtotal'] ?? 0 ) / $quantity, $currency ), $quantity, ]; } return [ 'h' => $cart->get_cart_hash(), 'i' => $items, 't' => Parse::money( $cart->get_total( 'edit' ), $currency ), ]; } /** * JSON safe to print inside a script tag. * * @param array $value Value. */ private static function json( array $value ): string { return (string) wp_json_encode( $value, JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); } /** * The tracker, called with the config above. * * `g` is the stage this page reaches. The stage only goes up within a visit, * and adding to the cart lifts it to `cart` whatever the page. An add is * found by comparing the cart with the last one sent; each product whose * quantity went up gets one `rahkar` / `add_to_cart` event named by its * parent id. The cart is looked at when the page loads, when WooCommerce's * cart hash cookie changes (checked every two seconds and on focus), and when * cart fragments arrive. The thank-you page sends no cart: the order ends it. */ private const SCRIPT = <<<'JS' function(c){ var S=['browse','product','cart','checkout'],L='rk_mtm_cart',busy=false; function stage(){var m=document.cookie.match(/(?:^|; )rk_mtm_stage=(\d)/);return m?+m[1]:0} function hash(){var m=document.cookie.match(/(?:^|; )woocommerce_cart_hash=([^;]*)/);return m?decodeURIComponent(m[1]):''} // Matomo swaps window._paq for its own object once loaded: always push to the current one. function P(a){(window._paq=window._paq||[]).push(a)} var st=Math.max(stage(),c.g),seen=hash(); function keepStage(){document.cookie='rk_mtm_stage='+st+';path=/;max-age=1800;SameSite=Lax'+('https:'===location.protocol?';Secure':'')} keepStage(); P(['setTrackerUrl',c.u+'js/']);P(['setSiteId',String(c.s)]); for(var i=0;i(p.q[k]||0)){ if(!up){up=true;if(st<2){st=2;keepStage();P(['setCustomDimension',3,S[st]])}} P(['trackEvent','rahkar','add_to_cart',k])}}} P(['clearEcommerceCart']); for(j=0;j