← All changes
|
src/Addons/ProductCatalogLoop/ProductCatalogLoop.php
+313
-15
6.4.0
→
8.0.2
View file →
| @@ -2,13 +2,17 @@ | ||
| 2 | 2 | |
| 3 | 3 | namespace TierPricingTable\Addons\ProductCatalogLoop; |
| 4 | 4 | |
| 5 | 5 | use TierPricingTable\Addons\AbstractAddon; |
| 6 | +use TierPricingTable\Addons\LayoutConfigurator\LayoutConfiguratorAddon; | |
| 6 | 7 | use TierPricingTable\Addons\ProductCatalogLoop\Settings\ProductCatalogLoopSettingsSection; |
| 8 | +use TierPricingTable\PriceManager; | |
| 9 | +use TierPricingTable\Settings\Settings; | |
| 7 | 10 | use WC_Product; |
| 11 | +use WP_Block; | |
| 8 | 12 | class ProductCatalogLoop extends AbstractAddon { |
| 9 | 13 | public function getName() : string { |
| 10 | - return __( 'Catalog display', 'tier-pricing-table' ); | |
| 14 | + return __( 'Shop & Categories', 'tier-pricing-table' ); | |
| 11 | 15 | } |
| 12 | 16 | |
| 13 | 17 | public function getDescription() : string { |
| 14 | 18 | return __( 'Display tiered pricing tables on the shop and category pages.', 'tier-pricing-table' ); |
| @@ -24,9 +28,10 @@ | ||
| 24 | 28 | |
| 25 | 29 | public function getRenderAttributes() : array { |
| 26 | 30 | if ( !ProductCatalogLoopSettingsSection::isCustomLayoutSettings() ) { |
| 27 | 31 | return array( |
| 28 | - 'display' => true, | |
| 32 | + 'display' => true, | |
| 33 | + 'tiers_limit' => ProductCatalogLoopSettingsSection::getTiersLimit(), | |
| 29 | 34 | ); |
| 30 | 35 | } |
| 31 | 36 | $args = array( |
| 32 | 37 | 'display_context' => 'shop-loop', |
| @@ -38,10 +43,22 @@ | ||
| 38 | 43 | 'quantity_column_title' => ProductCatalogLoopSettingsSection::getTableColumnsTitles()['head_quantity_text'], |
| 39 | 44 | 'price_column_title' => ProductCatalogLoopSettingsSection::getTableColumnsTitles()['head_price_text'], |
| 40 | 45 | 'discount_column_title' => ProductCatalogLoopSettingsSection::getTableColumnsTitles()['head_discount_text'], |
| 41 | 46 | 'show_discount_column' => ProductCatalogLoopSettingsSection::blocksShowDiscount(), |
| 47 | + 'discount_format' => ProductCatalogLoopSettingsSection::getDiscountFormat(), | |
| 48 | + 'tiers_order' => ProductCatalogLoopSettingsSection::getTiersOrder(), | |
| 49 | + 'layout_spacing' => ProductCatalogLoopSettingsSection::getLayoutSpacing(), | |
| 50 | + 'cell_padding' => ProductCatalogLoopSettingsSection::getCellPadding(), | |
| 51 | + 'discount_badge_color' => ProductCatalogLoopSettingsSection::getDiscountBadgeColor(), | |
| 52 | + 'active_tier_border' => ProductCatalogLoopSettingsSection::getActiveTierBorder(), | |
| 53 | + 'tiers_limit' => ProductCatalogLoopSettingsSection::getTiersLimit(), | |
| 42 | 54 | 'clickable_rows' => ProductCatalogLoopSettingsSection::isClickableTableRows(), |
| 43 | 55 | 'active_tier_color' => ProductCatalogLoopSettingsSection::getSelectedQuantityColor(), |
| 56 | + 'table_style' => ProductCatalogLoopSettingsSection::getTableStyle(), | |
| 57 | + 'blocks_style' => ProductCatalogLoopSettingsSection::getBlocksStyle(), | |
| 58 | + 'options_style' => ProductCatalogLoopSettingsSection::getOptionsStyle(), | |
| 59 | + 'dropdown_style' => ProductCatalogLoopSettingsSection::getDropdownStyle(), | |
| 60 | + 'plain_text_style' => ProductCatalogLoopSettingsSection::getPlainTextStyle(), | |
| 44 | 61 | 'options_show_original_product_price' => ProductCatalogLoopSettingsSection::isShowOriginalProductPriceInOptions(), |
| 45 | 62 | 'options_show_default_option' => ProductCatalogLoopSettingsSection::isShowDefaultOption(), |
| 46 | 63 | 'options_option_text' => ProductCatalogLoopSettingsSection::getOptionText(), |
| 47 | 64 | 'options_default_option_text' => ProductCatalogLoopSettingsSection::getDefaultOptionText(), |
| @@ -64,10 +81,76 @@ | ||
| 64 | 81 | $args['quantity_measurement_plural'] = $quantity_measurement['plural']; |
| 65 | 82 | return $args; |
| 66 | 83 | } |
| 67 | 84 | |
| 85 | + /** | |
| 86 | + * Block names whose items WooCommerce renders with the classic loop hooks fired as well (its archive | |
| 87 | + * template compatibility layer), on top of the block output. | |
| 88 | + */ | |
| 89 | + const PRODUCT_TEMPLATE_BLOCKS = array('woocommerce/product-template', 'core/post-template'); | |
| 90 | + | |
| 91 | + /** How many product template blocks are rendering right now (nested templates count up). */ | |
| 92 | + protected static int $productBlocksDepth = 0; | |
| 93 | + | |
| 94 | + /** | |
| 95 | + * True while a product template block renders its items. WooCommerce fires the classic loop hooks | |
| 96 | + * there too, and renderInProductBlocks() already puts the pricing, the badge and the button into the | |
| 97 | + * blocks, so the classic callbacks stand down to avoid a second copy outside the card's layout. | |
| 98 | + */ | |
| 99 | + public static function isRenderingProductBlocks() : bool { | |
| 100 | + return self::$productBlocksDepth > 0; | |
| 101 | + } | |
| 102 | + | |
| 103 | + public function enterProductBlocks( $pre, $block ) { | |
| 104 | + if ( $this->isProductTemplateBlock( (array) $block ) ) { | |
| 105 | + self::$productBlocksDepth++; | |
| 106 | + } | |
| 107 | + return $pre; | |
| 108 | + } | |
| 109 | + | |
| 110 | + public function leaveProductBlocks( $content, $block ) { | |
| 111 | + if ( $this->isProductTemplateBlock( (array) $block ) && self::$productBlocksDepth > 0 ) { | |
| 112 | + self::$productBlocksDepth--; | |
| 113 | + } | |
| 114 | + return $content; | |
| 115 | + } | |
| 116 | + | |
| 117 | + protected function isProductTemplateBlock( array $block ) : bool { | |
| 118 | + $name = (string) ($block['blockName'] ?? ''); | |
| 119 | + if ( 'woocommerce/product-template' === $name ) { | |
| 120 | + return true; | |
| 121 | + } | |
| 122 | + // a Query Loop of products: the one WooCommerce marks as its own, or one inheriting the archive query | |
| 123 | + $attrs = (array) ($block['attrs'] ?? array()); | |
| 124 | + return 'core/post-template' === $name && (!empty( $attrs['isInherited'] ) || 'woocommerce/product-query/product-template' === ($attrs['__woocommerceNamespace'] ?? '')); | |
| 125 | + } | |
| 126 | + | |
| 68 | 127 | public function run() { |
| 69 | - add_filter( 'tiered_pricing_table/settings/sections', array($this, 'addSettingSection') ); | |
| 128 | + add_filter( 'tiered_pricing_table/settings/sections', array($this, 'addSettingSection'), 5 ); | |
| 129 | + add_filter( | |
| 130 | + 'pre_render_block', | |
| 131 | + array($this, 'enterProductBlocks'), | |
| 132 | + 10, | |
| 133 | + 2 | |
| 134 | + ); | |
| 135 | + add_filter( | |
| 136 | + 'render_block', | |
| 137 | + array($this, 'leaveProductBlocks'), | |
| 138 | + 1, | |
| 139 | + 2 | |
| 140 | + ); | |
| 141 | + add_filter( 'tiered_pricing_table/text_template_variables', array($this, 'registerTemplateVariables') ); | |
| 142 | + // the badge is its own feature: it shows whether or not the pricing does | |
| 143 | + if ( ProductCatalogLoopSettingsSection::isBadgeEnabled() ) { | |
| 144 | + add_action( 'woocommerce_before_shop_loop_item_title', array($this, 'renderBadge'), 11 ); | |
| 145 | + } | |
| 146 | + // block themes and the Product Collection block render products without the classic loop hooks | |
| 147 | + add_filter( | |
| 148 | + 'render_block', | |
| 149 | + array($this, 'renderInProductBlocks'), | |
| 150 | + 10, | |
| 151 | + 3 | |
| 152 | + ); | |
| 70 | 153 | if ( !ProductCatalogLoopSettingsSection::isEnabled() ) { |
| 71 | 154 | return; |
| 72 | 155 | } |
| 73 | 156 | add_filter( |
| @@ -105,26 +188,237 @@ | ||
| 105 | 188 | remove_action( 'woocommerce_after_shop_loop_item', 'woocommerce_template_loop_product_link_close', 5 ); |
| 106 | 189 | } |
| 107 | 190 | add_action( $position['hook'], function () use($closeLink) { |
| 108 | 191 | global $product, $post; |
| 192 | + // inside a product template block the pricing is already in the blocks (renderInProductBlocks) | |
| 193 | + if ( self::isRenderingProductBlocks() ) { | |
| 194 | + return; | |
| 195 | + } | |
| 109 | 196 | if ( !$product instanceof WC_Product ) { |
| 110 | 197 | $productID = ( isset( $post ) ? $post->ID : null ); |
| 111 | 198 | $product = wc_get_product( $productID ); |
| 112 | 199 | } |
| 113 | - if ( !$product ) { | |
| 114 | - return; | |
| 200 | + if ( $closeLink ) { | |
| 201 | + echo '</a>'; | |
| 115 | 202 | } |
| 116 | - if ( !$product->is_purchasable() ) { | |
| 203 | + if ( !$product || !$product->is_purchasable() || !$this->shouldRender( $product ) ) { | |
| 117 | 204 | return; |
| 118 | 205 | } |
| 119 | - if ( $closeLink ) { | |
| 120 | - echo '</a>'; | |
| 121 | - } | |
| 122 | 206 | $this->render( $product, ProductCatalogLoopSettingsSection::useReducedStyles() ); |
| 123 | 207 | }, $position['priority'] ); |
| 124 | 208 | } ); |
| 125 | 209 | } |
| 126 | 210 | |
| 211 | + /** | |
| 212 | + * Whether this product list and this product are among the ones the settings allow. | |
| 213 | + */ | |
| 214 | + public function shouldRender( WC_Product $product, ?string $context = null ) : bool { | |
| 215 | + return $this->matchesContext( $context ) && $this->matchesCategories( $product ); | |
| 216 | + } | |
| 217 | + | |
| 218 | + protected function matchesContext( ?string $context = null ) : bool { | |
| 219 | + $context = ( $context ?: $this->getLoopContext() ); | |
| 220 | + return in_array( $context, ProductCatalogLoopSettingsSection::getContexts(), true ); | |
| 221 | + } | |
| 222 | + | |
| 223 | + protected function matchesCategories( WC_Product $product ) : bool { | |
| 224 | + $categories = ProductCatalogLoopSettingsSection::getCategories(); | |
| 225 | + if ( !$categories ) { | |
| 226 | + return true; | |
| 227 | + } | |
| 228 | + $productId = ( $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id() ); | |
| 229 | + // a chosen category covers its child categories | |
| 230 | + foreach ( $categories as $categoryId ) { | |
| 231 | + $children = get_term_children( $categoryId, 'product_cat' ); | |
| 232 | + if ( is_array( $children ) ) { | |
| 233 | + $categories = array_merge( $categories, array_map( 'intval', $children ) ); | |
| 234 | + } | |
| 235 | + } | |
| 236 | + return has_term( array_unique( $categories ), 'product_cat', $productId ); | |
| 237 | + } | |
| 238 | + | |
| 239 | + /** | |
| 240 | + * The kind of product list the classic loop is rendering, as one of the context keys. | |
| 241 | + */ | |
| 242 | + protected function getLoopContext() : string { | |
| 243 | + $name = (string) wc_get_loop_prop( 'name', '' ); | |
| 244 | + if ( 'related' === $name ) { | |
| 245 | + return 'related'; | |
| 246 | + } | |
| 247 | + if ( 'up-sells' === $name ) { | |
| 248 | + return 'upsells'; | |
| 249 | + } | |
| 250 | + if ( 'cross-sells' === $name ) { | |
| 251 | + return 'cross_sells'; | |
| 252 | + } | |
| 253 | + if ( wc_get_loop_prop( 'is_shortcode', false ) ) { | |
| 254 | + return 'shortcode'; | |
| 255 | + } | |
| 256 | + if ( is_search() ) { | |
| 257 | + return 'search'; | |
| 258 | + } | |
| 259 | + if ( is_product_category() ) { | |
| 260 | + return 'category'; | |
| 261 | + } | |
| 262 | + if ( is_product_tag() || is_product_taxonomy() ) { | |
| 263 | + return 'tag'; | |
| 264 | + } | |
| 265 | + if ( is_shop() ) { | |
| 266 | + return 'shop'; | |
| 267 | + } | |
| 268 | + return 'shortcode'; | |
| 269 | + } | |
| 270 | + | |
| 271 | + /** | |
| 272 | + * Product Collection block: the pricing goes around the product button or the title block, per the | |
| 273 | + * position option, and the badge into the product image block. | |
| 274 | + */ | |
| 275 | + public function renderInProductBlocks( $content, $block, $instance ) { | |
| 276 | + $name = $block['blockName'] ?? ''; | |
| 277 | + if ( !in_array( $name, array('woocommerce/product-button', 'core/post-title', 'woocommerce/product-image'), true ) ) { | |
| 278 | + return $content; | |
| 279 | + } | |
| 280 | + // only the blocks of a product list: the single product template uses the same blocks (inside the | |
| 281 | + // add-to-cart form, as the image), and there the product page has its own pricing | |
| 282 | + if ( !self::isRenderingProductBlocks() ) { | |
| 283 | + return $content; | |
| 284 | + } | |
| 285 | + if ( !$instance instanceof WP_Block || empty( $instance->context['postId'] ) ) { | |
| 286 | + return $content; | |
| 287 | + } | |
| 288 | + $postType = $instance->context['postType'] ?? get_post_type( (int) $instance->context['postId'] ); | |
| 289 | + if ( 'product' !== $postType || !$this->matchesContext( 'blocks' ) ) { | |
| 290 | + return $content; | |
| 291 | + } | |
| 292 | + $product = wc_get_product( (int) $instance->context['postId'] ); | |
| 293 | + if ( !$product || !$this->matchesCategories( $product ) ) { | |
| 294 | + return $content; | |
| 295 | + } | |
| 296 | + if ( 'woocommerce/product-image' === $name ) { | |
| 297 | + $badge = ( ProductCatalogLoopSettingsSection::isBadgeEnabled() ? $this->getBadgeHtml( $product ) : '' ); | |
| 298 | + return ( $badge ? preg_replace( | |
| 299 | + '/(<img\\b[^>]*>)/i', | |
| 300 | + '$1' . $badge, | |
| 301 | + $content, | |
| 302 | + 1 | |
| 303 | + ) : $content ); | |
| 304 | + } | |
| 305 | + if ( !ProductCatalogLoopSettingsSection::isEnabled() || !$product->is_purchasable() ) { | |
| 306 | + return $content; | |
| 307 | + } | |
| 308 | + $position = ProductCatalogLoopSettingsSection::getPosition(); | |
| 309 | + $target = ( 'woocommerce_shop_loop_item_title' === $position['hook'] ? 'core/post-title' : 'woocommerce/product-button' ); | |
| 310 | + if ( $name !== $target ) { | |
| 311 | + return $content; | |
| 312 | + } | |
| 313 | + $before = (int) $position['priority'] <= (( 'core/post-title' === $target ? 5 : 6 )); | |
| 314 | + ob_start(); | |
| 315 | + $this->render( $product, ProductCatalogLoopSettingsSection::useReducedStyles() ); | |
| 316 | + $pricing = ob_get_clean(); | |
| 317 | + return ( $before ? $pricing . $content : $content . $pricing ); | |
| 318 | + } | |
| 319 | + | |
| 320 | + /** | |
| 321 | + * The bulk savings badge on a classic loop item, right after the thumbnail. | |
| 322 | + */ | |
| 323 | + public function renderBadge() { | |
| 324 | + global $product, $post; | |
| 325 | + // inside a product template block the badge is already in the product image block | |
| 326 | + if ( self::isRenderingProductBlocks() ) { | |
| 327 | + return; | |
| 328 | + } | |
| 329 | + $item = ( $product instanceof WC_Product ? $product : (( isset( $post ) ? wc_get_product( $post->ID ) : null )) ); | |
| 330 | + if ( !$item || !$this->shouldRender( $item ) ) { | |
| 331 | + return; | |
| 332 | + } | |
| 333 | + echo $this->getBadgeHtml( $item ); | |
| 334 | + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in the helper | |
| 335 | + } | |
| 336 | + | |
| 337 | + /** | |
| 338 | + * The badge markup, or an empty string when the product's tiers give no saving. | |
| 339 | + */ | |
| 340 | + public function getBadgeHtml( WC_Product $product ) : string { | |
| 341 | + list( $lowest, $regular, $lowestQuantity ) = $this->getLowestTierPrice( $product ); | |
| 342 | + if ( null === $lowest || $regular <= 0 || $lowest >= $regular ) { | |
| 343 | + return ''; | |
| 344 | + } | |
| 345 | + $discount = (int) round( ($regular - (float) $lowest) / $regular * 100 ); | |
| 346 | + $text = strtr( ProductCatalogLoopSettingsSection::getBadgeTemplate(), array( | |
| 347 | + '{tp_max_discount}' => $discount . '%', | |
| 348 | + '{tp_lowest_price}' => wc_price( (float) $lowest ), | |
| 349 | + '{tp_lowest_quantity}' => $lowestQuantity, | |
| 350 | + '{tp_max_saving}' => wc_price( $regular - (float) $lowest ), | |
| 351 | + ) ); | |
| 352 | + $color = ProductCatalogLoopSettingsSection::getBadgeColor(); | |
| 353 | + if ( !$color ) { | |
| 354 | + $color = ( ProductCatalogLoopSettingsSection::isCustomLayoutSettings() ? ProductCatalogLoopSettingsSection::getSelectedQuantityColor() : (string) $this->getContainer()->getSettings()->get( 'selected_quantity_color', '#3858e9' ) ); | |
| 355 | + } | |
| 356 | + return '<span class="tpt-bulk-badge tpt-bulk-badge--' . esc_attr( ProductCatalogLoopSettingsSection::getBadgePosition() ) . '" style="background-color: ' . esc_attr( $color ) . '">' . wp_kses_post( $text ) . '</span>'; | |
| 357 | + } | |
| 358 | + | |
| 359 | + /** | |
| 360 | + * The lowest tier price a product (or any of its variations) reaches, the price it is compared with and | |
| 361 | + * the quantity that price starts at, all as displayed. | |
| 362 | + * | |
| 363 | + * @return array{0: float|null, 1: float, 2: int|string} | |
| 364 | + */ | |
| 365 | + protected function getLowestTierPrice( WC_Product $product ) : array { | |
| 366 | + $lowest = null; | |
| 367 | + $lowestQuantity = ''; | |
| 368 | + if ( $product->is_type( 'variable' ) ) { | |
| 369 | + $regular = (float) $product->get_variation_price( 'max', true ); | |
| 370 | + foreach ( $product->get_children() as $childId ) { | |
| 371 | + $rules = PriceManager::getPricingRule( (int) $childId )->getRules(); | |
| 372 | + if ( !$rules ) { | |
| 373 | + continue; | |
| 374 | + } | |
| 375 | + $child = wc_get_product( $childId ); | |
| 376 | + $price = ( $child ? (float) wc_get_price_to_display( $child, array( | |
| 377 | + 'price' => PriceManager::getPricingRule( (int) $childId )->getTierPrice( PHP_INT_MAX, false ), | |
| 378 | + ) ) : null ); | |
| 379 | + if ( null !== $price && (null === $lowest || $price < $lowest) ) { | |
| 380 | + $lowest = $price; | |
| 381 | + $lowestQuantity = (int) max( array_keys( $rules ) ); | |
| 382 | + } | |
| 383 | + } | |
| 384 | + } else { | |
| 385 | + $regular = (float) wc_get_price_to_display( $product ); | |
| 386 | + $rules = PriceManager::getPricingRule( $product->get_id() )->getRules(); | |
| 387 | + if ( $rules ) { | |
| 388 | + $lowest = (float) wc_get_price_to_display( $product, array( | |
| 389 | + 'price' => PriceManager::getPricingRule( $product->get_id() )->getTierPrice( PHP_INT_MAX, false ), | |
| 390 | + ) ); | |
| 391 | + $lowestQuantity = (int) max( array_keys( $rules ) ); | |
| 392 | + } | |
| 393 | + } | |
| 394 | + return array($lowest, $regular, $lowestQuantity); | |
| 395 | + } | |
| 396 | + | |
| 397 | + public function registerTemplateVariables( $variables ) { | |
| 398 | + $variables['tp_max_discount'] = array( | |
| 399 | + 'name' => __( 'Biggest discount', 'tier-pricing-table' ), | |
| 400 | + 'description' => __( '{tp_max_discount} - the biggest percentage discount the product\'s tiers give.', 'tier-pricing-table' ), | |
| 401 | + 'variableKey' => '{tp_max_discount}', | |
| 402 | + ); | |
| 403 | + $variables['tp_lowest_price'] = array( | |
| 404 | + 'name' => __( 'Lowest price', 'tier-pricing-table' ), | |
| 405 | + 'description' => __( '{tp_lowest_price} - the lowest tier price.', 'tier-pricing-table' ), | |
| 406 | + 'variableKey' => '{tp_lowest_price}', | |
| 407 | + ); | |
| 408 | + $variables['tp_lowest_quantity'] = array( | |
| 409 | + 'name' => __( 'Lowest price quantity', 'tier-pricing-table' ), | |
| 410 | + 'description' => __( '{tp_lowest_quantity} - the quantity the lowest tier price starts at.', 'tier-pricing-table' ), | |
| 411 | + 'variableKey' => '{tp_lowest_quantity}', | |
| 412 | + ); | |
| 413 | + $variables['tp_max_saving'] = array( | |
| 414 | + 'name' => __( 'Biggest saving', 'tier-pricing-table' ), | |
| 415 | + 'description' => __( '{tp_max_saving} - the saving per item at the lowest tier price.', 'tier-pricing-table' ), | |
| 416 | + 'variableKey' => '{tp_max_saving}', | |
| 417 | + ); | |
| 418 | + return $variables; | |
| 419 | + } | |
| 420 | + | |
| 127 | 421 | public function render( WC_product $product, $useReducedStyles = false ) { |
| 128 | 422 | $classes = 'tiered-pricing-shop-loop'; |
| 129 | 423 | if ( $useReducedStyles ) { |
| 130 | 424 | $classes .= ' tiered-pricing-shop-loop--reduced'; |
| @@ -136,15 +430,19 @@ | ||
| 136 | 430 | ) ); |
| 137 | 431 | } |
| 138 | 432 | |
| 139 | 433 | public function addSettingSection( $sections ) : array { |
| 140 | - $newSections = []; | |
| 141 | - for ($i = 0; $i < count( $sections ); $i++) { | |
| 142 | - if ( 1 === $i ) { | |
| 143 | - $newSections[] = new ProductCatalogLoopSettingsSection(); | |
| 434 | + // The layout configurator carries the catalog settings while it is on. | |
| 435 | + if ( LayoutConfiguratorAddon::isActive() ) { | |
| 436 | + return $sections; | |
| 437 | + } | |
| 438 | + $_sections = array(); | |
| 439 | + foreach ( $sections as $section ) { | |
| 440 | + $_sections[] = $section; | |
| 441 | + if ( $section->getSlug() === 'general' ) { | |
| 442 | + $_sections[] = new ProductCatalogLoopSettingsSection(); | |
| 144 | 443 | } |
| 145 | - $newSections[] = $sections[$i]; | |
| 146 | 444 | } |
| 147 | - return $newSections; | |
| 445 | + return $_sections; | |
| 148 | 446 | } |
| 149 | 447 | |
| 150 | 448 | } |