dispatcher = $dispatcher ?: new EventDispatcher(); } /** * Retrieves the event dispatcher instance. * * @return EventDispatcher */ public function getDispatcher(): EventDispatcher { return $this->dispatcher; } /** * Registers WooCommerce action hooks. */ public function register(): void { if (!class_exists('WooCommerce')) { return; } // Page-level funnel events are detected on template_redirect so they work with // both classic templates and block (Cart/Checkout/Order Confirmation) templates. add_action('template_redirect', [$this, 'trackPageEvents'], 20); // Add to cart fires for AJAX buttons, product forms and the Store API if (Options::get('wc_track_add_to_cart', true)) { add_action('woocommerce_add_to_cart', [$this, 'onAddToCart'], 10, 6); } // Remove from cart if (Options::get('wc_track_remove_from_cart', true)) { add_action('woocommerce_cart_item_removed', [$this, 'onCartItemRemoved'], 10, 2); } // Coupon applied & removed if (Options::get('wc_track_coupons', true)) { add_action('woocommerce_applied_coupon', [$this, 'onCouponApplied'], 10, 1); add_action('woocommerce_removed_coupon', [$this, 'onCouponRemoved'], 10, 1); } // Deliver events raised in requests that rendered no page add_action('wp_footer', [$this, 'flushDeferredEvents'], 90); add_action('wp_footer', [$this, 'outputOrderConfirmConfig'], 95); add_filter('woocommerce_add_to_cart_fragments', [$this, 'addEventsToCartFragments'], 10, 1); // Browser confirmation that Order Completed reached Zaraz (skips the server fallback) add_action('wp_ajax_wp_zbridge_confirm_order', [$this, 'handleConfirmOrder']); add_action('wp_ajax_nopriv_wp_zbridge_confirm_order', [$this, 'handleConfirmOrder']); } /** * Generates a deterministic, reproducible event ID for order deduplication. * * @param int|string $orderId * @param string $timestamp * @return string */ public static function generateEventId($orderId, string $timestamp = ''): string { $ts = $timestamp ?: 'ts'; return "order_{$orderId}_" . md5("order_{$orderId}_{$ts}"); } /** * Formats product data into a standardized Zaraz e-commerce product payload. * * @param array $productData * @return array */ public function formatProductPayload(array $productData): array { $payload = [ 'product_id' => (string) ($productData['id'] ?? ($productData['product_id'] ?? 0)), 'name' => (string) ($productData['name'] ?? ''), 'sku' => (string) ($productData['sku'] ?? ''), 'price' => (float) ($productData['price'] ?? 0.0), 'category' => (string) ($productData['category'] ?? ''), ]; if (!empty($productData['variant'])) { $payload['variant'] = (string) $productData['variant']; } if (isset($productData['quantity'])) { $payload['quantity'] = (int) $productData['quantity']; } if (!empty($productData['currency'])) { $payload['currency'] = (string) $productData['currency']; } return $payload; } /** * Formats order data into a Zaraz Order Completed payload. * * @param array $orderData * @return array */ public function formatOrderPayload(array $orderData): array { $orderId = (string) ($orderData['id'] ?? '0'); $createdAt = (string) ($orderData['created_at'] ?? gmdate('Y-m-d H:i:s')); $eventId = self::generateEventId($orderId, $createdAt); // No Zaraz tool reads customer contact details from the payload, so none are sent. // Add-ons can attach non-personal attributes here (Pro adds new/returning buyer). $userData = (array) apply_filters('zbridge_order_user_data', [], $orderData); $total = (float) ($orderData['total'] ?? 0.0); $tax = (float) ($orderData['tax'] ?? 0.0); $shipping = (float) ($orderData['shipping'] ?? 0.0); $payload = [ 'order_id' => $orderId, 'event_id' => $eventId, 'total' => $total, 'value' => $total, 'revenue' => round($total - $tax - $shipping, 2), 'tax' => $tax, 'shipping' => $shipping, 'discount' => (float) ($orderData['discount'] ?? 0.0), 'currency' => (string) ($orderData['currency'] ?? 'USD'), 'products' => array_values((array) ($orderData['items'] ?? [])), ]; if (!empty($userData)) { $payload['user_data'] = $userData; } if (!empty($orderData['coupon'])) { $payload['coupon'] = (string) $orderData['coupon']; } return $payload; } /** * Builds the normalized order data array shared by browser and server dispatch. * * @param object $order WC_Order * @return array */ public static function buildOrderData($order): array { $useParentId = (bool) Options::get('wc_use_parent_id_for_variations', false); $items = []; foreach ($order->get_items() as $item) { $product = method_exists($item, 'get_product') ? $item->get_product() : null; $productId = (int) $item->get_product_id(); $variationId = method_exists($item, 'get_variation_id') ? (int) $item->get_variation_id() : 0; $resolvedId = ($variationId > 0 && !$useParentId) ? $variationId : $productId; $items[] = [ 'product_id' => (string) $resolvedId, 'sku' => $product ? (string) $product->get_sku() : '', 'name' => (string) $item->get_name(), 'price' => (float) $order->get_item_total($item, false, true), 'quantity' => (int) $item->get_quantity(), 'category' => self::categoryName($productId), ]; } $get = static function (string $method, $default = '') use ($order) { return method_exists($order, $method) ? $order->$method() : $default; }; $coupons = (array) $get('get_coupon_codes', []); return [ 'id' => $order->get_id(), 'total' => (float) $get('get_total', 0.0), 'tax' => (float) $get('get_total_tax', 0.0), 'shipping' => (float) $get('get_shipping_total', 0.0), 'discount' => (float) $get('get_discount_total', 0.0), 'coupon' => implode(',', $coupons), 'currency' => (string) $get('get_currency', 'USD'), 'items' => $items, 'customer' => [ 'email' => (string) $get('get_billing_email'), 'phone' => (string) $get('get_billing_phone'), 'first_name' => (string) $get('get_billing_first_name'), 'last_name' => (string) $get('get_billing_last_name'), 'city' => (string) $get('get_billing_city'), 'state' => (string) $get('get_billing_state'), 'postcode' => (string) $get('get_billing_postcode'), 'country' => (string) $get('get_billing_country'), ], 'created_at' => $get('get_date_created', null) ? $order->get_date_created()->date('Y-m-d H:i:s') : '', 'customer_id' => method_exists($order, 'get_customer_id') ? $order->get_customer_id() : 0, ]; } /** * Returns the first product category name. * * @param int $productId * @return string */ private static function categoryName(int $productId): string { if (!$productId || !function_exists('get_the_terms')) { return ''; } $terms = get_the_terms($productId, 'product_cat'); return (is_array($terms) && !empty($terms)) ? (string) $terms[0]->name : ''; } /** * Builds a product payload from a WC_Product. * * @param object $product WC_Product * @param int $quantity * @return array */ private function productPayload($product, int $quantity = 0): array { $useParentId = (bool) Options::get('wc_use_parent_id_for_variations', false); $productId = (int) $product->get_id(); $parentId = (method_exists($product, 'is_type') && $product->is_type('variation')) ? (int) $product->get_parent_id() : 0; $resolvedId = ($useParentId && $parentId > 0) ? $parentId : $productId; $data = [ 'id' => $resolvedId, 'name' => $product->get_name(), 'sku' => $product->get_sku(), 'price' => (float) $product->get_price(), 'category' => self::categoryName($parentId ?: $productId), 'currency' => function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : 'USD', ]; if ($parentId > 0 && function_exists('wc_get_formatted_variation')) { $data['variant'] = wp_strip_all_tags(wc_get_formatted_variation($product, true, false, false)); } if ($quantity > 0) { $data['quantity'] = $quantity; } return $this->formatProductPayload($data); } /** * Products currently in the cart as Zaraz product payloads. * * @return array */ private function cartProducts(): array { $products = []; if (!function_exists('WC') || !WC()->cart) { return $products; } foreach (WC()->cart->get_cart() as $item) { if (!empty($item['data'])) { $products[] = $this->productPayload($item['data'], (int) ($item['quantity'] ?? 1)); } } return $products; } /** * Detects funnel pages (product, cart, checkout, order received) and queues their events. */ public function trackPageEvents(): void { if (function_exists('is_order_received_page') && is_order_received_page()) { if (Options::get('wc_track_purchase', true)) { $this->trackPurchase((int) get_query_var('order-received')); } return; } if (function_exists('is_product') && is_product()) { if (Options::get('wc_track_product_view', true)) { $this->trackProductView(); } return; } if (function_exists('is_cart') && is_cart()) { if (Options::get('wc_track_view_cart', true)) { $this->trackCartView(); } return; } if (function_exists('is_checkout') && is_checkout()) { if (Options::get('wc_track_begin_checkout', true)) { $this->trackBeginCheckout(); } } } /** * Tracks product view on single product pages. */ public function trackProductView(): void { $product = function_exists('wc_get_product') ? wc_get_product(get_queried_object_id()) : null; if (!$product) { return; } $this->dispatcher->push('Product Viewed', $this->productPayload($product), 'ecommerce'); } /** * Tracks order completion on the order received page, once per order. * * @param int $orderId */ public function trackPurchase(int $orderId): void { if (!$orderId || !function_exists('wc_get_order')) { return; } $order = wc_get_order($orderId); if (!$order) { return; } // Only the customer holding the order key may trigger the event // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Authorized by the WooCommerce order key, as on WooCommerce's own order-received page $key = isset($_GET['key']) ? sanitize_text_field(wp_unslash($_GET['key'])) : ''; if (!self::isValidOrderKey((string) $order->get_order_key(), $key)) { return; } // Reloading the thank-you page must not report the purchase again if ($order->get_meta(self::META_PURCHASE_RENDERED)) { return; } $order->update_meta_data(self::META_PURCHASE_RENDERED, time()); $order->save(); $payload = $this->formatOrderPayload(self::buildOrderData($order)); $this->dispatcher->push('Order Completed', $payload, 'ecommerce'); $this->orderConfirm = [ 'orderId' => $order->get_id(), 'key' => $key, 'ajaxUrl' => admin_url('admin-ajax.php'), ]; } /** * Tracks cart view. */ public function trackCartView(): void { if (!function_exists('WC') || !WC()->cart) { return; } $this->dispatcher->push('Cart Viewed', [ 'value' => (float) WC()->cart->get_total('edit'), 'currency' => function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : 'USD', 'products' => $this->cartProducts(), ], 'ecommerce'); } /** * Tracks checkout begin. */ public function trackBeginCheckout(): void { if (!function_exists('WC') || !WC()->cart) { return; } $this->dispatcher->push('Checkout Started', [ 'value' => (float) WC()->cart->get_total('edit'), 'currency' => function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : 'USD', 'products' => $this->cartProducts(), ], 'ecommerce'); } /** * Tracks add to cart from AJAX buttons, product forms and the Store API. * * @param string $cartItemKey * @param int $productId * @param int $quantity * @param int $variationId */ public function onAddToCart($cartItemKey = '', $productId = 0, $quantity = 1, $variationId = 0): void { $id = $variationId ? (int) $variationId : (int) $productId; $product = function_exists('wc_get_product') ? wc_get_product($id) : null; if (!$product) { return; } $this->queueDeferred('Product Added', $this->productPayload($product, max(1, (int) $quantity)), 'ecommerce'); } /** * Formats remove from cart product payload. * * @param array $itemData * @return array */ public function formatRemoveFromCartPayload(array $itemData): array { return [ 'product_id' => (string) (int) ($itemData['product_id'] ?? 0), 'name' => (string) ($itemData['name'] ?? ''), 'sku' => (string) ($itemData['sku'] ?? ''), 'price' => (float) ($itemData['price'] ?? 0.0), 'quantity' => (int) ($itemData['quantity'] ?? 1), ]; } /** * Handles coupon applied. * * @param string $couponCode */ public function onCouponApplied(string $couponCode): void { if (empty($couponCode)) { return; } $payload = [ 'coupon' => function_exists('sanitize_text_field') ? sanitize_text_field($couponCode) : trim(wp_strip_all_tags($couponCode)), ]; $this->queueDeferred('coupon_applied', $payload, 'track'); } /** * Handles coupon removed. * * @param string $couponCode */ public function onCouponRemoved(string $couponCode): void { if (empty($couponCode)) { return; } $payload = [ 'coupon' => function_exists('sanitize_text_field') ? sanitize_text_field($couponCode) : trim(wp_strip_all_tags($couponCode)), ]; $this->queueDeferred('coupon_removed', $payload, 'track'); } /** * Handles cart item removed hook. * * @param string $cartItemKey * @param mixed $cart */ public function onCartItemRemoved(string $cartItemKey, $cart = null): void { if (!$cart && function_exists('WC') && WC()->cart) { $cart = WC()->cart; } if (!$cart || !method_exists($cart, 'get_removed_cart_contents')) { return; } $removedContents = $cart->get_removed_cart_contents(); $item = $removedContents[$cartItemKey] ?? null; if (!$item) { return; } $productId = (int) (!empty($item['variation_id']) ? $item['variation_id'] : ($item['product_id'] ?? 0)); $product = function_exists('wc_get_product') ? wc_get_product($productId) : null; $payload = $this->formatRemoveFromCartPayload([ 'product_id' => (string) $productId, 'name' => $product ? $product->get_name() : '', 'sku' => $product ? $product->get_sku() : '', 'price' => $product ? (float) $product->get_price() : 0.0, 'quantity' => (int) ($item['quantity'] ?? 1), ]); $this->queueDeferred('Product Removed', $payload, 'ecommerce'); } /** * Queues an event raised in a request that may not render a page (AJAX, redirects). * * @param string $name * @param array $payload * @param string $type */ public function queueDeferred(string $name, array $payload, string $type = 'track'): void { $events = $this->getDeferred(); $events[] = ['name' => $name, 'payload' => $payload, 'type' => $type]; $this->setDeferred($events); } /** * Moves deferred events into the page's footer queue. */ public function flushDeferredEvents(): void { foreach ($this->getDeferred() as $event) { $this->dispatcher->push((string) $event['name'], (array) $event['payload'], (string) $event['type']); } $this->setDeferred([]); } /** * Delivers deferred events with WooCommerce cart fragments (AJAX add/remove). * * @param array $fragments * @return array */ public function addEventsToCartFragments($fragments): array { $fragments = (array) $fragments; $events = $this->getDeferred(); if (!empty($events)) { $fragments[self::FRAGMENT_KEY] = wp_json_encode(array_values($events)); $this->setDeferred([]); } return $fragments; } /** * Outputs the thank-you page beacon configuration before queued events render. */ public function outputOrderConfirmConfig(): void { if ($this->orderConfirm === null) { return; } echo '' . "\n"; } /** * Records that the browser delivered Order Completed to Zaraz. */ public function handleConfirmOrder(): void { // phpcs:disable WordPress.Security.NonceVerification.Missing -- Guest beacon from cached thank-you pages; authorized by the order key below $orderId = isset($_POST['order_id']) ? absint(wp_unslash($_POST['order_id'])) : 0; $key = isset($_POST['key']) ? sanitize_text_field(wp_unslash($_POST['key'])) : ''; // phpcs:enable WordPress.Security.NonceVerification.Missing $order = ($orderId && function_exists('wc_get_order')) ? wc_get_order($orderId) : null; if (!$order || !self::isValidOrderKey((string) $order->get_order_key(), $key)) { wp_send_json_error(['message' => 'Invalid order.'], 403); return; } if (!$order->get_meta(self::META_CLIENT_CONFIRMED)) { $order->update_meta_data(self::META_CLIENT_CONFIRMED, time()); $order->save(); (new \WPZaraz\Telemetry\TelemetryAggregator())->recordOrder( $order->get_id(), (float) $order->get_total(), false, (string) $order->get_currency() ); } wp_send_json_success(); } /** * Constant-time order key comparison. * * @param string $orderKey * @param string $provided * @return bool */ public static function isValidOrderKey(string $orderKey, string $provided): bool { return $orderKey !== '' && $provided !== '' && hash_equals($orderKey, $provided); } /** * @return array */ private function getDeferred(): array { if (function_exists('WC') && WC()->session) { return (array) WC()->session->get(self::SESSION_KEY, []); } return $this->deferredFallback; } /** * @param array $events */ private function setDeferred(array $events): void { if (function_exists('WC') && WC()->session) { WC()->session->set(self::SESSION_KEY, empty($events) ? null : $events); return; } $this->deferredFallback = $events; } }