PluginProbe
Tiered Pricing Table for WooCommerce / 8.1.0
Tiered Pricing Table for WooCommerce v8.1.0
8.1.0 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 All 92 releases
tier-pricing-table / src / Addons / LayoutConfigurator / LayoutConfigurator.php

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

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