'; } public function getSlug() : string { return 'shop-loop-display'; } public function getRenderAttributes() : array { if ( !ProductCatalogLoopSettingsSection::isCustomLayoutSettings() ) { return array( 'display' => true, 'tiers_limit' => ProductCatalogLoopSettingsSection::getTiersLimit(), ); } $args = array( 'display_context' => 'shop-loop', 'display' => true, 'display_type' => ProductCatalogLoopSettingsSection::getLayoutType(), 'title' => ProductCatalogLoopSettingsSection::getTitle(), 'quantity_type' => ProductCatalogLoopSettingsSection::getQuantityType(), 'getSelectedQuantityColor' => ProductCatalogLoopSettingsSection::getSelectedQuantityColor(), 'quantity_column_title' => ProductCatalogLoopSettingsSection::getTableColumnsTitles()['head_quantity_text'], 'price_column_title' => ProductCatalogLoopSettingsSection::getTableColumnsTitles()['head_price_text'], 'discount_column_title' => ProductCatalogLoopSettingsSection::getTableColumnsTitles()['head_discount_text'], 'show_discount_column' => ProductCatalogLoopSettingsSection::blocksShowDiscount(), 'discount_format' => ProductCatalogLoopSettingsSection::getDiscountFormat(), 'tiers_order' => ProductCatalogLoopSettingsSection::getTiersOrder(), 'layout_spacing' => ProductCatalogLoopSettingsSection::getLayoutSpacing(), 'cell_padding' => ProductCatalogLoopSettingsSection::getCellPadding(), 'discount_badge_color' => ProductCatalogLoopSettingsSection::getDiscountBadgeColor(), 'active_tier_border' => ProductCatalogLoopSettingsSection::getActiveTierBorder(), 'tiers_limit' => ProductCatalogLoopSettingsSection::getTiersLimit(), 'clickable_rows' => ProductCatalogLoopSettingsSection::isClickableTableRows(), 'active_tier_color' => ProductCatalogLoopSettingsSection::getSelectedQuantityColor(), 'table_style' => ProductCatalogLoopSettingsSection::getTableStyle(), 'blocks_style' => ProductCatalogLoopSettingsSection::getBlocksStyle(), 'options_style' => ProductCatalogLoopSettingsSection::getOptionsStyle(), 'dropdown_style' => ProductCatalogLoopSettingsSection::getDropdownStyle(), 'plain_text_style' => ProductCatalogLoopSettingsSection::getPlainTextStyle(), 'options_show_original_product_price' => ProductCatalogLoopSettingsSection::isShowOriginalProductPriceInOptions(), 'options_show_default_option' => ProductCatalogLoopSettingsSection::isShowDefaultOption(), 'options_option_text' => ProductCatalogLoopSettingsSection::getOptionText(), 'options_default_option_text' => ProductCatalogLoopSettingsSection::getDefaultOptionText(), 'plain_text_show_default_option' => ProductCatalogLoopSettingsSection::isShowFirstPlainTextTier(), 'plain_text_option_text' => ProductCatalogLoopSettingsSection::getPlainTextTemplate(), 'plain_text_default_option_text' => ProductCatalogLoopSettingsSection::getFirstTierPlainTextTemplate(), ); $default_quantity_measurement = array( 'singular' => '', 'plural' => '', ); $quantity_measurement = $default_quantity_measurement; if ( in_array( $args['display_type'], array('table', 'horizontal-table', 'tooltip') ) ) { $quantity_measurement = ProductCatalogLoopSettingsSection::getTableQuantityMeasurement(); } if ( 'blocks' === $args['display_type'] ) { $quantity_measurement = ProductCatalogLoopSettingsSection::getBlocksQuantityMeasurement(); } $args['quantity_measurement_singular'] = $quantity_measurement['singular']; $args['quantity_measurement_plural'] = $quantity_measurement['plural']; return $args; } /** * Block names whose items WooCommerce renders with the classic loop hooks fired as well (its archive * template compatibility layer), on top of the block output. */ const PRODUCT_TEMPLATE_BLOCKS = array('woocommerce/product-template', 'core/post-template'); /** How many product template blocks are rendering right now (nested templates count up). */ protected static int $productBlocksDepth = 0; /** * True while a product template block renders its items. WooCommerce fires the classic loop hooks * there too, and renderInProductBlocks() already puts the pricing, the badge and the button into the * blocks, so the classic callbacks stand down to avoid a second copy outside the card's layout. */ public static function isRenderingProductBlocks() : bool { return self::$productBlocksDepth > 0; } public function enterProductBlocks( $pre, $block ) { if ( $this->isProductTemplateBlock( (array) $block ) ) { self::$productBlocksDepth++; } return $pre; } public function leaveProductBlocks( $content, $block ) { if ( $this->isProductTemplateBlock( (array) $block ) && self::$productBlocksDepth > 0 ) { self::$productBlocksDepth--; } return $content; } protected function isProductTemplateBlock( array $block ) : bool { $name = (string) ($block['blockName'] ?? ''); if ( 'woocommerce/product-template' === $name ) { return true; } // a Query Loop of products: the one WooCommerce marks as its own, or one inheriting the archive query $attrs = (array) ($block['attrs'] ?? array()); return 'core/post-template' === $name && (!empty( $attrs['isInherited'] ) || 'woocommerce/product-query/product-template' === ($attrs['__woocommerceNamespace'] ?? '')); } public function run() { add_filter( 'tiered_pricing_table/settings/sections', array($this, 'addSettingSection'), 5 ); add_filter( 'pre_render_block', array($this, 'enterProductBlocks'), 10, 2 ); add_filter( 'render_block', array($this, 'leaveProductBlocks'), 1, 2 ); add_filter( 'tiered_pricing_table/text_template_variables', array($this, 'registerTemplateVariables') ); // the badge is its own feature: it shows whether or not the pricing does if ( ProductCatalogLoopSettingsSection::isBadgeEnabled() ) { add_action( 'woocommerce_before_shop_loop_item_title', array($this, 'renderBadge'), 11 ); } // block themes and the Product Collection block render products without the classic loop hooks add_filter( 'render_block', array($this, 'renderInProductBlocks'), 10, 3 ); if ( !ProductCatalogLoopSettingsSection::isEnabled() ) { return; } add_filter( 'tiered_pricing_table/frontend/variation_render_settings', function ( $settings, $productId, $context ) { if ( 'shop-loop' !== $context ) { return $settings; } return $this->getRenderAttributes(); }, 10, 3 ); add_filter( 'tiered_pricing_table/frontend/default_price_behaviour_type', function ( $type, $product, $priceHTML, $priceDisplayContext ) { if ( 'shop-loop' === $priceDisplayContext ) { return ( ProductCatalogLoopSettingsSection::isDynamicPrice() ? 'dynamic' : 'static' ); } return $type; }, 10, 4 ); add_action( 'init', function () { $position = ProductCatalogLoopSettingsSection::getPosition(); $closeLink = false; if ( 'woocommerce_shop_loop_item_title' === $position['hook'] ) { $closeLink = true; remove_action( 'woocommerce_after_shop_loop_item', 'woocommerce_template_loop_product_link_close', 5 ); } add_action( $position['hook'], function () use($closeLink) { global $product, $post; // inside a product template block the pricing is already in the blocks (renderInProductBlocks) if ( self::isRenderingProductBlocks() ) { return; } if ( !$product instanceof WC_Product ) { $productID = ( isset( $post ) ? $post->ID : null ); $product = wc_get_product( $productID ); } if ( $closeLink ) { echo ''; } if ( !$product || !$product->is_purchasable() || !$this->shouldRender( $product ) ) { return; } $this->render( $product, ProductCatalogLoopSettingsSection::useReducedStyles() ); }, $position['priority'] ); } ); } /** * Whether this product list and this product are among the ones the settings allow. */ public function shouldRender( WC_Product $product, ?string $context = null ) : bool { return $this->matchesContext( $context ) && $this->matchesCategories( $product ); } protected function matchesContext( ?string $context = null ) : bool { $context = ( $context ?: $this->getLoopContext() ); return in_array( $context, ProductCatalogLoopSettingsSection::getContexts(), true ); } protected function matchesCategories( WC_Product $product ) : bool { $categories = ProductCatalogLoopSettingsSection::getCategories(); if ( !$categories ) { return true; } $productId = ( $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id() ); // a chosen category covers its child categories foreach ( $categories as $categoryId ) { $children = get_term_children( $categoryId, 'product_cat' ); if ( is_array( $children ) ) { $categories = array_merge( $categories, array_map( 'intval', $children ) ); } } return has_term( array_unique( $categories ), 'product_cat', $productId ); } /** * The kind of product list the classic loop is rendering, as one of the context keys. */ protected function getLoopContext() : string { $name = (string) wc_get_loop_prop( 'name', '' ); if ( 'related' === $name ) { return 'related'; } if ( 'up-sells' === $name ) { return 'upsells'; } if ( 'cross-sells' === $name ) { return 'cross_sells'; } if ( wc_get_loop_prop( 'is_shortcode', false ) ) { return 'shortcode'; } if ( is_search() ) { return 'search'; } if ( is_product_category() ) { return 'category'; } if ( is_product_tag() || is_product_taxonomy() ) { return 'tag'; } if ( is_shop() ) { return 'shop'; } return 'shortcode'; } /** * Product Collection block: the pricing goes around the product button or the title block, per the * position option, and the badge into the product image block. */ public function renderInProductBlocks( $content, $block, $instance ) { $name = $block['blockName'] ?? ''; if ( !in_array( $name, array('woocommerce/product-button', 'core/post-title', 'woocommerce/product-image'), true ) ) { return $content; } // only the blocks of a product list: the single product template uses the same blocks (inside the // add-to-cart form, as the image), and there the product page has its own pricing if ( !self::isRenderingProductBlocks() ) { return $content; } if ( !$instance instanceof WP_Block || empty( $instance->context['postId'] ) ) { return $content; } $postType = $instance->context['postType'] ?? get_post_type( (int) $instance->context['postId'] ); if ( 'product' !== $postType || !$this->matchesContext( 'blocks' ) ) { return $content; } $product = wc_get_product( (int) $instance->context['postId'] ); if ( !$product || !$this->matchesCategories( $product ) ) { return $content; } if ( 'woocommerce/product-image' === $name ) { $badge = ( ProductCatalogLoopSettingsSection::isBadgeEnabled() ? $this->getBadgeHtml( $product ) : '' ); return ( $badge ? preg_replace( '/(]*>)/i', '$1' . $badge, $content, 1 ) : $content ); } if ( !ProductCatalogLoopSettingsSection::isEnabled() || !$product->is_purchasable() ) { return $content; } $position = ProductCatalogLoopSettingsSection::getPosition(); $target = ( 'woocommerce_shop_loop_item_title' === $position['hook'] ? 'core/post-title' : 'woocommerce/product-button' ); if ( $name !== $target ) { return $content; } $before = (int) $position['priority'] <= (( 'core/post-title' === $target ? 5 : 6 )); ob_start(); $this->render( $product, ProductCatalogLoopSettingsSection::useReducedStyles() ); $pricing = ob_get_clean(); return ( $before ? $pricing . $content : $content . $pricing ); } /** * The bulk savings badge on a classic loop item, right after the thumbnail. */ public function renderBadge() { global $product, $post; // inside a product template block the badge is already in the product image block if ( self::isRenderingProductBlocks() ) { return; } $item = ( $product instanceof WC_Product ? $product : (( isset( $post ) ? wc_get_product( $post->ID ) : null )) ); if ( !$item || !$this->shouldRender( $item ) ) { return; } echo $this->getBadgeHtml( $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in the helper } /** * The badge markup, or an empty string when the product's tiers give no saving. */ public function getBadgeHtml( WC_Product $product ) : string { list( $lowest, $regular, $lowestQuantity ) = $this->getLowestTierPrice( $product ); if ( null === $lowest || $regular <= 0 || $lowest >= $regular ) { return ''; } $discount = (int) round( ($regular - (float) $lowest) / $regular * 100 ); $text = strtr( ProductCatalogLoopSettingsSection::getBadgeTemplate(), array( '{tp_max_discount}' => $discount . '%', '{tp_lowest_price}' => wc_price( (float) $lowest ), '{tp_lowest_quantity}' => $lowestQuantity, '{tp_max_saving}' => wc_price( $regular - (float) $lowest ), ) ); $color = ProductCatalogLoopSettingsSection::getBadgeColor(); if ( !$color ) { $color = ( ProductCatalogLoopSettingsSection::isCustomLayoutSettings() ? ProductCatalogLoopSettingsSection::getSelectedQuantityColor() : (string) $this->getContainer()->getSettings()->get( 'selected_quantity_color', '#3858e9' ) ); } return '' . wp_kses_post( $text ) . ''; } /** * The lowest tier price a product (or any of its variations) reaches, the price it is compared with and * the quantity that price starts at, all as displayed. * * @return array{0: float|null, 1: float, 2: int|string} */ protected function getLowestTierPrice( WC_Product $product ) : array { $lowest = null; $lowestQuantity = ''; if ( $product->is_type( 'variable' ) ) { $regular = (float) $product->get_variation_price( 'max', true ); foreach ( $product->get_children() as $childId ) { $rules = PriceManager::getPricingRule( (int) $childId )->getRules(); if ( !$rules ) { continue; } $child = wc_get_product( $childId ); $price = ( $child ? (float) wc_get_price_to_display( $child, array( 'price' => PriceManager::getPricingRule( (int) $childId )->getTierPrice( PHP_INT_MAX, false ), ) ) : null ); if ( null !== $price && (null === $lowest || $price < $lowest) ) { $lowest = $price; $lowestQuantity = (int) max( array_keys( $rules ) ); } } } else { $regular = (float) wc_get_price_to_display( $product ); $rules = PriceManager::getPricingRule( $product->get_id() )->getRules(); if ( $rules ) { $lowest = (float) wc_get_price_to_display( $product, array( 'price' => PriceManager::getPricingRule( $product->get_id() )->getTierPrice( PHP_INT_MAX, false ), ) ); $lowestQuantity = (int) max( array_keys( $rules ) ); } } return array($lowest, $regular, $lowestQuantity); } public function registerTemplateVariables( $variables ) { $variables['tp_max_discount'] = array( 'name' => __( 'Biggest discount', 'tier-pricing-table' ), 'description' => __( '{tp_max_discount} - the biggest percentage discount the product\'s tiers give.', 'tier-pricing-table' ), 'variableKey' => '{tp_max_discount}', ); $variables['tp_lowest_price'] = array( 'name' => __( 'Lowest price', 'tier-pricing-table' ), 'description' => __( '{tp_lowest_price} - the lowest tier price.', 'tier-pricing-table' ), 'variableKey' => '{tp_lowest_price}', ); $variables['tp_lowest_quantity'] = array( 'name' => __( 'Lowest price quantity', 'tier-pricing-table' ), 'description' => __( '{tp_lowest_quantity} - the quantity the lowest tier price starts at.', 'tier-pricing-table' ), 'variableKey' => '{tp_lowest_quantity}', ); $variables['tp_max_saving'] = array( 'name' => __( 'Biggest saving', 'tier-pricing-table' ), 'description' => __( '{tp_max_saving} - the saving per item at the lowest tier price.', 'tier-pricing-table' ), 'variableKey' => '{tp_max_saving}', ); return $variables; } public function render( WC_product $product, $useReducedStyles = false ) { $classes = 'tiered-pricing-shop-loop'; if ( $useReducedStyles ) { $classes .= ' tiered-pricing-shop-loop--reduced'; } $this->getContainer()->getFileManager()->includeTemplate( 'frontend/shop-loop.php', array( 'classes' => $classes, 'product' => $product, 'settings' => $this->getRenderAttributes(), ) ); } public function addSettingSection( $sections ) : array { // The layout configurator carries the catalog settings while it is on. if ( LayoutConfiguratorAddon::isActive() ) { return $sections; } $_sections = array(); foreach ( $sections as $section ) { $_sections[] = $section; if ( $section->getSlug() === 'general' ) { $_sections[] = new ProductCatalogLoopSettingsSection(); } } return $_sections; } }