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 +318 -16 6.1.0 → 8.0.2 View file →
@@ -2,19 +2,27 @@
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 __( 'Product Catalog Loop', 'tier-pricing-table' );
14 + return __( 'Shop & Categories', 'tier-pricing-table' );
11 15 }
12 16
13 17 public function getDescription() : string {
14 - return __( 'Display tiered pricing in the product catalog (shop page.)', 'tier-pricing-table' );
18 + return __( 'Display tiered pricing tables on the shop and category pages.', 'tier-pricing-table' );
15 19 }
16 20
21 + public function getIcon() : string {
22 + return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4 10h12v2H4zm0-4h16v2H4zm0 8h8v2H4zm10 0v6l5-3z"/></svg>';
23 + }
24 +
17 25 public function getSlug() : string {
18 26 return 'shop-loop-display';
19 27 }
20 28
@@ -20,9 +28,10 @@
20 28
21 29 public function getRenderAttributes() : array {
22 30 if ( !ProductCatalogLoopSettingsSection::isCustomLayoutSettings() ) {
23 31 return array(
24 - 'display' => true,
32 + 'display' => true,
33 + 'tiers_limit' => ProductCatalogLoopSettingsSection::getTiersLimit(),
25 34 );
26 35 }
27 36 $args = array(
28 37 'display_context' => 'shop-loop',
@@ -34,10 +43,22 @@
34 43 'quantity_column_title' => ProductCatalogLoopSettingsSection::getTableColumnsTitles()['head_quantity_text'],
35 44 'price_column_title' => ProductCatalogLoopSettingsSection::getTableColumnsTitles()['head_price_text'],
36 45 'discount_column_title' => ProductCatalogLoopSettingsSection::getTableColumnsTitles()['head_discount_text'],
37 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(),
38 54 'clickable_rows' => ProductCatalogLoopSettingsSection::isClickableTableRows(),
39 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(),
40 61 'options_show_original_product_price' => ProductCatalogLoopSettingsSection::isShowOriginalProductPriceInOptions(),
41 62 'options_show_default_option' => ProductCatalogLoopSettingsSection::isShowDefaultOption(),
42 63 'options_option_text' => ProductCatalogLoopSettingsSection::getOptionText(),
43 64 'options_default_option_text' => ProductCatalogLoopSettingsSection::getDefaultOptionText(),
@@ -60,10 +81,76 @@
60 81 $args['quantity_measurement_plural'] = $quantity_measurement['plural'];
61 82 return $args;
62 83 }
63 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 +
64 127 public function run() {
65 - 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 + );
66 153 if ( !ProductCatalogLoopSettingsSection::isEnabled() ) {
67 154 return;
68 155 }
69 156 add_filter(
@@ -101,26 +188,237 @@
101 188 remove_action( 'woocommerce_after_shop_loop_item', 'woocommerce_template_loop_product_link_close', 5 );
102 189 }
103 190 add_action( $position['hook'], function () use($closeLink) {
104 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 + }
105 196 if ( !$product instanceof WC_Product ) {
106 197 $productID = ( isset( $post ) ? $post->ID : null );
107 198 $product = wc_get_product( $productID );
108 199 }
109 - if ( !$product ) {
110 - return;
200 + if ( $closeLink ) {
201 + echo '</a>';
111 202 }
112 - if ( !$product->is_purchasable() ) {
203 + if ( !$product || !$product->is_purchasable() || !$this->shouldRender( $product ) ) {
113 204 return;
114 205 }
115 - if ( $closeLink ) {
116 - echo '</a>';
117 - }
118 206 $this->render( $product, ProductCatalogLoopSettingsSection::useReducedStyles() );
119 207 }, $position['priority'] );
120 208 } );
121 209 }
122 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 +
123 421 public function render( WC_product $product, $useReducedStyles = false ) {
124 422 $classes = 'tiered-pricing-shop-loop';
125 423 if ( $useReducedStyles ) {
126 424 $classes .= ' tiered-pricing-shop-loop--reduced';
@@ -132,15 +430,19 @@
132 430 ) );
133 431 }
134 432
135 433 public function addSettingSection( $sections ) : array {
136 - $newSections = [];
137 - for ($i = 0; $i < count( $sections ); $i++) {
138 - if ( 1 === $i ) {
139 - $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();
140 443 }
141 - $newSections[] = $sections[$i];
142 444 }
143 - return $newSections;
445 + return $_sections;
144 446 }
145 447
146 448 }