PluginProbe
CartFlows – Funnel Builder & Checkout Plugin for WooCommerce / 3.3.0
CartFlows – Funnel Builder & Checkout Plugin for WooCommerce v3.3.0
3.3.0 3.2.1 3.2.0 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.1 trunk 1.0.4 1.1.0 1.1.0.1 1.1.1 1.1.10 1.1.11 1.1.12 1.1.13 1.1.14 1.1.15 1.1.16 1.1.17 1.1.18 1.1.19 1.1.2 All 162 releases
← All changes | admin-core/ajax/meta-data.php +127 -52 3.1.3 → 3.3.0 View file →
@@ -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();