PluginProbe
Product Labels, Quick View, Buy Now, Pre-Orders, Frequently Bought Together & More for WooCommerce – Merchant / 2.3.2
Product Labels, Quick View, Buy Now, Pre-Orders, Frequently Bought Together & More for WooCommerce – Merchant v2.3.2
2.3.2 2.3.1 2.3.0 2.2.8 2.2.7 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.11.0 1.11.1 1.11.2 1.6 1.7 1.8 1.8.1 1.8.2 1.8.3 1.9.0 1.9.1 1.9.10 1.9.11 All 60 releases
merchant / admin / classes / class-merchant-admin-ajax.php

class-merchant-admin-ajax.php in Product Labels, Quick View, Buy Now, Pre-Orders, Frequently Bought Together & More for WooCommerce – Merchant 2.3.2, at admin/classes/class-merchant-admin-ajax.php

1,147 lines 40.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Merchant Admin AJAX Handlers.
4 *
5 * Handles all AJAX requests for the Merchant admin options panel.
6 * Extracted from Merchant_Admin_Options to keep the orchestrator slim.
7 *
8 * @package Merchant
9 * @since 2.2.5
10 */
11
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 /**
17 * Merchant_Admin_Ajax
18 *
19 * Handles all WordPress AJAX requests for the Merchant admin options panel.
20 * Extracted from {@see Merchant_Admin_Options} to keep the orchestrator focused
21 * on rendering and saving. Each method is registered as a `wp_ajax_*` handler.
22 *
23 * Provides search endpoints for products, reviews, and Select2 content,
24 * as well as module settings backup/restore functionality.
25 *
26 * @since 2.2.5
27 */
28 class Merchant_Admin_Ajax {
29
30 /**
31 * Create a WP_Query instance.
32 *
33 * @since 2.2.5
34 *
35 * @param array $args Query arguments.
36 *
37 * @return WP_Query
38 */
39 protected static function make_wp_query( array $args ): WP_Query {
40 return new WP_Query( $args );
41 }
42
43 /**
44 * Create a WP_User_Query instance.
45 *
46 * @since 2.2.5
47 *
48 * @param array $args Query arguments.
49 *
50 * @return WP_User_Query
51 */
52 protected static function make_wp_user_query( array $args ): WP_User_Query {
53 return new WP_User_Query( $args );
54 }
55
56 /**
57 * Create a WC_Product_Query instance.
58 *
59 * @since 2.2.5
60 *
61 * @param array $args Query arguments.
62 *
63 * @return WC_Product_Query
64 */
65 protected static function make_wc_product_query( array $args ) {
66 return new WC_Product_Query( $args );
67 }
68
69 /**
70 * Constructor.
71 *
72 * Registers all `wp_ajax_*` action hooks for Merchant admin AJAX handlers.
73 *
74 * @since 2.2.5
75 */
76 public function __construct() {
77 add_action( 'wp_ajax_merchant_create_page_control', array( $this, 'create_page_control_ajax_callback' ) );
78 add_action( 'wp_ajax_merchant_admin_options_select_ajax', array( $this, 'select_content_ajax' ) );
79 add_action( 'wp_ajax_merchant_admin_products_search', array( $this, 'products_search' ) );
80 add_action( 'wp_ajax_merchant_get_review_images', array( $this, 'get_review_images' ) );
81 add_action( 'wp_ajax_merchant_search_reviews', array( $this, 'search_reviews' ) );
82 add_action( 'wp_ajax_merchant_load_more_reviews', array( $this, 'load_more_reviews' ) );
83 add_action( 'wp_ajax_merchant_get_module_settings', array( $this, 'get_module_settings' ) );
84 add_action( 'wp_ajax_merchant_restore_module_settings', array( $this, 'restore_module_settings' ) );
85 }
86
87 // ───────────────────────────────────────────────────────────────
88 // Page creation
89 // ───────────────────────────────────────────────────────────────
90
91 /**
92 * Create a new WordPress page via AJAX.
93 *
94 * @since 2.2.5
95 *
96 * @return void Sends JSON response and exits.
97 */
98 public function create_page_control_ajax_callback() {
99 check_ajax_referer( 'customize-create-page-control-nonce', 'nonce' );
100
101 if ( ! current_user_can( 'manage_options' ) ) {
102 wp_send_json_error( 'You are not allowed to do this.' );
103 }
104
105 $postarr = self::build_page_post_array();
106 $page_id = wp_insert_post( $postarr );
107
108 if ( is_wp_error( $page_id ) ) {
109 wp_send_json( array( 'status' => 'error' ) );
110 }
111
112 $option_name = isset( $_POST['option_name'] ) ? sanitize_text_field( $_POST['option_name'] ) : '';
113 if ( $option_name ) {
114 update_option( $option_name, $page_id );
115 }
116
117 wp_send_json( array( 'status' => 'success', 'page_id' => $page_id ) );
118 }
119
120 /**
121 * Build the post array for page creation from POST data.
122 *
123 * @since 2.2.5
124 *
125 * @return array Post array suitable for wp_insert_post.
126 */
127 private static function build_page_post_array(): array {
128 // Nonce verified in create_page() before calling this method.
129 // phpcs:ignore WordPress.Security.NonceVerification.Missing
130 $page_title = isset( $_POST['page_title'] ) ? sanitize_text_field( $_POST['page_title'] ) : '';
131 // phpcs:ignore WordPress.Security.NonceVerification.Missing
132 $page_meta_key = isset( $_POST['page_meta_key'] ) ? sanitize_text_field( $_POST['page_meta_key'] ) : '';
133 // phpcs:ignore WordPress.Security.NonceVerification.Missing
134 $page_meta_value = isset( $_POST['page_meta_value'] ) ? sanitize_text_field( $_POST['page_meta_value'] ) : '';
135
136 $meta_input = array();
137 if ( $page_meta_key && $page_meta_value ) {
138 $meta_input = array( $page_meta_key => $page_meta_value );
139 }
140
141 return array(
142 'post_type' => 'page',
143 'post_status' => 'publish',
144 'post_title' => $page_title,
145 'post_content' => '',
146 'meta_input' => $meta_input,
147 );
148 }
149
150 // ───────────────────────────────────────────────────────────────
151 // Select2 content search
152 // ───────────────────────────────────────────────────────────────
153
154 /**
155 * AJAX handler for Select2 content search.
156 *
157 * @since 2.2.5
158 *
159 * @return void Sends JSON response and exits.
160 */
161 public function select_content_ajax() {
162 $term = isset( $_GET['term'] ) ? sanitize_text_field( wp_unslash( $_GET['term'] ) ) : '';
163 $nonce = isset( $_GET['nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['nonce'] ) ) : '';
164 $source = isset( $_GET['source'] ) ? sanitize_text_field( wp_unslash( $_GET['source'] ) ) : '';
165
166 if ( ! current_user_can( 'manage_options' ) ) {
167 wp_send_json_error( 'You are not allowed to do this.' );
168 }
169
170 if ( empty( $term ) || empty( $source ) || empty( $nonce ) || ! wp_verify_nonce( $nonce, 'merchant_admin_options' ) ) {
171 wp_send_json_error();
172 }
173
174 $options = ( 'user' === $source )
175 ? self::search_users( $term )
176 : self::search_posts_or_products( $term, $source );
177
178 wp_send_json_success( $options );
179 }
180
181 /**
182 * Search posts or products for Select2.
183 *
184 * @since 2.2.5
185 *
186 * @param string $term Search term.
187 * @param string $source Post type ('post' or 'product').
188 *
189 * @return array Select2-compatible results.
190 */
191 private static function search_posts_or_products( string $term, string $source ): array {
192 $query = static::make_wp_query( array(
193 's' => $term,
194 'post_type' => $source,
195 'post_status' => 'publish',
196 'posts_per_page' => 25,
197 'order' => 'DESC',
198 ) );
199
200 $options = array();
201 foreach ( $query->posts as $post ) {
202 $options[] = array( 'id' => $post->ID, 'text' => $post->post_title );
203 }
204
205 return $options;
206 }
207
208 /**
209 * Search users for Select2.
210 *
211 * @since 2.2.5
212 *
213 * @param string $term Search term.
214 *
215 * @return array Select2-compatible results.
216 */
217 private static function search_users( string $term ): array {
218 $query = static::make_wp_user_query( array(
219 'search' => '*' . $term . '*',
220 'search_columns' => array( 'user_login', 'user_nicename', 'user_email', 'user_url' ),
221 'number' => 25,
222 ) );
223
224 $options = array();
225 foreach ( $query->results as $user ) {
226 $options[] = array( 'id' => $user->ID, 'text' => $user->display_name );
227 }
228
229 return $options;
230 }
231
232 // ───────────────────────────────────────────────────────────────
233 // Reviews
234 // ───────────────────────────────────────────────────────────────
235
236 /**
237 * AJAX handler for searching product reviews.
238 *
239 * @since 1.10.4
240 *
241 * @return void Sends JSON response and exits.
242 */
243 public function search_reviews() {
244 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( $_POST['nonce'] ), 'merchant_admin_options' ) ) {
245 wp_send_json_error( array( 'message' => esc_html__( 'Nonce verification failed', 'merchant' ) ) );
246 }
247
248 $term = '';
249 if ( isset( $_POST['search'] ) && ! empty( $_POST['search'] ) ) {
250 $term = trim( sanitize_text_field( $_POST['search'] ) );
251 }
252 wp_send_json_success( self::products_selector_search_results( $term ) );
253 }
254
255 /**
256 * AJAX handler for loading additional product reviews.
257 *
258 * @since 1.10.4
259 *
260 * @return void Sends JSON response and exits.
261 */
262 public function load_more_reviews() {
263 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( $_POST['nonce'] ), 'merchant_admin_options' ) ) {
264 wp_send_json_error( array( 'message' => esc_html__( 'Nonce verification failed', 'merchant' ) ) );
265 }
266
267 $product_id = isset( $_POST['product_id'] ) ? absint( $_POST['product_id'] ) : 0;
268 $offset = isset( $_POST['offset'] ) ? absint( $_POST['offset'] ) : 0;
269 if( 0 === $product_id ) {
270 wp_send_json_error( array( 'message' => esc_html__( 'Invalid product ID', 'merchant' ) ) );
271 }
272
273 wp_send_json_success( self::get_rendered_product_reviews( wc_get_product( $product_id ), $offset ) );
274 }
275
276 // ───────────────────────────────────────────────────────────────
277 // Module settings backup / restore
278 // ───────────────────────────────────────────────────────────────
279
280 /**
281 * AJAX handler for exporting module settings.
282 *
283 * @since 2.2.5
284 *
285 * @return void Sends JSON response and exits.
286 */
287 public function get_module_settings() {
288 if ( ! isset( $_GET['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( $_GET['nonce'] ), 'merchant_admin_options' ) ) {
289 wp_send_json_error( array( 'message' => esc_html__( 'Nonce verification failed', 'merchant' ) ) );
290 }
291
292 if ( ! current_user_can( 'manage_options' ) ) {
293 wp_send_json_error( array( 'message' => esc_html__( 'You do not have permission to access this page', 'merchant' ) ) );
294 }
295
296 if ( ! isset( $_GET['module_id'] ) ) {
297 wp_send_json_error( array( 'message' => esc_html__( 'Module ID is required', 'merchant' ) ) );
298 }
299
300 $module_id = sanitize_text_field( $_GET['module_id'] );
301
302 $modules = merchant_get_modules_data();
303
304 if ( ! isset( $modules[ $module_id ] ) ) {
305 wp_send_json_error( array( 'message' => esc_html__( 'Module not found', 'merchant' ) ) );
306 }
307
308 $module_object = Merchant_Modules::get_module( $module_id );
309
310 if ( ! $module_object ) {
311 wp_send_json_error( array( 'message' => esc_html__( 'Module not available', 'merchant' ) ) );
312 }
313
314 wp_send_json_success( array(
315 'timestamp' => time(),
316 'module_id' => $module_id,
317 'settings' => $module_object->get_module_settings(),
318 ) );
319 }
320
321 /**
322 * AJAX handler for restoring module settings from a backup.
323 *
324 * @since 2.2.5
325 *
326 * @return void Sends JSON response and exits.
327 */
328 public function restore_module_settings() {
329 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( $_POST['nonce'] ), 'merchant_admin_options' ) ) {
330 wp_send_json_error( array( 'message' => esc_html__( 'Nonce verification failed', 'merchant' ) ) );
331 }
332
333 if ( ! current_user_can( 'manage_options' ) ) {
334 wp_send_json_error( array( 'message' => esc_html__( 'You do not have permission to access this page', 'merchant' ) ) );
335 }
336
337 $module_id = self::get_validated_module_id( $_POST, 'module_id' );
338 $sanitized_settings = self::parse_and_validate_settings( $module_id );
339
340 $module_object = Merchant_Modules::get_module( $module_id );
341
342 if ( ! $module_object ) {
343 wp_send_json_error( array( 'message' => esc_html__( 'Module not available', 'merchant' ) ) );
344 }
345
346 $module_object->update_module_settings( $sanitized_settings['settings'] );
347
348 $admin_url = add_query_arg( array(
349 'page' => 'merchant',
350 'module' => $module_id,
351 ), esc_url( admin_url( 'admin.php' ) ) );
352
353 wp_send_json_success( array(
354 'message' => esc_html__( 'Settings restored successfully', 'merchant' ),
355 'redirect_url' => $admin_url,
356 ) );
357 }
358
359 /**
360 * Validate and return a module ID from request data.
361 *
362 * @since 2.2.5
363 *
364 * @param array $data Request data ($_GET or $_POST).
365 * @param string $key Key to look up.
366 *
367 * @return string Sanitized module ID.
368 */
369 private static function get_validated_module_id( array $data, string $key ): string {
370 if ( ! isset( $data[ $key ] ) ) {
371 wp_send_json_error( array( 'message' => esc_html__( 'Module ID is required', 'merchant' ) ) );
372 }
373
374 $module_id = sanitize_text_field( $data[ $key ] );
375 $modules = merchant_get_modules_data();
376
377 if ( ! isset( $modules[ $module_id ] ) ) {
378 wp_send_json_error( array( 'message' => esc_html__( 'Module not found', 'merchant' ) ) );
379 }
380
381 return $module_id;
382 }
383
384 /**
385 * Parse, validate, and sanitize module settings from POST JSON.
386 *
387 * @since 2.2.5
388 *
389 * @param string $module_id Expected module ID for cross-check.
390 *
391 * @return array Sanitized settings array.
392 */
393 private static function parse_and_validate_settings( string $module_id ): array {
394 // Nonce verified in restore_module_settings() before calling this method.
395 // phpcs:ignore WordPress.Security.NonceVerification.Missing
396 if ( ! isset( $_POST['module_settings'] ) ) {
397 wp_send_json_error( array( 'message' => esc_html__( 'Default settings not found', 'merchant' ) ) );
398 }
399
400 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing
401 $module_settings = json_decode( wp_unslash( $_POST['module_settings'] ), true );
402
403 if ( json_last_error() !== JSON_ERROR_NONE ) {
404 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing
405 wp_send_json_error( array( 'message' => esc_html__( 'Invalid settings data', 'merchant' ), $_POST['module_settings'] ) );
406 }
407
408 $sanitized = map_deep( $module_settings, 'sanitize_text_field' );
409
410 if ( ! isset( $sanitized['module_id'] ) || $sanitized['module_id'] !== $module_id ) {
411 wp_send_json_error( array( 'message' => esc_html__( 'Invalid module ID', 'merchant' ) ) );
412 }
413
414 if ( ! isset( $sanitized['settings'] ) || ! is_array( $sanitized['settings'] ) ) {
415 wp_send_json_error( array( 'message' => esc_html__( 'Invalid or missing settings data', 'merchant' ) ) );
416 }
417
418 return $sanitized;
419 }
420
421 // ───────────────────────────────────────────────────────────────
422 // Review images
423 // ───────────────────────────────────────────────────────────────
424
425 /**
426 * AJAX handler for retrieving review images.
427 *
428 * @since 1.10.4
429 *
430 * @return void Sends JSON response and exits.
431 */
432 public function get_review_images() {
433 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( $_POST['nonce'] ), 'merchant_admin_options' ) ) {
434 wp_send_json_error( array( 'message' => esc_html__( 'Nonce verification failed', 'merchant' ) ) );
435 }
436 $review_id = 0;
437 if ( ! isset( $_POST['review_id'] ) || ! is_numeric( $_POST['review_id'] ) ) {
438 wp_send_json_error( array( 'message' => esc_html__( 'Invalid review ID', 'merchant' ) ) );
439 } else {
440 $review_id = absint( $_POST['review_id'] );
441 }
442 $photos_ids = get_comment_meta( $review_id, 'review_images', true );
443 if ( empty( $photos_ids ) ) {
444 wp_send_json_error( array( 'message' => esc_html__( 'No images found', 'merchant' ) ) );
445 }
446 $images = array_map( static function ( $image_id ) {
447 return wp_get_attachment_image_url( $image_id, 'full' );
448 }, $photos_ids );
449 $response = '<div class="review-photos-popup images-count-' . count( $images ) . '" data-images-count="' . count( $images ) . '">';
450 $response .= '<div class="overlay"></div>';
451 foreach ( $images as $image ) {
452 $response .= '<div class="review-photo"><a href="' . esc_url( $image ) . '" target="_blank"><img src="' . esc_url( $image ) . '" alt=""></a></div>';
453 }
454 $response .= '</div>';
455
456 wp_send_json_success( $response );
457 }
458
459 // ───────────────────────────────────────────────────────────────
460 // Product search
461 // ───────────────────────────────────────────────────────────────
462
463 /**
464 * AJAX handler for WooCommerce product search.
465 *
466 * Orchestrates: validate → parse types → build query → render results.
467 *
468 * @since 2.2.5
469 *
470 * @return void Outputs HTML and exits.
471 */
472 public function products_search() {
473 check_ajax_referer( 'merchant_admin_options', 'nonce' );
474
475 if ( ! isset( $_POST['keyword'] ) || empty( $_POST['keyword'] ) ) {
476 wp_die();
477 return; // @codeCoverageIgnore
478 }
479
480 $keyword = sanitize_text_field( $_POST['keyword'] );
481 $types = self::get_product_search_types();
482 $hierarchy = self::is_hierarchy_search( $types );
483 $added_ids = isset( $_POST['ids'] ) ? explode( ',', sanitize_text_field( $_POST['ids'] ) ) : array();
484 $query_args = self::build_product_search_query_args( $keyword, $types, $added_ids );
485 $query = static::make_wp_query( $query_args );
486
487 if ( $query->have_posts() ) {
488 echo self::render_product_search_results( $query, $types, $hierarchy ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
489 wp_reset_postdata();
490 } else {
491 // translators: %s is the search keyword
492 echo wp_kses( '<ul><span>' . sprintf( esc_html__( 'No results found for "%s"', 'merchant' ), $keyword ) . '</span></ul>', array(
493 'ul' => array(),
494 'span' => array(),
495 ) );
496 }
497
498 wp_die();
499 }
500
501 /**
502 * Parse product types from POST data.
503 *
504 * @since 2.2.5
505 *
506 * @return string[] Product type slugs.
507 */
508 private static function get_product_search_types(): array {
509 // Nonce verified in products_search() before calling this method.
510 // phpcs:ignore WordPress.Security.NonceVerification.Missing
511 if ( isset( $_POST['product_types'] ) && ! empty( $_POST['product_types'] ) ) {
512 // phpcs:ignore WordPress.Security.NonceVerification.Missing
513 return explode( ',', sanitize_text_field( $_POST['product_types'] ) );
514 }
515
516 return array( 'simple', 'variable' );
517 }
518
519 /**
520 * Determine whether the search should show parent/child hierarchy.
521 *
522 * @since 2.2.5
523 *
524 * @param string[] $types Product types.
525 *
526 * @return bool
527 */
528 private static function is_hierarchy_search( array $types ): bool {
529 return in_array( 'all', $types, true )
530 || ( in_array( 'variation', $types, true ) && in_array( 'variable', $types, true ) );
531 }
532
533 /**
534 * Build WP_Query arguments for product search.
535 *
536 * @since 2.2.5
537 *
538 * @param string $keyword Search keyword.
539 * @param string[] $types Product type slugs.
540 * @param string[] $added_ids Already-added product IDs to exclude.
541 *
542 * @return array WP_Query arguments.
543 */
544 private static function build_product_search_query_args( string $keyword, array $types, array $added_ids ): array {
545 if ( is_numeric( $keyword ) ) {
546 $args = array(
547 'p' => absint( $keyword ),
548 'post_type' => 'product',
549 );
550 } else {
551 $args = array(
552 'post_type' => 'product',
553 'post_status' => array( 'publish', 'private' ),
554 's' => $keyword,
555 'posts_per_page' => 10,
556 'post__not_in' => array_map( 'absint', $added_ids ),
557 );
558
559 if ( ! empty( $types ) && ! in_array( 'all', $types, true ) ) {
560 $product_types = $types;
561 if ( in_array( 'variation', $types, true ) ) {
562 $product_types[] = 'variable';
563 }
564 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
565 $args['tax_query'] = array(
566 array(
567 'taxonomy' => 'product_type',
568 'field' => 'slug',
569 'terms' => $product_types,
570 ),
571 );
572 }
573 }
574
575 return self::append_category_filter( $args );
576 }
577
578 /**
579 * Append a product_cat tax_query clause if categories are present in POST.
580 *
581 * @since 2.2.5
582 *
583 * @param array $args Existing WP_Query arguments.
584 *
585 * @return array Modified arguments.
586 */
587 private static function append_category_filter( array $args ): array {
588 // Nonce verified in products_search() before calling this method.
589 // phpcs:ignore WordPress.Security.NonceVerification.Missing
590 $categories = array_map( 'sanitize_text_field', $_POST['categories'] ?? array() );
591 if ( is_array( $categories ) && ! empty( $categories ) ) {
592 $args['tax_query'][] = array(
593 'taxonomy' => 'product_cat',
594 'field' => 'slug',
595 'terms' => $categories,
596 'operator' => 'IN',
597 );
598 }
599
600 return $args;
601 }
602
603 /**
604 * Render product search results as HTML list items.
605 *
606 * @since 2.2.5
607 *
608 * @param WP_Query $query The executed query.
609 * @param string[] $types Product type slugs.
610 * @param bool $hierarchy Whether to show hierarchy.
611 *
612 * @return string HTML output.
613 */
614 private static function render_product_search_results( WP_Query $query, array $types, bool $hierarchy ): string {
615 $html = '<ul>';
616
617 while ( $query->have_posts() ) {
618 $query->the_post();
619 $_product = wc_get_product( get_the_ID() );
620
621 if ( ! $_product ) {
622 continue;
623 }
624
625 if ( ! $_product->is_type( 'variable' ) || in_array( 'variable', $types, true ) || in_array( 'all', $types, true ) ) {
626 $html .= static::product_data_li( $_product, true, $hierarchy );
627 }
628
629 $html .= self::render_product_variations( $_product, $types, $hierarchy );
630 }
631
632 $html .= '</ul>';
633
634 return $html;
635 }
636
637 /**
638 * Render child variations for a variable product.
639 *
640 * @since 2.2.5
641 *
642 * @param WC_Product $product Parent product.
643 * @param string[] $types Product type slugs.
644 * @param bool $hierarchy Whether to show hierarchy styling.
645 *
646 * @return string HTML list items for variations.
647 */
648 private static function render_product_variations( $product, array $types, bool $hierarchy ): string {
649 if ( ! $product->is_type( 'variable' ) ) {
650 return '';
651 }
652
653 if ( ! empty( $types ) && ! in_array( 'all', $types, true ) && ! in_array( 'variation', $types, true ) ) {
654 return '';
655 }
656
657 $children = $product->get_children();
658 if ( ! is_array( $children ) || empty( $children ) ) {
659 return '';
660 }
661
662 $html = '';
663 foreach ( $children as $child_id ) {
664 $child_product = wc_get_product( $child_id );
665 /** @var WC_Product_Variation $child_product */
666 if ( ! static::are_variation_attributes_set( $child_product ) ) {
667 continue;
668 }
669 $html .= static::product_data_li( $child_product, true, $hierarchy );
670 }
671
672 return $html;
673 }
674
675 // ───────────────────────────────────────────────────────────────
676 // Products selector (reviews)
677 // ───────────────────────────────────────────────────────────────
678
679 /**
680 * Search products with reviews and render results.
681 *
682 * @since 1.10.4
683 *
684 * @param string $term Optional search term.
685 * @param int[] $exclude Optional product IDs to exclude.
686 *
687 * @return string Rendered HTML.
688 */
689 public static function products_selector_search_results( $term = '', $exclude = array() ) {
690 $args = array(
691 'post_type' => 'product',
692 'post_status' => 'any',
693 'posts_per_page' => 20,
694 'post__not_in' => $exclude,
695 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
696 'meta_key' => '_wc_review_count',
697 'orderby' => 'meta_value_num',
698 'order' => 'DESC',
699 );
700
701 if ( $term ) {
702 $args['s'] = $term;
703 }
704
705 $query = static::make_wc_product_query( $args );
706 $products = $query->get_products();
707
708 $response = '';
709 foreach ( $products as $product ) {
710 $response .= self::render_product_review_item( $product );
711 }
712
713 return $response;
714 }
715
716 /**
717 * Render a single product item in the reviews selector.
718 *
719 * @since 2.2.5
720 *
721 * @param WC_Product $product The product.
722 *
723 * @return string Rendered HTML.
724 */
725 protected static function render_product_review_item( $product ): string {
726 $reviews_count = $product->get_review_count();
727 $reviews_text = _n( 'Review', 'Reviews', $reviews_count, 'merchant' );
728 $has_reviews_cl = $reviews_count > 0 ? ' product-item-has-reviews' : '';
729 $product_id = $product->get_id();
730
731 $html = '<div class="product-item' . $has_reviews_cl . '" data-id="' . $product_id . '">';
732 $html .= self::render_product_review_header( $product, $reviews_count, $reviews_text );
733
734 if ( $reviews_count > 0 ) {
735 $reviews = self::get_rendered_product_reviews( $product );
736 $rendered_reviews = $reviews['reviews'] . $reviews['load_more'];
737 $html .= '<div class="product-reviews" data-id="' . esc_attr( $product_id ) . '">' . $rendered_reviews . '</div>';
738 }
739
740 $html .= '</div>';
741
742 return $html;
743 }
744
745 /**
746 * Render the header section of a product review item.
747 *
748 * @since 2.2.5
749 *
750 * @param WC_Product $product The product.
751 * @param int $reviews_count Number of reviews.
752 * @param string $reviews_text Localized review/reviews label.
753 *
754 * @return string Rendered HTML.
755 */
756 protected static function render_product_review_header( $product, int $reviews_count, string $reviews_text ): string {
757 $product_id = $product->get_id();
758 $edit_link = get_edit_post_link( $product_id );
759
760 $html = '<div class="header">';
761 $html .= '<div class="product-image"><a href="' . $edit_link . '" target="_blank">'
762 . get_the_post_thumbnail( $product_id, 'thumbnail' ) . '</a></div>';
763 $html .= '<div class="product-title"><a href="' . $edit_link . '" target="_blank">' . $product->get_name() . '</a></div>';
764 $html .= '<div class="spacer"></div>';
765 $html .= '<div class="rating-stars">' . wc_get_rating_html( $product->get_average_rating(), $reviews_count ) . '</div>';
766
767 if ( $reviews_count > 0 ) {
768 $html .= '<div class="selected-reviews-count"><span class="counter">0</span>
769 <div class="tooltip">' . esc_attr__( 'Number of selected reviews', 'merchant' ) . '</div></div>';
770 }
771
772 $html .= '<div class="product-status product-status-' . esc_attr( $product->get_status() ) . '">' . esc_html( $product->get_status() ) . '</div>';
773 $html .= '<div class="product-reviews-count">' . $reviews_count . ' ' . $reviews_text . '</div>';
774 $html .= '<div class="product-expander"><svg width="12" height="13" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9.5 5.07422L6 8.57422L2.5 5.07422" stroke="#a6a4a4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></div>';
775 $html .= '</div>'; // .header
776
777 return $html;
778 }
779
780 /**
781 * Get rendered HTML for a product's reviews with pagination.
782 *
783 * @since 1.10.4
784 *
785 * @param WC_Product $product The WooCommerce product object.
786 * @param int $offset Number of reviews to skip.
787 *
788 * @return array{reviews: string, load_more: string}
789 */
790 public static function get_rendered_product_reviews( $product, $offset = 0 ) {
791 $reviews_count = $product->get_review_count();
792 if ( $reviews_count === 0 ) {
793 return array( 'reviews' => '', 'load_more' => '' );
794 }
795
796 /** @since 1.10.4 */
797 $reviews_needed = apply_filters( 'merchant_reviews_per_page', 10 );
798 $reviews = get_comments( array(
799 'post_id' => $product->get_id(),
800 'status' => array( 'approve', 'spam', 'hold' ),
801 'type' => 'review',
802 'number' => $reviews_needed,
803 'offset' => $offset,
804 'fields' => 'ids',
805 ) );
806
807 $response = '';
808 if ( ! empty( $reviews ) ) {
809 $response .= '<div class="reviews-wrapper">';
810 foreach ( $reviews as $review ) {
811 $response .= self::get_review( $review );
812 }
813 $response .= '</div>';
814 }
815
816 $load_more = self::render_load_more_button( $reviews_count, $reviews_needed, $offset );
817
818 return array( 'reviews' => $response, 'load_more' => $load_more );
819 }
820
821 /**
822 * Render a "load more" button if additional reviews exist.
823 *
824 * @since 2.2.5
825 *
826 * @param int $total Total review count.
827 * @param int $per_page Reviews per page.
828 * @param int $offset Current offset.
829 *
830 * @return string HTML or empty string.
831 */
832 private static function render_load_more_button( int $total, int $per_page, int $offset ): string {
833 if ( $total <= $per_page || $offset + $per_page >= $total ) {
834 return '';
835 }
836
837 return '<div class="product-reviews-load-more">'
838 . '<button type="button">' . esc_html__( 'Load more', 'merchant' ) . '</button>'
839 . '</div>';
840 }
841
842 // ───────────────────────────────────────────────────────────────
843 // Single review rendering
844 // ───────────────────────────────────────────────────────────────
845
846 /**
847 * Render a single review comment as HTML.
848 *
849 * @since 1.10.4
850 *
851 * @param int $review_id The WooCommerce review (comment) ID.
852 *
853 * @return string Rendered review HTML.
854 */
855 public static function get_review( $review_id ) {
856 $review = get_comment( $review_id );
857
858 if ( ! $review ) {
859 /** @since 1.10.4 */
860 return apply_filters( 'merchant_single_review_item_rendered', '', null );
861 }
862
863 $product = wc_get_product( $review->comment_post_ID );
864
865 if ( ! $product ) {
866 /** @since 1.10.4 */
867 return apply_filters( 'merchant_single_review_item_rendered', '', $review );
868 }
869
870 $response = self::render_review_header( $review, $product );
871 $response .= self::render_review_body( $review, $product );
872 $response .= self::render_review_actions( $review );
873 $response .= '</div>';
874
875 /** @since 1.10.4 */
876 return apply_filters( 'merchant_single_review_item_rendered', $response, $review );
877 }
878
879 /**
880 * Render review header: checkbox + author + product image/name + rating.
881 *
882 * @since 2.2.5
883 *
884 * @param object $review Comment object.
885 * @param WC_Product $product Product object.
886 *
887 * @return string HTML.
888 */
889 private static function render_review_header( $review, $product ): string {
890 $edit_link = get_edit_post_link( $product->get_id() );
891 $rating = get_comment_meta( $review->comment_ID, 'rating', true );
892 $status = wp_get_comment_status( $review->comment_ID );
893
894 $html = '<div class="product-review" data-id="' . esc_attr( $review->comment_ID ) . '" data-product-id="' . esc_attr( $product->get_id() ) . '">';
895 $html .= '<div class="product-review-add-checkbox"><input type="checkbox" class="review-checkbox" title="' . esc_html__( 'Add this review', 'merchant' ) . '"></div>';
896 $html .= '<div class="product-review-author">' . esc_html( $review->comment_author ) . '</div>';
897 $html .= '<div class="product-review-product-image"><a href="' . $edit_link . '" target="_blank">' . $product->get_image( 'thumbnail' ) . '</a></div>';
898 $html .= '<div class="product-review-product-name"><a href="' . $edit_link . '" target="_blank">' . esc_html( $product->get_name() ) . '</a></div>';
899 $html .= '<div class="product-review-rating">' . wc_get_rating_html( $rating ) . '</div>';
900 $html .= '<div class="product-review-status"><div class="status status-' . esc_attr( $status ) . '">' . esc_html( $status ) . '</div></div>';
901
902 return $html;
903 }
904
905 /**
906 * Render review body: content + photos + date + edit link.
907 *
908 * @since 2.2.5
909 *
910 * @param object $review Comment object.
911 * @param WC_Product $product Product object.
912 *
913 * @return string HTML.
914 */
915 private static function render_review_body( $review, $product ): string {
916 $html = '<div class="product-review-content">' . esc_html( wp_trim_words( $review->comment_content, 8, '...' ) ) . '</div>';
917 $html .= '<div class="spacer"></div>';
918 $html .= '<div class="product-review-photos">'
919 . '<div class="tooltip">' . esc_attr__( 'View customer\'s uploaded review photos.', 'merchant' ) . '</div>'
920 . self::get_review_main_photo( $review->comment_ID )
921 . '</div>';
922 $html .= '<div class="product-review-date">' . esc_html( get_comment_date( 'j/n/Y', $review->comment_ID ) ) . '</div>';
923 $html .= '<div class="product-review-edit"><a href="' . esc_url( get_edit_comment_link( $review->comment_ID ) ) . '" target="_blank">' . esc_html__( 'Edit', 'merchant' ) . '</a></div>';
924
925 return $html;
926 }
927
928 /**
929 * Render review action buttons: delete + move.
930 *
931 * @since 2.2.5
932 *
933 * @param object $review Comment object.
934 *
935 * @return string HTML.
936 */
937 private static function render_review_actions( $review ): string {
938 $html = '<button class="product-review-delete">
939 <svg width="80px" height="80px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M6 7V18C6 19.1046 6.89543 20 8 20H16C17.1046 20 18 19.1046 18 18V7M6 7H5M6 7H8M18 7H19M18 7H16M10 11V16M14 11V16M8 7V5C8 3.89543 8.89543 3 10 3H14C15.1046 3 16 3.89543 16 5V7M8 7H16" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path> </g></svg>
940 </button>';
941 $html .= '<button class="product-review-move">
942 <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M12 3V21M12 3L9 6M12 3L15 6M12 21L15 18M12 21L9 18M3 12H21M3 12L6 15M3 12L6 9M21 12L18 9M21 12L18 15" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path> </g></svg>
943 </button>';
944
945 return $html;
946 }
947
948 /**
949 * Get the primary photo thumbnail for a review.
950 *
951 * @since 1.10.4
952 *
953 * @param int $comment_id The review (comment) ID.
954 *
955 * @return string Rendered HTML or empty string.
956 */
957 public static function get_review_main_photo( $comment_id ) {
958 $photos_ids = get_comment_meta( $comment_id, 'review_images', true );
959 if ( empty( $photos_ids ) ) {
960 return '';
961 }
962 $response = '';
963 $first_image = wp_get_attachment_image_url( $photos_ids[0], array( 60, 60 ) );
964 $counter = '';
965 if ( count( $photos_ids ) > 1 ) {
966 $counter = '<span class="count">' . count( $photos_ids ) . '</span>';
967 }
968 $response .= '<div class="review-photo"><img src="' . esc_url( $first_image ) . '" alt="">' . $counter . '</div>';
969
970 return $response;
971 }
972
973 // ───────────────────────────────────────────────────────────────
974 // Product data list item
975 // ───────────────────────────────────────────────────────────────
976
977 /**
978 * Render a product as an `<li>` element for the product selector.
979 *
980 * @since 2.2.5
981 *
982 * @param WC_Product $product The WooCommerce product object.
983 * @param bool|array $search Whether this is a search result (true = "Add" button).
984 * @param bool $hierarchy Whether to apply hierarchy indentation.
985 *
986 * @return string Rendered HTML.
987 */
988 public static function product_data_li( $product, $search = false, $hierarchy = false ): string {
989 $attrs = self::build_product_li_attributes( $product, $hierarchy );
990
991 $html = '<li ' . $attrs . '>';
992 $html .= self::render_product_image_html( $product );
993 $html .= self::render_product_data_html( $product );
994 $html .= self::render_product_type_and_button( $product, $search );
995 $html .= '</li>';
996
997 return $html;
998 }
999
1000 /**
1001 * Build HTML attributes for a product list item.
1002 *
1003 * @since 2.2.5
1004 *
1005 * @param WC_Product $product Product object.
1006 * @param bool $hierarchy Whether to show hierarchy.
1007 *
1008 * @return string Attribute string.
1009 */
1010 private static function build_product_li_attributes( $product, bool $hierarchy ): string {
1011 $product_id = $product->get_id();
1012 $product_sku = $product->get_sku();
1013 $product_name = $product->get_name();
1014 $price = $product->get_price();
1015 $key = $product_id . '_' . $product_sku;
1016
1017 $item_class = 'product-item';
1018 if ( $hierarchy && $product->is_type( 'variation' ) ) {
1019 $item_class .= ' hierarchy-style';
1020 }
1021
1022 return 'class="' . esc_attr( $item_class ) . '"'
1023 . ' data-key="' . esc_attr( $key ) . '"'
1024 . ' data-name="' . esc_attr( $product_name ) . '"'
1025 . ' data-sku="' . esc_attr( $product_sku ) . '"'
1026 . ' data-id="' . esc_attr( $product_id ) . '"'
1027 . ' data-price="' . esc_attr( $price ) . '"';
1028 }
1029
1030 /**
1031 * Render the product image with wp_kses.
1032 *
1033 * @since 2.2.5
1034 *
1035 * @param WC_Product $product Product object.
1036 *
1037 * @return string Sanitized image HTML.
1038 */
1039 private static function render_product_image_html( $product ): string {
1040 /** @since 1.9.1 */
1041 $product_image = apply_filters(
1042 'merchant_product_item_product_image',
1043 '<span class="img">' . $product->get_image( array( 30, 30 ) ) . '</span>',
1044 $product
1045 );
1046
1047 return wp_kses( $product_image, array(
1048 'span' => array( 'class' => true ),
1049 'img' => array(
1050 'src' => true, 'alt' => true, 'decoding' => true, 'srcset' => true,
1051 'loading' => true, 'sizes' => true, 'class' => true, 'width' => true, 'height' => true,
1052 ),
1053 ) );
1054 }
1055
1056 /**
1057 * Render product name, price, and sold-individually notice.
1058 *
1059 * @since 2.2.5
1060 *
1061 * @param WC_Product $product Product object.
1062 *
1063 * @return string HTML.
1064 */
1065 private static function render_product_data_html( $product ): string {
1066 $price_html = wp_kses( $product->get_price_html(), array(
1067 'span' => array( 'class' => true ),
1068 'del' => array( 'aria-hidden' => true ),
1069 'ins' => array(),
1070 'bdi' => array(),
1071 ) );
1072
1073 $sold_individually = $product->is_sold_individually()
1074 ? '<span class="info">' . esc_html__( 'sold individually', 'merchant' ) . '</span> '
1075 : '';
1076
1077 return '<span class="data">'
1078 . '<span class="name">' . esc_html( $product->get_name() ) . '</span>'
1079 . '<span class="info">' . $price_html . '</span> '
1080 . $sold_individually
1081 . '</span>';
1082 }
1083
1084 /**
1085 * Render product type badge and add/remove button.
1086 *
1087 * @since 2.2.5
1088 *
1089 * @param WC_Product $product Product object.
1090 * @param bool $search True for "Add" button, false for "Remove".
1091 *
1092 * @return string HTML.
1093 */
1094 private static function render_product_type_and_button( $product, bool $search ): string {
1095 $product_id = $product->get_id();
1096 $edit_link = get_edit_post_link( $product_id );
1097 if ( $product->is_type( 'variation' ) ) {
1098 $edit_link = get_edit_post_link( $product->get_parent_id() );
1099 }
1100
1101 /** @since 1.9.0 */
1102 $product_info = apply_filters( 'merchant_pro_product_bundle_item_product_info', $product->get_type() . '<br/>#' . $product_id, $product );
1103
1104 $btn_label = $search
1105 ? esc_html__( 'Add', 'merchant' )
1106 : esc_html__( 'Remove', 'merchant' );
1107 $btn_char = $search ? '+' : '×';
1108
1109 $remove_btn = '<span class="remove hint--left" aria-label="' . $btn_label . '">' . $btn_char . '</span>';
1110
1111 return '<span class="type"><a href="' . esc_url( $edit_link ) . '" target="_blank">' . wp_kses_post( $product_info ) . '</a></span> '
1112 . wp_kses( $remove_btn, array( 'span' => array( 'class' => true, 'aria-label' => true ) ) );
1113 }
1114
1115 // ───────────────────────────────────────────────────────────────
1116 // Variation helpers
1117 // ───────────────────────────────────────────────────────────────
1118
1119 /**
1120 * Check if all variation attributes are set for a product variation.
1121 *
1122 * @since 2.2.5
1123 *
1124 * @param WC_Product_Variation $variation The variation product object.
1125 *
1126 * @return bool True if all attributes are set, false otherwise.
1127 */
1128 public static function are_variation_attributes_set( $variation ) {
1129 if ( $variation && $variation->is_type( 'variation' ) ) {
1130 $variation_attributes = $variation->get_variation_attributes();
1131
1132 foreach ( $variation_attributes as $attribute => $value ) {
1133 if ( empty( $value ) ) {
1134 return false;
1135 }
1136 }
1137
1138 return true;
1139 }
1140
1141 return false;
1142 }
1143 }
1144
1145 // Initialize.
1146 new Merchant_Admin_Ajax();
1147