| @@ -29,8 +29,33 @@ | ||
| 29 | 29 | */ |
| 30 | 30 | private static $instance; |
| 31 | 31 | |
| 32 | 32 | /** |
| 33 | + * Rows the pickers display, before `cartflows_product_search_result_limit` | |
| 34 | + * adjusts it for the product endpoint. | |
| 35 | + * | |
| 36 | + * @since x.x.x | |
| 37 | + * @var int | |
| 38 | + */ | |
| 39 | + const SEARCH_RESULT_LIMIT = 20; | |
| 40 | + | |
| 41 | + /** | |
| 42 | + * Floor for the rows read before the supported-product-type filter narrows them. | |
| 43 | + * | |
| 44 | + * @since x.x.x | |
| 45 | + * @var int | |
| 46 | + */ | |
| 47 | + const SEARCH_CANDIDATE_LIMIT = 100; | |
| 48 | + | |
| 49 | + /** | |
| 50 | + * Ceiling for `cartflows_product_search_result_limit`. | |
| 51 | + * | |
| 52 | + * @since x.x.x | |
| 53 | + * @var int | |
| 54 | + */ | |
| 55 | + const SEARCH_RESULT_LIMIT_MAX = 200; | |
| 56 | + | |
| 57 | + /** | |
| 33 | 58 | * Initiator |
| 34 | 59 | * |
| 35 | 60 | * @since 1.0.0 |
| 36 | 61 | * @return object initialized object of class. |
| @@ -58,9 +83,16 @@ | ||
| 58 | 83 | $this->init_ajax_events( $ajax_events ); |
| 59 | 84 | } |
| 60 | 85 | |
| 61 | 86 | /** |
| 62 | - * Clone step with its meta. | |
| 87 | + * AJAX handler to search products for the admin pickers. | |
| 88 | + * | |
| 89 | + * Searches titles, SKUs and GTINs only, ranked exact match first. It | |
| 90 | + * deliberately does not search descriptions. | |
| 91 | + * | |
| 92 | + * @see \Cartflows_Product_Search::search_product_ids() | |
| 93 | + * | |
| 94 | + * @return void | |
| 63 | 95 | */ |
| 64 | 96 | public function json_search_products() { |
| 65 | 97 | |
| 66 | 98 | if ( ! current_user_can( 'cartflows_manage_flows_steps' ) ) { |
| @@ -68,10 +100,8 @@ | ||
| 68 | 100 | } |
| 69 | 101 | |
| 70 | 102 | check_ajax_referer( 'cartflows_json_search_products', 'security' ); |
| 71 | 103 | |
| 72 | - global $wpdb; | |
| 73 | - | |
| 74 | 104 | if ( ! isset( $_POST['term'] ) ) { |
| 75 | 105 | return; |
| 76 | 106 | } |
| 77 | 107 | |
| @@ -76,8 +106,13 @@ | ||
| 76 | 106 | } |
| 77 | 107 | |
| 78 | 108 | $term = ! empty( $_POST['term'] ) ? sanitize_text_field( wp_unslash( $_POST['term'] ) ) : ''; |
| 79 | 109 | |
| 110 | + // sanitize_text_field() trims, so a whitespace-only term arrives empty here and would otherwise match every product. | |
| 111 | + if ( '' === trim( $term ) ) { | |
| 112 | + wp_send_json( array() ); | |
| 113 | + } | |
| 114 | + | |
| 80 | 115 | // CartFlows supported product types. |
| 81 | 116 | $supported_product_types = apply_filters( 'cartflows_supported_product_types_for_search', array( 'simple', 'variable', 'variation', 'subscription', 'variable-subscription', 'subscription_variation', 'course' ) ); |
| 82 | 117 | |
| 83 | 118 | // Allowed product types. |
| @@ -109,23 +144,29 @@ | ||
| 109 | 144 | |
| 110 | 145 | $supported_product_types = array_diff( $supported_product_types, $excluded_product_types ); |
| 111 | 146 | } |
| 112 | 147 | |
| 113 | - // Get all products data. | |
| 114 | - $data = \WC_Data_Store::load( 'product' ); | |
| 115 | - $ids = $data->search_products( $term, '', true, false, 11 ); | |
| 148 | + // Resolved before the search so raising the filter actually widens the candidate set it is sliced from. | |
| 149 | + $result_limit = (int) apply_filters( 'cartflows_product_search_result_limit', self::SEARCH_RESULT_LIMIT ); | |
| 116 | 150 | |
| 117 | - // Get all product objects. | |
| 118 | - $product_objects = array_filter( array_map( 'wc_get_product', $ids ), 'wc_products_array_filter_readable' ); | |
| 151 | + // Clamped at both ends: the candidate set is a multiple of this, and an unbounded filter value would exhaust memory here. | |
| 152 | + $result_limit = min( self::SEARCH_RESULT_LIMIT_MAX, max( 1, $result_limit ) ); | |
| 153 | + $candidate_limit = max( self::SEARCH_CANDIDATE_LIMIT, $result_limit * 5 ); | |
| 119 | 154 | |
| 120 | - // Remove the product objects whose product type are not in supported array. | |
| 121 | - $product_objects = array_filter( | |
| 122 | - $product_objects, | |
| 123 | - function ( $arr ) use ( $supported_product_types ) { | |
| 124 | - return $arr && is_a( $arr, 'WC_Product' ) && in_array( $arr->get_type(), $supported_product_types, true ); | |
| 125 | - } | |
| 126 | - ); | |
| 155 | + // Excluded here rather than in the browser: filtering after the cap hides products that were never sent. | |
| 156 | + $excluded_product_ids = isset( $_POST['exclude_product_ids'] ) ? $this->sanitize_id_list( sanitize_text_field( wp_unslash( $_POST['exclude_product_ids'] ) ) ) : array(); | |
| 127 | 157 | |
| 158 | + // Read a wide candidate set: the supported-type filter below would otherwise shrink an already capped list. | |
| 159 | + $ids = \Cartflows_Product_Search::search_product_ids( $term, $candidate_limit ); | |
| 160 | + | |
| 161 | + // One prime beats the post and meta reads wc_get_product() and get_type() would each make per candidate. | |
| 162 | + if ( ! empty( $ids ) ) { | |
| 163 | + _prime_post_caches( $ids, true, true ); | |
| 164 | + } | |
| 165 | + | |
| 166 | + // Hydrated lazily and capped pair-aware: the candidate set is far wider than the rows that get rendered. | |
| 167 | + $product_objects = \Cartflows_Product_Search::collect_supported_products( $ids, $supported_product_types, $result_limit, $excluded_product_ids ); | |
| 168 | + | |
| 128 | 169 | $products_found = array(); |
| 129 | 170 | |
| 130 | 171 | foreach ( $product_objects as $product_object ) { |
| 131 | 172 | $formatted_name = $product_object->get_name(); |
| @@ -152,8 +193,9 @@ | ||
| 152 | 193 | 'product_name' => $product_object->get_name(), |
| 153 | 194 | 'product_desc' => $product_object->get_short_description(), |
| 154 | 195 | 'product_image' => get_the_post_thumbnail_url( $product_object->get_id() ), |
| 155 | 196 | 'product_type' => $product_type, |
| 197 | + 'type_label' => $this->get_product_type_label( $product_object ), | |
| 156 | 198 | 'stock_status' => $availibility_text, |
| 157 | 199 | 'in_stock' => $is_in_stock, |
| 158 | 200 | 'price_range' => $product_price_range, |
| 159 | 201 | ) |
| @@ -163,9 +205,13 @@ | ||
| 163 | 205 | wp_send_json( $products_found ); |
| 164 | 206 | } |
| 165 | 207 | |
| 166 | 208 | /** |
| 167 | - * Function to search coupons | |
| 209 | + * AJAX handler to search coupons for the admin pickers. | |
| 210 | + * | |
| 211 | + * Matches anywhere in the coupon code, ranked exact then prefix then the rest. | |
| 212 | + * | |
| 213 | + * @return void | |
| 168 | 214 | */ |
| 169 | 215 | public function json_search_coupons() { |
| 170 | 216 | |
| 171 | 217 | if ( ! current_user_can( 'cartflows_manage_flows_steps' ) ) { |
| @@ -173,58 +219,41 @@ | ||
| 173 | 219 | } |
| 174 | 220 | |
| 175 | 221 | check_ajax_referer( 'cartflows_json_search_coupons', 'security' ); |
| 176 | 222 | |
| 177 | - global $wpdb; | |
| 178 | - | |
| 179 | 223 | if ( ! isset( $_POST['term'] ) ) { |
| 180 | 224 | return; |
| 181 | 225 | } |
| 182 | 226 | |
| 183 | 227 | $term = ! empty( $_POST['term'] ) ? sanitize_text_field( wp_unslash( $_POST['term'] ) ) : ''; |
| 184 | - if ( empty( $term ) ) { | |
| 228 | + | |
| 229 | + if ( '' === trim( $term ) ) { | |
| 185 | 230 | die(); |
| 186 | 231 | } |
| 187 | 232 | |
| 188 | - $posts = wp_cache_get( 'wcf_search_coupons', 'wcf_funnel_Cart' ); | |
| 233 | + $ids = \Cartflows_Coupon_Search::search_coupon_ids( $term, self::SEARCH_CANDIDATE_LIMIT ); | |
| 234 | + $coupons_found = \Cartflows_Coupon_Search::format_for_picker( $ids, self::SEARCH_RESULT_LIMIT ); | |
| 189 | 235 | |
| 190 | - if ( false === $posts ) { | |
| 191 | - $posts = $wpdb->get_results( | |
| 192 | - $wpdb->prepare( | |
| 193 | - "SELECT * | |
| 194 | - FROM {$wpdb->prefix}posts | |
| 195 | - WHERE post_type = %s | |
| 196 | - AND post_title LIKE %s | |
| 197 | - AND post_status = %s", | |
| 198 | - 'shop_coupon', | |
| 199 | - $wpdb->esc_like( $term ) . '%', | |
| 200 | - 'publish' | |
| 201 | - ) | |
| 202 | - ); // db call ok. | |
| 203 | - wp_cache_set( 'wcf_search_coupons', $posts, 'wcf_funnel_Cart' ); | |
| 204 | - } | |
| 236 | + wp_send_json( $coupons_found ); | |
| 237 | + } | |
| 205 | 238 | |
| 206 | - $coupons_found = array(); | |
| 207 | - $all_discount_types = wc_get_coupon_types(); | |
| 239 | + /** | |
| 240 | + * Sanitize a comma separated list of product IDs. | |
| 241 | + * | |
| 242 | + * @param mixed $raw Raw request value. | |
| 243 | + * @return array<int, int> Positive integer IDs. | |
| 244 | + */ | |
| 245 | + public function sanitize_id_list( $raw ) { | |
| 208 | 246 | |
| 209 | - if ( $posts ) { | |
| 210 | - foreach ( $posts as $post ) { | |
| 247 | + if ( is_string( $raw ) ) { | |
| 248 | + $raw = explode( ',', $raw ); | |
| 249 | + } | |
| 211 | 250 | |
| 212 | - $discount_type = get_post_meta( $post->ID, 'discount_type', true ); | |
| 213 | - | |
| 214 | - if ( ! empty( $all_discount_types[ $discount_type ] ) ) { | |
| 215 | - array_push( | |
| 216 | - $coupons_found, | |
| 217 | - array( | |
| 218 | - 'value' => get_the_title( $post->ID ), | |
| 219 | - 'label' => get_the_title( $post->ID ) . ' (Type: ' . $all_discount_types[ $discount_type ] . ')', | |
| 220 | - ) | |
| 221 | - ); | |
| 222 | - } | |
| 223 | - } | |
| 251 | + if ( ! is_array( $raw ) ) { | |
| 252 | + return array(); | |
| 224 | 253 | } |
| 225 | 254 | |
| 226 | - wp_send_json( $coupons_found ); | |
| 255 | + return array_values( array_filter( array_map( 'absint', $raw ) ) ); | |
| 227 | 256 | } |
| 228 | 257 | |
| 229 | 258 | /** |
| 230 | 259 | * Function to sanitize the product type data attribute. |
| @@ -283,8 +312,37 @@ | ||
| 283 | 312 | return $availability_text; |
| 284 | 313 | } |
| 285 | 314 | |
| 286 | 315 | /** |
| 316 | + * Get a short, human readable label for product types that behave differently in a funnel. | |
| 317 | + * | |
| 318 | + * Returned as its own field rather than appended to the product name, because the name is | |
| 319 | + * also used for the selected-product chip. Only types that need distinguishing are labelled; | |
| 320 | + * everything else returns an empty string so no tag is rendered. | |
| 321 | + * | |
| 322 | + * @since 3.1.4 | |
| 323 | + * @param \WC_Product $product The product object. | |
| 324 | + * @return string Label to show beside the product, or an empty string. | |
| 325 | + */ | |
| 326 | + public function get_product_type_label( $product ) { | |
| 327 | + | |
| 328 | + if ( ! is_a( $product, 'WC_Product' ) ) { | |
| 329 | + return ''; | |
| 330 | + } | |
| 331 | + | |
| 332 | + $labels = apply_filters( | |
| 333 | + 'cartflows_product_type_labels_for_search', | |
| 334 | + array( | |
| 335 | + 'bundle' => __( 'Bundle', 'cartflows' ), | |
| 336 | + ) | |
| 337 | + ); | |
| 338 | + | |
| 339 | + $product_type = $product->get_type(); | |
| 340 | + | |
| 341 | + return isset( $labels[ $product_type ] ) ? $labels[ $product_type ] : ''; | |
| 342 | + } | |
| 343 | + | |
| 344 | + /** | |
| 287 | 345 | * Function to generate a formatted price range for a product. |
| 288 | 346 | * |
| 289 | 347 | * This function determines the price range for a product based on its type. For variable products, it calculates the price range from the minimum and maximum prices of its variations. For other product types, it uses the product's price or regular price if available. |
| 290 | 348 | * The function returns a formatted string representing the price range. |
| @@ -314,8 +372,25 @@ | ||
| 314 | 372 | |
| 315 | 373 | if ( $min_reg_price !== $max_reg_price && ! is_null( $min_reg_price ) && ! is_null( $max_reg_price ) ) { |
| 316 | 374 | if ( ! empty( $original_price ) ) { |
| 317 | 375 | $original_price = html_entity_decode( wp_strip_all_tags( wc_price( $min_reg_price ) ) ) . ' – ' . html_entity_decode( wp_strip_all_tags( wc_price( $max_reg_price ) ) ); |
| 376 | + } | |
| 377 | + } | |
| 378 | + } elseif ( 'bundle' === $product_type && method_exists( $product, 'get_bundle_price' ) ) { | |
| 379 | + /* | |
| 380 | + * A bundle's own price is only its container base price, which is 0 whenever the | |
| 381 | + * bundled items are priced individually. Use the bundle's min/max price instead so | |
| 382 | + * the selector shows what the customer would actually pay, as it does for variable | |
| 383 | + * products. Guarded on the method so this stays inert without Product Bundles. | |
| 384 | + */ | |
| 385 | + $min_price = $product->get_bundle_price( 'min', false ); | |
| 386 | + $max_price = $product->get_bundle_price( 'max', false ); | |
| 387 | + | |
| 388 | + if ( '' !== $min_price && null !== $min_price ) { | |
| 389 | + $original_price = html_entity_decode( wp_strip_all_tags( wc_price( $min_price ) ) ); | |
| 390 | + | |
| 391 | + if ( '' !== $max_price && null !== $max_price && (float) $max_price !== (float) $min_price ) { | |
| 392 | + $original_price .= ' – ' . html_entity_decode( wp_strip_all_tags( wc_price( $max_price ) ) ); | |
| 318 | 393 | } |
| 319 | 394 | } |
| 320 | 395 | } else { |
| 321 | 396 | $price = $product->get_price(); |