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
tier-pricing-table / src / Addons / ProductCatalogLoop / ProductCatalogLoop.php

ProductCatalogLoop.php in Tiered Pricing Table for WooCommerce 8.0.2, at src/Addons/ProductCatalogLoop/ProductCatalogLoop.php

449 lines 21.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace TierPricingTable\Addons\ProductCatalogLoop;
4
5 use TierPricingTable\Addons\AbstractAddon;
6 use TierPricingTable\Addons\LayoutConfigurator\LayoutConfiguratorAddon;
7 use TierPricingTable\Addons\ProductCatalogLoop\Settings\ProductCatalogLoopSettingsSection;
8 use TierPricingTable\PriceManager;
9 use TierPricingTable\Settings\Settings;
10 use WC_Product;
11 use WP_Block;
12 class ProductCatalogLoop extends AbstractAddon {
13 public function getName() : string {
14 return __( 'Shop & Categories', 'tier-pricing-table' );
15 }
16
17 public function getDescription() : string {
18 return __( 'Display tiered pricing tables on the shop and category pages.', 'tier-pricing-table' );
19 }
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
25 public function getSlug() : string {
26 return 'shop-loop-display';
27 }
28
29 public function getRenderAttributes() : array {
30 if ( !ProductCatalogLoopSettingsSection::isCustomLayoutSettings() ) {
31 return array(
32 'display' => true,
33 'tiers_limit' => ProductCatalogLoopSettingsSection::getTiersLimit(),
34 );
35 }
36 $args = array(
37 'display_context' => 'shop-loop',
38 'display' => true,
39 'display_type' => ProductCatalogLoopSettingsSection::getLayoutType(),
40 'title' => ProductCatalogLoopSettingsSection::getTitle(),
41 'quantity_type' => ProductCatalogLoopSettingsSection::getQuantityType(),
42 'getSelectedQuantityColor' => ProductCatalogLoopSettingsSection::getSelectedQuantityColor(),
43 'quantity_column_title' => ProductCatalogLoopSettingsSection::getTableColumnsTitles()['head_quantity_text'],
44 'price_column_title' => ProductCatalogLoopSettingsSection::getTableColumnsTitles()['head_price_text'],
45 'discount_column_title' => ProductCatalogLoopSettingsSection::getTableColumnsTitles()['head_discount_text'],
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(),
54 'clickable_rows' => ProductCatalogLoopSettingsSection::isClickableTableRows(),
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(),
61 'options_show_original_product_price' => ProductCatalogLoopSettingsSection::isShowOriginalProductPriceInOptions(),
62 'options_show_default_option' => ProductCatalogLoopSettingsSection::isShowDefaultOption(),
63 'options_option_text' => ProductCatalogLoopSettingsSection::getOptionText(),
64 'options_default_option_text' => ProductCatalogLoopSettingsSection::getDefaultOptionText(),
65 'plain_text_show_default_option' => ProductCatalogLoopSettingsSection::isShowFirstPlainTextTier(),
66 'plain_text_option_text' => ProductCatalogLoopSettingsSection::getPlainTextTemplate(),
67 'plain_text_default_option_text' => ProductCatalogLoopSettingsSection::getFirstTierPlainTextTemplate(),
68 );
69 $default_quantity_measurement = array(
70 'singular' => '',
71 'plural' => '',
72 );
73 $quantity_measurement = $default_quantity_measurement;
74 if ( in_array( $args['display_type'], array('table', 'horizontal-table', 'tooltip') ) ) {
75 $quantity_measurement = ProductCatalogLoopSettingsSection::getTableQuantityMeasurement();
76 }
77 if ( 'blocks' === $args['display_type'] ) {
78 $quantity_measurement = ProductCatalogLoopSettingsSection::getBlocksQuantityMeasurement();
79 }
80 $args['quantity_measurement_singular'] = $quantity_measurement['singular'];
81 $args['quantity_measurement_plural'] = $quantity_measurement['plural'];
82 return $args;
83 }
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
127 public function run() {
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 );
153 if ( !ProductCatalogLoopSettingsSection::isEnabled() ) {
154 return;
155 }
156 add_filter(
157 'tiered_pricing_table/frontend/variation_render_settings',
158 function ( $settings, $productId, $context ) {
159 if ( 'shop-loop' !== $context ) {
160 return $settings;
161 }
162 return $this->getRenderAttributes();
163 },
164 10,
165 3
166 );
167 add_filter(
168 'tiered_pricing_table/frontend/default_price_behaviour_type',
169 function (
170 $type,
171 $product,
172 $priceHTML,
173 $priceDisplayContext
174 ) {
175 if ( 'shop-loop' === $priceDisplayContext ) {
176 return ( ProductCatalogLoopSettingsSection::isDynamicPrice() ? 'dynamic' : 'static' );
177 }
178 return $type;
179 },
180 10,
181 4
182 );
183 add_action( 'init', function () {
184 $position = ProductCatalogLoopSettingsSection::getPosition();
185 $closeLink = false;
186 if ( 'woocommerce_shop_loop_item_title' === $position['hook'] ) {
187 $closeLink = true;
188 remove_action( 'woocommerce_after_shop_loop_item', 'woocommerce_template_loop_product_link_close', 5 );
189 }
190 add_action( $position['hook'], function () use($closeLink) {
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 }
196 if ( !$product instanceof WC_Product ) {
197 $productID = ( isset( $post ) ? $post->ID : null );
198 $product = wc_get_product( $productID );
199 }
200 if ( $closeLink ) {
201 echo '</a>';
202 }
203 if ( !$product || !$product->is_purchasable() || !$this->shouldRender( $product ) ) {
204 return;
205 }
206 $this->render( $product, ProductCatalogLoopSettingsSection::useReducedStyles() );
207 }, $position['priority'] );
208 } );
209 }
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
421 public function render( WC_product $product, $useReducedStyles = false ) {
422 $classes = 'tiered-pricing-shop-loop';
423 if ( $useReducedStyles ) {
424 $classes .= ' tiered-pricing-shop-loop--reduced';
425 }
426 $this->getContainer()->getFileManager()->includeTemplate( 'frontend/shop-loop.php', array(
427 'classes' => $classes,
428 'product' => $product,
429 'settings' => $this->getRenderAttributes(),
430 ) );
431 }
432
433 public function addSettingSection( $sections ) : array {
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();
443 }
444 }
445 return $_sections;
446 }
447
448 }
449