api_client = $api_client; $this->settings = $settings; $this->init_hooks(); $this->create_events_table(); } /** * Initialize WordPress hooks */ private function init_hooks() { // Product view tracking add_action( 'woocommerce_single_product_summary', array( $this, 'track_product_view' ), 5 ); // Cart event tracking add_action( 'woocommerce_add_to_cart', array( $this, 'track_add_to_cart' ), 10, 6 ); add_action( 'woocommerce_cart_item_removed', array( $this, 'track_remove_from_cart' ), 10, 2 ); add_action( 'woocommerce_after_cart_item_quantity_update', array( $this, 'track_cart_quantity_update' ), 10, 4 ); // Checkout tracking add_action( 'woocommerce_checkout_init', array( $this, 'track_checkout_start' ) ); add_action( 'woocommerce_checkout_order_processed', array( $this, 'track_purchase' ), 10, 3 ); // AJAX hooks for frontend events add_action( 'wp_ajax_activetrail_track_event', array( $this, 'ajax_track_event' ) ); add_action( 'wp_ajax_nopriv_activetrail_track_event', array( $this, 'ajax_track_event' ) ); // Batch event processing add_action( 'activetrail_process_event_queue', array( $this, 'process_event_queue' ) ); // Enqueue frontend scripts add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_frontend_scripts' ) ); } /** * Create events tracking table */ private function create_events_table() { global $wpdb; $table_name = $wpdb->prefix . 'activetrail_events'; $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE $table_name ( id bigint(20) NOT NULL AUTO_INCREMENT, event_type varchar(50) NOT NULL, customer_email varchar(100) DEFAULT NULL, customer_id bigint(20) DEFAULT NULL, session_id varchar(255) DEFAULT NULL, product_id bigint(20) DEFAULT NULL, order_id bigint(20) DEFAULT NULL, event_data longtext DEFAULT NULL, event_value decimal(10,2) DEFAULT NULL, currency varchar(3) DEFAULT NULL, created_at datetime DEFAULT CURRENT_TIMESTAMP, synced tinyint(1) DEFAULT 0, sync_attempts int(11) DEFAULT 0, last_sync_attempt datetime DEFAULT NULL, PRIMARY KEY (id), KEY event_type (event_type), KEY customer_email (customer_email), KEY customer_id (customer_id), KEY product_id (product_id), KEY synced (synced), KEY created_at (created_at) ) $charset_collate;"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql ); } /** * Enqueue frontend scripts for event tracking */ public function enqueue_frontend_scripts() { if ( ! is_admin() ) { wp_enqueue_script( 'activetrail-event-tracker', ACTIVETRAIL_WC_PLUGIN_URL . 'assets/js/event-tracker.js', array( 'jquery' ), ACTIVETRAIL_WC_VERSION, true ); wp_localize_script( 'activetrail-event-tracker', 'activetrail_events', array( 'ajax_url' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'activetrail_track_event' ), 'customer_email' => $this->get_customer_email(), 'session_id' => $this->get_session_id(), 'currency' => get_woocommerce_currency(), 'debug' => defined( 'WP_DEBUG' ) && WP_DEBUG, ) ); } } /** * Track product view event */ public function track_product_view() { global $product; if ( ! $product || ! is_product() ) { return; } $this->track_event( 'product_view', array( 'product_id' => $product->get_id(), 'product_name' => $product->get_name(), 'product_price' => $product->get_price(), 'product_category' => $this->get_product_categories( $product ), 'product_sku' => $product->get_sku(), 'product_url' => get_permalink( $product->get_id() ), ) ); } /** * Track add to cart event * * @param string $cart_item_key Cart item key * @param int $product_id Product ID * @param int $quantity Quantity * @param int $variation_id Variation ID * @param array $variation Variation data * @param array $cart_item_data Cart item data */ public function track_add_to_cart( $cart_item_key, $product_id, $quantity, $variation_id, $variation, $cart_item_data ) { $product = wc_get_product( $product_id ); if ( ! $product ) { return; } // Cast before arithmetic. WC_Product::get_price() returns a string and can be // empty/non-numeric (e.g. a variable product with a price range), which triggers // "TypeError: Unsupported operand types: int * string" on PHP 8+. Casting keeps // this correct on both PHP 7 and PHP 8. $price = (float) $product->get_price(); $quantity = (int) $quantity; $line_total = $price * $quantity; $this->track_event( 'add_to_cart', array( 'product_id' => $product_id, 'variation_id' => $variation_id, 'product_name' => $product->get_name(), 'product_price' => $price, 'quantity' => $quantity, 'line_total' => $line_total, 'product_category' => $this->get_product_categories( $product ), 'product_sku' => $product->get_sku(), 'cart_total' => WC()->cart ? WC()->cart->get_total( 'edit' ) : 0, ), $line_total ); } /** * Track remove from cart event * * @param string $cart_item_key Cart item key * @param WC_Cart $cart Cart object */ public function track_remove_from_cart( $cart_item_key, $cart ) { // Get the removed item from cart contents $removed_item = isset( $cart->removed_cart_contents[ $cart_item_key ] ) ? $cart->removed_cart_contents[ $cart_item_key ] : null; if ( ! $removed_item ) { $this->log( 'Failed to track remove from cart: removed item not found for key ' . $cart_item_key, 'warning' ); return; } $product = isset( $removed_item['data'] ) && $removed_item['data'] ? $removed_item['data'] : null; // Fallback: WooCommerce may not always put the product object in removed_cart_contents (e.g. session storage). if ( ! $product && ! empty( $removed_item['product_id'] ) ) { $product = wc_get_product( ! empty( $removed_item['variation_id'] ) ? $removed_item['variation_id'] : $removed_item['product_id'] ); } if ( ! $product || ! is_a( $product, 'WC_Product' ) ) { $this->log( 'Failed to track remove from cart: product data missing for key ' . $cart_item_key, 'warning' ); return; } $this->track_event( 'remove_from_cart', array( 'product_id' => isset( $removed_item['product_id'] ) ? $removed_item['product_id'] : 0, 'variation_id' => isset( $removed_item['variation_id'] ) ? $removed_item['variation_id'] : 0, 'product_name' => $product->get_name(), 'product_price' => $product->get_price(), 'quantity' => isset( $removed_item['quantity'] ) ? $removed_item['quantity'] : 1, 'line_total' => isset( $removed_item['line_total'] ) ? $removed_item['line_total'] : 0, 'product_category' => $this->get_product_categories( $product ), 'product_sku' => $product->get_sku(), 'cart_total' => $cart->get_total( 'edit' ), ), isset( $removed_item['line_total'] ) ? $removed_item['line_total'] : 0 ); } /** * Track cart quantity update event * * @param string $cart_item_key Cart item key * @param int $quantity New quantity * @param int $old_quantity Old quantity * @param WC_Cart $cart Cart object */ public function track_cart_quantity_update( $cart_item_key, $quantity, $old_quantity, $cart ) { $cart_item = $cart->get_cart_item( $cart_item_key ); if ( ! $cart_item ) { return; } $product = $cart_item['data']; if ( ! $product ) { return; } $this->track_event( 'cart_update', array( 'product_id' => $cart_item['product_id'], 'variation_id' => $cart_item['variation_id'] ?? 0, 'product_name' => $product->get_name(), 'product_price' => $product->get_price(), 'old_quantity' => $old_quantity, 'new_quantity' => $quantity, 'quantity_change' => $quantity - $old_quantity, 'line_total' => $cart_item['line_total'], 'product_category' => $this->get_product_categories( $product ), 'product_sku' => $product->get_sku(), 'cart_total' => $cart->get_total( 'edit' ), ), $cart_item['line_total'] ); } /** * Track checkout start event */ public function track_checkout_start() { if ( ! WC()->cart || WC()->cart->is_empty() ) { return; } $cart_items = array(); foreach ( WC()->cart->get_cart() as $cart_item ) { $product = $cart_item['data']; if ( $product ) { $cart_items[] = array( 'product_id' => $cart_item['product_id'], 'product_name' => $product->get_name(), 'quantity' => $cart_item['quantity'], 'price' => $product->get_price(), 'line_total' => $cart_item['line_total'], ); } } $this->track_event( 'checkout_start', array( 'cart_total' => WC()->cart->get_total( 'edit' ), 'cart_items_count' => WC()->cart->get_cart_contents_count(), 'cart_items' => $cart_items, 'currency' => get_woocommerce_currency(), ), WC()->cart->get_total( 'edit' ) ); } /** * Track purchase event (enhanced) * * @param int $order_id Order ID * @param array $posted_data Posted data * @param WC_Order $order Order object */ public function track_purchase( $order_id, $posted_data, $order ) { $order_items = array(); foreach ( $order->get_items() as $item ) { $product = $item->get_product(); if ( $product ) { $item_quantity = (int) $item->get_quantity(); $item_total = (float) $item->get_total(); $order_items[] = array( 'product_id' => $product->get_id(), 'product_name' => $product->get_name(), 'quantity' => $item_quantity, 'price' => $item_quantity > 0 ? ( $item_total / $item_quantity ) : 0, 'line_total' => $item_total, 'category' => $this->get_product_categories( $product ), ); } } $this->track_event( 'purchase', array( 'order_id' => $order_id, 'order_number' => $order->get_order_number(), 'order_total' => $order->get_total(), 'order_subtotal' => $order->get_subtotal(), 'order_tax' => $order->get_total_tax(), 'order_shipping' => $order->get_shipping_total(), 'order_items' => $order_items, 'order_items_count' => count( $order_items ), 'currency' => $order->get_currency(), 'payment_method' => $order->get_payment_method(), 'coupons' => $order->get_coupon_codes(), ), $order->get_total(), $order_id ); } /** * AJAX handler for frontend event tracking */ public function ajax_track_event() { check_ajax_referer( 'activetrail_track_event', 'nonce' ); $event_type = isset( $_POST['event_type'] ) ? sanitize_text_field( wp_unslash( $_POST['event_type'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- event_data is an array that will be sanitized when used $event_data = isset( $_POST['event_data'] ) ? wp_unslash( $_POST['event_data'] ) : array(); $event_value = isset( $_POST['event_value'] ) ? floatval( wp_unslash( $_POST['event_value'] ) ) : 0; if ( empty( $event_type ) ) { wp_send_json_error( 'Event type is required' ); } // Sanitize event data $event_data = $this->sanitize_event_data( $event_data ); $result = $this->track_event( $event_type, $event_data, $event_value ); if ( $result ) { wp_send_json_success( 'Event tracked successfully' ); } else { wp_send_json_error( 'Failed to track event' ); } } /** * Track an event * * @param string $event_type Event type * @param array $event_data Event data * @param float $event_value Event value (optional) * @param int $order_id Order ID (optional) * @return bool */ public function track_event( $event_type, $event_data = array(), $event_value = 0, $order_id = null ) { global $wpdb; // Check if event tracking is enabled for this event type if ( ! $this->is_event_tracking_enabled( $event_type ) ) { return false; } $table_name = $wpdb->prefix . 'activetrail_events'; $data = array( 'event_type' => $event_type, 'customer_email' => $this->get_customer_email(), 'customer_id' => get_current_user_id() ?: null, 'session_id' => $this->get_session_id(), 'product_id' => isset( $event_data['product_id'] ) ? intval( $event_data['product_id'] ) : null, 'order_id' => $order_id, 'event_data' => wp_json_encode( $event_data ), 'event_value' => $event_value, 'currency' => get_woocommerce_currency(), 'created_at' => current_time( 'mysql' ), ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for event tracking $result = $wpdb->insert( $table_name, $data ); if ( $result ) { $this->log( 'Event tracked: ' . $event_type . ' (ID: ' . $wpdb->insert_id . ')' ); // Add to immediate processing queue for high-priority events if ( in_array( $event_type, array( 'purchase', 'add_to_cart' ) ) ) { $this->queue_event_for_immediate_processing( $wpdb->insert_id ); } return true; } else { $this->log( 'Failed to track event: ' . $event_type, 'error' ); return false; } } /** * Queue event for immediate processing * * @param int $event_id Event ID */ private function queue_event_for_immediate_processing( $event_id ) { // Process immediately for critical events wp_schedule_single_event( time() + 10, 'activetrail_process_single_event', array( $event_id ) ); } /** * Process event queue (scheduled task) */ public function process_event_queue() { global $wpdb; $table_name = $wpdb->prefix . 'activetrail_events'; $table = esc_sql( $table_name ); // Get unsynced events (limit to 50 per batch) // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Direct query needed for event tracking, table name is escaped // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is escaped with esc_sql() // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed for event tracking $events = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is escaped with esc_sql() "SELECT * FROM {$table} WHERE synced = 0 AND (sync_attempts < 3 OR sync_attempts IS NULL) ORDER BY created_at ASC LIMIT 50" ); if ( empty( $events ) ) { return; } $this->log( 'Processing ' . count( $events ) . ' events from queue' ); foreach ( $events as $event ) { $this->process_single_event( $event ); } } /** * Process a single event * * @param object $event Event object from database */ private function process_single_event( $event ) { global $wpdb; $table_name = $wpdb->prefix . 'activetrail_events'; // Update sync attempt // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed for event tracking $wpdb->update( $table_name, array( 'sync_attempts' => intval( $event->sync_attempts ) + 1, 'last_sync_attempt' => current_time( 'mysql' ), ), array( 'id' => $event->id ) ); // Send event to ActiveTrail (this would be implemented based on their event API) $result = $this->send_event_to_activetrail( $event ); if ( $result['success'] ) { // Mark as synced // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed for event tracking $wpdb->update( $table_name, array( 'synced' => 1 ), array( 'id' => $event->id ) ); $this->log( 'Event synced successfully: ' . $event->event_type . ' (ID: ' . $event->id . ')' ); } else { $this->log( 'Failed to sync event: ' . $event->event_type . ' (ID: ' . $event->id . ') - ' . $result['message'], 'error' ); } } /** * Send event to ActiveTrail * Note: This is a placeholder - ActiveTrail doesn't have a specific events API endpoint * Events would typically be sent as part of customer data or custom fields * * @param object $event Event object * @return array */ private function send_event_to_activetrail( $event ) { // For now, we'll log the event and mark as successful // In a real implementation, you might: // 1. Send as custom fields in customer updates // 2. Use ActiveTrail's webhook system // 3. Store in custom fields for campaign targeting $this->log( 'Event data prepared for ActiveTrail: ' . $event->event_type, 'info' ); return array( 'success' => true, 'message' => 'Event logged locally (ActiveTrail integration pending)', ); } /** * Get customer email * * @return string */ private function get_customer_email() { $email = ''; // Try to get from current user if ( is_user_logged_in() ) { $user = wp_get_current_user(); $email = $user->user_email; } // Try to get from WooCommerce customer if ( empty( $email ) && WC()->customer ) { $email = WC()->customer->get_billing_email(); } return $email; } /** * Get session ID * * @return string */ private function get_session_id() { if ( WC()->session ) { return WC()->session->get_customer_id(); } return session_id() ?: uniqid( 'activetrail_' ); } /** * Get product categories * * @param WC_Product $product Product object * @return string */ private function get_product_categories( $product ) { $categories = wp_get_post_terms( $product->get_id(), 'product_cat', array( 'fields' => 'names' ) ); return is_array( $categories ) ? implode( ', ', $categories ) : ''; } /** * Check if event tracking is enabled for event type * * @param string $event_type Event type * @return bool */ private function is_event_tracking_enabled( $event_type ) { $enabled_events = $this->settings['enabled_events'] ?? array( 'product_view', 'add_to_cart', 'remove_from_cart', 'cart_update', 'checkout_start', 'purchase', ); return in_array( $event_type, $enabled_events ); } /** * Sanitize event data * * @param array $data Event data * @return array */ private function sanitize_event_data( $data ) { if ( ! is_array( $data ) ) { return array(); } $sanitized = array(); foreach ( $data as $key => $value ) { $key = sanitize_key( $key ); if ( is_string( $value ) ) { $sanitized[ $key ] = sanitize_text_field( $value ); } elseif ( is_numeric( $value ) ) { $sanitized[ $key ] = floatval( $value ); } elseif ( is_array( $value ) ) { $sanitized[ $key ] = $this->sanitize_event_data( $value ); } else { $sanitized[ $key ] = sanitize_text_field( strval( $value ) ); } } return $sanitized; } /** * Get event statistics * * @param int $days Number of days to look back * @return array */ public function get_event_statistics( $days = 30 ) { global $wpdb; $table_name = $wpdb->prefix . 'activetrail_events'; $table = esc_sql( $table_name ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching $stats = $wpdb->get_results( $wpdb->prepare( " SELECT event_type, COUNT(*) AS count, SUM(event_value) AS total_value, AVG(event_value) AS avg_value FROM {$table} WHERE created_at >= DATE_SUB(NOW(), INTERVAL %d DAY) GROUP BY event_type ORDER BY count DESC ", $days ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching return $stats; } /** * Clean old events * * @param int $days Days to keep * @return int Number of deleted events */ public function clean_old_events( $days = 90 ) { global $wpdb; $table_name = $wpdb->prefix . 'activetrail_events'; $table = esc_sql( $table_name ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching $deleted = $wpdb->query( $wpdb->prepare( " DELETE FROM {$table} WHERE created_at < DATE_SUB(NOW(), INTERVAL %d DAY) ", $days ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching $this->log( "Cleaned {$deleted} old events" ); return $deleted; } /** * Log message * * @param string $message Log message * @param string $level Log level */ private function log( $message, $level = 'info' ) { if ( function_exists( 'activetrail_logger' ) ) { $level_map = array( 'info' => 'INFO', 'debug' => 'DEBUG', 'warning' => 'WARNING', 'error' => 'ERROR', ); $mapped_level = isset( $level_map[ $level ] ) ? $level_map[ $level ] : 'INFO'; activetrail_logger()->log( $message, $mapped_level, 'GENERAL' ); } } }