PluginProbe
Tiered Pricing Table for WooCommerce / 8.0.2
Tiered Pricing Table for WooCommerce v8.0.2
8.0.2 7.1.7 7.1.5 7.1.4 7.1.1 6.5.0 6.4.0 6.1.0 trunk 1.0 1.1 2.0 2.0.1 2.0.2 2.1 2.1.1 2.1.2 2.2.0 2.2.1 2.2.2 2.2.3 2.3.0 2.3.1 2.3.2 2.3.3 All 91 releases
← 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 }