WooCommerce Code Reference

class-product-collection.php

Source code

<?php
/**
 * This file is part of the WooCommerce Email Editor package
 *
 * @package Automattic\WooCommerce\EmailEditor
 */

declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Integrations\WooCommerce\Renderer\Blocks;

use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use WP_Query;

/**
 * Renders a product collection block for email.
 */
class Product_Collection extends Abstract_Product_Block_Renderer {
	/**
	 * Render the product collection block content for email.
	 *
	 * @param string            $block_content Block content.
	 * @param array             $parsed_block Parsed block.
	 * @param Rendering_Context $rendering_context Rendering context.
	 * @return string
	 */
	protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
		// Create a query for the Product Collection block.
		$query = $this->prepare_and_execute_query( $parsed_block );

		$content = '';

		foreach ( $parsed_block['innerBlocks'] as $inner_block ) {
			switch ( $inner_block['blockName'] ) {
				case 'woocommerce/product-template':
					$content .= $this->render_product_template( $inner_block, $query );
					break;
				default:
					$content .= render_block( $inner_block );
					break;
			}
		}

		wp_reset_postdata();

		return $content;
	}

	/**
	 * Render the product template block.
	 *
	 * @param array     $inner_block Inner block data.
	 * @param \WP_Query $query WP_Query object.
	 * @return string
	 */
	private function render_product_template( array $inner_block, \WP_Query $query ): string {
		if ( ! $query->have_posts() ) {
			return $this->render_no_results_message();
		}

		$posts       = $query->get_posts();
		$total_count = count( $posts );

		if ( 0 === $total_count ) {
			return $this->render_no_results_message();
		}

		$products = array_filter(
			array_map(
				function ( $post ) {
					return $post instanceof \WP_Post ? wc_get_product( $post->ID ) : null;
				},
				$posts
			)
		);
		return $this->render_product_grid( $products, $inner_block );
	}

	/**
	 * Render product grid using HTML table structure for email compatibility.
	 *
	 * @param array $products Array of WC_Product objects.
	 * @param array $inner_block Inner block data.
	 * @return string
	 */
	private function render_product_grid( array $products, array $inner_block ): string {
		// We start with supporting 1 product per row.
		$content = '';
		foreach ( $products as $product ) {
			$content .= $this->add_spacer(
				$this->render_product_content( $product, $inner_block ),
				$inner_block['email_attrs'] ?? array()
			);
		}

		return $content;
	}

	/**
	 * Render default product content when no inner blocks are present.
	 *
	 * @param \WC_Product|null $product Product object.
	 * @param array            $template_block Inner block data.
	 * @return string
	 */
	private function render_product_content( ?\WC_Product $product, array $template_block ): string {
		$content = '';

		if ( ! $product ) {
			return $content;
		}

		foreach ( $template_block['innerBlocks'] as $inner_block ) {
			switch ( $inner_block['blockName'] ) {
				case 'woocommerce/product-price':
				case 'woocommerce/product-button':
				case 'woocommerce/product-sale-badge':
				case 'woocommerce/product-image':
					$inner_block['context']           = $inner_block['context'] ?? array();
					$inner_block['context']['postId'] = $product->get_id();
					$content                         .= render_block( $inner_block );
					break;
				case 'core/post-title':
					global $post;
					$original_post           = $post;
					$original_global_product = $GLOBALS['product'] ?? null;

					$product_post = get_post( $product->get_id() );

					$post               = $product_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
					$GLOBALS['product'] = $product;

					$inner_block['context']           = $inner_block['context'] ?? array();
					$inner_block['context']['postId'] = $product->get_id();

					$content .= render_block( $inner_block );

					$post               = $original_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
					$GLOBALS['product'] = $original_global_product;
					break;
				default:
					break;
			}
		}

		return $content;
	}

	/**
	 * Prepare and execute a query for the Product Collection block using the original QueryBuilder.
	 *
	 * @param array $parsed_block Parsed block data.
	 * @return WP_Query
	 */
	private function prepare_and_execute_query( array $parsed_block ): WP_Query {
		$collection  = $parsed_block['attrs']['collection'] ?? '';
		$query_attrs = $parsed_block['attrs']['query'] ?? array();

		// Build a direct WP_Query for email rendering (not using ProductCollection QueryBuilder).
		// The QueryBuilder is designed for REST/frontend context, not email rendering.
		$query_args = array(
			'post_type'      => 'product',
			'post_status'    => 'publish',
			'posts_per_page' => (int) ( $query_attrs['perPage'] ?? 9 ),
			'orderby'        => sanitize_key( $query_attrs['orderBy'] ?? 'menu_order' ),
			'order'          => sanitize_key( $query_attrs['order'] ?? 'asc' ),
			'meta_query'     => array(), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
			'tax_query'      => array(), // phpcs:ignore WordPress.DB.SlowDBQuery
		);

		// Handle search.
		if ( ! empty( $query_attrs['search'] ) ) {
			$query_args['s'] = sanitize_text_field( (string) $query_attrs['search'] );
		}

		// Handle offset.
		if ( isset( $query_attrs['offset'] ) ) {
			$query_args['offset'] = (int) $query_attrs['offset'];
		}

		// Handle exclusions.
		if ( isset( $query_attrs['exclude'] ) && is_array( $query_attrs['exclude'] ) ) {
			$query_args['post__not_in'] = array_map(
				static function ( $id ) {
					return is_numeric( $id ) ? (int) $id : 0;
				},
				$query_attrs['exclude']
			);
		}

		// Handle handpicked products.
		if ( ! empty( $query_attrs['woocommerceHandPickedProducts'] ) ) {
			$query_args['post__in'] = array_map(
				static function ( $id ) {
					return is_numeric( $id ) ? (int) $id : 0;
				},
				$query_attrs['woocommerceHandPickedProducts']
			);
			$query_args['orderby']  = 'post__in';
		}

		// Handle featured products - use the WooCommerce way.
		$is_featured = $query_attrs['featured'] ?? false;
		if ( 'woocommerce/product-collection/featured' === $collection || $is_featured ) {
			// Use WooCommerce's built-in function to get featured products query.
			$featured_query = wc_get_product_visibility_term_ids();
			if ( isset( $featured_query['featured'] ) ) {
				$query_args['tax_query'][] = array(
					'taxonomy' => 'product_visibility',
					'field'    => 'term_taxonomy_id',
					'terms'    => array( (int) $featured_query['featured'] ),
					'operator' => 'IN',
				);
			}
		}

		// Handle on-sale products.
		$is_on_sale = $query_attrs['woocommerceOnSale'] ?? false;
		if ( 'woocommerce/product-collection/on-sale' === $collection || $is_on_sale ) {
			$query_args['meta_query'][] = array(
				'relation' => 'OR',
				array(
					'key'     => '_sale_price',
					'value'   => '',
					'compare' => '!=',
				),
			);
		}

		// Handle stock status (only if not all statuses are selected).
		$stock_status = $query_attrs['woocommerceStockStatus'] ?? array();
		if ( ! empty( $stock_status ) && ! $this->is_all_stock_statuses( $stock_status ) ) {
			$query_args['meta_query'][] = array(
				'key'     => '_stock_status',
				'value'   => $stock_status,
				'compare' => 'IN',
			);
		}

		// Handle taxonomies (categories, tags, etc.).
		if ( ! empty( $query_attrs['taxQuery'] ) ) {
			$tax_queries             = $this->build_tax_query( $query_attrs['taxQuery'] );
			$query_args['tax_query'] = array_merge( $query_args['tax_query'], $tax_queries ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
		}

		// Handle product attributes.
		if ( ! empty( $query_attrs['woocommerceAttributes'] ) ) {
			$attribute_queries       = $this->build_attribute_query( $query_attrs['woocommerceAttributes'] );
			$query_args['tax_query'] = array_merge( $query_args['tax_query'], $attribute_queries ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
		}

		// Handle special collections: upsells, cross-sells, related.
		$product_ids_to_include = $this->get_collection_specific_product_ids( $collection, $parsed_block );
		if ( ! empty( $product_ids_to_include ) ) {
			$query_args['post__in'] = $product_ids_to_include;
		}

		// Set tax_query relation if multiple tax queries.
		if ( count( $query_args['tax_query'] ) > 1 ) {
			$query_args['tax_query']['relation'] = 'AND';
		}

		$wp_query = new WP_Query( $query_args );

		return $wp_query;
	}


	/**
	 * Check if all stock statuses are selected (meaning no filtering needed).
	 *
	 * @param array $stock_status Stock status values from block attributes.
	 * @return bool
	 */
	private function is_all_stock_statuses( array $stock_status ): bool {
		if ( empty( $stock_status ) ) {
			return true; // Empty means all statuses.
		}

		$all_stock_statuses = array_keys( wc_get_product_stock_status_options() );
		return count( $stock_status ) === count( $all_stock_statuses ) &&
			count( array_diff( $stock_status, $all_stock_statuses ) ) === 0 &&
			count( array_diff( $all_stock_statuses, $stock_status ) ) === 0;
	}

	/**
	 * Build tax query from taxQuery block attributes.
	 *
	 * @param array $tax_query_input Tax query input from block attributes.
	 * @return array
	 */
	private function build_tax_query( array $tax_query_input ): array {
		$tax_queries = array();

		if ( empty( $tax_query_input ) ) {
			return $tax_queries;
		}

		$first_key = array_key_first( $tax_query_input );
		// If not a numeric array of clauses, assume object map: { taxonomy => [termIds] }.
		if ( ! is_int( $first_key ) ) {
			foreach ( $tax_query_input as $taxonomy => $terms ) {
				if ( ! empty( $terms ) ) {
					$tax_queries[] = array(
						'taxonomy' => $taxonomy,
						'field'    => 'term_id',
						'terms'    => array_map(
							static function ( $id ) {
								return is_numeric( $id ) ? (int) $id : 0;
							},
							(array) $terms
						),
					);
				}
			}
		} else {
			$tax_queries = $tax_query_input;
		}

		return $tax_queries;
	}

	/**
	 * Build attribute query from woocommerceAttributes block attributes.
	 *
	 * @param array $attributes Attribute filters from block attributes.
	 * @return array
	 */
	private function build_attribute_query( array $attributes ): array {
		$attribute_queries = array();

		foreach ( $attributes as $attribute ) {
			if ( ! empty( $attribute['taxonomy'] ) && ! empty( $attribute['termId'] ) ) {
				$attribute_queries[] = array(
					'taxonomy' => $attribute['taxonomy'],
					'field'    => 'term_id',
					'terms'    => array( (int) $attribute['termId'] ),
				);
			}
		}

		return $attribute_queries;
	}

	/**
	 * Get specific product IDs for collection types that need them (upsell, cross-sell, related).
	 *
	 * @param string $collection Collection type.
	 * @param array  $parsed_block Parsed block data.
	 * @return array Array of product IDs or empty array.
	 */
	private function get_collection_specific_product_ids( string $collection, array $parsed_block ): array {
		switch ( $collection ) {
			case 'woocommerce/product-collection/upsells':
				return $this->get_upsell_product_ids( $parsed_block );

			case 'woocommerce/product-collection/cross-sells':
				return $this->get_cross_sell_product_ids( $parsed_block );

			case 'woocommerce/product-collection/related':
				return $this->get_related_product_ids( $parsed_block );

			default:
				return array();
		}
	}

	/**
	 * Get upsell product IDs.
	 *
	 * @param array $parsed_block Parsed block data.
	 * @return array Array of upsell product IDs.
	 */
	private function get_upsell_product_ids( array $parsed_block ): array {
		$product_references = $this->get_product_references_for_collection( $parsed_block );

		if ( empty( $product_references ) ) {
			return array( -1 ); // Return -1 to ensure no products are found.
		}

		$products = array_filter( array_map( 'wc_get_product', $product_references ) );

		if ( empty( $products ) ) {
			return array( -1 );
		}

		$all_upsells = array();
		foreach ( $products as $product ) {
			$all_upsells = array_merge( $all_upsells, $product->get_upsell_ids() );
		}

		// Remove duplicates and product references (don't show what's already in context).
		$unique_upsells = array_unique( $all_upsells );
		$upsells        = array_diff( $unique_upsells, $product_references );

		return ! empty( $upsells ) ? $upsells : array( -1 );
	}

	/**
	 * Get cross-sell product IDs.
	 *
	 * @param array $parsed_block Parsed block data.
	 * @return array Array of cross-sell product IDs.
	 */
	private function get_cross_sell_product_ids( array $parsed_block ): array {
		$product_references = $this->get_product_references_for_collection( $parsed_block );

		if ( empty( $product_references ) ) {
			return array( -1 ); // Return -1 to ensure no products are found.
		}

		$products = array_filter( array_map( 'wc_get_product', $product_references ) );

		if ( empty( $products ) ) {
			return array( -1 );
		}

		$product_ids = array_map(
			function ( $product ) {
				return $product->get_id();
			},
			$products
		);

		$all_cross_sells = array();
		foreach ( $products as $product ) {
			$all_cross_sells = array_merge( $all_cross_sells, $product->get_cross_sell_ids() );
		}

		// Remove duplicates and product references (don't show what's already in context).
		$unique_cross_sells = array_unique( $all_cross_sells );
		$cross_sells        = array_diff( $unique_cross_sells, $product_ids );

		return ! empty( $cross_sells ) ? $cross_sells : array( -1 );
	}

	/**
	 * Get related product IDs.
	 *
	 * @param array $parsed_block Parsed block data.
	 * @return array Array of related product IDs.
	 */
	private function get_related_product_ids( array $parsed_block ): array {
		$product_references = $this->get_product_references_for_collection( $parsed_block );

		if ( empty( $product_references ) ) {
			return array( -1 ); // Return -1 to ensure no products are found.
		}

		// For related products, we only use the first product reference.
		$product_reference = $product_references[0];

		if ( empty( $product_reference ) ) {
			return array( -1 );
		}

		// Get related products using WooCommerce's built-in function.
		$related_ids = wc_get_related_products( $product_reference, 100 );
		return ! empty( $related_ids ) ? $related_ids : array( -1 );
	}

	/**
	 * Get product references for collections (handles different contexts).
	 *
	 * @param array $parsed_block Parsed block data.
	 * @return array Array of product IDs or empty array.
	 */
	private function get_product_references_for_collection( array $parsed_block ): array {
		$query_attrs        = $parsed_block['attrs']['query'] ?? array();
		$product_references = array();

		// First try to get from productReference in query attributes.
		if ( ! empty( $query_attrs['productReference'] ) ) {
			$product_references = array( (int) $query_attrs['productReference'] );
		}

		// If no product reference found, try to get from global context.
		if ( empty( $product_references ) ) {
			global $product;
			if ( $product && is_a( $product, 'WC_Product' ) ) {
				$product_references = array( $product->get_id() );
			}
		}

		// In email context, we might need additional context sources.
		// This could be extended based on email type (order confirmation, etc.).

		return $product_references;
	}

	/**
	 * Render a no results message.
	 *
	 * @return string
	 */
	private function render_no_results_message(): string {
		return sprintf(
			'<div style="text-align: center; padding: 20px; color: #666;">%s</div>',
			esc_html__( 'No products found.', 'woocommerce' )
		);
	}
}