*/ private array $tracked_conversion_orders = []; public function __construct( bool $registerHooks = true ) { if ( ! $registerHooks ) { return; } // Keep cleanup registered while disabled, including for returning shoppers // whose saved cart still contains a previously selected bump. add_action( 'woocommerce_cart_loaded_from_session', [ $this, 'syncDisabledBumps' ], 1 ); add_action( 'woocommerce_before_calculate_totals', [ $this, 'removeDisabledBumps' ], 1 ); // Render bump on checkout add_action( 'woocommerce_review_order_before_submit', [ $this, 'renderBump' ], 10 ); // Handle bump add via AJAX add_action( 'wp_ajax_arrayfunnel_add_bump', [ $this, 'ajaxAddBump' ] ); add_action( 'wp_ajax_nopriv_arrayfunnel_add_bump', [ $this, 'ajaxAddBump' ] ); // Remove bump if unchecked add_action( 'wp_ajax_arrayfunnel_remove_bump', [ $this, 'ajaxRemoveBump' ] ); add_action( 'wp_ajax_nopriv_arrayfunnel_remove_bump', [ $this, 'ajaxRemoveBump' ] ); // Track bump acceptance on order create add_action( 'woocommerce_checkout_create_order_line_item', [ $this, 'markBumpItem' ], 10, 4 ); // Track conversion event (classic + blocks + payment callbacks). add_action( 'woocommerce_checkout_order_created', [ $this, 'trackBumpConversion' ], 10, 1 ); add_action( 'woocommerce_checkout_order_processed', [ $this, 'trackBumpConversionByOrderId' ], 10, 3 ); add_action( 'woocommerce_store_api_checkout_order_processed', [ $this, 'trackBumpConversion' ], 10, 1 ); add_action( 'woocommerce_payment_complete', [ $this, 'trackBumpConversionByOrderId' ], 10, 1 ); } public static function isEnabled(): bool { return 'yes' === get_option( 'arrayfunnel_order_bumps_enabled', 'yes' ); } /** Recalculate cached totals after removing bumps from a returning cart. */ public function syncDisabledBumps( $cart ): void { if ( $this->removeDisabledBumps( $cart ) ) { $cart->calculate_totals(); } } /** * Remove only module-added cart lines, leaving ordinary purchases untouched. * Clear their undo entries as well so a disabled offer cannot be restored. */ public function removeDisabledBumps( $cart ): bool { if ( self::isEnabled() || ! $cart instanceof \WC_Cart ) { return false; } $changed = false; foreach ( $cart->get_cart() as $key => $item ) { if ( isset( $item['arrayfunnel_bump_id'] ) ) { $changed = $cart->remove_cart_item( $key ) || $changed; } } $removed_items = $cart->get_removed_cart_contents(); foreach ( $removed_items as $key => $item ) { if ( isset( $item['arrayfunnel_bump_id'] ) ) { unset( $removed_items[ $key ] ); $changed = true; } } $cart->set_removed_cart_contents( $removed_items ); return $changed; } public function renderBump(): void { if ( ! self::isEnabled() || ! function_exists( 'WC' ) || ! WC()->cart ) { return; } $bumps = $this->getActiveBumpsForCart(); if ( empty( $bumps ) ) { return; } $max_display = max( 1, (int) apply_filters( 'arrayfunnel_order_bump_max_display', 10 ) ); $shown = 0; foreach ( $bumps as $bump ) { if ( $shown >= $max_display ) { break; } $product = wc_get_product( $bump->product_id ); if ( ! $product || ! $product->is_purchasable() || ! $product->is_in_stock() ) { continue; } $template = (string) ( $bump->template ?? 'classic' ); if ( ! in_array( $template, self::TEMPLATES, true ) ) { $template = 'classic'; } $this->trackEvent( 'bump_impression', (int) $bump->id ); include dirname( __DIR__ ) . "/views/bump-{$template}.php"; ++$shown; } } public function ajaxAddBump(): void { check_ajax_referer( 'arrayfunnel_checkout_nonce', 'arrayfunnel_nonce' ); $this->requireEnabled(); $this->requireCart(); $bump_id = absint( wp_unslash( $_POST['arrayfunnel_bump_id'] ?? 0 ) ); $product_id = absint( wp_unslash( $_POST['arrayfunnel_product_id'] ?? 0 ) ); if ( ! $bump_id || ! $product_id ) { wp_send_json_error( __( 'Invalid request.', 'arrayfunnel' ), 400 ); } // Verify product $product = wc_get_product( $product_id ); if ( ! $product || ! $product->is_purchasable() ) { wp_send_json_error( __( 'Product not available.', 'arrayfunnel' ), 400 ); } // Add to cart with bump flag try { $cart_item_key = WC()->cart->add_to_cart( $product_id, 1, 0, [], [ 'arrayfunnel_bump_id' => $bump_id, ] ); if ( ! $cart_item_key ) { // Check if there are any notices from WooCommerce $notices = wc_get_notices( 'error' ); $message = ! empty( $notices ) ? wp_strip_all_tags( (string) ( $notices[0]['notice'] ?? '' ) ) : __( 'Could not add product to cart. It might require selection of options.', 'arrayfunnel' ); wc_clear_notices(); wp_send_json_error( $message, 400 ); } } catch ( \Exception $e ) { wp_send_json_error( __( 'Could not add product to cart.', 'arrayfunnel' ), 500 ); } $this->trackEvent( 'bump_accepted', $bump_id ); // Important: Force calculation and session save WC()->cart->calculate_totals(); WC()->cart->maybe_set_cart_cookies(); if ( is_object( WC()->session ) ) { WC()->session->set( 'cart', WC()->cart->get_cart_for_session() ); } // Return updated totals and fragments for the frontend $fragments = []; if ( function_exists( 'woocommerce_order_review' ) ) { ob_start(); woocommerce_order_review(); $fragments['.woocommerce-checkout-review-order-table'] = ob_get_clean(); } wp_send_json_success( [ 'cart_total' => WC()->cart->get_total(), 'fragments' => $fragments, 'message' => __( 'Added to your order!', 'arrayfunnel' ), ] ); } public function ajaxRemoveBump(): void { check_ajax_referer( 'arrayfunnel_checkout_nonce', 'arrayfunnel_nonce' ); $this->requireEnabled(); $this->requireCart(); $product_id = absint( wp_unslash( $_POST['arrayfunnel_product_id'] ?? 0 ) ); foreach ( WC()->cart->get_cart() as $key => $item ) { if ( (int) $item['product_id'] === $product_id && isset( $item['arrayfunnel_bump_id'] ) ) { WC()->cart->remove_cart_item( $key ); break; } } WC()->cart->calculate_totals(); WC()->cart->maybe_set_cart_cookies(); if ( is_object( WC()->session ) ) { WC()->session->set( 'cart', WC()->cart->get_cart_for_session() ); } $fragments = []; if ( function_exists( 'woocommerce_order_review' ) ) { ob_start(); woocommerce_order_review(); $fragments['.woocommerce-checkout-review-order-table'] = ob_get_clean(); } wp_send_json_success( [ 'cart_total' => WC()->cart->get_total(), 'fragments' => $fragments ] ); } /** Abort the request when WooCommerce has no cart to act on. */ private function requireCart(): void { if ( ! function_exists( 'WC' ) || ! WC()->cart ) { wp_send_json_error( __( 'Cart is unavailable.', 'arrayfunnel' ), 400 ); } } /** Reject stale checkout controls after the administrator disables the module. */ private function requireEnabled(): void { if ( ! self::isEnabled() ) { wp_send_json_error( __( 'Order bumps are disabled.', 'arrayfunnel' ), 403 ); } } public function markBumpItem( \WC_Order_Item_Product $item, string $cart_item_key, array $cart_item, \WC_Order $order ): void { if ( self::isEnabled() && isset( $cart_item['arrayfunnel_bump_id'] ) ) { $item->add_meta_data( '_arrayfunnel_bump_id', (int) $cart_item['arrayfunnel_bump_id'], true ); $item->add_meta_data( '_arrayfunnel_is_bump', true, true ); } } public function trackBumpConversion( $order ): void { if ( is_numeric( $order ) ) { $this->trackBumpConversionByOrderId( (int) $order ); return; } if ( ! $order instanceof \WC_Order ) { return; } $order_id = (int) $order->get_id(); if ( isset( $this->tracked_conversion_orders[ $order_id ] ) ) { return; } $this->tracked_conversion_orders[ $order_id ] = true; foreach ( $order->get_items() as $item ) { $bump_id = $item->get_meta( '_arrayfunnel_bump_id' ); if ( $bump_id ) { $this->trackEvent( 'bump_converted', (int) $bump_id, $order_id ); } } } public function trackBumpConversionByOrderId( int $order_id ): void { if ( $order_id <= 0 ) { return; } $order = wc_get_order( $order_id ); if ( ! $order instanceof \WC_Order ) { return; } $this->trackBumpConversion( $order ); } /** * Get every active bump matching the current cart. * * @return array */ public function getActiveBumpsForCart(): array { if ( ! self::isEnabled() ) { return []; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $bumps = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}arrayfunnel_order_bumps WHERE status = %d ORDER BY priority ASC", 1 ) ); if ( empty( $bumps ) ) { return []; } $matched = []; foreach ( $bumps as $bump ) { if ( $this->checkCondition( $bump ) && ! $this->isAlreadyInCart( (int) $bump->product_id ) ) { $matched[] = $bump; } } return $matched; } private function checkCondition( object $bump ): bool { $type = $bump->condition_type ?? 'all'; $value = $bump->condition_value ?? ''; switch ( $type ) { case 'all': return true; case 'product': $product_ids = array_map( 'absint', (array) json_decode( $value, true ) ); foreach ( WC()->cart->get_cart() as $item ) { if ( in_array( (int) $item['product_id'], $product_ids, true ) ) { return true; } } return false; case 'category': $category_ids = array_map( 'absint', (array) json_decode( $value, true ) ); foreach ( WC()->cart->get_cart() as $item ) { $terms = wp_get_post_terms( $item['product_id'], 'product_cat', [ 'fields' => 'ids' ] ); if ( array_intersect( $category_ids, $terms ) ) { return true; } } return false; case 'cart_total': $data = json_decode( $value, true ); $min = (float) ( $data['min'] ?? 0 ); $max = (float) ( $data['max'] ?? PHP_INT_MAX ); $total = (float) WC()->cart->get_cart_contents_total(); return $total >= $min && $total <= $max; default: return true; } } private function isAlreadyInCart( int $product_id ): bool { foreach ( WC()->cart->get_cart() as $item ) { if ( (int) $item['product_id'] === $product_id ) { return true; } } return false; } private function trackEvent( string $event_type, int $bump_id, int $order_id = 0 ): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $inserted = $wpdb->insert( $wpdb->prefix . 'arrayfunnel_analytics', [ 'event_type' => $event_type, 'order_id' => $order_id > 0 ? $order_id : 0, 'meta' => wp_json_encode( [ 'bump_id' => $bump_id ] ), 'session_id' => $this->getSessionId(), 'created_at' => current_time( 'mysql', true ), ], [ '%s', '%d', '%s', '%s', '%s' ] ); if ( false !== $inserted ) { $this->invalidateAnalyticsCache(); } } private function invalidateAnalyticsCache(): void { global $wpdb; $patterns = [ '_transient_arrayfunnel_overview_%', '_transient_timeout_arrayfunnel_overview_%', '_transient_arrayfunnel_revenue_chart_%', '_transient_timeout_arrayfunnel_revenue_chart_%', '_transient_arrayfunnel_events_log_%', '_transient_timeout_arrayfunnel_events_log_%', ]; foreach ( $patterns as $pattern ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $pattern ) ); } } private function getSessionId(): string { if ( function_exists( 'WC' ) && WC()->session ) { $customer_id = WC()->session->get_customer_id(); if ( ! empty( $customer_id ) ) { return hash( 'sha256', 'arrayfunnel_analytics|' . (string) $customer_id ); } } return wp_generate_uuid4(); } /** * Get bumps for the REST API and admin application. * * @param int $limit Maximum number of records. * @param int $offset Record offset. */ public function getBumps( int $limit = 100, int $offset = 0 ): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}arrayfunnel_order_bumps ORDER BY priority ASC LIMIT %d OFFSET %d", $limit, $offset ) ); } public function countBumps( ?int $status = null ): int { global $wpdb; if ( null === $status ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}arrayfunnel_order_bumps" ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}arrayfunnel_order_bumps WHERE status = %d", $status ) ); } public function getBump( int $id ): ?object { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}arrayfunnel_order_bumps WHERE id = %d", $id ) ); } public function createBump( array $data ): int { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->insert( $wpdb->prefix . 'arrayfunnel_order_bumps', [ 'title' => sanitize_text_field( $data['title'] ?? '' ), 'product_id' => absint( $data['product_id'] ?? 0 ), 'condition_type' => sanitize_key( $data['condition_type'] ?? 'all' ), 'condition_value' => wp_json_encode( $data['condition_value'] ?? null ), 'display_settings' => wp_json_encode( $data['display_settings'] ?? [] ), 'template' => sanitize_key( $data['template'] ?? 'classic' ), 'status' => absint( $data['status'] ?? 1 ), 'priority' => absint( $data['priority'] ?? 0 ), 'created_at' => current_time( 'mysql', true ), ], [ '%s', '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%s' ] ); return (int) $wpdb->insert_id; } /** * Update the supplied columns of a bump. * * @param int $id Bump ID. * @param array $data Columns to write. Unknown keys are ignored. */ public function updateBump( int $id, array $data ): bool { global $wpdb; $columns = [ 'title' => [ '%s', static fn( $value ) => sanitize_text_field( (string) $value ) ], 'product_id' => [ '%d', 'absint' ], 'condition_type' => [ '%s', static fn( $value ) => sanitize_key( (string) $value ) ], 'condition_value' => [ '%s', 'wp_json_encode' ], 'display_settings' => [ '%s', 'wp_json_encode' ], 'template' => [ '%s', static fn( $value ) => sanitize_key( (string) $value ) ], 'status' => [ '%d', 'absint' ], 'priority' => [ '%d', 'absint' ], ]; $update = []; $format = []; foreach ( $columns as $column => [ $placeholder, $sanitizer ] ) { if ( ! array_key_exists( $column, $data ) ) { continue; } $update[ $column ] = $sanitizer( $data[ $column ] ); $format[] = $placeholder; } if ( empty( $update ) ) { return false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching return (bool) $wpdb->update( $wpdb->prefix . 'arrayfunnel_order_bumps', $update, [ 'id' => $id ], $format, [ '%d' ] ); } public function deleteBump( int $id ): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching return (bool) $wpdb->delete( $wpdb->prefix . 'arrayfunnel_order_bumps', [ 'id' => $id ], [ '%d' ] ); } }