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 / LayoutConfigurator / LayoutConfigurator.php

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

905 lines 44.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php namespace TierPricingTable\Addons\LayoutConfigurator;
2
3 use TierPricingTable\Addons\AbstractAddon;
4 use TierPricingTable\Core\ServiceContainer;
5 use TierPricingTable\Core\ServiceContainerTrait;
6 use TierPricingTable\Settings\Sections\GeneralSection\GeneralSection;
7 use TierPricingTable\Settings\Settings;
8 use TierPricingTable\TierPricingTablePlugin;
9
10 /**
11 * Layout configurator: one settings field that controls every product-page layout option
12 * (layout, design style, title, position, quantity format and unit, column headers, text
13 * templates, tooltip icon, colours and the layout toggles) with a live preview inside a
14 * product-page skeleton. The custom table columns add-on renders its editor inside it. Rendered by a React app (js-source, built
15 * to build/ with wp-scripts).
16 *
17 * Every option keeps its own id and is submitted through hidden inputs inside the field, so
18 * WooCommerce's settings save path is unchanged. The other fields of the settings page keep
19 * reading the options through the hidden inputs (the app dispatches change events on them).
20 */
21 class LayoutConfigurator {
22
23 use ServiceContainerTrait;
24
25 const FIELD_TYPE = 'tpt_layout_configurator';
26
27 const SCRIPT_HANDLE = 'tiered-pricing/settings/layout-configurator';
28
29 /**
30 * Options managed by the configurator: id (without prefix) => default.
31 */
32 const OPTIONS = array(
33 'display' => 'yes',
34 'display_type' => 'table',
35 'pricing_table_style' => 'default',
36 'pricing_blocks_style' => 'default',
37 'pricing_options_style' => 'default',
38 'pricing_dropdown_style' => 'default',
39 'pricing_plain_text_style' => 'default',
40 'table_title' => '',
41 'position_hook' => 'woocommerce_before_add_to_cart_button',
42 'quantity_type' => 'range',
43 'tiers_order' => 'asc',
44 'compact_layout' => 'no',
45 'layout_spacing' => '',
46 'cell_padding' => '',
47 'discount_badge_color' => '',
48 'table_active_border' => 'yes',
49 'selected_quantity_color' => '#3858e9',
50 'show_discount_column' => 'yes',
51 'discount_format' => 'percentage',
52 // options / dropdown layouts
53 'options_option_text' => '',
54 'options_show_default_option' => 'yes',
55 'options_default_option_text' => '',
56 'options_show_original_product_price' => 'yes',
57 'options_show_total' => 'yes',
58 // tooltip layout
59 'tooltip_color' => '#3858e9',
60 'tooltip_size' => '15',
61 'tooltip_border' => 'yes',
62 // table layouts: column headers (an empty header hides the column)
63 'head_quantity_text' => '',
64 'head_discount_text' => '',
65 'head_price_text' => '',
66 // plain text layout
67 'plain_text_template' => '',
68 'plain_text_show_first_tier' => 'yes',
69 'plain_text_first_tier_template' => '',
70 // layouts with selectable tiers
71 'clickable_table_rows' => 'yes',
72 // product page price line
73 'product_page_price_format' => 'custom',
74 'update_price_on_product_page' => 'yes',
75 'show_tiered_price_as_discount' => 'yes',
76 'show_total_price' => 'no',
77 'show_total_price_non_tiered' => 'no',
78 );
79
80 /**
81 * "You Save" badge options (the add-on's), managed here while that add-on is on.
82 */
83 const YOU_SAVE_OPTIONS = array(
84 'you_save_enabled' => 'yes',
85 'you_save_consider_sale_price' => 'yes',
86 'you_save_non_tiered' => 'no',
87 'you_save_template' => '',
88 'you_save_text_color' => '#FF0000',
89 );
90
91 const YOU_SAVE_ADDON_SLUG = 'you-save';
92
93 /**
94 * Pricing summary block options (the add-on's), managed here while that add-on is on.
95 */
96 const SUMMARY_OPTIONS = array(
97 'display_summary' => 'yes',
98 'display_summary_non_tiered' => 'no',
99 'summary_title' => '',
100 'summary_position_hook' => 'woocommerce_after_add_to_cart_button',
101 'summary_type' => 'table',
102 'summary_total_label' => '',
103 'summary_each_label' => '',
104 );
105
106 const SUMMARY_ADDON_SLUG = 'pricing-summary';
107
108 /**
109 * Shop & category price format options (the catalog prices add-on's), managed here while it is on.
110 */
111 const CATALOG_PRICE_OPTIONS = array(
112 'tiered_price_at_catalog' => 'yes',
113 'tiered_price_at_catalog_for_variable' => 'no',
114 'tiered_price_at_catalog_type' => 'lowest',
115 'tiered_price_at_catalog_custom_template' => '',
116 'lowest_prefix' => '',
117 );
118
119 const CATALOG_PRICE_ADDON_SLUG = 'catalog-prices';
120
121 /**
122 * Cart page price options (the cart options add-on's), managed here while it is on.
123 */
124 const CART_OPTIONS = array(
125 'show_discount_in_cart' => 'yes',
126 'show_subtotal_as_discount_in_cart' => 'yes',
127 'consider_sale_price_as_discount_in_cart' => 'no',
128 'cart_total_savings' => 'no',
129 'cart_total_savings_label' => '',
130 );
131
132 const CART_ADDON_SLUG = 'cart-options';
133
134 /**
135 * Cart upsell message options (the cart upsells add-on's), managed here while it is on.
136 */
137 const UPSELL_OPTIONS = array(
138 'cart_upsell_enabled' => 'no',
139 'cart_upsell_template' => '',
140 'cart_upsell_color' => '#059669',
141 'cart_upsell_link' => 'yes',
142 'cart_upsell_progress' => 'no',
143 );
144
145 const UPSELL_ADDON_SLUG = 'cart-upsells';
146
147 /**
148 * Options stored as HTML templates (sanitised with wp_kses_post, like the text-template field).
149 */
150 const HTML_OPTIONS = array( 'options_option_text', 'options_default_option_text', 'plain_text_template', 'plain_text_first_tier_template', 'you_save_template', 'tiered_price_at_catalog_custom_template', 'cart_upsell_template', 'badge_template' );
151
152 /**
153 * Options that only work with premium code; shown locked otherwise.
154 */
155 const PREMIUM_OPTIONS = array( 'options_show_total', 'clickable_table_rows' );
156
157 /**
158 * Options stored as singular/plural pairs (quantity unit labels), keyed by the layouts they apply to.
159 */
160 const UNIT_OPTIONS = array(
161 'table_quantity_measurement' => array( 'table', 'tooltip', 'horizontal-table' ),
162 'blocks_quantity_measurement' => array( 'blocks' ),
163 );
164
165 /**
166 * Shop & category page options, managed by the configurator while the catalog add-on is on:
167 * configurator key => option id (without the settings prefix). Keys shared with the product page
168 * mean the same thing there; the last four are catalog-only.
169 */
170 const CATALOG_OPTIONS = array(
171 'display' => 'shop_loop_display_enabled',
172 'display_type' => 'shop_loop_display_layout',
173 'pricing_table_style' => 'shop_loop_display_pricing_table_style',
174 'pricing_blocks_style' => 'shop_loop_display_pricing_blocks_style',
175 'pricing_options_style' => 'shop_loop_display_pricing_options_style',
176 'pricing_dropdown_style' => 'shop_loop_display_pricing_dropdown_style',
177 'pricing_plain_text_style' => 'shop_loop_display_pricing_plain_text_style',
178 'table_title' => 'shop_loop_display_title',
179 'position_hook' => 'shop_loop_display_position',
180 'quantity_type' => 'shop_loop_display_quantity_type',
181 'tiers_order' => 'shop_loop_display_tiers_order',
182 'layout_spacing' => 'shop_loop_display_layout_spacing',
183 'cell_padding' => 'shop_loop_display_cell_padding',
184 'discount_badge_color' => 'shop_loop_display_discount_badge_color',
185 'table_active_border' => 'shop_loop_display_table_active_border',
186 'selected_quantity_color' => 'shop_loop_display_selected_quantity_color',
187 'show_discount_column' => 'shop_loop_display_blocks_show_discount',
188 'discount_format' => 'shop_loop_display_discount_format',
189 'options_option_text' => 'shop_loop_display_options_option_text',
190 'options_show_default_option' => 'shop_loop_display_options_show_default_option',
191 'options_default_option_text' => 'shop_loop_display_options_default_option_text',
192 'options_show_original_product_price' => 'shop_loop_display_options_show_original_product_price',
193 'options_show_total' => 'shop_loop_display_options_show_total',
194 'head_quantity_text' => 'shop_loop_display_quantity_column_title',
195 'head_discount_text' => 'shop_loop_display_discount_column_title',
196 'head_price_text' => 'shop_loop_display_price_column_title',
197 'plain_text_template' => 'shop_loop_display_plain_text_template',
198 'plain_text_show_first_tier' => 'shop_loop_display_plain_text_show_first_tier',
199 'plain_text_first_tier_template' => 'shop_loop_display_plain_text_first_tier_template',
200 'clickable_table_rows' => 'shop_loop_display_clickable_table_rows',
201 // catalog-only
202 'layout_settings' => 'shop_loop_display_layout_settings',
203 'use_reduced_styles' => 'shop_loop_display_use_reduced_styles',
204 'dynamic_price' => 'shop_loop_display_dynamic_price',
205 'show_quantity_field' => 'shop_loop_display_show_quantity_field',
206 'scope' => 'shop_loop_display_scope',
207 'contexts' => 'shop_loop_display_contexts',
208 'categories' => 'shop_loop_display_categories',
209 'tiers_limit' => 'shop_loop_display_tiers_limit',
210 'badge_enabled' => 'shop_loop_display_badge_enabled',
211 'badge_template' => 'shop_loop_display_badge_template',
212 'badge_position' => 'shop_loop_display_badge_position',
213 'badge_color' => 'shop_loop_display_badge_color',
214 );
215
216 const CATALOG_UNIT_OPTIONS = array(
217 'table_quantity_measurement' => 'shop_loop_display_table_quantity_measurement',
218 'blocks_quantity_measurement' => 'shop_loop_display_blocks_quantity_measurement',
219 );
220
221 const CATALOG_PREMIUM_OPTIONS = array( 'options_show_total', 'clickable_table_rows', 'show_quantity_field' );
222
223 const CATALOG_ADDON_SLUG = 'shop-loop-display';
224
225 public function __construct() {
226 add_action( 'woocommerce_admin_field_' . self::FIELD_TYPE, array( $this, 'render' ) );
227
228 $htmlOptions = self::HTML_OPTIONS;
229 foreach ( self::HTML_OPTIONS as $key ) {
230 if ( isset( self::CATALOG_OPTIONS[ $key ] ) ) {
231 $htmlOptions[] = self::CATALOG_OPTIONS[ $key ];
232 }
233 }
234 foreach ( $htmlOptions as $id ) {
235 add_filter( 'woocommerce_admin_settings_sanitize_option_' . self::getOptionId( $id ), function ( $value, $option, $rawValue ) {
236 return wp_kses_post( is_string( $rawValue ) ? $rawValue : '' );
237 }, 10, 3 );
238 }
239
240 // numbers: pixels within a range, or empty where empty means "auto"
241 self::sanitizeNumber( 'tooltip_size', 8, 64, '15' );
242 self::sanitizeNumber( 'layout_spacing', 0, 80, '' );
243 self::sanitizeNumber( 'shop_loop_display_layout_spacing', 0, 80, '' );
244 self::sanitizeNumber( 'cell_padding', 0, 60, '' );
245 self::sanitizeNumber( 'shop_loop_display_cell_padding', 0, 60, '' );
246 self::sanitizeNumber( 'shop_loop_display_tiers_limit', 0, 20, '' );
247
248 // lists stored as comma-separated strings: known context keys, category ids
249 add_filter( 'woocommerce_admin_settings_sanitize_option_' . self::getOptionId( 'shop_loop_display_contexts' ), function ( $value ) {
250 $known = array_keys( \TierPricingTable\Addons\ProductCatalogLoop\Settings\ProductCatalogLoopSettingsSection::getContextOptions() );
251
252 return implode( ',', array_values( array_intersect( \TierPricingTable\Settings\CustomOptions\TPTCheckboxListOption::toArray( $value ), $known ) ) );
253 } );
254 add_filter( 'woocommerce_admin_settings_sanitize_option_' . self::getOptionId( 'shop_loop_display_categories' ), function ( $value ) {
255 return implode( ',', array_filter( array_map( 'intval', \TierPricingTable\Settings\CustomOptions\TPTCheckboxListOption::toArray( $value ) ) ) );
256 } );
257
258 // colours: a hex colour, or the option's default
259 foreach ( array( 'selected_quantity_color' => '#3858e9', 'tooltip_color' => '#3858e9', 'you_save_text_color' => '#FF0000', 'cart_upsell_color' => '#059669', 'shop_loop_display_selected_quantity_color' => '#3858e9' ) as $id => $default ) {
260 add_filter( 'woocommerce_admin_settings_sanitize_option_' . self::getOptionId( $id ), function ( $value ) use ( $default ) {
261 $color = is_string( $value ) ? sanitize_hex_color( trim( $value ) ) : null;
262
263 return $color ? $color : $default;
264 } );
265 }
266
267 // optional colours: a hex colour, or empty (the design style's own colours)
268 foreach ( array( 'discount_badge_color', 'shop_loop_display_discount_badge_color', 'shop_loop_display_badge_color' ) as $id ) {
269 add_filter( 'woocommerce_admin_settings_sanitize_option_' . self::getOptionId( $id ), function ( $value ) {
270 $color = is_string( $value ) ? sanitize_hex_color( trim( $value ) ) : null;
271
272 return $color ? $color : '';
273 } );
274 }
275
276 // choices: only a known value is saved; anything else keeps the stored value. The allowed values are
277 // resolved when a save happens (they carry translated labels, which must not load this early).
278 foreach ( self::CHOICE_OPTIONS as $id ) {
279 add_filter( 'woocommerce_admin_settings_sanitize_option_' . self::getOptionId( $id ), function ( $value ) use ( $id ) {
280 $allowed = self::getChoiceLists()[ $id ] ?? null;
281
282 if ( null === $allowed || ( is_string( $value ) && in_array( $value, $allowed, true ) ) ) {
283 return $value;
284 }
285
286 return ServiceContainer::getInstance()->getSettings()->get( $id, $allowed[0] ?? '' );
287 } );
288 }
289 }
290
291 /**
292 * Single-choice options whose saved value is checked against getChoiceLists().
293 */
294 const CHOICE_OPTIONS = array(
295 'shop_loop_display_badge_position',
296 'shop_loop_display_scope',
297 'display_type', 'pricing_table_style', 'pricing_blocks_style', 'pricing_options_style', 'pricing_dropdown_style', 'pricing_plain_text_style',
298 'quantity_type', 'tiers_order', 'discount_format', 'product_page_price_format', 'position_hook', 'summary_type', 'tiered_price_at_catalog_type',
299 'shop_loop_display_layout', 'shop_loop_display_pricing_table_style', 'shop_loop_display_pricing_blocks_style', 'shop_loop_display_pricing_options_style',
300 'shop_loop_display_pricing_dropdown_style', 'shop_loop_display_pricing_plain_text_style', 'shop_loop_display_quantity_type', 'shop_loop_display_tiers_order',
301 'shop_loop_display_discount_format', 'shop_loop_display_layout_settings', 'shop_loop_display_position',
302 );
303
304 /**
305 * Allowed values per single-choice option (product page and shop keys).
306 */
307 public static function getChoiceLists(): array {
308 $layouts = array_keys( TierPricingTablePlugin::getAvailablePricingLayouts() );
309 $styles = GeneralSection::getStyleOptions();
310 $catalog = GeneralSection::getStyleOptions( true );
311 $lists = array(
312 'display_type' => $layouts,
313 'pricing_table_style' => array_keys( $styles['table'] ),
314 'pricing_blocks_style' => array_keys( $styles['blocks'] ),
315 'pricing_options_style' => array_keys( $styles['options'] ),
316 'pricing_dropdown_style' => array_keys( $styles['dropdown'] ),
317 'pricing_plain_text_style' => array_keys( $styles['plain-text'] ),
318 'quantity_type' => array( 'range', 'static' ),
319 'tiers_order' => array( 'asc', 'desc' ),
320 'discount_format' => array( 'percentage', 'amount', 'both' ),
321 'product_page_price_format' => array( 'custom', 'same_as_catalog' ),
322 'shop_loop_display_badge_position' => array( 'top-left', 'top-right' ),
323 'shop_loop_display_scope' => array( 'everywhere', 'selected' ),
324 'position_hook' => array_merge( array_keys( self::getPositionOptions() ), array( GeneralSection::NONE_POSITION ) ),
325 'shop_loop_display_layout' => array_values( array_diff( $layouts, array( 'tooltip' ) ) ),
326 'shop_loop_display_pricing_table_style' => array_keys( $catalog['table'] ),
327 'shop_loop_display_pricing_blocks_style' => array_keys( $catalog['blocks'] ),
328 'shop_loop_display_pricing_options_style' => array_keys( $catalog['options'] ),
329 'shop_loop_display_pricing_dropdown_style' => array_keys( $catalog['dropdown'] ),
330 'shop_loop_display_pricing_plain_text_style' => array_keys( $catalog['plain-text'] ),
331 'shop_loop_display_quantity_type' => array( 'range', 'static' ),
332 'shop_loop_display_tiers_order' => array( 'asc', 'desc' ),
333 'shop_loop_display_discount_format' => array( 'percentage', 'amount', 'both' ),
334 'shop_loop_display_layout_settings' => array( 'default', 'custom' ),
335 'shop_loop_display_position' => array_keys( self::getCatalogPositionOptions() ),
336 );
337
338 if ( self::isSummaryAvailable() ) {
339 $lists['summary_type'] = array( 'detailed', 'compact', 'inline' );
340 }
341 if ( self::isCatalogPriceAvailable() ) {
342 $lists['tiered_price_at_catalog_type'] = array( 'lowest', 'range', 'custom' );
343 }
344
345 return $lists;
346 }
347
348 /**
349 * Save a numeric option as an integer within a range; an empty value stays empty when allowed.
350 */
351 protected static function sanitizeNumber( string $id, int $min, int $max, string $empty ) {
352 add_filter( 'woocommerce_admin_settings_sanitize_option_' . self::getOptionId( $id ), function ( $value ) use ( $min, $max, $empty ) {
353 if ( ! is_scalar( $value ) || '' === trim( (string) $value ) || ! is_numeric( $value ) ) {
354 return $empty;
355 }
356
357 return (string) max( $min, min( $max, (int) $value ) );
358 } );
359 }
360
361 /**
362 * Option defaults, including the translated template defaults.
363 */
364 public static function getDefaults(): array {
365 $defaults = self::OPTIONS;
366
367 $defaults['options_option_text'] = __( '<strong>Buy {tp_quantity} pieces and save {tp_rounded_discount}%</strong>', 'tier-pricing-table' );
368 $defaults['options_default_option_text'] = __( '<strong>Buy {tp_quantity} pieces</strong>', 'tier-pricing-table' );
369 $defaults['head_quantity_text'] = __( 'Quantity', 'tier-pricing-table' );
370 $defaults['head_discount_text'] = __( 'Discount (%)', 'tier-pricing-table' );
371 $defaults['head_price_text'] = __( 'Price', 'tier-pricing-table' );
372 $defaults['plain_text_template'] = __( '<strong>Buy {tp_quantity} pieces for {tp_price} each and save {tp_rounded_discount}%</strong>', 'tier-pricing-table' );
373 $defaults['plain_text_first_tier_template'] = __( '<strong>Buy {tp_quantity} pieces for {tp_price} each</strong>', 'tier-pricing-table' );
374 // stores that used the old "tiered price at product page" switch keep the catalog format
375 $defaults['product_page_price_format'] = 'yes' === ServiceContainer::getInstance()->getSettings()->get( 'tiered_price_at_product_page', 'no' ) ? 'same_as_catalog' : 'custom';
376
377 if ( self::isYouSaveAvailable() ) {
378 $defaults = array_merge( $defaults, self::YOU_SAVE_OPTIONS );
379 $defaults['you_save_template'] = __( 'You save {tp_ys_total_price}', 'tier-pricing-table' );
380 }
381 if ( self::isSummaryAvailable() ) {
382 $defaults = array_merge( $defaults, self::SUMMARY_OPTIONS );
383 $defaults['summary_total_label'] = __( 'Total:', 'tier-pricing-table' );
384 $defaults['summary_each_label'] = __( 'Each: ', 'tier-pricing-table' );
385 }
386 if ( self::isCatalogPriceAvailable() ) {
387 $defaults = array_merge( $defaults, self::CATALOG_PRICE_OPTIONS );
388 $defaults['tiered_price_at_catalog_custom_template'] = __( 'From {tp_lowest_price} instead of {tp_original_price}', 'tier-pricing-table' );
389 $defaults['lowest_prefix'] = __( 'From', 'tier-pricing-table' );
390 }
391 if ( self::isCartAvailable() ) {
392 $defaults = array_merge( $defaults, self::CART_OPTIONS );
393 $defaults['cart_total_savings_label'] = __( 'Total savings', 'tier-pricing-table' );
394 }
395 if ( self::isUpsellAvailable() ) {
396 $defaults = array_merge( $defaults, self::UPSELL_OPTIONS );
397 $defaults['cart_upsell_template'] = __( 'Add <b>{tp_required_quantity}</b> more to get <b>{tp_next_price}</b> each', 'tier-pricing-table' );
398 }
399
400 return $defaults;
401 }
402
403 public static function isCartAvailable(): bool {
404 return AbstractAddon::isAddonEnabled( self::CART_ADDON_SLUG );
405 }
406
407 public static function isUpsellAvailable(): bool {
408 return AbstractAddon::isAddonEnabled( self::UPSELL_ADDON_SLUG );
409 }
410
411 public static function isSummaryAvailable(): bool {
412 return AbstractAddon::isAddonEnabled( self::SUMMARY_ADDON_SLUG );
413 }
414
415 public static function isCatalogPriceAvailable(): bool {
416 return AbstractAddon::isAddonEnabled( self::CATALOG_PRICE_ADDON_SLUG );
417 }
418
419 /**
420 * Whether the "You Save" add-on is on, i.e. the configurator also manages the badge options.
421 */
422 public static function isYouSaveAvailable(): bool {
423 return AbstractAddon::isAddonEnabled( self::YOU_SAVE_ADDON_SLUG );
424 }
425
426 /**
427 * Sample amounts for the skeleton's price line and summary: the preview product at the first discounted tier.
428 */
429 public function getSamples(): array {
430 $plain = function ( $html ) {
431 return html_entity_decode( wp_strip_all_tags( (string) $html ), ENT_QUOTES, 'UTF-8' );
432 };
433
434 $price = LayoutPreview::PRICE;
435 $tiers = LayoutPreview::SAMPLE_TIERS;
436 $quantity = (int) array_key_first( $tiers );
437 $discount = (float) reset( $tiers );
438 $tierPrice = round( $price * ( 1 - $discount ), 2 );
439 $lowest = round( $price * ( 1 - max( $tiers ) ), 2 );
440
441 // the next tier after the sample quantity, for the cart upsell message
442 $quantities = array_keys( $tiers );
443 $nextQuantity = (int) ( $quantities[1] ?? $quantities[0] );
444 $nextPrice = round( $price * ( 1 - (float) $tiers[ $nextQuantity ] ), 2 );
445 $percent = function ( float $from, float $to ) {
446 $discount = $from > 0 ? ( $from - $to ) / $from * 100 : 0;
447
448 return number_format( $discount, wc_get_price_decimals(), wc_get_price_decimal_separator(), wc_get_price_thousand_separator() );
449 };
450
451 $tierList = array( array( 'quantity' => 1, 'price' => (float) $price ) );
452 foreach ( $tiers as $tierQuantity => $tierDiscount ) {
453 $tierList[] = array( 'quantity' => (int) $tierQuantity, 'price' => round( $price * ( 1 - (float) $tierDiscount ), 2 ) );
454 }
455
456 return array(
457 'regular' => (float) $price,
458 'tiers' => $tierList,
459 'upsell' => array(
460 'requiredQuantity' => max( 0, $nextQuantity - $quantity ),
461 'targetQuantity' => $nextQuantity,
462 'progress' => $nextQuantity > 0 ? (int) round( $quantity / $nextQuantity * 100 ) : 0,
463 'nextPrice' => $plain( wc_price( $nextPrice ) ),
464 'nextDiscount' => $percent( $tierPrice, $nextPrice ),
465 'actualDiscount' => $percent( $price, $tierPrice ),
466 ),
467 'quantity' => $quantity,
468 'price' => $plain( wc_price( $price ) ),
469 'tierPrice' => $plain( wc_price( $tierPrice ) ),
470 'lowest' => $plain( wc_price( $lowest ) ),
471 'total' => $plain( wc_price( $price * $quantity ) ),
472 'tierTotal' => $plain( wc_price( $tierPrice * $quantity ) ),
473 'saving' => $plain( wc_price( $price - $tierPrice ) ),
474 'savingTotal' => $plain( wc_price( ( $price - $tierPrice ) * $quantity ) ),
475 'maxDiscount' => (int) round( max( $tiers ) * 100 ) . '%',
476 'maxSaving' => $plain( wc_price( $price - $lowest ) ),
477 'lowestQuantity' => (int) max( array_keys( $tiers ) ),
478 'discount' => (int) round( $discount * 100 ),
479 'productName' => __( 'Product name', 'tier-pricing-table' ),
480 );
481 }
482
483 /**
484 * Whether premium code is available (the woocommerce.com build has no Freemius and is always premium).
485 */
486 public static function isPremium(): bool {
487 $premium = function_exists( 'tpt_fs' ) ? (bool) tpt_fs()->can_use_premium_code() : true;
488
489 return (bool) apply_filters( 'tiered_pricing_table/settings/is_premium', $premium );
490 }
491
492 public static function getOptionId( string $id ): string {
493 return Settings::SETTINGS_PREFIX . $id;
494 }
495
496 /**
497 * Settings entries for the options the configurator saves: rendered by nothing, saved by
498 * WooCommerce from the hidden inputs.
499 */
500 public static function getSavedOnlyFields(): array {
501 $fields = array();
502 foreach ( self::getDefaults() as $id => $default ) {
503 if ( 'display_type' === $id ) {
504 continue; // the configurator field itself carries this id
505 }
506 $fields[] = array(
507 'id' => self::getOptionId( $id ),
508 'type' => 'tpt_saved_only',
509 'default' => $default,
510 );
511 }
512 $others = self::getUnitDefaults();
513 if ( self::isCatalogAvailable() ) {
514 $others = array_merge( $others, self::getCatalogDefaults(), self::getCatalogUnitDefaults() );
515 }
516 foreach ( $others as $id => $default ) {
517 $fields[] = array(
518 'id' => self::getOptionId( $id ),
519 'type' => 'tpt_saved_only',
520 'default' => $default,
521 );
522 }
523
524 return $fields;
525 }
526
527 /**
528 * Default singular/plural unit labels per unit option.
529 */
530 public static function getUnitDefaults(): array {
531 return array(
532 'table_quantity_measurement' => array( 'singular' => '', 'plural' => '' ),
533 'blocks_quantity_measurement' => array(
534 'singular' => _n( 'piece', 'pieces', 1, 'tier-pricing-table' ),
535 'plural' => _n( 'piece', 'pieces', 2, 'tier-pricing-table' ),
536 ),
537 );
538 }
539
540 /**
541 * Stored unit labels, normalised to singular/plural strings.
542 */
543 public static function getUnitValues(): array {
544 $values = array();
545 foreach ( self::getUnitDefaults() as $id => $default ) {
546 $stored = ServiceContainer::getInstance()->getSettings()->get( $id, $default );
547 $stored = is_array( $stored ) ? $stored : array();
548 $values[ $id ] = array(
549 'singular' => isset( $stored['singular'] ) ? (string) $stored['singular'] : $default['singular'],
550 'plural' => isset( $stored['plural'] ) ? (string) $stored['plural'] : $default['plural'],
551 );
552 }
553
554 return $values;
555 }
556
557 /**
558 * Whether the catalog add-on is on, i.e. the configurator also manages the shop & category options.
559 */
560 public static function isCatalogAvailable(): bool {
561 return AbstractAddon::isAddonEnabled( self::CATALOG_ADDON_SLUG );
562 }
563
564 /**
565 * Catalog option defaults: option id => default. Shared options default like the product page ones;
566 * the design styles follow the product page until a catalog style is saved.
567 */
568 public static function getCatalogDefaults(): array {
569 $product = self::getDefaults();
570 $defaults = array();
571 foreach ( self::CATALOG_OPTIONS as $key => $id ) {
572 $defaults[ $id ] = $product[ $key ] ?? '';
573 }
574 $defaults['shop_loop_display_enabled'] = 'no';
575 $defaults['shop_loop_display_position'] = 'woocommerce_after_shop_loop_item__6';
576 $defaults['shop_loop_display_pricing_table_style'] = self::catalogStyle( 'table', GeneralSection::getPricingTableStyle() );
577 $defaults['shop_loop_display_pricing_blocks_style'] = self::catalogStyle( 'blocks', GeneralSection::getPricingBlocksStyle() );
578 $defaults['shop_loop_display_pricing_options_style'] = self::catalogStyle( 'options', GeneralSection::getPricingOptionsStyle() );
579 $defaults['shop_loop_display_pricing_dropdown_style'] = self::catalogStyle( 'dropdown', GeneralSection::getPricingDropdownStyle() );
580 $defaults['shop_loop_display_pricing_plain_text_style'] = self::catalogStyle( 'plain-text', GeneralSection::getPricingPlainTextStyle() );
581 $defaults['shop_loop_display_discount_column_title'] = __( 'Discount', 'tier-pricing-table' );
582 $defaults['shop_loop_display_layout_settings'] = 'default';
583 $defaults['shop_loop_display_use_reduced_styles'] = 'yes';
584 $defaults['shop_loop_display_dynamic_price'] = 'yes';
585 $defaults['shop_loop_display_show_quantity_field'] = 'no';
586 $defaults['shop_loop_display_scope'] = 'everywhere';
587 $defaults['shop_loop_display_contexts'] = '';
588 $defaults['shop_loop_display_categories'] = '';
589 $defaults['shop_loop_display_tiers_limit'] = '';
590 $defaults['shop_loop_display_badge_enabled'] = 'no';
591 $defaults['shop_loop_display_badge_template'] = \TierPricingTable\Addons\ProductCatalogLoop\Settings\ProductCatalogLoopSettingsSection::getDefaultBadgeTemplate();
592 $defaults['shop_loop_display_badge_position'] = 'top-left';
593 $defaults['shop_loop_display_badge_color'] = '';
594
595 return $defaults;
596 }
597
598 /**
599 * A design style as shop and category pages render it: a withheld style falls back to the default.
600 */
601 public static function catalogStyle( string $layout, string $style ): string {
602 $excluded = GeneralSection::getCatalogExcludedStyles();
603
604 return in_array( $style, (array) ( $excluded[ $layout ] ?? array() ), true ) ? 'default' : $style;
605 }
606
607 public static function getCatalogUnitDefaults(): array {
608 $defaults = array();
609 foreach ( self::getUnitDefaults() as $key => $default ) {
610 $defaults[ self::CATALOG_UNIT_OPTIONS[ $key ] ] = $default;
611 }
612
613 return $defaults;
614 }
615
616 /**
617 * Stored catalog values (scalars as strings, unit labels normalised), keyed by option id.
618 */
619 public static function getCatalogValues(): array {
620 $settings = ServiceContainer::getInstance()->getSettings();
621 $values = array();
622 // options where an empty string is a value of its own (an empty header hides that column)
623 $keepEmpty = array( 'shop_loop_display_title', 'shop_loop_display_quantity_column_title', 'shop_loop_display_discount_column_title', 'shop_loop_display_price_column_title' );
624 foreach ( self::getCatalogDefaults() as $id => $default ) {
625 $value = $settings->get( $id, $default );
626 $values[ $id ] = is_scalar( $value ) && ( '' !== $value || in_array( $id, $keepEmpty, true ) ) ? (string) $value : (string) $default;
627 }
628 foreach ( self::getCatalogUnitDefaults() as $id => $default ) {
629 $stored = $settings->get( $id, $default );
630 $stored = is_array( $stored ) ? $stored : array();
631 $values[ $id ] = array(
632 'singular' => isset( $stored['singular'] ) ? (string) $stored['singular'] : $default['singular'],
633 'plural' => isset( $stored['plural'] ) ? (string) $stored['plural'] : $default['plural'],
634 );
635 }
636
637 return $values;
638 }
639
640 public static function getCatalogPositionOptions(): array {
641 return array(
642 'woocommerce_after_shop_loop_item__6' => __( 'Above the add to cart button', 'tier-pricing-table' ),
643 'woocommerce_after_shop_loop_item__15' => __( 'Below the add to cart button', 'tier-pricing-table' ),
644 'woocommerce_shop_loop_item_title__5' => __( 'Above the product title', 'tier-pricing-table' ),
645 'woocommerce_shop_loop_item_title__15' => __( 'Below the product title', 'tier-pricing-table' ),
646 );
647 }
648
649 public static function getPositionOptions(): array {
650 return array(
651 'woocommerce_before_add_to_cart_button' => __( 'Above the add to cart button', 'tier-pricing-table' ),
652 'woocommerce_after_add_to_cart_button' => __( 'Below the add to cart button', 'tier-pricing-table' ),
653 'woocommerce_before_add_to_cart_form' => __( 'Above the add to cart form', 'tier-pricing-table' ),
654 'woocommerce_after_add_to_cart_form' => __( 'Below the add to cart form', 'tier-pricing-table' ),
655 'woocommerce_single_product_summary' => __( 'Above the product title', 'tier-pricing-table' ),
656 'woocommerce_before_single_product_summary' => __( 'Before the product summary', 'tier-pricing-table' ),
657 'woocommerce_after_single_product_summary' => __( 'After the product summary', 'tier-pricing-table' ),
658 );
659 }
660
661 public static function getStyleOptions(): array {
662 return GeneralSection::getStyleOptions();
663 }
664
665 /**
666 * Configuration handed to the app.
667 */
668 public function getConfig(): array {
669 $values = array();
670 foreach ( self::getDefaults() as $id => $default ) {
671 $value = $this->getContainer()->getSettings()->get( $id, $default );
672 $values[ $id ] = is_scalar( $value ) ? (string) $value : $default;
673 }
674 // classic "none" position: shown as automatic display switched off, position back to the default
675 $values['display'] = GeneralSection::isAutomaticDisplayEnabled() ? 'yes' : 'no';
676 $values['position_hook'] = GeneralSection::getPositionHook();
677 $values = array_merge( $values, self::getUnitValues() );
678 if ( self::isCatalogAvailable() ) {
679 $values = array_merge( $values, self::getCatalogValues() );
680 }
681
682 /** @var LayoutPreview $preview */
683 $preview = $this->getContainer()->get( 'settings.layout_preview' );
684 $samples = $this->getSamples();
685
686 return array(
687 'prefix' => Settings::SETTINGS_PREFIX,
688 'title' => __( 'Pricing Display', 'tier-pricing-table' ),
689 'values' => $values,
690 'layouts' => TierPricingTablePlugin::getAvailablePricingLayouts(),
691 'styles' => self::getStyleOptions(),
692 'positions' => self::getPositionOptions(),
693 'compactFor' => array( 'table', 'options', 'blocks', 'dropdown', 'horizontal-table' ),
694 // styles whose gap between tiers the "Spacing" option controls (the others keep their own)
695 'spacingFor' => array(
696 'blocks' => array( 'default', 'style-1', 'style-2', 'style-3', 'style-4', 'style-5', 'style-7' ),
697 'options' => array( 'default', 'style-3', 'style-4', 'style-5' ),
698 'table' => array( 'style-2', 'style-3', 'style-4' ),
699 ),
700 'spacingMax' => 40,
701 // layouts whose cell padding the "Cell padding" option controls
702 'cellPaddingFor' => array( 'table', 'horizontal-table' ),
703 'cellPaddingMax' => 30,
704 // design styles whose discount badge the "Discount badge colour" option recolours: the colour shown for "Auto"
705 // ('active' is the active tier colour) and what the colour paints: a tint with coloured text, a solid badge
706 // with white text, or the savings bar and its percentage
707 'badgeColorStyles' => array( 'table' => array(
708 'style-2' => array( 'default' => '#b91c1c', 'kind' => 'tint' ),
709 'style-3' => array( 'default' => 'active', 'kind' => 'solid' ),
710 'style-4' => array( 'default' => 'active', 'kind' => 'solid' ),
711 'style-5' => array( 'default' => 'active', 'kind' => 'bar' ),
712 'style-6' => array( 'default' => '#059669', 'kind' => 'tint' ),
713 ) ),
714 // design styles that mark the active tier with a left border the "Active tier left border" toggle controls
715 'activeBorderStyles' => array( 'table' => array( 'style-1', 'style-5', 'style-6' ) ),
716 'discountFor' => array( 'blocks', 'options' ),
717 // options styles that print a discount label (the toggle applies to those only)
718 'discountStyles' => array( 'options' => array( 'style-3', 'style-4', 'style-5', 'style-6' ) ),
719 // layouts (and, for options, the design styles) that print a discount per tier
720 'discountFormatFor' => array( 'table', 'horizontal-table', 'tooltip', 'blocks', 'options', 'dropdown' ),
721 'discountFormatStyles' => array( 'options' => array( 'style-3', 'style-4', 'style-5', 'style-6' ), 'dropdown' => array( 'style-1' ) ),
722 'unitFor' => self::UNIT_OPTIONS,
723 'tooltipFor' => array( 'tooltip' ),
724 'columnsFor' => array( 'table', 'horizontal-table', 'tooltip' ),
725 'clickableFor' => array( 'table', 'horizontal-table', 'blocks', 'options', 'tooltip', 'dropdown', 'plain-text' ),
726 'plainTextFor' => array( 'plain-text' ),
727 'customColumns' => has_action( 'tiered_pricing_table/settings/table_columns/end' ),
728 'catalog' => self::isCatalogAvailable() ? array(
729 'keys' => self::CATALOG_OPTIONS,
730 'unitKeys' => self::CATALOG_UNIT_OPTIONS,
731 'layouts' => array_diff_key( TierPricingTablePlugin::getAvailablePricingLayouts(), array( 'tooltip' => true ) ),
732 'positions' => self::getCatalogPositionOptions(),
733 'premiumOptions' => self::CATALOG_PREMIUM_OPTIONS,
734 'excludedStyles' => GeneralSection::getCatalogExcludedStyles(),
735 'contexts' => \TierPricingTable\Addons\ProductCatalogLoop\Settings\ProductCatalogLoopSettingsSection::getContextOptions(),
736 'scopes' => \TierPricingTable\Addons\ProductCatalogLoop\Settings\ProductCatalogLoopSettingsSection::getScopeOptions(),
737 // the chosen categories' names; other categories are searched through WooCommerce's AJAX category search
738 'categoryNames' => (object) \TierPricingTable\Addons\ProductCatalogLoop\Settings\ProductCatalogLoopSettingsSection::getCategoryNames( \TierPricingTable\Settings\CustomOptions\TPTCheckboxListOption::toArray( ServiceContainer::getInstance()->getSettings()->get( 'shop_loop_display_categories', '' ) ) ),
739 'categorySearch' => array( 'url' => admin_url( 'admin-ajax.php' ), 'action' => 'woocommerce_json_search_categories', 'nonce' => wp_create_nonce( 'search-categories' ) ),
740 'badgePositions' => \TierPricingTable\Addons\ProductCatalogLoop\Settings\ProductCatalogLoopSettingsSection::getBadgePositionOptions(),
741 ) : null,
742 'premium' => self::isPremium(),
743 'premiumOptions' => self::PREMIUM_OPTIONS,
744 'optionsLayout' => array(
745 'templates' => array( 'options', 'dropdown' ),
746 'original' => array( 'options', 'dropdown' ),
747 'defaultOpt' => array( 'options' ),
748 'total' => array( 'options' ),
749 'totalStyles' => array( 'default', 'style-1', 'style-2', 'style-3', 'style-4', 'style-5', 'style-6' ),
750 ),
751 'placeholders' => array(
752 'options_option_text' => array( 'tp_quantity', 'tp_discount', 'tp_rounded_discount', 'tp_base_unit_name' ),
753 'options_default_option_text' => array( 'tp_quantity', 'tp_base_unit_name' ),
754 'plain_text_template' => array( 'tp_quantity', 'tp_discount', 'tp_price', 'tp_rounded_discount', 'tp_base_unit_name' ),
755 'plain_text_first_tier_template' => array( 'tp_quantity', 'tp_price', 'tp_base_unit_name' ),
756 'you_save_template' => array( 'tp_ys_price', 'tp_ys_total_price', 'tp_ys_percentage_discount' ),
757 'tiered_price_at_catalog_custom_template' => array( 'tp_lowest_price', 'tp_prices_range', 'tp_original_price' ),
758 'cart_upsell_template' => array( 'tp_required_quantity', 'tp_next_price', 'tp_next_discount', 'tp_actual_discount' ),
759 'badge_template' => array( 'tp_max_discount', 'tp_lowest_price', 'tp_lowest_quantity', 'tp_max_saving' ),
760 ),
761 'placeholder' => function_exists( 'wc_placeholder_img_src' ) ? wc_placeholder_img_src( 'woocommerce_single' ) : '',
762 'youSave' => self::isYouSaveAvailable(),
763 'samples' => $samples,
764 'summary' => self::isSummaryAvailable() ? array(
765 'positions' => self::getPositionOptions(),
766 'types' => array(
767 'detailed' => __( 'Detailed', 'tier-pricing-table' ),
768 'table' => __( 'Compact', 'tier-pricing-table' ),
769 'inline' => __( 'Inline labels', 'tier-pricing-table' ),
770 ),
771 ) : null,
772 'cart' => self::isCartAvailable() || self::isUpsellAvailable() ? array(
773 'prices' => self::isCartAvailable(),
774 'upsells' => self::isUpsellAvailable(),
775 ) : null,
776 'catalogPrice' => self::isCatalogPriceAvailable() ? array(
777 'types' => array(
778 'lowest' => array(
779 'label' => __( 'Lowest price', 'tier-pricing-table' ),
780 /* translators: %s: example price */
781 'example' => sprintf( __( 'e.g. From %s', 'tier-pricing-table' ), $samples['lowest'] ),
782 ),
783 'range' => array(
784 'label' => __( 'Price range', 'tier-pricing-table' ),
785 /* translators: 1: lowest example price, 2: highest example price */
786 'example' => sprintf( __( 'e.g. %1$s - %2$s', 'tier-pricing-table' ), $samples['lowest'], $samples['price'] ),
787 ),
788 'custom' => array(
789 'label' => __( 'Custom template', 'tier-pricing-table' ),
790 'example' => __( 'Your own text built from variables', 'tier-pricing-table' ),
791 ),
792 ),
793 ) : null,
794 'priceFormats' => array(
795 'same_as_catalog' => array(
796 'label' => __( 'Match shop & categories', 'tier-pricing-table' ),
797 'example' => __( 'Lowest price or a price range, as on the shop page', 'tier-pricing-table' ),
798 ),
799 'custom' => array(
800 'label' => __( 'Price for the selected quantity', 'tier-pricing-table' ),
801 'example' => __( 'Follows the quantity the customer enters', 'tier-pricing-table' ),
802 ),
803 ),
804 'quantityTypes' => array(
805 'range' => array( 'label' => __( 'Range', 'tier-pricing-table' ), 'example' => __( '10 - 24 pieces', 'tier-pricing-table' ) ),
806 'static' => array( 'label' => __( 'Static values', 'tier-pricing-table' ), 'example' => __( '10 pieces', 'tier-pricing-table' ) ),
807 ),
808 'tierOrders' => array(
809 'asc' => array( 'label' => __( 'Ascending', 'tier-pricing-table' ), 'example' => __( 'Smallest quantity first', 'tier-pricing-table' ) ),
810 'desc' => array( 'label' => __( 'Descending', 'tier-pricing-table' ), 'example' => __( 'Biggest discount first', 'tier-pricing-table' ) ),
811 ),
812 'discountFormats' => array(
813 'percentage' => array( 'label' => __( 'Percentage', 'tier-pricing-table' ), 'example' => $samples['discount'] . '%' ),
814 'amount' => array( 'label' => __( 'Amount saved', 'tier-pricing-table' ), 'example' => $samples['saving'] ),
815 'both' => array( 'label' => __( 'Both', 'tier-pricing-table' ), 'example' => $samples['discount'] . '% (' . $samples['saving'] . ')' ),
816 ),
817 'ajax' => array(
818 'url' => admin_url( 'admin-ajax.php' ),
819 'action' => LayoutPreview::AJAX_ACTION,
820 'nonce' => wp_create_nonce( LayoutPreview::AJAX_ACTION ),
821 ),
822 'stylesheets' => $preview->getStylesheetURLs(),
823 'inlineStyles' => $preview->getInlineStyles(),
824 // WooCommerce scopes its classic price colours to themes that are not block themes
825 'blockTheme' => function_exists( 'wp_is_block_theme' ) && wp_is_block_theme(),
826 'currency' => array(
827 'price' => html_entity_decode( wp_strip_all_tags( wc_price( LayoutPreview::PRICE ) ), ENT_QUOTES, 'UTF-8' ),
828 'symbol' => html_entity_decode( get_woocommerce_currency_symbol(), ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, 'UTF-8' ),
829 'position' => get_option( 'woocommerce_currency_pos', 'left' ),
830 'decimals' => wc_get_price_decimals(),
831 'decimalSeparator' => wc_get_price_decimal_separator(),
832 'thousandSeparator' => wc_get_price_thousand_separator(),
833 ),
834 );
835 }
836
837 public function enqueueAssets() {
838 $dir = plugin_dir_path( __FILE__ ) . 'build/';
839 $assetFile = file_exists( $dir . 'index.asset.php' ) ? include $dir . 'index.asset.php' : array( 'dependencies' => array(), 'version' => TierPricingTablePlugin::VERSION );
840
841 wp_enqueue_script( self::SCRIPT_HANDLE, plugins_url( 'build/index.js', __FILE__ ), $assetFile['dependencies'], $assetFile['version'], true );
842 wp_set_script_translations( self::SCRIPT_HANDLE, 'tier-pricing-table', $this->getContainer()->getFileManager()->getPluginDirectory() . 'languages' );
843
844 if ( file_exists( $dir . 'style-index.css' ) ) {
845 wp_enqueue_style( self::SCRIPT_HANDLE, plugins_url( 'build/style-index.css', __FILE__ ), array( 'wp-components' ), $assetFile['version'] );
846 } else {
847 wp_enqueue_style( 'wp-components' );
848 }
849
850 wp_add_inline_script( self::SCRIPT_HANDLE, 'window.tptLayoutConfigurator = ' . wp_json_encode( $this->getConfig() ) . ';', 'before' );
851 }
852
853 public function render( $value ) {
854 $this->enqueueAssets();
855
856 $value = wp_parse_args( $value, array(
857 'id' => self::getOptionId( 'display_type' ),
858 'title' => '',
859 'desc' => '',
860 ) );
861
862 $values = $this->getConfig()['values'];
863 ?>
864 <?php
865 // WooCommerce renders fields inside <table class="form-table">, whose fixed layout keeps a
866 // spanning cell inside the label column. The configurator needs the full width, so it closes
867 // the table, renders as a block and reopens the table for the fields that follow.
868 ?>
869 </table>
870 <div id="tpt-layout-configurator" class="tpt-layout-configurator" aria-label="<?php echo esc_attr( $value['title'] ); ?>">
871 <?php foreach ( $values as $id => $stored ) : ?>
872 <?php if ( is_array( $stored ) ) : ?>
873 <?php foreach ( array( 'singular', 'plural' ) as $form ) : ?>
874 <input type="hidden"
875 name="<?php echo esc_attr( self::getOptionId( $id ) . '[' . $form . ']' ); ?>"
876 value="<?php echo esc_attr( $stored[ $form ] ); ?>"
877 data-tpt-lc-option="<?php echo esc_attr( $id . '.' . $form ); ?>">
878 <?php endforeach; ?>
879 <?php else : ?>
880 <input type="hidden"
881 name="<?php echo esc_attr( self::getOptionId( $id ) ); ?>"
882 id="<?php echo esc_attr( self::getOptionId( $id ) ); ?>"
883 value="<?php echo esc_attr( $stored ); ?>"
884 data-tpt-lc-option="<?php echo esc_attr( $id ); ?>">
885 <?php endif; ?>
886 <?php endforeach; ?>
887 <div class="tpt-layout-configurator__app" data-tpt-lc-root>
888 <p class="tpt-layout-configurator__loading"><?php esc_html_e( 'Loading the layout configurator…', 'tier-pricing-table' ); ?></p>
889 </div>
890 <?php if ( has_action( 'tiered_pricing_table/settings/table_columns/end' ) ) : ?>
891 <?php // Custom table columns add-on. The app moves this block under the controls and shows it for table layouts. ?>
892 <div class="tpt-layout-configurator__custom-columns" data-tpt-lc-custom-columns hidden>
893 <?php do_action( 'tiered_pricing_table/settings/table_columns/after_fields' ); ?>
894 <?php do_action( 'tiered_pricing_table/settings/table_columns/end' ); ?>
895 </div>
896 <?php endif; ?>
897 <?php if ( ! empty( $value['desc'] ) ) : ?>
898 <p class="description tpt-layout-configurator__desc"><?php echo esc_html( $value['desc'] ); ?></p>
899 <?php endif; ?>
900 </div>
901 <table class="form-table">
902 <?php
903 }
904 }
905