should_skip_tracking() ) { return; } wp_enqueue_script( 'maple-carts-tracking', MAPLE_CARTS_URL . 'assets/js/tracking.js', [ 'jquery' ], MAPLE_CARTS_VERSION, true ); $session_id = $this->get_or_create_session_id(); wp_localize_script( 'maple-carts-tracking', 'mapleCartsData', [ 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'maple_carts_nonce' ), 'sessionId' => $session_id, 'gdprNotice' => Maple_Carts::get_option( 'gdpr_notice', '' ), ] ); } /** * Check if tracking should be skipped. * * @return bool */ private function should_skip_tracking() { // Skip if disabled via cookie. if ( isset( $_COOKIE['maple_carts_skip'] ) ) { return true; } // Skip for excluded roles. if ( is_user_logged_in() ) { $user = wp_get_current_user(); $exclude_roles = Maple_Carts::get_option( 'exclude_roles', [] ); if ( ! empty( $exclude_roles ) && array_intersect( $user->roles, $exclude_roles ) ) { return true; } } return false; } /** * Get or create session ID. * * @return string */ private function get_or_create_session_id() { if ( WC()->session ) { $session_id = WC()->session->get( 'maple_carts_session_id' ); if ( ! $session_id ) { $session_id = wp_generate_uuid4(); WC()->session->set( 'maple_carts_session_id', $session_id ); } return $session_id; } // Fallback to cookie when WC session is unavailable. if ( isset( $_COOKIE['maple_carts_session'] ) ) { return sanitize_text_field( wp_unslash( $_COOKIE['maple_carts_session'] ) ); } // Generate new session ID and persist to cookie. $session_id = wp_generate_uuid4(); $this->set_session_cookie( $session_id ); return $session_id; } /** * Set session cookie for fallback persistence. * * @param string $session_id Session ID. */ private function set_session_cookie( $session_id ) { if ( headers_sent() ) { return; } $secure = is_ssl(); $httponly = true; $samesite = 'Lax'; $expire = time() + YEAR_IN_SECONDS; if ( PHP_VERSION_ID >= 70300 ) { setcookie( 'maple_carts_session', $session_id, [ 'expires' => $expire, 'path' => '/', 'domain' => '', 'secure' => $secure, 'httponly' => $httponly, 'samesite' => $samesite, ] ); } else { setcookie( 'maple_carts_session', $session_id, $expire, '/; SameSite=' . $samesite, '', $secure, $httponly ); } // Also set in $_COOKIE for immediate availability. $_COOKIE['maple_carts_session'] = $session_id; } /** * AJAX handler for saving cart data. * Includes rate limiting for security. */ public function ajax_save_cart() { check_ajax_referer( 'maple_carts_nonce', 'nonce' ); // Rate limiting: max 10 requests per minute per IP. $ip = $this->get_client_ip(); $rate_key = 'maple_carts_rate_' . md5( $ip ); $requests = (int) get_transient( $rate_key ); if ( $requests >= 10 ) { wp_send_json_error( 'Rate limit exceeded. Please try again later.' ); } set_transient( $rate_key, $requests + 1, MINUTE_IN_SECONDS ); // Check for consent if required. if ( ! Maple_Carts_Privacy::has_consent() ) { wp_send_json_error( 'Consent not given' ); } // Record consent. Maple_Carts_Privacy::record_consent(); $session_id = isset( $_POST['session_id'] ) ? sanitize_text_field( wp_unslash( $_POST['session_id'] ) ) : ''; $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; if ( empty( $session_id ) ) { wp_send_json_error( 'Missing session ID' ); } // Get cart data from WooCommerce. $cart = WC()->cart; if ( ! $cart || $cart->is_empty() ) { wp_send_json_error( 'Cart is empty' ); } $cart_contents = []; foreach ( $cart->get_cart() as $key => $item ) { $product = $item['data']; $cart_contents[] = [ 'product_id' => $item['product_id'], 'variation_id' => $item['variation_id'] ?? 0, 'quantity' => $item['quantity'], 'name' => $product->get_name(), 'price' => $product->get_price(), 'image' => wp_get_attachment_image_url( $product->get_image_id(), 'thumbnail' ), 'permalink' => $product->get_permalink(), ]; } // Collect billing data. $billing_data = []; $billing_fields = [ 'first_name', 'last_name', 'phone', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country' ]; foreach ( $billing_fields as $field ) { $key = 'billing_' . $field; if ( isset( $_POST[ $key ] ) ) { $billing_data[ $field ] = sanitize_text_field( wp_unslash( $_POST[ $key ] ) ); } } $first_name = $billing_data['first_name'] ?? ''; $last_name = $billing_data['last_name'] ?? ''; $customer_name = trim( "$first_name $last_name" ); // Calculate items count. $items_count = 0; foreach ( $cart_contents as $item ) { $items_count += $item['quantity']; } // Prepare cart data. $data = [ 'session_id' => $session_id, 'email' => $email, 'cart_contents' => maybe_serialize( $cart_contents ), 'cart_total' => $cart->get_cart_contents_total(), 'items_count' => $items_count, 'currency' => get_woocommerce_currency(), 'customer_name' => $customer_name ?: null, 'customer_phone' => $billing_data['phone'] ?? null, 'billing_data' => maybe_serialize( $billing_data ), 'ip_address' => $this->get_client_ip(), 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : null, ]; // Generate recovery URL. $data['recovery_url'] = $this->generate_recovery_url( $session_id ); $cart_id = Maple_Carts_DB::save_cart( $data ); if ( $cart_id ) { wp_send_json_success( [ 'cart_id' => $cart_id ] ); } else { wp_send_json_error( 'Failed to save cart' ); } } /** * Get client IP address. * * @return string */ private function get_client_ip() { $ip_keys = [ 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR' ]; foreach ( $ip_keys as $key ) { if ( ! empty( $_SERVER[ $key ] ) ) { $ip = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ); // Handle comma-separated IPs. if ( strpos( $ip, ',' ) !== false ) { $ip = trim( explode( ',', $ip )[0] ); } if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) { return $ip; } } } return '0.0.0.0'; } /** * Generate recovery URL with HMAC signature. * * @param string $session_id Session ID. * @param string $coupon Optional coupon code. * @return string */ public function generate_recovery_url( $session_id, $coupon = '' ) { $data = [ 'sid' => $session_id, 'c' => $coupon, 't' => time(), ]; // Sign the token with HMAC. $payload = wp_json_encode( $data ); $signature = hash_hmac( 'sha256', $payload, $this->get_token_secret() ); $token = base64_encode( $payload . '|' . $signature ); return add_query_arg( 'maple_recover', $token, wc_get_checkout_url() ); } /** * Generate unsubscribe URL with HMAC signature. * * @param string $session_id Session ID. * @return string */ public function generate_unsubscribe_url( $session_id ) { $data = [ 'sid' => $session_id, 't' => time(), ]; $payload = wp_json_encode( $data ); $signature = hash_hmac( 'sha256', $payload, $this->get_token_secret() ); $token = base64_encode( $payload . '|' . $signature ); return add_query_arg( 'maple_unsubscribe', $token, home_url() ); } /** * Verify and decode a signed token. * * @param string $token The token to verify. * @param int $max_age Maximum age in seconds (0 = no expiry). * @return array|false Decoded data or false if invalid. */ private function verify_token( $token, $max_age = 0 ) { $decoded = base64_decode( $token, true ); if ( ! $decoded || strpos( $decoded, '|' ) === false ) { return false; } $parts = explode( '|', $decoded, 2 ); if ( count( $parts ) !== 2 ) { return false; } list( $payload, $signature ) = $parts; // Verify HMAC signature (timing-safe comparison). $expected_signature = hash_hmac( 'sha256', $payload, $this->get_token_secret() ); if ( ! hash_equals( $expected_signature, $signature ) ) { return false; } $data = json_decode( $payload, true ); if ( ! $data ) { return false; } // Check token age if max_age is set. if ( $max_age > 0 && isset( $data['t'] ) ) { if ( ( time() - $data['t'] ) > $max_age ) { return false; } } return $data; } /** * Get secret key for token signing. * * @return string */ private function get_token_secret() { // Use WordPress auth key + salt for signing. return wp_salt( 'auth' ) . 'maple_carts'; } /** * Handle cart recovery. */ public function handle_recovery() { if ( ! isset( $_GET['maple_recover'] ) ) { return; } $token = sanitize_text_field( wp_unslash( $_GET['maple_recover'] ) ); // Verify signed token (valid for 30 days). $data = $this->verify_token( $token, 30 * DAY_IN_SECONDS ); if ( ! $data || empty( $data['sid'] ) ) { wc_add_notice( __( 'This recovery link has expired or is invalid.', 'maple-carts' ), 'error' ); wp_safe_redirect( wc_get_page_permalink( 'shop' ) ); exit; } $session_id = sanitize_text_field( $data['sid'] ); $cart = Maple_Carts_DB::get_cart_by_session( $session_id ); if ( ! $cart || ! in_array( $cart->status, [ 'abandoned', 'active' ], true ) ) { return; } // Restore cart contents. $cart_contents = maybe_unserialize( $cart->cart_contents ); if ( ! empty( $cart_contents ) && is_array( $cart_contents ) ) { WC()->cart->empty_cart(); foreach ( $cart_contents as $item ) { $product_id = $item['product_id']; $quantity = $item['quantity']; $variation_id = $item['variation_id'] ?? 0; // Check if product exists and is in stock. $product = wc_get_product( $variation_id ? $variation_id : $product_id ); if ( $product && $product->is_in_stock() ) { WC()->cart->add_to_cart( $product_id, $quantity, $variation_id ); } } } // Pre-fill billing data. $billing_data = maybe_unserialize( $cart->billing_data ); if ( ! empty( $billing_data ) ) { WC()->session->set( 'maple_carts_billing', $billing_data ); add_filter( 'woocommerce_checkout_get_value', [ $this, 'prefill_checkout_fields' ], 10, 2 ); } // Apply coupon if provided. $coupon = ! empty( $data['c'] ) ? sanitize_text_field( $data['c'] ) : ''; if ( $coupon && ! WC()->cart->has_discount( $coupon ) ) { WC()->cart->apply_coupon( $coupon ); } // Store session ID for tracking. WC()->session->set( 'maple_carts_session_id', $session_id ); WC()->session->set( 'maple_carts_recovering', true ); // Redirect to clean URL. wp_safe_redirect( wc_get_checkout_url() ); exit; } /** * Pre-fill checkout fields. * * @param mixed $value Default value. * @param string $input Field name. * @return mixed */ public function prefill_checkout_fields( $value, $input ) { $billing_data = WC()->session->get( 'maple_carts_billing' ); if ( ! $billing_data ) { return $value; } $field = str_replace( 'billing_', '', $input ); if ( isset( $billing_data[ $field ] ) ) { return $billing_data[ $field ]; } return $value; } /** * Handle unsubscribe. */ public function handle_unsubscribe() { if ( ! isset( $_GET['maple_unsubscribe'] ) ) { return; } $token = sanitize_text_field( wp_unslash( $_GET['maple_unsubscribe'] ) ); // Verify signed token (valid for 90 days). $data = $this->verify_token( $token, 90 * DAY_IN_SECONDS ); if ( ! $data || empty( $data['sid'] ) ) { wc_add_notice( __( 'This unsubscribe link has expired or is invalid.', 'maple-carts' ), 'error' ); wp_safe_redirect( wc_get_page_permalink( 'shop' ) ); exit; } $session_id = sanitize_text_field( $data['sid'] ); $cart = Maple_Carts_DB::get_cart_by_session( $session_id ); if ( $cart ) { global $wpdb; $wpdb->update( $wpdb->prefix . MAPLE_CARTS_TABLE, [ 'unsubscribed' => 1 ], [ 'id' => $cart->id ] ); // Cancel pending emails. Maple_Carts_DB::cancel_pending_emails( $cart->id ); // Set cookie to skip future tracking (with security flags). $secure = is_ssl(); $httponly = true; $samesite = 'Lax'; if ( PHP_VERSION_ID >= 70300 ) { setcookie( 'maple_carts_skip', '1', [ 'expires' => time() + YEAR_IN_SECONDS, 'path' => '/', 'secure' => $secure, 'httponly' => $httponly, 'samesite' => $samesite, ] ); } else { setcookie( 'maple_carts_skip', '1', time() + YEAR_IN_SECONDS, '/; SameSite=' . $samesite, '', $secure, $httponly ); } wc_add_notice( __( 'You have been unsubscribed from cart reminder emails.', 'maple-carts' ), 'success' ); } wp_safe_redirect( wc_get_page_permalink( 'shop' ) ); exit; } /** * Handle new order creation. * * @param int $order_id Order ID. */ public function handle_new_order( $order_id ) { $this->process_order_completion( $order_id ); } /** * Handle order complete (thank you page). * * @param int $order_id Order ID. */ public function handle_order_complete( $order_id ) { $this->process_order_completion( $order_id ); } /** * Process order completion. * * @param int $order_id Order ID. */ private function process_order_completion( $order_id ) { $order = wc_get_order( $order_id ); if ( ! $order ) { return; } $email = $order->get_billing_email(); if ( ! $email ) { return; } // Check session first. $session_id = null; $is_recovery = false; if ( WC()->session ) { $session_id = WC()->session->get( 'maple_carts_session_id' ); $is_recovery = WC()->session->get( 'maple_carts_recovering' ); } $cart = null; if ( $session_id ) { $cart = Maple_Carts_DB::get_cart_by_session( $session_id ); } if ( ! $cart ) { $cart = Maple_Carts_DB::get_cart_by_email( $email ); } if ( ! $cart ) { return; } // Determine new status and recovery source. $new_status = 'converted'; $recovery_source = null; if ( in_array( $cart->status, [ 'abandoned' ], true ) ) { $new_status = 'recovered'; // Determine recovery source from UTM parameters. if ( $is_recovery ) { // Came via recovery link - check UTM source for attribution. // phpcs:ignore WordPress.Security.NonceVerification.Recommended $utm_source = isset( $_GET['utm_source'] ) ? sanitize_text_field( wp_unslash( $_GET['utm_source'] ) ) : ''; switch ( $utm_source ) { case 'mailjet': $recovery_source = 'mailjet'; break; case 'maple_carts': $recovery_source = 'builtin_email'; break; default: // Generic recovery link (e.g., shared, bookmarked). $recovery_source = 'email_link'; break; } } else { // Returned directly without clicking recovery link. $recovery_source = 'direct'; } // Notify admin of recovery. if ( 'yes' === Maple_Carts::get_option( 'notify_admin', 'yes' ) ) { $this->send_admin_recovery_notification( $order, $cart ); } } Maple_Carts_DB::update_cart_status( $cart->id, $new_status, [ 'recovery_source' => $recovery_source ] ); Maple_Carts_DB::cancel_pending_emails( $cart->id ); // Clear session data. if ( WC()->session ) { WC()->session->__unset( 'maple_carts_session_id' ); WC()->session->__unset( 'maple_carts_recovering' ); WC()->session->__unset( 'maple_carts_billing' ); } } /** * Handle order status change. * * @param int $order_id Order ID. * @param string $old_status Old status. * @param string $new_status New status. */ public function handle_order_status_change( $order_id, $old_status, $new_status ) { // Handle completed/processing orders. $completed_statuses = [ 'completed', 'processing' ]; if ( in_array( $new_status, $completed_statuses, true ) ) { $this->process_order_completion( $order_id ); } } /** * Mark carts as abandoned. * Processes in batches to prevent memory issues. */ public function mark_abandoned_carts() { global $wpdb; // Prevent overlapping cron runs. if ( get_transient( 'maple_carts_marking_abandoned' ) ) { return; } set_transient( 'maple_carts_marking_abandoned', 1, 300 ); // 5 minute lock. $table = $wpdb->prefix . MAPLE_CARTS_TABLE; $cutoff_time = (int) Maple_Carts::get_option( 'cart_cutoff_time', 15 ); $cutoff = gmdate( 'Y-m-d H:i:s', strtotime( "-{$cutoff_time} minutes" ) ); $batch_size = 100; // Process 100 at a time. // Get active carts that should be marked as abandoned (limited batch). // Use COALESCE to handle existing carts that don't have last_tracked_at set yet. $carts = $wpdb->get_results( $wpdb->prepare( "SELECT id, email, billing_data FROM {$table} WHERE status = 'active' AND email IS NOT NULL AND email != '' AND COALESCE(last_tracked_at, updated_at) < %s LIMIT %d", $cutoff, $batch_size ) ); foreach ( $carts as $cart ) { // Determine abandonment type. $abandonment_type = $this->detect_abandonment_type( $cart ); Maple_Carts_DB::update_cart_status( $cart->id, 'abandoned', [ 'abandonment_type' => $abandonment_type, ] ); Maple_Carts_DB::schedule_emails( $cart->id ); } delete_transient( 'maple_carts_marking_abandoned' ); } /** * Detect abandonment type based on cart data. * * @param object $cart Cart object (minimal data from query). * @return string Abandonment type: browsing, checkout, or payment. */ private function detect_abandonment_type( $cart ) { // No email = browsing abandonment (but we don't track these, so this shouldn't happen). if ( empty( $cart->email ) ) { return 'browsing'; } // Check billing data for payment indicators. $billing_data = maybe_unserialize( $cart->billing_data ); // If we have address data, they got further into checkout. $has_address = ! empty( $billing_data['address_1'] ) || ! empty( $billing_data['city'] ) || ! empty( $billing_data['postcode'] ); if ( $has_address ) { // They filled out billing = likely payment abandonment. return 'payment'; } // Just email = checkout abandonment. return 'checkout'; } /** * Send admin recovery notification. * * @param WC_Order $order Order object. * @param object $cart Cart object. */ private function send_admin_recovery_notification( $order, $cart ) { $admin_email = Maple_Carts::get_option( 'admin_email', get_option( 'admin_email' ) ); $site_name = get_bloginfo( 'name' ); $subject = sprintf( /* translators: %1$s: Site name, %2$d: Order ID */ __( '[%1$s] Recovered Order #%2$d', 'maple-carts' ), $site_name, $order->get_id() ); $message = sprintf( /* translators: %1$d: Order ID, %2$s: Cart total, %3$s: Order link */ __( "Great news! An abandoned cart has been recovered.\n\nOrder #%1\$d\nRecovered Value: %2\$s\n\nView order: %3\$s", 'maple-carts' ), $order->get_id(), wc_price( $order->get_total() ), admin_url( 'post.php?post=' . $order->get_id() . '&action=edit' ) ); wp_mail( $admin_email, $subject, $message ); } }